Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
node_modules
PLAN.md
*.log
20 changes: 19 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,25 @@

## [Unreleased]

_No unreleased changes._
### Added

- Backend tools: add `agents.js list --json` support with expanded session metadata (`session_id`, `created_at`, `workspace`, `title`, `dynamic_title`, `template_id`, `template_name`, `last_output_at`, `output_active`). ([#31](https://github.com/kcosr/termstation/pull/31))
- Backend tools: add `agents.js create --workspace` plus `AGENTS_WORKSPACE` support, defaulting peer agent sessions to the `Default` workspace.
- Backend tools: restore full title overrides for `agents.js create` via `--title` and `SESSION_TITLE`, with `--title` taking precedence over the environment variable.
- Frontend: add a Developer WebSocket Session Trace viewer (in-memory ring buffer) with refresh, clear, and copy actions for client-side session/websocket event debugging. ([#31](https://github.com/kcosr/termstation/pull/31))

### Changed

- Backend tools: require an explicit `-` sentinel to read `agents.js create/send` message text from stdin, so omitted messages no longer block on non-TTY stdin.

### Fixed

- Backend: ensure `SESSION_TOK` and `SESSIONS_API_BASE_URL` are injected for `isolation_mode=none` sessions, including route-created and auto-started sessions. ([#31](https://github.com/kcosr/termstation/pull/31))
- Backend/frontend: stop dynamic OSC title churn from forcing repeated session refresh work while an explicit session title override is set.
- Frontend desktop: keep keyboard session-tab navigation local to the current window instead of focusing a dedicated session window. ([#31](https://github.com/kcosr/termstation/pull/31))
- Frontend desktop/macOS: remove dedicated-window header/sidebar toggle inset in fullscreen by syncing renderer fullscreen state reliably for child windows. ([#31](https://github.com/kcosr/termstation/pull/31))
- Frontend mobile: reduce Android dictation-triggered horizontal panning by constraining terminal container overflow and adjusting xterm helper textarea placement on coarse-pointer devices. ([#31](https://github.com/kcosr/termstation/pull/31))
- Frontend: restrict foreground session reattach/history reload handling to actual mobile runtimes so desktop and non-mobile web do not redraw active sessions on every app focus change.

## [0.0.8] - 2026-03-13

Expand Down
11 changes: 7 additions & 4 deletions backend/config/files/agent_communication.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,18 @@ Only create or use peer agent sessions when the user explicitly asks you to invo
- Do not create peer sessions proactively if the user has not requested one.
- Always use `{BOOTSTRAP_DIR}/bin/agents.js` — do not print peer messages directly.
- Create peer (`ISSUE_ID` is required so the peer session is linked to the correct issue):
- `ISSUE_ID=<issue-id> {BOOTSTRAP_DIR}/bin/agents.js create <agent> [--description "..."]`
- `ISSUE_ID=<issue-id> {BOOTSTRAP_DIR}/bin/agents.js create <agent> [--title "<full title>"] [--description "..."] [--workspace "<name>"]`
- Example for code review:
`ISSUE_ID=1 {BOOTSTRAP_DIR}/bin/agents.js create claude --description 'Review PR #2 for issue #1 (Gitea TypeScript webhook handler)'`
`ISSUE_ID=1 {BOOTSTRAP_DIR}/bin/agents.js create claude --title 'Review PR #2' --workspace 'Reviews'`
- To pipe the initial prompt, pass `-` as the final argument.
- Workspace defaults to `Default`. Override it with `--workspace` or `AGENTS_WORKSPACE=<name>`.
- Title can be fully overridden with `--title` or `SESSION_TITLE=<full title>`. `--title` takes precedence.
- Send a message:
- Single-line:
`{BOOTSTRAP_DIR}/bin/agents.js send <peer-session-id> "Message"`
- Multi-line (preferred):
- Multi-line (preferred, pass `-` to read from stdin):
```bash
cat << 'MSG' | {BOOTSTRAP_DIR}/bin/agents.js send <peer-session-id>
cat << 'MSG' | {BOOTSTRAP_DIR}/bin/agents.js send <peer-session-id> -
Please review PR #<pr-number> for #<issue-number>.
MSG
```
Expand Down
20 changes: 11 additions & 9 deletions backend/files/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ To send a message to a peer agent:
Examples (single‑line):
- `{BOOTSTRAP_DIR}/bin/agents.js send <peer-session-id> "Hello! How can I help?"`

Preferred for multi‑line/special characters (single‑quoted heredoc to avoid shell expansion):
Preferred for multi‑line/special characters (single‑quoted heredoc to avoid shell expansion, with `-` to read from stdin):
```bash
cat << 'MSG' | {BOOTSTRAP_DIR}/bin/agents.js send <peer-session-id>
cat << 'MSG' | {BOOTSTRAP_DIR}/bin/agents.js send <peer-session-id> -
Please review MR !<mr-number> for #<issue-number>.

Summary
Expand All @@ -55,17 +55,19 @@ To get a list of active peer agent session IDs, use:
- `{BOOTSTRAP_DIR}/bin/agents.js list`

To create a new peer agent (when user asks you to get help from claude, codex, or cursor):
- Use `{BOOTSTRAP_DIR}/bin/agents.js create <agent> [--description "<brief>"]` (you can also pipe a prompt)
- Example (single‑line): `{BOOTSTRAP_DIR}/bin/agents.js create claude --description "Review my MR changes"`
- Preferred heredoc for multi‑line prompts or special characters:
- Use `{BOOTSTRAP_DIR}/bin/agents.js create <agent> [--title "<full title>"] [--description "<brief>"] [--workspace "<name>"]`
- Example (single‑line): `{BOOTSTRAP_DIR}/bin/agents.js create claude --title "Review Session" --workspace "Reviews"`
- Preferred heredoc for multi‑line prompts or special characters (pass `-` to read the prompt from stdin):
```bash
cat << 'MSG' | {BOOTSTRAP_DIR}/bin/agents.js create claude --description "Review MR changes"
cat << 'MSG' | {BOOTSTRAP_DIR}/bin/agents.js create claude --description "Review MR changes" -
Please review MR !<mr-number> for #<issue-number> — brief summary, key files, and links.
MSG
```
- On success, it prints: `Peer agent <id> is available`
- Then send your instructions with: `{BOOTSTRAP_DIR}/bin/agents.js send <peer-id> "<your message>"`
- Never create more than one session to the same agent.
- Workspace defaults to `Default`. Override it with `--workspace` or `AGENTS_WORKSPACE=<name>`.
- Title can be fully overridden with `--title` or `SESSION_TITLE=<full title>`. `--title` takes precedence.

To stop a peer agent session:
- Only stop peer sessions when the user explicitly instructs you to do so.
Expand All @@ -80,10 +82,10 @@ Session Title and Issue Assignment
- With `REPO` and `ISSUE_ID`: `<repo> #<issue_id>`
- With `REPO` only: `<repo>`
- Otherwise: `Session for <agent>`
- Provide a brief `--description` to append to the title (recommended). Example:
- Provide a brief `--description` to append to the auto-generated title when you do not need a full override. Example:
- `ISSUE_ID=751 {BOOTSTRAP_DIR}/bin/agents.js create codex --description "Implement pagination"`
- Title becomes: `<repo> #751: Implement pagination`
- Note: `SESSION_TITLE` is no longer supported; use `--description` for a short suffix, or pass a full custom title via future tooling when available.
- Title becomes: `<repo> #751: Implement pagination`
- Use `SESSION_TITLE` or `--title` when you want a full custom title instead of the computed default. `--title` takes precedence over `SESSION_TITLE`.
- Branch is derived automatically as `issue/<ISSUE_ID>` when `ISSUE_ID` is set (unless `BRANCH` is provided).
- Example:
- `ISSUE_ID=751 {BOOTSTRAP_DIR}/bin/agents.js create codex`
Expand Down
11 changes: 9 additions & 2 deletions backend/models/terminal-session.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export class TerminalSession {
this.workspace_service_port = Number.isFinite(Number(options.workspace_service_port))
? Math.floor(Number(options.workspace_service_port))
: null;
this.session_token = typeof options.session_token === 'string' ? options.session_token : '';
// Fork metadata
this.is_fork = options.is_fork === true;
this.forked_from_session_id = options.forked_from_session_id || null;
Expand Down Expand Up @@ -224,6 +225,10 @@ export class TerminalSession {
TERMSTATION_USER: this.created_by,
SESSIONS_BASE_URL: config.SESSIONS_BASE_URL
};
if (String(this.isolation_mode || 'none') === 'none') {
if (this.session_token) env.SESSION_TOK = this.session_token;
if (config.SESSIONS_API_BASE_URL) env.SESSIONS_API_BASE_URL = config.SESSIONS_API_BASE_URL;
}

// For host sessions (non-container) without a per-session bootstrap, make
// backend-managed bootstrap tools available by appending backend/bootstrap/bin
Expand Down Expand Up @@ -446,11 +451,13 @@ export class TerminalSession {
if (typeof data === 'string' && data) {
const { title, carry } = parseOscTitles(data, this._oscBuffer || '');
this._oscBuffer = carry || '';
// If changed, update and broadcast session update
// If changed, update and broadcast session update unless an explicit
// title override is already set for the session.
if (title && title !== this.dynamic_title) {
this.dynamic_title = title;
try {
if (global.connectionManager) {
const hasExplicitTitle = typeof this.title === 'string' && this.title.trim().length > 0;
if (!hasExplicitTitle && global.connectionManager) {
global.connectionManager.broadcast({
type: 'session_updated',
update_type: 'updated',
Expand Down
1 change: 1 addition & 0 deletions backend/routes/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -919,6 +919,7 @@ router.post('/', async (req, res) => {
workspace_service_port: workspaceServicePort,
// Persist effective parameters (provided + defaults) on the session
template_parameters: resolvedTemplateParameters,
session_token: sessionUnifiedToken || '',
session_id: initialSessionId,
// Optional alias (safe slug only)
...(computedAlias ? { session_alias: computedAlias } : {})
Expand Down
1 change: 1 addition & 0 deletions backend/services/auto-start.js
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ export async function runAutoStartTemplates({ logger } = {}) {
workspace_service_enabled_for_session: workspaceServiceEnabledForSession,
workspace_service_port: workspaceServicePort,
template_parameters: resolveWithDefaults(tplForRun, paramValues),
session_token: sessionUnifiedToken || '',
// Pass alias to SessionManager so it registers the mapping
...(computedAlias ? { session_alias: computedAlias } : {})
};
Expand Down
11 changes: 10 additions & 1 deletion backend/tests/agents-config.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { loadConfig } from '../tools/agents/lib/config.mjs';
const originalSessionId = process.env.SESSION_ID;
const originalApiBase = process.env.SESSIONS_API_BASE_URL;
const originalForge = process.env.FORGE;
const originalSessionTitle = process.env.SESSION_TITLE;

beforeEach(() => {
if (!process.env.SESSION_ID) process.env.SESSION_ID = 'test-session-id';
Expand All @@ -19,6 +20,9 @@ afterEach(() => {

if (originalForge === undefined) delete process.env.FORGE;
else process.env.FORGE = originalForge;

if (originalSessionTitle === undefined) delete process.env.SESSION_TITLE;
else process.env.SESSION_TITLE = originalSessionTitle;
});

describe('agents config FORGE handling', () => {
Expand All @@ -33,5 +37,10 @@ describe('agents config FORGE handling', () => {
const cfg = loadConfig();
expect(cfg.FORGE).toBe('');
});
});

it('includes SESSION_TITLE from environment when set', () => {
process.env.SESSION_TITLE = 'My explicit title';
const cfg = loadConfig();
expect(cfg.SESSION_TITLE).toBe('My explicit title');
});
});
23 changes: 23 additions & 0 deletions backend/tests/agents-io.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import {
resolveInlineMessageArg,
shouldReadMessageFromStdin,
} from '../tools/agents/lib/io.mjs';

describe('agents stdin/message resolution', () => {
it('keeps regular message arguments inline', () => {
expect(resolveInlineMessageArg('review this change')).toBe('review this change');
});

it('treats "-" as the explicit stdin sentinel', () => {
expect(resolveInlineMessageArg('-')).toBe('');
expect(shouldReadMessageFromStdin('-')).toBe(true);
});

it('does not read stdin implicitly when no message is provided', () => {
expect(resolveInlineMessageArg(undefined)).toBe('');
expect(resolveInlineMessageArg('')).toBe('');
expect(shouldReadMessageFromStdin(undefined)).toBe(false);
expect(shouldReadMessageFromStdin('')).toBe(false);
});
});
46 changes: 46 additions & 0 deletions backend/tests/agents-title.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest';
import { resolveCreateTitle } from '../tools/agents/agents.mjs';

describe('resolveCreateTitle', () => {
it('defaults to Session for <agent> when no repo or override is provided', () => {
expect(resolveCreateTitle({ agent: 'codex' })).toBe('Session for codex');
});

it('uses repo and issue id when available', () => {
expect(resolveCreateTitle({
agent: 'codex',
repo: 'devtools/termstation',
issueId: '123',
})).toBe('devtools/termstation #123');
});

it('appends description to computed titles when no explicit title override is present', () => {
expect(resolveCreateTitle({
agent: 'codex',
repo: 'devtools/termstation',
issueId: '123',
description: 'Review reconnect behavior',
})).toBe('devtools/termstation #123: Review reconnect behavior');
});

it('prefers --title over SESSION_TITLE and computed titles', () => {
expect(resolveCreateTitle({
agent: 'codex',
repo: 'devtools/termstation',
issueId: '123',
description: 'Review reconnect behavior',
optionTitle: 'Manual override',
envTitle: 'Env override',
})).toBe('Manual override');
});

it('uses SESSION_TITLE when --title is not provided', () => {
expect(resolveCreateTitle({
agent: 'codex',
repo: 'devtools/termstation',
issueId: '123',
description: 'Review reconnect behavior',
envTitle: 'Env override',
})).toBe('Env override');
});
});
24 changes: 24 additions & 0 deletions backend/tests/agents-workspace.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { resolveCreateWorkspace } from '../tools/agents/agents.mjs';

describe('resolveCreateWorkspace', () => {
it('defaults to Default when no override is provided', () => {
expect(resolveCreateWorkspace({})).toBe('Default');
});

it('uses AGENTS_WORKSPACE when provided', () => {
expect(resolveCreateWorkspace({ envWorkspace: 'Reviews' })).toBe('Reviews');
});

it('prefers the CLI workspace option over AGENTS_WORKSPACE', () => {
expect(resolveCreateWorkspace({
optionWorkspace: 'Pairing',
envWorkspace: 'Reviews',
})).toBe('Pairing');
});

it('normalizes default workspace casing', () => {
expect(resolveCreateWorkspace({ optionWorkspace: 'default' })).toBe('Default');
expect(resolveCreateWorkspace({ envWorkspace: 'DEFAULT' })).toBe('Default');
});
});
80 changes: 80 additions & 0 deletions backend/tests/terminal-session-dynamic-title-broadcast.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
import { createTestConfig, cleanupTestConfig } from './helpers/test-utils.mjs';

let configDir;
let TerminalSession;
let mockPtyProcess;

vi.mock('node-pty', () => ({
spawn: vi.fn(() => {
mockPtyProcess = {
pid: 12345,
_onData: null,
_onExit: null,
onData: vi.fn((cb) => { mockPtyProcess._onData = cb; }),
onExit: vi.fn((cb) => { mockPtyProcess._onExit = cb; }),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn()
};
return mockPtyProcess;
})
}));

beforeEach(async () => {
configDir = createTestConfig();
process.env.TERMSTATION_CONFIG_DIR = configDir;
({ TerminalSession } = await import('../models/terminal-session.js'));
global.connectionManager = { broadcast: vi.fn() };
});

afterEach(() => {
cleanupTestConfig(configDir);
delete process.env.TERMSTATION_CONFIG_DIR;
delete global.connectionManager;
mockPtyProcess = null;
vi.clearAllMocks();
});

describe('TerminalSession OSC dynamic title broadcasts', () => {
it('broadcasts dynamic title updates when no explicit title is set', async () => {
const session = new TerminalSession({
session_id: 'osc-broadcast-no-title',
working_directory: '/tmp',
save_session_history: false
});

await session.createPtyProcess();

mockPtyProcess._onData('\u001b]0;Rotating title\u0007');

expect(session.dynamic_title).toBe('Rotating title');
expect(global.connectionManager.broadcast).toHaveBeenCalledTimes(1);
expect(global.connectionManager.broadcast).toHaveBeenCalledWith(
expect.objectContaining({
type: 'session_updated',
update_type: 'updated',
session_data: expect.objectContaining({
session_id: 'osc-broadcast-no-title',
dynamic_title: 'Rotating title'
})
})
);
});

it('tracks dynamic title updates without broadcasting when an explicit title is set', async () => {
const session = new TerminalSession({
session_id: 'osc-broadcast-explicit-title',
title: 'Pinned title',
working_directory: '/tmp',
save_session_history: false
});

await session.createPtyProcess();

mockPtyProcess._onData('\u001b]0;Rotating title\u0007');

expect(session.dynamic_title).toBe('Rotating title');
expect(global.connectionManager.broadcast).not.toHaveBeenCalled();
});
});
Loading