`;
+
+ it('slugs every heading, and a repeated title gets -1, -2 like GitHub', () => {
+ const { doc, anchors } = boot(html);
+ anchors.assign(doc);
+ const slugs = [...doc.querySelectorAll('h1, h2, h3')].map((h) => (h as HTMLElement).dataset.mdAnchor);
+ expect(slugs).toEqual(['overview', 'install', 'usage', 'install-1', 'install-2', 'my-title']);
+ anchors.assign(doc); // idempotent
+ expect([...doc.querySelectorAll('h1, h2, h3')].map((h) => (h as HTMLElement).dataset.mdAnchor)).toEqual(slugs);
+ });
+
+ it('finds a heading by its slug, case-insensitively, percent-encoded or with a space', () => {
+ const { doc, anchors } = boot(html);
+ const title = doc.querySelectorAll('h2')[2];
+ for (const href of ['#my-title', '#My-Title', '#my%20title', '#My Title']) {
+ expect(anchors.find(doc, href), href).toBe(title);
+ }
+ expect(anchors.find(doc, '#install-2')?.tagName).toBe('H3');
+ });
+
+ it('finds an explicit id the author wrote, and treats # as the top of the document', () => {
+ const { doc, anchors } = boot(html);
+ expect(anchors.find(doc, '#custom-spot')?.id).toBe('custom-spot');
+ expect(anchors.find(doc, '#')).toBe(doc);
+ expect(anchors.find(doc, '')).toBe(doc);
+ });
+
+ it('returns null for a fragment that matches nothing, and a malformed escape is used as written', () => {
+ const { doc, anchors } = boot(html);
+ expect(anchors.find(doc, '#nope')).toBeNull();
+ expect(() => anchors.find(doc, '#%E0%A4%A')).not.toThrow();
+ });
+
+ it('never resolves against the app: a heading cannot claim an element id outside the document', () => {
+ const { doc, anchors } = boot('
Settings
');
+ const found = anchors.find(doc, '#settings');
+ expect(found?.tagName).toBe('H1'); // the heading, not
in the app
+ expect(found?.id).toBe(''); // and the heading was not given an id
+ });
+});
+
+describe('the click delegate', () => {
+ it('handles fragment links before anything that would let the browser follow them', () => {
+ const delegate = APP.slice(APP.indexOf('_bindResponseViewerInteractions(body) {'));
+ const fragment = delegate.indexOf('a[href^="#"]');
+ expect(fragment).toBeGreaterThan(-1);
+ // After file-path links (their href is '#', they open the preview) and before the loopback/new-tab handling.
+ expect(fragment).toBeGreaterThan(delegate.indexOf("closest('a.rv-path')"));
+ expect(fragment).toBeLessThan(delegate.indexOf('openLinkThroughWebTabIfLoopback'));
+ expect(delegate.slice(fragment, fragment + 400)).toContain('preventDefault');
+ });
+});
From 26487f0ec57df3b6f8c2f8cb41d7cce4e56de918 Mon Sep 17 00:00:00 2001
From: Devvyn <22340871+opticon454@users.noreply.github.com>
Date: Thu, 8 Oct 2026 20:41:03 +0800
Subject: [PATCH 02/76] fix(cli-install): install npm CLIs to ~/.local when the
npm global prefix is not writable
installEnv() only redirected NPM_CONFIG_PREFIX inside the Docker container
(CODEMAN_IN_CONTAINER=1). On a native install with a root-owned system node
(prefix /usr) `npm install -g @deepseek-ai/dsh` run as the server user died
with EACCES (exit 243), so DeepSeek, pi and other npm-based CLIs could not be
installed from Settings. Ask npm for its global prefix and, when the server
user cannot write it, redirect to $HOME/.local like the container path does.
An operator-set NPM_CONFIG_PREFIX and a writable prefix are left alone.
---
.changeset/install-npm-prefix-writable.md | 5 +++
src/web/routes/cli-registry-routes.ts | 54 ++++++++++++++++++++---
test/routes/cli-registry-routes.test.ts | 35 ++++++++++++++-
3 files changed, 87 insertions(+), 7 deletions(-)
create mode 100644 .changeset/install-npm-prefix-writable.md
diff --git a/.changeset/install-npm-prefix-writable.md b/.changeset/install-npm-prefix-writable.md
new file mode 100644
index 00000000..75143f4c
--- /dev/null
+++ b/.changeset/install-npm-prefix-writable.md
@@ -0,0 +1,5 @@
+---
+'aicodeman': patch
+---
+
+Installing an npm-based CLI from Settings (DeepSeek's `dsh`, pi, ...) no longer fails with EACCES on a native install whose system node keeps its global prefix under `/usr`. When the npm global prefix is not writable by the Codeman user, the install now goes to `~/.local` (where Codeman already looks for CLIs), as the Docker deployment already did. A prefix you set yourself, or one that is writable, is left alone.
diff --git a/src/web/routes/cli-registry-routes.ts b/src/web/routes/cli-registry-routes.ts
index 3b75ffda..88f076ee 100644
--- a/src/web/routes/cli-registry-routes.ts
+++ b/src/web/routes/cli-registry-routes.ts
@@ -29,7 +29,9 @@
* write path (`registry-writer.ts` mirrors `custom-model-hosts.ts`).
*/
-import { spawn } from 'node:child_process';
+import { execFileSync, spawn } from 'node:child_process';
+import { accessSync, constants as fsConstants } from 'node:fs';
+import { join } from 'node:path';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { getAuthUser, isAdmin, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
@@ -207,14 +209,54 @@ const CLI_INSTALL_TIMEOUT_MS = 300_000;
*/
const installsInFlight = new Set();
+/**
+ * True when `npm install -g` can write to this process's npm global prefix, or when that cannot
+ * be determined (then nothing is redirected: a wrong guess would move installs somewhere the
+ * user did not choose). `npm config get prefix` is asked rather than guessed from `process.execPath`
+ * because a user `.npmrc` / `NPM_CONFIG_PREFIX` can point it anywhere.
+ */
+export function npmGlobalPrefixWritable(source: NodeJS.ProcessEnv): boolean {
+ try {
+ const prefix = execFileSync('npm', ['config', 'get', 'prefix'], {
+ env: source,
+ encoding: 'utf8',
+ timeout: 5_000,
+ stdio: ['ignore', 'pipe', 'ignore'],
+ }).trim();
+ if (!prefix) return true;
+ // npm creates lib/node_modules under the prefix; check that directory when it exists, else the prefix.
+ for (const dir of [join(prefix, 'lib', 'node_modules'), prefix]) {
+ try {
+ accessSync(dir, fsConstants.W_OK);
+ return true;
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return false;
+ }
+ }
+ return false;
+ } catch {
+ return true;
+ }
+}
+
/**
* The server's environment minus every `CODEMAN_*` variable. An install script is third-party
* code, and those variables carry Codeman's own secrets and wiring (`CODEMAN_PASSWORD`, the
- * data dir, the tmux socket), none of which an installer needs. Inside the Docker Compose
- * container (`CODEMAN_IN_CONTAINER=1`) it also points `NPM_CONFIG_PREFIX` at `$HOME/.local`,
- * so an `npm install -g` lands on the persistent home mount instead of the image.
+ * data dir, the tmux socket), none of which an installer needs.
+ *
+ * It also points `NPM_CONFIG_PREFIX` at `$HOME/.local` so an `npm install -g` lands somewhere the
+ * server user can write and Codeman's resolvers already search (`~/.local/bin`):
+ * - inside the Docker Compose container (`CODEMAN_IN_CONTAINER=1`), so installs survive an image
+ * update (the image's own prefix is image content);
+ * - on a native install whose npm global prefix is not writable by the server user (a system node
+ * under `/usr`, installed by root). Without this `npm install -g` died with EACCES (exit 243),
+ * e.g. DeepSeek's `npm install -g @deepseek-ai/dsh`. An explicit `NPM_CONFIG_PREFIX` the
+ * operator set is respected, and so is a prefix that is writable (nvm, `~/.npm-global`, ...).
*/
-export function installEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
+export function installEnv(
+ source: NodeJS.ProcessEnv = process.env,
+ prefixWritable: (env: NodeJS.ProcessEnv) => boolean = npmGlobalPrefixWritable
+): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
for (const [key, value] of Object.entries(source)) {
if (!key.startsWith('CODEMAN_')) env[key] = value;
@@ -225,6 +267,8 @@ export function installEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.Proc
// list, so npm-based installs are redirected there. curl|bash installers already target HOME.
if (source.CODEMAN_IN_CONTAINER === '1' && source.HOME) {
env.NPM_CONFIG_PREFIX = `${source.HOME}/.local`;
+ } else if (process.platform !== 'win32' && source.HOME && !source.NPM_CONFIG_PREFIX && !prefixWritable(env)) {
+ env.NPM_CONFIG_PREFIX = `${source.HOME}/.local`;
}
return env;
}
diff --git a/test/routes/cli-registry-routes.test.ts b/test/routes/cli-registry-routes.test.ts
index bc33b1f8..03fe2650 100644
--- a/test/routes/cli-registry-routes.test.ts
+++ b/test/routes/cli-registry-routes.test.ts
@@ -15,7 +15,12 @@ import { EventEmitter } from 'node:events';
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync, statSync } from 'node:fs';
import { dirname } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
-import { installEnv, registerCliRegistryRoutes, type CliListItem } from '../../src/web/routes/cli-registry-routes.js';
+import {
+ installEnv,
+ npmGlobalPrefixWritable,
+ registerCliRegistryRoutes,
+ type CliListItem,
+} from '../../src/web/routes/cli-registry-routes.js';
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
import { CreateSessionSchema } from '../../src/web/schemas.js';
import { buildSpawnCommandFromRegistry } from '../../src/session-cli-registry-bridge.js';
@@ -711,7 +716,7 @@ describe('registry writes are serialized and never clobber a file the reader wou
} finally {
delete process.env.CODEMAN_TEST_SECRET;
}
- expect(installEnv({ CODEMAN_PASSWORD: 'x', HOME: '/h' })).toEqual({ HOME: '/h' });
+ expect(installEnv({ CODEMAN_PASSWORD: 'x', HOME: '/h' }, () => true)).toEqual({ HOME: '/h' });
});
it('redirects npm installs to the persistent HOME inside the Compose container', () => {
@@ -719,6 +724,32 @@ describe('registry writes are serialized and never clobber a file the reader wou
installEnv({ CODEMAN_IN_CONTAINER: '1', HOME: '/home/codeman', NPM_CONFIG_PREFIX: '/opt/codeman-cli' })
).toEqual({ HOME: '/home/codeman', NPM_CONFIG_PREFIX: '/home/codeman/.local' });
});
+
+ it('redirects npm installs to ~/.local on a native install whose global prefix is not writable', () => {
+ // A system node under /usr: `npm install -g` as the server user dies with EACCES (exit 243).
+ expect(installEnv({ HOME: '/home/dev', CODEMAN_PASSWORD: 'x' }, () => false)).toEqual({
+ HOME: '/home/dev',
+ NPM_CONFIG_PREFIX: '/home/dev/.local',
+ });
+ });
+
+ it('leaves a writable, explicit or undeterminable npm prefix alone on a native install', () => {
+ expect(installEnv({ HOME: '/home/dev' }, () => true)).toEqual({ HOME: '/home/dev' });
+ // An operator-set prefix wins even if it is not writable: it is theirs to fix.
+ expect(installEnv({ HOME: '/home/dev', NPM_CONFIG_PREFIX: '/opt/npm' }, () => false)).toEqual({
+ HOME: '/home/dev',
+ NPM_CONFIG_PREFIX: '/opt/npm',
+ });
+ // No HOME means nowhere to redirect to.
+ expect(installEnv({ PATH: '/usr/bin' }, () => false)).toEqual({ PATH: '/usr/bin' });
+ });
+
+ it('npmGlobalPrefixWritable follows npm config and treats a probe failure as writable', () => {
+ // Real probe against this machine: must return a boolean and never throw.
+ expect(typeof npmGlobalPrefixWritable({ PATH: process.env.PATH })).toBe('boolean');
+ // npm not found on PATH: cannot tell, so do not redirect.
+ expect(npmGlobalPrefixWritable({ PATH: '/nonexistent' })).toBe(true);
+ });
});
/**
From 9c63c41283b3b30cba7f7e60e49b8dfaf96b3524 Mon Sep 17 00:00:00 2001
From: Devvyn <22340871+opticon454@users.noreply.github.com>
Date: Wed, 7 Oct 2026 13:53:06 +0800
Subject: [PATCH 03/76] feat(settings): add an Apply button that saves without
closing
Switches such as MCP server sync only unlock their controls once saved, and
Save closed the modal, so the user had to reopen Settings to continue. Apply
runs the same save, keeps the modal open and refreshes the dependent groups
(MCP sync, custom model endpoints, CLI management) in place.
---
.changeset/settings-apply-button.md | 5 ++
src/web/public/index.html | 2 +
src/web/public/settings-ui.js | 45 +++++++++++++-
src/web/public/styles.css | 6 ++
test/settings-apply-button.test.ts | 93 +++++++++++++++++++++++++++++
5 files changed, 148 insertions(+), 3 deletions(-)
create mode 100644 .changeset/settings-apply-button.md
create mode 100644 test/settings-apply-button.test.ts
diff --git a/.changeset/settings-apply-button.md b/.changeset/settings-apply-button.md
new file mode 100644
index 00000000..950394df
--- /dev/null
+++ b/.changeset/settings-apply-button.md
@@ -0,0 +1,5 @@
+---
+'aicodeman': minor
+---
+
+Settings has an Apply button next to Save. It saves the same way but keeps Settings open and refreshes the groups that depend on a saved value, so switching on MCP server sync (or custom model endpoints, or CLI management) brings up its controls straight away instead of needing a close-and-reopen. The MCP sync hint now says to Apply or Save first.
diff --git a/src/web/public/index.html b/src/web/public/index.html
index 335d3785..d28db067 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -1635,6 +1635,7 @@
Save; row-reverse keeps Save to the left of it on phones. -->
+
@@ -3037,6 +3038,7 @@
+
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js
index a419121b..a7c2f77a 100644
--- a/src/web/public/settings-ui.js
+++ b/src/web/public/settings-ui.js
@@ -1225,7 +1225,7 @@ Object.assign(CodemanApp.prototype, {
};
// Switched on in this modal but not saved yet: the routes would only answer "disabled".
if (!this._mcpSyncSavedOn) {
- show('Save settings to turn MCP sync on first, then reopen Settings to preview or sync.');
+ show('Apply or Save settings to turn MCP sync on first, then preview or sync.');
return;
}
if (apply && !confirm('Add missing MCP servers to every installed, enabled CLI\'s config file? Env values and headers on those servers are copied too.')) return;
@@ -2506,7 +2506,25 @@ Object.assign(CodemanApp.prototype, {
claudeEl.className = 'voice-provider-status' + (status?.available ? ' active' : '');
},
+ /**
+ * Apply button: the same save as Save, but the modal stays open and the groups that depend on
+ * a saved value (MCP sync, custom model endpoints, CLI management) are refreshed in place, so
+ * a switch that unlocks more settings needs no close-and-reopen. It is a wrapper rather than
+ * an option on saveAppSettings() so that function's signature (which tests locate by text)
+ * stays as it was.
+ */
+ async applyAppSettings() {
+ if (this._applyingSettings) return;
+ this._applyingSettings = true;
+ try {
+ await this.saveAppSettings();
+ } finally {
+ this._applyingSettings = false;
+ }
+ },
+
async saveAppSettings() {
+ const keepOpen = this._applyingSettings === true;
// Gesture overlay is injected at page render (server-side), so a change to it
// only takes effect on reload — remember the prior value to decide below.
const _prev = this.loadAppSettingsFromStorage();
@@ -2866,7 +2884,7 @@ Object.assign(CodemanApp.prototype, {
this.saveAppSettingsToStorage(settings);
const cb = document.getElementById('appSettingsTunnelEnabled');
if (cb) cb.checked = false;
- this.closeAppSettings();
+ if (!keepOpen) this.closeAppSettings();
return;
}
@@ -2881,7 +2899,7 @@ Object.assign(CodemanApp.prototype, {
if (webhookError) {
this.showToast(`Settings saved, but not the webhook: ${webhookError}`, 'warning');
} else {
- this.showToast('Settings saved', 'success');
+ this.showToast(keepOpen ? 'Settings applied' : 'Settings saved', 'success');
}
// Show tunnel-specific feedback if toggled on
@@ -2895,6 +2913,8 @@ Object.assign(CodemanApp.prototype, {
if (webhookError) {
document.getElementById('webhookGroup')?.scrollIntoView({ block: 'center' });
+ } else if (keepOpen) {
+ this._refreshSettingsAfterApply(settings);
} else {
this.closeAppSettings();
}
@@ -2916,6 +2936,25 @@ Object.assign(CodemanApp.prototype, {
}
},
+ /**
+ * After Apply: bring the groups whose contents depend on a SAVED value up to date without
+ * reopening the modal. openAppSettings does the same on open; this is the part of it that
+ * a save can change, without touching what the user is editing or the scroll position.
+ */
+ _refreshSettingsAfterApply(settings) {
+ // The MCP routes read the saved flag, so switching it on is only usable from now.
+ this._mcpSyncSavedOn = settings.mcpSyncEnabled === true;
+ const out = this.$('mcpSyncResult');
+ if (this._mcpSyncSavedOn && out && out.textContent.startsWith('Apply or Save settings')) {
+ out.style.display = 'none';
+ out.innerHTML = '';
+ }
+ this.applyMcpSyncVisibility();
+ this.applyCustomModelEndpointsVisibility();
+ this.applyCliManagementVisibility();
+ this._applyDoctorAdminGate();
+ },
+
// Load model configuration from server for the settings modal
async loadModelConfigForSettings() {
try {
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index df4d47bf..b5e7ead1 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -16974,6 +16974,12 @@ html[data-tab-orientation='vertical'] .home-sessions {
transform 0.1s ease;
}
+:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-save.set-head-apply {
+ background: transparent;
+ color: var(--text);
+ box-shadow: inset 0 0 0 1px var(--border);
+}
+
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-save:hover {
filter: brightness(1.08);
}
diff --git a/test/settings-apply-button.test.ts b/test/settings-apply-button.test.ts
new file mode 100644
index 00000000..c8e989e1
--- /dev/null
+++ b/test/settings-apply-button.test.ts
@@ -0,0 +1,93 @@
+/**
+ * @fileoverview Settings "Apply": saves like Save but keeps the modal open and refreshes the
+ * groups that depend on a saved value (MCP sync, custom model endpoints, CLI management).
+ */
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import vm from 'node:vm';
+import { describe, expect, it, vi } from 'vitest';
+
+const publicDir = resolve(import.meta.dirname, '../src/web/public');
+const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
+const settingsUi = readFileSync(resolve(publicDir, 'settings-ui.js'), 'utf8');
+
+function loadApp() {
+ const CodemanApp = function CodemanApp() {} as unknown as { prototype: Record };
+ const context = vm.createContext({
+ CodemanApp,
+ localStorage: { getItem: () => null, setItem: () => {} },
+ document: { getElementById: () => null, querySelectorAll: () => [], addEventListener: () => {} },
+ window: {},
+ console,
+ });
+ vm.runInContext(settingsUi, context, { filename: 'settings-ui.js' });
+ return new (CodemanApp as unknown as new () => Record)();
+}
+
+describe('Settings Apply button', () => {
+ it('sits next to Save in the footer and the phone header, wired to applyAppSettings', () => {
+ const start = html.indexOf('
');
+ const modal = html.slice(start, html.indexOf('', start));
+ expect(modal.match(/onclick="app\.applyAppSettings\(\)"/g)).toHaveLength(2);
+ expect(modal.match(/onclick="app\.saveAppSettings\(\)"/g)).toHaveLength(2);
+ });
+
+ it('flags the save as keep-open while it runs and clears the flag afterwards', async () => {
+ const app = loadApp();
+ const seen: boolean[] = [];
+ app.saveAppSettings = vi.fn(async () => {
+ seen.push(app._applyingSettings);
+ });
+ await app.applyAppSettings();
+ expect(seen).toEqual([true]);
+ expect(app._applyingSettings).toBe(false);
+ });
+
+ it('clears the flag even when the save throws, and ignores a second click mid-save', async () => {
+ const app = loadApp();
+ let release!: () => void;
+ app.saveAppSettings = vi.fn(() => new Promise((r) => (release = r)));
+ const first = app.applyAppSettings();
+ await app.applyAppSettings();
+ expect(app.saveAppSettings).toHaveBeenCalledTimes(1);
+ release();
+ await first;
+
+ app.saveAppSettings = vi.fn(async () => {
+ throw new Error('boom');
+ });
+ await expect(app.applyAppSettings()).rejects.toThrow('boom');
+ expect(app._applyingSettings).toBe(false);
+ });
+
+ it('refreshes the dependent groups from the saved values', () => {
+ const app = loadApp();
+ const out = {
+ textContent: 'Apply or Save settings to turn MCP sync on first',
+ style: { display: 'block' },
+ innerHTML: 'x',
+ };
+ app.$ = (id: string) => (id === 'mcpSyncResult' ? out : null);
+ app.applyMcpSyncVisibility = vi.fn();
+ app.applyCustomModelEndpointsVisibility = vi.fn();
+ app.applyCliManagementVisibility = vi.fn();
+ app._applyDoctorAdminGate = vi.fn();
+ app._mcpSyncSavedOn = false;
+
+ app._refreshSettingsAfterApply({ mcpSyncEnabled: true });
+
+ expect(app._mcpSyncSavedOn).toBe(true);
+ expect(out.style.display).toBe('none');
+ expect(app.applyMcpSyncVisibility).toHaveBeenCalled();
+ expect(app.applyCustomModelEndpointsVisibility).toHaveBeenCalled();
+ expect(app.applyCliManagementVisibility).toHaveBeenCalled();
+ });
+
+ it('only closes the modal on a plain Save', () => {
+ const body = settingsUi.slice(settingsUi.indexOf('\n async saveAppSettings() {'));
+ expect(body).toContain('const keepOpen = this._applyingSettings === true;');
+ expect(body).toMatch(
+ /else if \(keepOpen\) \{\s*this\._refreshSettingsAfterApply\(settings\);\s*\} else \{\s*this\.closeAppSettings\(\);/
+ );
+ });
+});
From 57d5c7b1c23b547bfc20c8005367192b8314ca02 Mon Sep 17 00:00:00 2001
From: Devvyn <22340871+opticon454@users.noreply.github.com>
Date: Fri, 9 Oct 2026 16:13:48 +0800
Subject: [PATCH 04/76] feat(mcp-sync): sync GitHub Copilot CLI's MCP servers
too
Copilot CLI keeps its user MCP list in ~/.copilot/mcp-config.json (COPILOT_HOME moves it) but is
not a Codeman run mode, so it has no registry entry. Add the copilot-json dialect (mcpServers,
tools ["*"], type local/http/sse, checked against `copilot mcp add` 1.0.94) and declare Copilot as
a sync-only target in src/mcp-sync-targets.ts, listed after the registry CLIs. A server switched
off with `copilot mcp disable` is recorded in settings.json (disabledMcpServers), not on the
entry: sync reads that list so it is not copied, and reports the target unreadable if the file
is not valid JSON instead of guessing.
---
.changeset/mcp-sync-copilot.md | 5 +
docs/api-reference.md | 2 +-
docs/cli-registry.md | 6 +-
docs/wiki/Settings-Reference.md | 2 +-
src/config/cli-registry/schema.ts | 1 +
src/config/cli-registry/types.ts | 8 +-
src/mcp-sync-targets.ts | 74 +++++++++++++++
src/mcp-sync.ts | 61 ++++++++++++
src/web/public/index.html | 6 +-
src/web/routes/mcp-sync-routes.ts | 13 ++-
test/mcp-sync-targets.test.ts | 54 +++++++++++
test/mcp-sync.test.ts | 151 ++++++++++++++++++++++++++++++
12 files changed, 372 insertions(+), 11 deletions(-)
create mode 100644 .changeset/mcp-sync-copilot.md
create mode 100644 src/mcp-sync-targets.ts
create mode 100644 test/mcp-sync-targets.test.ts
diff --git a/.changeset/mcp-sync-copilot.md b/.changeset/mcp-sync-copilot.md
new file mode 100644
index 00000000..e531f06b
--- /dev/null
+++ b/.changeset/mcp-sync-copilot.md
@@ -0,0 +1,5 @@
+---
+'aicodeman': minor
+---
+
+MCP server sync now includes GitHub Copilot CLI. If `copilot` is installed (or `~/.copilot/mcp-config.json` exists), its MCP servers are copied into your other CLIs and theirs into it, with the same rules as the others: only missing servers are added, nothing is edited or removed, a server you switched off with `copilot mcp disable` is not copied, and a file that receives env values or headers stays readable by you only. `COPILOT_HOME` is followed. Copilot is not a Codeman run mode, so it is declared as a sync-only target rather than a CLI-registry entry.
diff --git a/docs/api-reference.md b/docs/api-reference.md
index 47c22e0b..1bac2642 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -812,7 +812,7 @@ Copies MCP servers between the agent CLIs' own user-level config files (`docs/cl
Result (`data`):
- `applied` — `false` for the dry run.
-- `targets[]` — one per enabled CLI that declares an MCP config: `id`, `label`, `file`, `status`, `error?`, `servers` (names it already has), `added` (names added, or that would be), `skipped` (names its dialect cannot express, e.g. SSE for Codex and Antigravity).
+- `targets[]` — one per enabled CLI that declares an MCP config, plus GitHub Copilot CLI (`id: "copilot"`, a sync-only target that is not a run mode): `id`, `label`, `file`, `status`, `error?`, `servers` (names it already has), `added` (names added, or that would be), `skipped` (names its dialect cannot express, e.g. SSE for Codex and Antigravity).
- `status`: `ok`; `absent` (not installed and no config file, so not read or created); `skipped` (the CLI's relocation env var, e.g. `CODEX_HOME`, is set to a relative path in the server's environment, so its file cannot be located safely and is neither read nor written); `unreadable` (the file exists but cannot be parsed safely, so it is not written); `failed` (a read or write error, the file may be unchanged).
- `error` says why a target is not `ok`. A parse failure is reported by position only (`not valid TOML (line 3, column 21)`, `not valid JSON`), never with text from the file.
- `file` honours each CLI's own relocation env var as the server process sees it (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`); see `docs/cli-registry.md`.
diff --git a/docs/cli-registry.md b/docs/cli-registry.md
index 10e49c6b..b2407c87 100644
--- a/docs/cli-registry.md
+++ b/docs/cli-registry.md
@@ -265,9 +265,11 @@ A module-level const freezes at first import, and the failure is asymmetric: a C
## MCP server sync
-`capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity; every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`.
+`capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity (plus GitHub Copilot CLI as a sync-only target, below); every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`.
-`relocation` (`{ envVar, path }`) names the env var the CLI itself reads to move that file: claude `CLAUDE_CONFIG_DIR` (`.claude.json` under it), codex `CODEX_HOME` (`config.toml`), opencode `XDG_CONFIG_HOME` (`opencode/opencode.json`) and gemini `GEMINI_CLI_HOME` (`.gemini/settings.json`); antigravity follows `$HOME` only, so it declares none. The var is read from the SERVER process env at call time, which is the env the CLIs Codeman spawns inherit. An absolute value moves the file to `/`, an empty one counts as unset (as it does for each CLI), and anything else reports the target `skipped` with the reason instead of writing a file the CLI never reads. A per-session relocation (a session's own `CLAUDE_CONFIG_DIR` in `envOverrides`) is not followed: the sync only knows the server's environment.
+**Tools that are not run modes.** GitHub Copilot CLI keeps an MCP list worth syncing but Codeman does not launch it, so it has no registry entry. `src/mcp-sync-targets.ts` declares such tools as plain data (`MCP_SYNC_ONLY_TOOLS`: id, label, config path, dialect, relocation var, the binary whose presence means "installed"). They join the registry CLIs as sync targets (listed after them, so a registry CLI's definition wins a same-name difference), under the same rules: installed or already configured, otherwise `absent`. Copilot's dialect is `copilot-json` (`~/.copilot/mcp-config.json`, relocated by `COPILOT_HOME`; checked against `copilot mcp add` 1.0.94): `mcpServers`, each entry with `tools` (`["*"]` = all), `type` `local` | `http` | `sse`, `command`/`args`/`env` or `url`/`headers`. `copilot mcp disable` does not mark the entry: it lists the name under `disabledMcpServers` in `settings.json` beside the config. Sync reads that list (never writes it) so a disabled server is not copied, and reports the target `unreadable` if `settings.json` is not valid JSON rather than guessing.
+
+`relocation` (`{ envVar, path }`) names the env var the CLI itself reads to move that file: claude `CLAUDE_CONFIG_DIR` (`.claude.json` under it), codex `CODEX_HOME` (`config.toml`), opencode `XDG_CONFIG_HOME` (`opencode/opencode.json`) gemini `GEMINI_CLI_HOME` (`.gemini/settings.json`) and, for the sync-only Copilot CLI, `COPILOT_HOME` (`mcp-config.json`); antigravity follows `$HOME` only, so it declares none. The var is read from the SERVER process env at call time, which is the env the CLIs Codeman spawns inherit. An absolute value moves the file to `/`, an empty one counts as unset (as it does for each CLI), and anything else reports the target `skipped` with the reason instead of writing a file the CLI never reads. A per-session relocation (a session's own `CLAUDE_CONFIG_DIR` in `envOverrides`) is not followed: the sync only knows the server's environment.
The rules the module keeps and the tests pin: it only ADDS (a name already defined, in any shape, is never edited or removed; a same-name difference is reported as a conflict); a server switched off in its own CLI is not copied; it never writes a file it could not parse (opencode JSONC with comments, a TOML file with a duplicate table) and re-parses the new text before writing; codex TOML is read with a real parser (`smol-toml`), so CRLF files and inline tables are handled; names such as `__proto__` are ignored and every table keyed by an untrusted name has no prototype; a symlinked config is written through, not replaced; a file that receives `env`/`headers` is left `0600`; only one apply runs at a time; and its result carries server names only, never env values or headers, and never file text: a parse failure is reported by line and column, not by the parser's message (smol-toml prints a code frame of the offending lines and V8's JSON errors quote source, either of which can hold a secret). The schema restricts `path` and `relocation.path` to a relative path without `..`, since sync writes to it.
diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md
index ff31d1d3..81d9112a 100644
--- a/docs/wiki/Settings-Reference.md
+++ b/docs/wiki/Settings-Reference.md
@@ -142,7 +142,7 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
| Default Codex reasoning effort | Reasoning level for new local Codex sessions; empty uses Codex's own config. |
| Bypass approvals and sandbox | Starts new Codex sessions with `--dangerously-bypass-approvals-and-sandbox`. Read [Agent CLIs](Agent-CLIs) before enabling. |
| Animated status effects | Cosmetic. |
-| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and save, then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME` or `GEMINI_CLI_HOME` in Codeman's own environment is followed. |
+| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) and GitHub Copilot CLI has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and save, then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME` or `GEMINI_CLI_HOME` in Codeman's own environment is followed. |
### Notifications
diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts
index 5159a203..093e5c79 100644
--- a/src/config/cli-registry/schema.ts
+++ b/src/config/cli-registry/schema.ts
@@ -449,6 +449,7 @@ const capabilitiesSchema = z
'codex-toml',
'opencode-json',
'antigravity-json',
+ 'copilot-json',
] as const satisfies readonly McpConfigFormat[]),
// The env var the CLI reads to move the file, and the path under it (same no-traversal
// rule: sync writes there too). Resolved from the server env at call time, never here.
diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts
index 09cc3136..22d70928 100644
--- a/src/config/cli-registry/types.ts
+++ b/src/config/cli-registry/types.ts
@@ -105,7 +105,13 @@ export type ModelConfigResolverName = 'deepseek-route';
export type LaunchDefaultSettingKey = 'codexModel' | 'codexReasoningEffort';
/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */
-export type McpConfigFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json';
+export type McpConfigFormat =
+ | 'claude-json'
+ | 'gemini-json'
+ | 'codex-toml'
+ | 'opencode-json'
+ | 'antigravity-json'
+ | 'copilot-json';
export interface CliLaunch {
params: Record;
diff --git a/src/mcp-sync-targets.ts b/src/mcp-sync-targets.ts
new file mode 100644
index 00000000..a2292f6e
--- /dev/null
+++ b/src/mcp-sync-targets.ts
@@ -0,0 +1,74 @@
+/**
+ * @fileoverview MCP sync targets that are not Codeman run modes.
+ *
+ * `mcpSyncTargets()` (routes/mcp-sync-routes.ts) takes the registry's enabled CLIs that declare an
+ * `mcpConfig`. Some tools read an MCP server list worth keeping in step with the others but are not
+ * something Codeman launches, so they have no registry entry (and no id to branch on): GitHub
+ * Copilot CLI is the first. They are plain data here, take part only when installed or when their
+ * config file already exists (an absent tool is reported `absent`, never created), and sort after
+ * the registry CLIs, so when two definitions of a name differ the registry CLI's is the one copied.
+ *
+ * @module mcp-sync-targets
+ */
+
+import { accessSync, constants as fsConstants } from 'node:fs';
+import { homedir } from 'node:os';
+import { delimiter, join } from 'node:path';
+import type { McpConfigFormat } from './config/cli-registry/types.js';
+import type { McpSyncTarget } from './mcp-sync.js';
+
+export interface McpSyncOnlyTool {
+ id: string;
+ label: string;
+ /** Home-relative default location of the MCP config file. */
+ path: string;
+ format: McpConfigFormat;
+ /** The env var the tool reads to move its home, and the file under it. */
+ relocation?: { envVar: string; path: string };
+ /** The executable whose presence on this machine means the tool is installed. */
+ binary: string;
+}
+
+export const MCP_SYNC_ONLY_TOOLS: readonly McpSyncOnlyTool[] = [
+ {
+ id: 'copilot',
+ label: 'GitHub Copilot CLI',
+ path: '.copilot/mcp-config.json',
+ format: 'copilot-json',
+ // COPILOT_HOME replaces ~/.copilot (checked: `COPILOT_HOME= copilot mcp list` reads ).
+ relocation: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' },
+ binary: 'copilot',
+ },
+];
+
+/** `name` is an executable file in the server's PATH, `~/.local/bin` or `/usr/local/bin`. */
+export function binaryOnPath(name: string, env: Record = process.env): boolean {
+ const dirs = [
+ ...(env.PATH ?? '').split(delimiter).filter(Boolean),
+ join(homedir(), '.local', 'bin'),
+ '/usr/local/bin',
+ ];
+ return dirs.some((dir) => {
+ try {
+ accessSync(join(dir, name), fsConstants.X_OK);
+ return true;
+ } catch {
+ return false;
+ }
+ });
+}
+
+/** The sync-only tools as sync targets, skipping any id the registry already provides. */
+export function mcpSyncOnlyTargets(
+ taken: ReadonlySet,
+ isInstalled: (binary: string) => boolean = binaryOnPath
+): McpSyncTarget[] {
+ return MCP_SYNC_ONLY_TOOLS.filter((t) => !taken.has(t.id)).map((t) => ({
+ id: t.id,
+ label: t.label,
+ path: t.path,
+ format: t.format,
+ ...(t.relocation ? { relocation: t.relocation } : {}),
+ installed: isInstalled(t.binary),
+ }));
+}
diff --git a/src/mcp-sync.ts b/src/mcp-sync.ts
index 1e092ad0..14a9f67a 100644
--- a/src/mcp-sync.ts
+++ b/src/mcp-sync.ts
@@ -284,6 +284,29 @@ function toOpencode(s: McpServer): Record {
return { type: 'remote', url: s.url, ...(s.headers ? { headers: s.headers } : {}), enabled: true };
}
+/**
+ * GitHub Copilot CLI (`copilot mcp add`): `~/.copilot/mcp-config.json`, `mcpServers`. A stdio server is
+ * `type: "local"`; every entry carries `tools` (`["*"]` = all). Whether a server is switched off is NOT in
+ * this file: `copilot mcp disable` records the name in `settings.json` beside it (`disabledMcpServers`).
+ */
+function fromCopilot(raw: unknown): McpServer | null {
+ if (!isRecord(raw)) return null;
+ if ((raw.type === 'http' || raw.type === 'sse') && typeof raw.url === 'string') {
+ return clean({ transport: raw.type, url: raw.url, headers: strMap(raw.headers) });
+ }
+ if ((raw.type === undefined || raw.type === 'local' || raw.type === 'stdio') && typeof raw.command === 'string') {
+ return clean({ transport: 'stdio', command: raw.command, args: strArr(raw.args), env: strMap(raw.env) });
+ }
+ return null;
+}
+
+function toCopilot(s: McpServer): Record {
+ if (s.transport === 'stdio') {
+ return { tools: ['*'], type: 'local', command: s.command, args: s.args ?? [], ...(s.env ? { env: s.env } : {}) };
+ }
+ return { tools: ['*'], type: s.transport, url: s.url, ...(s.headers ? { headers: s.headers } : {}) };
+}
+
interface JsonDialect {
/** Key holding the server table. */
key: string;
@@ -291,12 +314,23 @@ interface JsonDialect {
to(s: McpServer): Record | null;
/** Top-level keys to seed when creating the file from nothing. */
seed?: Record;
+ /**
+ * A file beside the config that lists the names of servers the user switched off (the switch is
+ * not stored on the server entry). Read, never written.
+ */
+ disabledIn?: { file: string; key: string };
}
const JSON_DIALECTS: Record, JsonDialect> = {
'claude-json': { key: 'mcpServers', from: fromClaude, to: toClaude },
'gemini-json': { key: 'mcpServers', from: fromGemini, to: toGemini },
'antigravity-json': { key: 'mcpServers', from: fromAntigravity, to: toAntigravity },
+ 'copilot-json': {
+ key: 'mcpServers',
+ from: fromCopilot,
+ to: toCopilot,
+ disabledIn: { file: 'settings.json', key: 'disabledMcpServers' },
+ },
'opencode-json': {
key: 'mcp',
from: fromOpencode,
@@ -558,6 +592,32 @@ function resolveFile(
return { file: join(dir, rel.path) };
}
+/**
+ * Mark the servers a CLI keeps switched off in a companion file (`JsonDialect.disabledIn`) as
+ * disabled, so they are not copied. If that file cannot be read as intended the target is
+ * reported unreadable rather than guessing: a guess could switch a server on everywhere.
+ */
+async function applyCompanionDisabled(format: McpFormat, file: string, servers: McpServerMap): Promise {
+ if (format === 'codex-toml') return;
+ const companion = JSON_DIALECTS[format].disabledIn;
+ if (!companion) return;
+ const text = await readText(join(dirname(file), companion.file));
+ if (text === null || !text.trim()) return;
+ let doc: unknown;
+ try {
+ doc = JSON.parse(text);
+ } catch {
+ throw new McpConfigError(
+ `${companion.file} next to the config is not valid JSON, so which servers are switched off is unknown`
+ );
+ }
+ const list = isRecord(doc) ? doc[companion.key] : undefined;
+ if (list === undefined) return;
+ const names = strArr(list);
+ if (!names) throw new McpConfigError(`"${companion.key}" in ${companion.file} is not a list of names`);
+ for (const n of names) if (n in servers) servers[n] = { ...servers[n], disabled: true };
+}
+
let applying = false;
/**
@@ -611,6 +671,7 @@ async function run(targets: McpSyncTarget[], opts: McpSyncOptions, unsupported:
continue;
}
const parsed = parseConfig(s.t.format, await readText(s.file));
+ await applyCompanionDisabled(s.t.format, s.file, parsed.servers);
s.servers = parsed.servers;
s.names = parsed.names;
s.res.servers = [...parsed.names];
diff --git a/src/web/public/index.html b/src/web/public/index.html
index 335d3785..e486a02c 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -2644,17 +2644,17 @@
MCP servers
synced
-
+
Enable MCP server sync
- Adds a control that copies MCP servers between your enabled CLIs by writing their own config files. Off by default: this changes other tools' configuration, not just Codeman's.
+ Adds a control that copies MCP servers between your enabled CLIs, and GitHub Copilot CLI if it is installed, by writing their own config files. Off by default: this changes other tools' configuration, not just Codeman's.
Sync MCP servers across CLIs
- Copies each installed, enabled CLI's MCP servers into the others. Only adds missing servers; never edits, removes or copies a server you switched off. Env values and headers are copied too, so a file that receives them is left readable by you only. The previous file is kept as .codeman-bak (overwritten by each sync). A config dir moved by the CLI's own env var (CODEX_HOME, CLAUDE_CONFIG_DIR, XDG_CONFIG_HOME, GEMINI_CLI_HOME) is followed as Codeman's server sees it; a per-session override is not.
+ Copies each installed, enabled CLI's MCP servers (and GitHub Copilot CLI's) into the others. Only adds missing servers; never edits, removes or copies a server you switched off. Env values and headers are copied too, so a file that receives them is left readable by you only. The previous file is kept as .codeman-bak (overwritten by each sync). A config dir moved by the CLI's own env var (CODEX_HOME, CLAUDE_CONFIG_DIR, XDG_CONFIG_HOME, GEMINI_CLI_HOME, COPILOT_HOME) is followed as Codeman's server sees it; a per-session override is not.
diff --git a/src/web/routes/mcp-sync-routes.ts b/src/web/routes/mcp-sync-routes.ts
index 51f8d7ae..f6812568 100644
--- a/src/web/routes/mcp-sync-routes.ts
+++ b/src/web/routes/mcp-sync-routes.ts
@@ -10,7 +10,8 @@
* only, never env values, headers or file content (a parse failure is reported by position).
*
* A CLI takes part when it is ENABLED in the registry, declares an `mcpConfig`, and is installed
- * or already has its config file; one that is enabled but absent from the machine is reported
+ * or already has its config file (Copilot CLI, which is not a registry CLI, takes part when installed
+ * or when its config file exists); one that is enabled but absent from the machine is reported
* `absent` and never created. Its file is located with this process's env (the env the CLIs
* Codeman spawns inherit), so a relocation var such as `CODEX_HOME` is followed.
*/
@@ -28,6 +29,7 @@ import { isMultiUserMode } from '../../config/multiuser.js';
import { enabledClis } from '../../config/cli-registry/registry.js';
import { isCliEntryInstalled, probeStockCliAvailability } from '../../utils/cli-installed-probes.js';
import { McpSyncBusyError, syncMcpServers, type McpSyncTarget } from '../../mcp-sync.js';
+import { mcpSyncOnlyTargets } from '../../mcp-sync-targets.js';
/** Default OFF, same shape as `readCliManagementEnabled`: read fresh so a toggle applies at once. */
export async function readMcpSyncEnabled(): Promise {
@@ -35,9 +37,13 @@ export async function readMcpSyncEnabled(): Promise {
return settings.mcpSyncEnabled === true;
}
-/** Enabled CLIs that declare an MCP config file, in registry order (first definition wins). */
+/**
+ * Enabled CLIs that declare an MCP config file, in registry order (first definition wins), then the
+ * sync-only tools (src/mcp-sync-targets.ts: Copilot CLI), which come last so a registry CLI's
+ * definition wins a same-name difference.
+ */
export function mcpSyncTargets(availability: Record): McpSyncTarget[] {
- return enabledClis()
+ const registry = enabledClis()
.filter((e) => e.capabilities.mcpConfig)
.sort((a, b) => a.order - b.order)
.map((e) => ({
@@ -46,6 +52,7 @@ export function mcpSyncTargets(availability: Record): McpSyncTa
...e.capabilities.mcpConfig!,
installed: isCliEntryInstalled(e, availability),
}));
+ return [...registry, ...mcpSyncOnlyTargets(new Set(registry.map((t) => t.id)))];
}
/**
diff --git a/test/mcp-sync-targets.test.ts b/test/mcp-sync-targets.test.ts
new file mode 100644
index 00000000..03993255
--- /dev/null
+++ b/test/mcp-sync-targets.test.ts
@@ -0,0 +1,54 @@
+// @vitest-environment node
+import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { binaryOnPath, MCP_SYNC_ONLY_TOOLS, mcpSyncOnlyTargets } from '../src/mcp-sync-targets.js';
+import { mcpSyncTargets } from '../src/web/routes/mcp-sync-routes.js';
+
+describe('sync-only MCP targets (tools that are not Codeman run modes)', () => {
+ it('declares GitHub Copilot CLI with its config file, dialect and relocation var', () => {
+ const t = mcpSyncOnlyTargets(new Set(), () => true).find((x) => x.id === 'copilot')!;
+ expect(t).toMatchObject({
+ label: 'GitHub Copilot CLI',
+ path: '.copilot/mcp-config.json',
+ format: 'copilot-json',
+ relocation: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' },
+ installed: true,
+ });
+ });
+
+ it('asks about the declared binary, and reports it not installed when it is missing', () => {
+ const asked: string[] = [];
+ const t = mcpSyncOnlyTargets(new Set(), (b) => (asked.push(b), false));
+ expect(asked).toEqual(MCP_SYNC_ONLY_TOOLS.map((x) => x.binary));
+ expect(t.every((x) => x.installed === false)).toBe(true);
+ });
+
+ it('yields to a registry CLI that already uses the id', () => {
+ expect(mcpSyncOnlyTargets(new Set(['copilot']), () => true)).toEqual([]);
+ });
+
+ it('comes after the registry CLIs in the targets the route syncs', () => {
+ const ids = mcpSyncTargets({}).map((t) => t.id);
+ expect(ids).toContain('copilot');
+ expect(ids.indexOf('copilot')).toBe(ids.length - 1);
+ });
+});
+
+describe('binaryOnPath', () => {
+ let dir: string;
+ beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), 'bin-on-path-'));
+ });
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
+
+ it('finds an executable in PATH and ignores a non-executable file of the same name', () => {
+ writeFileSync(join(dir, 'fake-tool'), '#!/bin/sh\n');
+ chmodSync(join(dir, 'fake-tool'), 0o644);
+ expect(binaryOnPath('fake-tool', { PATH: dir })).toBe(false);
+ chmodSync(join(dir, 'fake-tool'), 0o755);
+ expect(binaryOnPath('fake-tool', { PATH: dir })).toBe(true);
+ expect(binaryOnPath('no-such-tool-xyz', { PATH: dir })).toBe(false);
+ });
+});
diff --git a/test/mcp-sync.test.ts b/test/mcp-sync.test.ts
index e3907095..9d552223 100644
--- a/test/mcp-sync.test.ts
+++ b/test/mcp-sync.test.ts
@@ -585,3 +585,154 @@ describe('relocated config dirs (the CLI reads its file somewhere else)', () =>
}
});
});
+
+describe('GitHub Copilot CLI (copilot-json)', () => {
+ const COPILOT: McpSyncTarget = {
+ id: 'copilot',
+ label: 'GitHub Copilot CLI',
+ path: '.copilot/mcp-config.json',
+ format: 'copilot-json',
+ relocation: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' },
+ installed: true,
+ };
+ const withCopilot = (...ids: string[]) => [...only(...ids), COPILOT];
+
+ // Captured from `copilot mcp add` (1.0.94) into a throwaway COPILOT_HOME.
+ const REAL = JSON.stringify({
+ mcpServers: {
+ h1: {
+ tools: ['*'],
+ type: 'http',
+ url: 'https://example.invalid/mcp',
+ headers: { Authorization: 'Bearer x', 'X-A': 'b' },
+ },
+ s1: { tools: ['*'], type: 'sse', url: 'https://example.invalid/sse' },
+ l1: { tools: ['*'], type: 'local', command: 'node', args: ['server.js', '--flag'], env: { K: 'V', K2: 'V2' } },
+ l2: { tools: ['a', 'b'], type: 'local', command: 'npx', args: ['-y', 'pkg'] },
+ },
+ });
+
+ it('reads what `copilot mcp add` writes: local, http and sse, ignoring the tools filter', () => {
+ const servers = parseServers('copilot-json', REAL);
+ expect(servers.h1).toEqual({
+ transport: 'http',
+ url: 'https://example.invalid/mcp',
+ headers: { Authorization: 'Bearer x', 'X-A': 'b' },
+ });
+ expect(servers.s1).toEqual({ transport: 'sse', url: 'https://example.invalid/sse' });
+ expect(servers.l1).toEqual({
+ transport: 'stdio',
+ command: 'node',
+ args: ['server.js', '--flag'],
+ env: { K: 'V', K2: 'V2' },
+ });
+ expect(servers.l2).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', 'pkg'] });
+ });
+
+ it('writes servers the way `copilot mcp add` does: tools ["*"], type local/http/sse', () => {
+ const out = JSON.parse(
+ addServers('copilot-json', null, {
+ fs: { transport: 'stdio', command: 'npx', args: ['-y', 'fs'], env: { T: '1' } },
+ web: { transport: 'http', url: 'https://w.test/mcp', headers: { A: 'b' } },
+ live: { transport: 'sse', url: 'https://l.test/sse' },
+ })
+ );
+ expect(out.mcpServers.fs).toEqual({
+ tools: ['*'],
+ type: 'local',
+ command: 'npx',
+ args: ['-y', 'fs'],
+ env: { T: '1' },
+ });
+ expect(out.mcpServers.web).toEqual({ tools: ['*'], type: 'http', url: 'https://w.test/mcp', headers: { A: 'b' } });
+ expect(out.mcpServers.live).toEqual({ tools: ['*'], type: 'sse', url: 'https://l.test/sse' });
+ });
+
+ it('keeps an existing server, its tools filter and every other key when adding', () => {
+ const out = JSON.parse(
+ addServers('copilot-json', JSON.stringify({ other: 1, mcpServers: JSON.parse(REAL).mcpServers }), {
+ l2: { transport: 'stdio', command: 'changed' },
+ n: { transport: 'stdio', command: 'x' },
+ })
+ );
+ expect(out.other).toBe(1);
+ expect(out.mcpServers.l2.tools).toEqual(['a', 'b']);
+ expect(out.mcpServers.l2.command).toBe('npx');
+ expect(out.mcpServers.n.type).toBe('local');
+ });
+
+ it('copies between Copilot and another CLI in both directions, with sse expressed', async () => {
+ put('.claude.json', JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'] } } }));
+ put('.copilot/mcp-config.json', REAL);
+ const r = await syncMcpServers(withCopilot('claude'), { apply: true, home });
+ expect(result(r, 'copilot').added).toEqual(['fs']);
+ expect(result(r, 'claude').added.sort()).toEqual(['h1', 'l1', 'l2', 's1']);
+ expect(JSON.parse(get('.copilot/mcp-config.json')).mcpServers.fs).toMatchObject({
+ type: 'local',
+ command: 'npx',
+ tools: ['*'],
+ });
+ expect(JSON.parse(get('.claude.json')).mcpServers.s1).toMatchObject({ type: 'sse' });
+ // second run: nothing left to do
+ const again = await syncMcpServers(withCopilot('claude'), { apply: true, home });
+ expect(again.targets.every((t) => t.added.length === 0)).toBe(true);
+ });
+
+ it('does not copy a server switched off with `copilot mcp disable` (it lives in settings.json)', async () => {
+ put('.copilot/mcp-config.json', REAL);
+ put('.copilot/settings.json', JSON.stringify({ disabledMcpServers: ['l2'], theme: 'dark' }));
+ put('.claude.json', JSON.stringify({ mcpServers: {} }));
+ const r = await syncMcpServers(withCopilot('claude'), { apply: true, home });
+ expect(result(r, 'claude').added.sort()).toEqual(['h1', 'l1', 's1']);
+ expect(JSON.parse(get('.claude.json')).mcpServers.l2).toBeUndefined();
+ expect(r.disabled).toEqual(['l2']);
+ // settings.json is only ever read
+ expect(JSON.parse(get('.copilot/settings.json'))).toEqual({ disabledMcpServers: ['l2'], theme: 'dark' });
+ });
+
+ it('a name switched off in Copilot but live in another CLI is still synced from the live one', async () => {
+ put('.copilot/mcp-config.json', REAL);
+ put('.copilot/settings.json', JSON.stringify({ disabledMcpServers: ['l2'] }));
+ put('.claude.json', JSON.stringify({ mcpServers: { l2: { type: 'stdio', command: 'npx', args: ['-y', 'pkg'] } } }));
+ const r = await syncMcpServers(withCopilot('claude'), { apply: false, home });
+ expect(r.disabled).toEqual([]);
+ expect(result(r, 'claude').added.sort()).toEqual(['h1', 'l1', 's1']);
+ });
+
+ it('reports the target unreadable, and writes nothing, when settings.json is not valid JSON', async () => {
+ put('.copilot/mcp-config.json', REAL);
+ put('.copilot/settings.json', '{ "disabledMcpServers": ["l2", "SECRET-NAME" ');
+ put('.claude.json', JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx' } } }));
+ const r = await syncMcpServers(withCopilot('claude'), { apply: true, home });
+ expect(result(r, 'copilot').status).toBe('unreadable');
+ expect(result(r, 'copilot').error).toContain('settings.json');
+ expect(result(r, 'copilot').error).not.toContain('SECRET-NAME');
+ expect(get('.copilot/mcp-config.json')).toBe(REAL);
+ });
+
+ it('follows COPILOT_HOME and never touches ~/.copilot then', async () => {
+ put('.claude.json', JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'] } } }));
+ const copilotHome = join(home, 'elsewhere/copilot');
+ const r = await syncMcpServers(withCopilot('claude'), { apply: true, home, env: { COPILOT_HOME: copilotHome } });
+ expect(result(r, 'copilot').file).toBe(join(copilotHome, 'mcp-config.json'));
+ expect(JSON.parse(readFileSync(join(copilotHome, 'mcp-config.json'), 'utf8')).mcpServers.fs.type).toBe('local');
+ expect(existsSync(join(home, '.copilot'))).toBe(false);
+ });
+
+ it('is left alone when it is neither installed nor configured', async () => {
+ put('.claude.json', JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx' } } }));
+ const r = await syncMcpServers([...only('claude'), { ...COPILOT, installed: false }], { apply: true, home });
+ expect(result(r, 'copilot').status).toBe('absent');
+ expect(existsSync(join(home, '.copilot'))).toBe(false);
+ });
+
+ it('keeps the env values of a copied server out of the result and the file private', async () => {
+ put(
+ '.claude.json',
+ JSON.stringify({ mcpServers: { s: { type: 'stdio', command: 'x', env: { TOKEN: 'hunter2' } } } })
+ );
+ const r = await syncMcpServers(withCopilot('claude'), { apply: true, home });
+ expect(JSON.stringify(r)).not.toContain('hunter2');
+ expect(statSync(join(home, '.copilot/mcp-config.json')).mode & 0o077).toBe(0);
+ });
+});
From 6d862d5323763ca6e48e5d8e20d1ad7723d86450 Mon Sep 17 00:00:00 2001
From: Randalix
Date: Thu, 8 Oct 2026 14:37:46 +0200
Subject: [PATCH 05/76] refactor(cli): one credential reader for every client
of the API
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`readCodemanEnv()` in cli.ts and `parseEnvFile()`/`readCodemanCredentials()` in
tui-client.ts were two copies of the same `.env` reader (the TUI's said so in a
comment), and `codeman agent` was about to need a third. They move into
`src/codeman-credentials.ts`; `codeman attach` and the TUI read from it, and the
TUI keeps re-exporting its names so nothing that imports them changes.
The lookup order is one pure function, `credentialsFrom(env, fileEnv)`: each
field from the environment, then the file, username defaulting to `admin` — the
order attach and the TUI already used. `readCodemanCredentials` takes the
environment as a parameter so a caller with its own (the agent CLI's guard) gets
the same answer. The parser now also tolerates an `export ` prefix, which the
agent skill's preamble already accepted in the same file.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
src/cli.ts | 31 ++-----------
src/codeman-credentials.ts | 75 ++++++++++++++++++++++++++++++++
src/tui/tui-client.ts | 56 ++++--------------------
test/codeman-credentials.test.ts | 61 ++++++++++++++++++++++++++
4 files changed, 148 insertions(+), 75 deletions(-)
create mode 100644 src/codeman-credentials.ts
create mode 100644 test/codeman-credentials.test.ts
diff --git a/src/cli.ts b/src/cli.ts
index 1083f777..0622087e 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -15,6 +15,7 @@ import { existsSync, readFileSync } from 'node:fs';
import { isAbsolute, join } from 'node:path';
import { homedir } from 'node:os';
import { dataPath } from './config/instance.js';
+import { readCodemanCredentials } from './codeman-credentials.js';
import { casePath } from './config/cases-dir.js';
import { assertValidBasePath } from './config/base-path.js';
import { installAgentSkillInto, removeAgentSkillFrom, type AgentSkillApplyResult } from './hooks-config.js';
@@ -42,32 +43,8 @@ function makeAttachmentMagicLink(filePath: string): string {
return `codeman://attach?path=${encodeURIComponent(filePath)}`;
}
-function readCodemanEnv(): Record {
- const envPath = dataPath('.env');
- try {
- const text = readFileSync(envPath, 'utf-8');
- const result: Record = {};
- for (const rawLine of text.split(/\r?\n/)) {
- const line = rawLine.trim();
- if (!line || line.startsWith('#')) continue;
- const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
- if (!match) continue;
- let value = match[2].trim();
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
- value = value.slice(1, -1);
- }
- result[match[1]] = value;
- }
- return result;
- } catch {
- return {};
- }
-}
-
async function postAttachment(apiUrl: string, sessionId: string, filePath: string): Promise {
- const envFile = readCodemanEnv();
- const username = process.env.CODEMAN_USERNAME || envFile.CODEMAN_USERNAME || 'admin';
- const password = process.env.CODEMAN_PASSWORD || envFile.CODEMAN_PASSWORD;
+ const { username, password } = readCodemanCredentials();
const url = new URL(`/api/sessions/${encodeURIComponent(sessionId)}/attachments`, apiUrl);
const body = JSON.stringify({ path: filePath });
const transport = url.protocol === 'https:' ? https : http;
@@ -641,9 +618,7 @@ function probeWebServerAt(base: string): Promise {
} catch {
return Promise.resolve(null);
}
- const envFile = readCodemanEnv();
- const username = process.env.CODEMAN_USERNAME || envFile.CODEMAN_USERNAME || 'admin';
- const password = process.env.CODEMAN_PASSWORD || envFile.CODEMAN_PASSWORD;
+ const { username, password } = readCodemanCredentials();
const transport = url.protocol === 'https:' ? https : http;
const headers: Record = { Accept: 'application/json' };
if (password) {
diff --git a/src/codeman-credentials.ts b/src/codeman-credentials.ts
new file mode 100644
index 00000000..fd18dfc4
--- /dev/null
+++ b/src/codeman-credentials.ts
@@ -0,0 +1,75 @@
+/**
+ * @fileoverview Credentials for a client of this Codeman instance's own API.
+ *
+ * Env first, the data dir's `.env` as the fallback — the hand-authored file
+ * `codeman attach`, `codeman tui` and `codeman agent` all read. One reader, so the
+ * three clients cannot drift on quoting, comments or the default username.
+ *
+ * @module codeman-credentials
+ */
+import { readFileSync } from 'node:fs';
+import { dataPath } from './config/instance.js';
+
+export interface CodemanCredentials {
+ username: string;
+ /** Absent when no password is configured (or only the server's environment has it). */
+ password?: string;
+}
+
+/**
+ * Parse a `KEY=value` env file: blank lines and `#` comments skipped, an `export `
+ * prefix tolerated (the file is hand-authored, often sourced by a shell too), one
+ * layer of matching quotes stripped, anything that is not an assignment ignored.
+ */
+export function parseEnvFile(text: string): Record {
+ const result: Record = {};
+ for (const rawLine of text.split(/\r?\n/)) {
+ const line = rawLine.trim();
+ if (!line || line.startsWith('#')) continue;
+ const match = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
+ if (!match) continue;
+ let value = match[2].trim();
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
+ value = value.slice(1, -1);
+ }
+ result[match[1]] = value;
+ }
+ return result;
+}
+
+/** The data dir's `.env`, parsed. Absent or unreadable means `{}`. */
+export function readCodemanEnvFile(envFilePath: string = dataPath('.env')): Record {
+ try {
+ return parseEnvFile(readFileSync(envFilePath, 'utf-8'));
+ } catch {
+ return {};
+ }
+}
+
+/**
+ * The lookup order every client uses, per field: the environment, then the `.env`
+ * file, then (username only) `admin`. Pure, so a caller with its own environment
+ * object (`codeman agent`'s guard takes one for testability) gets the same answer.
+ */
+export function credentialsFrom(env: NodeJS.ProcessEnv, fileEnv: Record): CodemanCredentials {
+ const username = env.CODEMAN_USERNAME || fileEnv.CODEMAN_USERNAME || 'admin';
+ const password = env.CODEMAN_PASSWORD || fileEnv.CODEMAN_PASSWORD;
+ return password ? { username, password } : { username };
+}
+
+/**
+ * Credentials for the API. No password means no auth is configured, or the user has
+ * it only in the server's environment, in which case the API answers 401.
+ */
+export function readCodemanCredentials(
+ envFilePath: string = dataPath('.env'),
+ env: NodeJS.ProcessEnv = process.env
+): CodemanCredentials {
+ return credentialsFrom(env, readCodemanEnvFile(envFilePath));
+}
+
+/** `Authorization` header value, or undefined when there is no password to send. */
+export function basicAuthHeader(credentials: CodemanCredentials): string | undefined {
+ if (!credentials.password) return undefined;
+ return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
+}
diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts
index 56e515db..b90863cc 100644
--- a/src/tui/tui-client.ts
+++ b/src/tui/tui-client.ts
@@ -48,6 +48,12 @@ import https from 'node:https';
import { hostname as osHostname } from 'node:os';
import { promisify } from 'node:util';
import { CODEMAN_INSTANCE, dataPath, resolveTmuxSocketName } from '../config/instance.js';
+import {
+ basicAuthHeader,
+ parseEnvFile,
+ readCodemanCredentials,
+ type CodemanCredentials,
+} from '../codeman-credentials.js';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import { probeServer } from '../daemon-control.js';
import { getErrorMessage } from '../types/api.js';
@@ -281,53 +287,9 @@ export function tuiServerCandidates(env: { apiUrl?: string; port?: string | numb
return [`https://127.0.0.1:${port}`, `http://127.0.0.1:${port}`];
}
-/**
- * Parse a `KEY=value` env file. Mirrors `readCodemanEnv()` in `cli.ts`: blank
- * lines and `#` comments skipped, one layer of matching quotes stripped.
- */
-export function parseEnvFile(text: string): Record {
- const result: Record = {};
- for (const rawLine of text.split(/\r?\n/)) {
- const line = rawLine.trim();
- if (!line || line.startsWith('#')) continue;
- const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
- if (!match) continue;
- let value = match[2].trim();
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
- value = value.slice(1, -1);
- }
- result[match[1]] = value;
- }
- return result;
-}
-
-export interface TuiCredentials {
- username: string;
- password?: string;
-}
-
-/**
- * Credentials for the API, env first and the data dir's `.env` as the fallback,
- * exactly like the `codeman attach` path. No password means no auth is
- * configured (or the user has it only in the server's environment, in which
- * case the API answers 401 and `connect()` reports `authRequired`).
- */
-export function readCodemanCredentials(envFilePath = dataPath('.env')): TuiCredentials {
- let fileEnv: Record = {};
- try {
- fileEnv = parseEnvFile(readFileSync(envFilePath, 'utf-8'));
- } catch {
- /* absent or unreadable: env-only */
- }
- const username = process.env.CODEMAN_USERNAME || fileEnv.CODEMAN_USERNAME || 'admin';
- const password = process.env.CODEMAN_PASSWORD || fileEnv.CODEMAN_PASSWORD;
- return password ? { username, password } : { username };
-}
-
-export function basicAuthHeader(credentials: TuiCredentials): string | undefined {
- if (!credentials.password) return undefined;
- return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
-}
+// One credential reader for every client of the API (attach, tui, agent).
+export { parseEnvFile, readCodemanCredentials, basicAuthHeader };
+export type TuiCredentials = CodemanCredentials;
// ─────────────────────────────────────────────────────────────────────────────
// Degraded mode
diff --git a/test/codeman-credentials.test.ts b/test/codeman-credentials.test.ts
new file mode 100644
index 00000000..44be6612
--- /dev/null
+++ b/test/codeman-credentials.test.ts
@@ -0,0 +1,61 @@
+/**
+ * @fileoverview The one credential reader `codeman attach`, `codeman tui` and
+ * `codeman agent` share: env first, the data dir's `.env` as the fallback.
+ */
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { basicAuthHeader, readCodemanCredentials, readCodemanEnvFile } from '../src/codeman-credentials.js';
+
+describe('readCodemanCredentials', () => {
+ let dir: string;
+ const saved = { user: process.env.CODEMAN_USERNAME, pass: process.env.CODEMAN_PASSWORD };
+
+ beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), 'codeman-cred-'));
+ delete process.env.CODEMAN_USERNAME;
+ delete process.env.CODEMAN_PASSWORD;
+ });
+
+ afterEach(() => {
+ rmSync(dir, { recursive: true, force: true });
+ if (saved.user === undefined) delete process.env.CODEMAN_USERNAME;
+ else process.env.CODEMAN_USERNAME = saved.user;
+ if (saved.pass === undefined) delete process.env.CODEMAN_PASSWORD;
+ else process.env.CODEMAN_PASSWORD = saved.pass;
+ });
+
+ it('falls back to the .env file, quotes and an export prefix stripped, default user admin', () => {
+ const env = join(dir, '.env');
+ writeFileSync(env, '# a comment\nnot an assignment\nexport CODEMAN_PASSWORD="hunter2"\n');
+ expect(readCodemanCredentials(env)).toEqual({ username: 'admin', password: 'hunter2' });
+ });
+
+ it('prefers the environment over the file', () => {
+ const env = join(dir, '.env');
+ writeFileSync(env, 'CODEMAN_USERNAME=file\nCODEMAN_PASSWORD=file-pass\n');
+ process.env.CODEMAN_USERNAME = 'envuser';
+ process.env.CODEMAN_PASSWORD = 'env-pass';
+ expect(readCodemanCredentials(env)).toEqual({ username: 'envuser', password: 'env-pass' });
+ });
+
+ it('an absent file means no password, and no header to send', () => {
+ const creds = readCodemanCredentials(join(dir, 'missing'));
+ expect(creds).toEqual({ username: 'admin' });
+ expect(basicAuthHeader(creds)).toBeUndefined();
+ expect(readCodemanEnvFile(join(dir, 'missing'))).toEqual({});
+ });
+
+ it('takes an explicit environment, field by field', () => {
+ const env = join(dir, '.env');
+ writeFileSync(env, 'CODEMAN_USERNAME=joe\n');
+ expect(readCodemanCredentials(env, { CODEMAN_PASSWORD: 'pw' })).toEqual({ username: 'joe', password: 'pw' });
+ });
+
+ it('builds a Basic header from a password', () => {
+ expect(basicAuthHeader({ username: 'joe', password: 'pw' })).toBe(
+ `Basic ${Buffer.from('joe:pw').toString('base64')}`
+ );
+ });
+});
From 0481db569d82a62bf7297e266d4a940369ffbc37 Mon Sep 17 00:00:00 2001
From: Randalix
Date: Thu, 8 Oct 2026 15:00:29 +0200
Subject: [PATCH 06/76] =?UTF-8?q?feat(cli):=20add=20`codeman=20agent`=20?=
=?UTF-8?q?=E2=80=94=20session-to-session=20verbs=20for=20every=20CLI=20mo?=
=?UTF-8?q?de?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The agent skill teaches the session verbs to claude only (Codeman seeds its
preamble for local claude sessions). An opencode, codex, pi or gemini agent has
the same environment — CODEMAN_MUX, CODEMAN_SESSION_ID and CODEMAN_API_URL are
exported into every pane — and nothing that teaches it the verbs, so
`codeman agent ls|spawn|send|wait|read|interrupt|rm` packages them as commands.
It is a client of the server, like `codeman tui`, and stays out of
`codeman session` (which drives the in-process SessionManager). No new route and
no second transport: everything goes through CODEMAN_API_URL, so auth,
ownership and the per-session waiter cap apply unchanged. Credentials and the
Basic header come from src/codeman-credentials.ts, in the same order attach and
the TUI use.
Invariants, each in test/cli-agent.test.ts:
- Refuses outside a Codeman session (CODEMAN_MUX=1 + CODEMAN_API_URL); never
guesses a URL.
- `send` takes the prompt as ONE argument (an unquoted multi-line `$(…)` would
otherwise be split by the shell and re-joined into one line), transmits
printable text plus Enter only, and refuses multi-line input loudly instead of
letting sendInput weld the lines. No resend loop of its own: the server's
SubmitVerifier owns the swallowed-Enter case. ESC exists only as `interrupt`,
which never appends Enter.
- `rm` fails closed: empty id, an unprovable self id, or a prefix match in
either direction refuses.
- Nothing mode-shaped in the CLI: the readiness mark `spawn` waits for comes
from the registry (new `capabilities.composerReadyMark`: claude's composer
hint `shift+tab`, deepseek's `❯`), `read` relies on the route's own answer
dispatch, and a `stop`/`blocked` the session cannot fire is the server's 400,
passed through. A `/wait` timeout is a 200 with `timedOut`: exit 2 with a
neutral line, not an error. A worker that dies during spawn's readiness wait
is exit 3, like every other wait.
- `X-Codeman-Agent-Origin` rides only spawn's quick-start, the one request that
may create a case directory; every other verb leaves it off. Server-side,
test/routes/agent-case-marker-routes.test.ts pins that a POST /api/sessions on
an existing workingDir is never labelled, header or not, and that quick-start
labels only a directory it creates.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
CLAUDE.md | 2 +
README.md | 17 +
docs/api-reference.md | 4 +
plugins/codeman/skills/codeman/SKILL.md | 6 +
skills/codeman/SKILL.md | 6 +
src/cli-agent.ts | 929 +++++++++++++++++++
src/cli.ts | 5 +
src/config/cli-registry/schema.ts | 2 +
src/config/cli-registry/stock.ts | 5 +
src/config/cli-registry/types.ts | 11 +
test/cli-agent.test.ts | 646 +++++++++++++
test/cli-commands.test.ts | 2 +
test/routes/agent-case-marker-routes.test.ts | 31 +
13 files changed, 1666 insertions(+)
create mode 100644 src/cli-agent.ts
create mode 100644 test/cli-agent.test.ts
diff --git a/CLAUDE.md b/CLAUDE.md
index d122f877..440211af 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -200,6 +200,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop`/`blocked` exist for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
+**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents in modes that never get the claude-only preamble (`seedAgentSessionPreamble` is local-claude only; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is a CLIENT: no new route, no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md`
+
**Agent-created case marker** (`src/agent-case-marker.ts`): a case dir that `POST /api/quick-start` CREATES for an agent-driven spawn (signal: the preamble's `X-Codeman-Agent-Origin` header / `agentOrigin` field, else a resolved `parentSessionId`) gets `.codeman-agent-case.json`, published as `agentCreated` on `GET /api/cases`; `GET /api/cases/agent-created` is the cleanup listing (`inUse`, `modifiedAt`) behind Add Case → Manage. ⚠️ Only the branch that CREATES the directory may write it: never label a linked case, cloned repo or pre-existing path (it drives a recursive delete). ⚠️ Reading is total: anything but a well-formed v1 marker reads as not agent-created. ⚠️ Removal stays on `DELETE /api/cases/:name` (the ONE recursive-delete path), and the sweep excludes `inUse` cases. ⚠️ Changing the preamble's headers requires bumping `CODEMAN_PREAMBLE`. → [architecture-invariants#agent-created-case-marker](docs/architecture-invariants.md#agent-created-case-marker)
**Agent preamble cache GC**: the §0 preamble seeded per claude session (`$XDG_CACHE_HOME/codeman-agent-.sh`) is now REMOVED with the session (`removeAgentSessionPreamble` from `_doCleanupSession`, `killMux` only — a detach leaves the session recoverable and its agent would come back to a loader whose file we deleted) and swept at boot (`pruneAgentSessionPreambles(this.sessions.keys())`, once, after restore, so every session this instance owns is in the keep set). Nothing removed them before: 236 leftovers measured on a working machine, the oldest three weeks old. ⚠️ The sweep needs BOTH guards — never a live session's file at any age (the two-line loader reads it mid-run), and `AGENT_PREAMBLE_MAX_AGE_MS` (7d) of age on top, which is what keeps ANOTHER instance's sessions (whose ids this process cannot see) out of the blast radius. Losing one is degradation, not breakage: the §0 fallback block rewrites it. Tests live with the seed's in `test/agent-skill.test.ts`.
diff --git a/README.md b/README.md
index 9a7143e3..b9700a2f 100644
--- a/README.md
+++ b/README.md
@@ -944,6 +944,23 @@ codeman tui --list # numbered session list (plain tex
codeman tui 3 # attach to session 3 of that list
```
+### `codeman agent` — session-to-session verbs in every CLI mode
+
+The skill above is claude-shaped (Codeman seeds its preamble for claude sessions only). An `opencode`, `codex`, `pi` or `gemini` agent has the same environment (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane) but nothing that teaches it the verbs — so `codeman agent` packages them as commands. It is a thin client over the endpoints listed under [API](#api): no new route, no new transport, auth and ownership unchanged. One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*.
+
+```bash
+codeman agent ls # sessions; * marks this one
+SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait for the composer where the mode has a ready mark
+codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000 # --wait = default signal set
+codeman agent read "$SID" # last answer (as the server reads it for that mode)
+codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode)
+codeman agent wait "$SID" --match DONE_4711 # marker wait for hook-less modes (opencode, pi, …)
+codeman agent interrupt "$SID" # a bare ESC, conversation intact
+codeman agent rm "$SID" # refuses your own id
+```
+
+Rules the commands enforce rather than document: they refuse outside a Codeman session and never guess a URL; `send` transmits printable text plus Enter only (a control byte such as `Ctrl+C` is `app_exit` in opencode — ESC exists solely as `interrupt`, which never appends Enter); `rm` refuses an empty id, an unprovable self id and a prefix match in either direction. Ids may be the 8-character prefixes `ls` prints (resolved through the list; an ambiguous prefix refuses). Exit codes: `0` delivered/matched/signal, `1` error, `2` timeout, `3` the worker exited or the wait ended without an answer (`delivered:false`, `ended:true`), `4` refused. `spawn` prints the id alone on stdout (prose goes to stderr), so `SID=$(…)` captures exactly the id. `--json` prints the envelope's `data` for every verb. `--until stop` on a mode without hook signals is the server's 400, passed through — the marker path (`--match`) is the answer there, exactly as for the skill.
+
### Hooks (events flowing _back_ to Codeman)
Codeman registers Claude Code hooks that `POST /api/hook-event` (`permission_prompt`, `idle_prompt`, `stop`, `task_completed`, …) so the dashboard reacts in real time. This endpoint is auth-exempt on loopback but, under a managed tunnel, requires the `X-Codeman-Hook-Secret` header (read it from `$CODEMAN_HOOK_SECRET_FILE`). You normally don't call this by hand — Codeman wires it up — but it's how the autonomy layers "see" what the agent is doing.
diff --git a/docs/api-reference.md b/docs/api-reference.md
index 47c22e0b..09022fc5 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -466,6 +466,10 @@ geometry was read. The capture runs synchronous tmux calls on the server; the
| `tail=` | Keep the newest `` of the result (`truncationReason: 'tail'` when it cut). |
| `lines=` | With `full=1` only: read at most `` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. `truncated` and `truncationReason` describe byte cuts only, not this bound. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. |
+## The `codeman agent` CLI (client over these endpoints)
+
+`codeman agent ls|spawn|send|wait|read|interrupt|rm` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents in modes that never receive the claude-only skill preamble. It adds no route: `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until `; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour.
+
## Session lineage (`parentSessionId`)
A create request may name the session that spawned it, which the web UI draws as a
diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md
index d328b967..c965c998 100644
--- a/plugins/codeman/skills/codeman/SKILL.md
+++ b/plugins/codeman/skills/codeman/SKILL.md
@@ -29,6 +29,12 @@ worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpo
tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and
direct messaging to claude workers in [reference/messaging.md](reference/messaging.md).
+Workers in **every other mode** never receive this preamble, but they have the same
+environment: tell them *"other sessions: `codeman agent --help`"* — the bundled CLI
+(`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) is the same verbs over the
+same endpoints, with the guards below (no control bytes, no self-delete, no guessed
+URL) enforced in code.
+
## 0. Guard and bootstrap
If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md
index d328b967..c965c998 100644
--- a/skills/codeman/SKILL.md
+++ b/skills/codeman/SKILL.md
@@ -29,6 +29,12 @@ worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpo
tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and
direct messaging to claude workers in [reference/messaging.md](reference/messaging.md).
+Workers in **every other mode** never receive this preamble, but they have the same
+environment: tell them *"other sessions: `codeman agent --help`"* — the bundled CLI
+(`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) is the same verbs over the
+same endpoints, with the guards below (no control bytes, no self-delete, no guessed
+URL) enforced in code.
+
## 0. Guard and bootstrap
If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
diff --git a/src/cli-agent.ts b/src/cli-agent.ts
new file mode 100644
index 00000000..7b73e1fd
--- /dev/null
+++ b/src/cli-agent.ts
@@ -0,0 +1,929 @@
+/**
+ * @fileoverview `codeman agent …` — session-to-session verbs for the agent running
+ * inside a Codeman session, in every CLI mode.
+ *
+ * A thin HTTP client over endpoints that already exist (`quick-start`, `input`,
+ * `wait`, `wait-output`, `last-response`, `terminal`, `DELETE sessions/:id`). It
+ * invents no route and no transport: everything goes through `CODEMAN_API_URL`, so
+ * auth, ownership and the per-session waiter cap apply unchanged. The behaviour is
+ * the packaged agent skill's (`skills/codeman`), ported from shell prose into code
+ * with tests, so a `codex`/`opencode`/`pi` agent — which never gets the claude-only
+ * preamble — has the same verbs from one line in its AGENTS.md.
+ *
+ * Invariants (each asserted in `test/cli-agent.test.ts`):
+ * 1. Refuses outside a Codeman session (`CODEMAN_MUX=1` + `CODEMAN_API_URL`); it
+ * never guesses a URL — a server you are not part of is not yours to drive.
+ * 2. `send` transmits printable text plus `\r` only. ESC exists solely as
+ * `interrupt`, which never appends `\r`. A stray control byte is a dead session
+ * in the fullscreen TUIs (opencode's `Ctrl+C` is `app_exit`).
+ * 3. `rm` fails closed: empty id, a short self id, or a prefix match in EITHER
+ * direction refuses. Ids appear in full and 8-char form, so equality alone
+ * misses a real combination — and the miss deletes the caller.
+ *
+ * Commands live here as functions returning an exit code, not calling
+ * `process.exit`, so the whole surface is unit-testable against a fake server.
+ *
+ * @module cli-agent
+ */
+import http from 'node:http';
+import https from 'node:https';
+import type { Command } from 'commander';
+import {
+ basicAuthHeader,
+ credentialsFrom,
+ readCodemanEnvFile,
+ type CodemanCredentials,
+} from './codeman-credentials.js';
+import { getCli } from './config/cli-registry/registry.js';
+import { GLYPH, palette, table } from './cli-style.js';
+import { getErrorMessage } from './types.js';
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Context and guard
+// ─────────────────────────────────────────────────────────────────────────────
+
+export interface AgentContext {
+ /** Base URL of the Codeman server, from `CODEMAN_API_URL`. */
+ apiUrl: string;
+ /** This session's id, from `CODEMAN_SESSION_ID`. */
+ selfId: string;
+ /** Basic-auth credentials, when the server has a password. */
+ auth?: CodemanCredentials;
+}
+
+/** Thrown when the process is not inside a Codeman-managed session. */
+export class AgentGuardError extends Error {}
+
+/** Exit codes shared by every verb; a shell agent can branch on them. */
+export const EXIT = {
+ ok: 0,
+ error: 1,
+ timeout: 2,
+ dead: 3,
+ refused: 4,
+} as const;
+
+/**
+ * Resolve the context from the environment, or throw `AgentGuardError`.
+ *
+ * Credentials in the order every client of the API uses (`credentialsFrom`, shared
+ * with `codeman attach` and the TUI): each field from the environment (a session
+ * inherits the server's), then the data dir's `.env`. No password means the server
+ * is open (single-user) — or it is not, and the 401 says so.
+ */
+export function resolveAgentContext(
+ env: NodeJS.ProcessEnv = process.env,
+ envFile: () => Record = readCodemanEnvFile
+): AgentContext {
+ if (env.CODEMAN_MUX !== '1') {
+ throw new AgentGuardError('Not inside a Codeman-managed session (CODEMAN_MUX is not 1); refusing to act.');
+ }
+ const apiUrl = env.CODEMAN_API_URL?.trim();
+ if (!apiUrl) {
+ throw new AgentGuardError('CODEMAN_API_URL is not set; refusing to guess a server.');
+ }
+ const selfId = env.CODEMAN_SESSION_ID?.trim();
+ if (!selfId) {
+ throw new AgentGuardError('CODEMAN_SESSION_ID is not set; cannot tell which session is me.');
+ }
+ const credentials = credentialsFrom(env, envFile());
+ return { apiUrl, selfId, auth: credentials.password ? credentials : undefined };
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Pure helpers (the invariants)
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Is `id` this session? Prefix in BOTH directions, because ids appear in full and
+ * in 8-char form (mux names, UI surfaces, Docker's truncated `$SELF`). A self id
+ * shorter than 8 characters cannot prove anything and is treated as "maybe me".
+ */
+export function isSelfSession(selfId: string, id: string): boolean {
+ if (!id || selfId.length < 8) return true;
+ return id.startsWith(selfId) || selfId.startsWith(id);
+}
+
+/** Why `rm` refuses, or `undefined` when the delete may go ahead. */
+export function deleteRefusal(selfId: string, id: string): string | undefined {
+ if (!id) return 'refusing: empty session id';
+ if (selfId.length < 8) return 'refusing: own session id unset or too short to prove this is not me';
+ if (isSelfSession(selfId, id)) return `refusing: ${id} is me`;
+ return undefined;
+}
+
+/**
+ * Why `send` refuses this text, or `undefined` when it is printable. The composer
+ * takes one line; the server strips `\r`/`\n` but everything else below 0x20 (and
+ * DEL) reaches the pane as a keypress. None of that is a prompt.
+ */
+/**
+ * The prompt `send` was given, which must be ONE argument. Joining several with spaces
+ * would turn an unquoted `$(cat notes.txt)`, which the shell splits on every newline,
+ * back into a single line, so the multi-line refusal below would never see it.
+ */
+export function sendPromptFromArgs(words: readonly string[]): { text: string } | { error: string } {
+ if (words.length === 1) return { text: words[0] };
+ return {
+ error: `refusing: the prompt must be ONE argument, got ${words.length} — quote it (\`send "…"\`)`,
+ };
+}
+
+export function inputRefusal(text: string): string | undefined {
+ if (text.length === 0) return 'refusing: empty input (use `interrupt` for ESC, `send ""` is never a prompt)';
+ // The composer is one line: the server strips newlines, which silently joins the
+ // lines into one prompt, and a tab reaches the pane as a keypress (claude: mode toggle).
+ if (/[\n\r\t]/.test(text)) {
+ return 'refusing: input must be a single line (the composer strips newlines and would join your lines) — join them yourself, or write a file into the workspace and send its path';
+ }
+ // C0, DEL and C1 (U+0080–U+009F: an 8-bit CSI is still a CSI to a terminal).
+ // eslint-disable-next-line no-control-regex
+ const control = text.match(/[\x00-\x1f\x7f-\x9f]/);
+ if (control) {
+ const code = control[0].charCodeAt(0).toString(16).padStart(2, '0');
+ return `refusing: input contains control byte 0x${code}; send transmits printable text only (ESC is \`interrupt\`)`;
+ }
+ return undefined;
+}
+
+/**
+ * Body for `POST /sessions/:id/input` on the send path: text plus `\r` unless the
+ * caller asked to type without submitting. Never anything else.
+ */
+export function buildSendBody(
+ text: string,
+ options: { enter: boolean; clientId: string; seq: number; wait?: string | true; waitTimeout?: number }
+): Record {
+ const body: Record = {
+ input: options.enter ? `${text}\r` : text,
+ useMux: true,
+ clientId: options.clientId,
+ seq: options.seq,
+ };
+ if (options.wait !== undefined) body.wait = options.wait;
+ if (options.waitTimeout !== undefined) body.waitTimeout = options.waitTimeout;
+ return body;
+}
+
+/** Body for the interrupt path: a bare ESC, and nothing appended — ever. */
+export function buildInterruptBody(clientId: string, seq: number): Record {
+ return { input: '\u001b', useMux: true, clientId, seq };
+}
+
+/** `clientId` for this caller: fixed per sending session, so `seq` stays monotonic. */
+export function defaultClientId(selfId: string, suffix = ''): string {
+ return `codeman-agent-cli-${selfId.slice(0, 8)}${suffix ? `-${suffix}` : ''}`;
+}
+
+/** Strip ANSI CSI/charset sequences from a terminal buffer (GNU and BSD alike). */
+export function stripAnsi(text: string): string {
+ // eslint-disable-next-line no-control-regex
+ return text.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '').replace(/\x1b[()][AB0]/g, '');
+}
+
+/** Parse a positive-integer option (`--timeout` ms, `--tail` bytes); the server rejects anything else. */
+export function parsePositiveInt(raw: string | undefined, fallback: number, flag = '--timeout'): number {
+ if (raw === undefined) return fallback;
+ const n = Number(raw);
+ if (!Number.isInteger(n) || n <= 0) {
+ throw new Error(`${flag} must be a positive integer, got "${raw}"`);
+ }
+ return n;
+}
+
+/**
+ * Exit code for a wait result: matched or a signal → ok, `exit` or `ended` → dead,
+ * timeout → timeout. `ended` is checked BEFORE the happy paths: a worker that dies
+ * during `--until stop` comes back as `ended:true, signal:null` (the registry only
+ * satisfies waiters that listed `exit`, then cancels the rest), and a `--match` on
+ * a dead worker as `ended:true, matched:false` — both are "dead", never "done".
+ */
+export function waitExitCode(wait: WaitResult | undefined): number {
+ if (!wait) return EXIT.error;
+ if (wait.signal === 'exit' || wait.ended) return EXIT.dead;
+ if (wait.timedOut) return EXIT.timeout;
+ if (wait.matched === false) return EXIT.timeout;
+ return EXIT.ok;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// HTTP
+// ─────────────────────────────────────────────────────────────────────────────
+
+export interface ApiEnvelope {
+ success: boolean;
+ data?: T;
+ error?: string;
+ errorCode?: string;
+}
+
+export interface ApiResponse {
+ status: number;
+ /** Parsed envelope, or `undefined` when the body was not JSON (auth guards answer in plain text). */
+ json?: ApiEnvelope;
+ text: string;
+}
+
+export interface WaitResult {
+ /** The signal that fired, or null when the wait ended without one. */
+ signal?: string | null;
+ timedOut?: boolean;
+ /** The session went away (deleted / torn down / the write failed) before the wait resolved. */
+ ended?: boolean;
+ timeoutMs?: number;
+ until?: string[];
+ matched?: boolean;
+ match?: string;
+ snippet?: string;
+ immediate?: boolean;
+}
+
+export interface RequestOptions {
+ method: 'GET' | 'POST' | 'DELETE';
+ path: string;
+ query?: Record;
+ body?: Record;
+ headers?: Record;
+ /** Socket timeout; long-polls pass their own timeout plus headroom. */
+ timeoutMs?: number;
+}
+
+export type ApiRequest = (ctx: AgentContext, options: RequestOptions) => Promise;
+
+/** Every request carries these; they are ignored on endpoints that do not read them. */
+export function baseHeaders(ctx: AgentContext): Record {
+ const headers: Record = {
+ Accept: 'application/json',
+ // Tags sessions this caller spawns as its children (lineage in the web UI).
+ // Cosmetic, never fails a call. NOT X-Codeman-Agent-Origin: that one marks a case
+ // directory as deletable agent scratch, so it rides only the spawn request that
+ // may create one (see agentSpawn), never anything else.
+ 'X-Codeman-Parent-Session': ctx.selfId,
+ };
+ const authorization = ctx.auth ? basicAuthHeader(ctx.auth) : undefined;
+ if (authorization) headers.Authorization = authorization;
+ return headers;
+}
+
+/** The real transport. `rejectUnauthorized:false` because the HTTPS install uses a self-signed cert. */
+export const httpRequest: ApiRequest = (ctx, options) => {
+ const url = new URL(options.path, ctx.apiUrl);
+ for (const [key, value] of Object.entries(options.query ?? {})) {
+ if (value !== undefined) url.searchParams.set(key, String(value));
+ }
+ const bodyText = options.body === undefined ? undefined : JSON.stringify(options.body);
+ const headers: Record = { ...baseHeaders(ctx), ...(options.headers ?? {}) };
+ if (bodyText !== undefined) {
+ headers['Content-Type'] = 'application/json';
+ headers['Content-Length'] = Buffer.byteLength(bodyText);
+ }
+ const transport = url.protocol === 'https:' ? https : http;
+ return new Promise((resolve, reject) => {
+ const req = transport.request(
+ {
+ protocol: url.protocol,
+ hostname: url.hostname,
+ port: url.port,
+ method: options.method,
+ path: `${url.pathname}${url.search}`,
+ rejectUnauthorized: false,
+ headers,
+ timeout: options.timeoutMs ?? 30_000,
+ },
+ (res) => {
+ const chunks: Buffer[] = [];
+ res.on('data', (chunk: Buffer) => chunks.push(chunk));
+ res.on('end', () => {
+ const text = Buffer.concat(chunks).toString('utf-8');
+ let json: ApiEnvelope | undefined;
+ try {
+ json = JSON.parse(text) as ApiEnvelope;
+ } catch {
+ json = undefined;
+ }
+ resolve({ status: res.statusCode ?? 0, json, text });
+ });
+ }
+ );
+ req.on('timeout', () => req.destroy(new Error(`request timed out after ${options.timeoutMs ?? 30_000} ms`)));
+ req.on('error', reject);
+ if (bodyText !== undefined) req.write(bodyText);
+ req.end();
+ });
+};
+
+/** One line describing a failed response, for humans. Plain-text guards (401/403/429) have no envelope. */
+export function describeFailure(res: ApiResponse): string {
+ if (res.json && !res.json.success) {
+ return `${res.json.errorCode ?? 'ERROR'}: ${res.json.error ?? 'request failed'} (HTTP ${res.status})`;
+ }
+ const text = res.text.trim().split('\n')[0] ?? '';
+ if (res.status === 401)
+ return `HTTP 401 ${text}: the server wants a password (CODEMAN_PASSWORD, or the data dir's .env)`;
+ return `HTTP ${res.status}${text ? ` ${text}` : ''}`;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Commands
+// ─────────────────────────────────────────────────────────────────────────────
+
+export interface AgentIo {
+ out: (line: string) => void;
+ err: (line: string) => void;
+}
+
+export interface AgentDeps {
+ ctx: AgentContext;
+ request: ApiRequest;
+ io: AgentIo;
+ json: boolean;
+ /** Clock for `seq`; injectable so tests are deterministic. */
+ now?: () => number;
+}
+
+/** Print `data` as JSON (the `--json` path) — always the envelope's `data`, never a reshaped copy. */
+function emitJson(deps: AgentDeps, data: unknown): void {
+ deps.io.out(JSON.stringify(data, null, 2));
+}
+
+function fail(deps: AgentDeps, message: string, code: number = EXIT.error): number {
+ if (deps.json) {
+ deps.io.out(JSON.stringify({ success: false, error: message }));
+ } else {
+ deps.io.err(palette.err(`${GLYPH.fail} ${message}`));
+ }
+ return code;
+}
+
+interface SessionRow {
+ id: string;
+ name?: string;
+ mode?: string;
+ status?: string;
+ workingDir?: string;
+ pid?: number | null;
+ parentSessionId?: string | null;
+}
+
+/** A full session id (the only form the routes accept); `ls` prints the 8-char prefix. */
+const FULL_ID_LENGTH = 36;
+
+/**
+ * Turn the id a human typed into the one the routes accept. `ls` prints 8-char
+ * prefixes and the routes answer 404 to those (measured live), so anything shorter
+ * than a full id resolves through the session list; an ambiguous prefix refuses
+ * rather than picking one.
+ */
+export async function resolveSessionId(deps: AgentDeps, id: string): Promise<{ id: string } | { error: string }> {
+ if (!id) return { error: 'refusing: empty session id' };
+ if (id.length >= FULL_ID_LENGTH) return { id };
+ const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' });
+ if (!res.json?.success) return { error: describeFailure(res) };
+ const matches = ((res.json.data as SessionRow[] | undefined) ?? []).filter((s) => s.id.startsWith(id));
+ if (matches.length === 1) return { id: matches[0].id };
+ if (matches.length === 0) return { error: `no session starts with "${id}" (see \`agent ls\`)` };
+ return { error: `"${id}" is ambiguous: ${matches.map((s) => s.id.slice(0, 13)).join(', ')}` };
+}
+
+/** `agent ls` — every session the caller can see, self marked. */
+export async function agentLs(deps: AgentDeps): Promise {
+ const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' });
+ if (!res.json?.success) return fail(deps, describeFailure(res));
+ const sessions = (res.json.data as SessionRow[] | undefined) ?? [];
+ if (deps.json) {
+ emitJson(
+ deps,
+ sessions.map((s) => ({ ...s, self: isSelfSession(deps.ctx.selfId, s.id) }))
+ );
+ return EXIT.ok;
+ }
+ if (sessions.length === 0) {
+ deps.io.out(palette.muted('(no sessions)'));
+ return EXIT.ok;
+ }
+ const rows = sessions.map((s) => [
+ isSelfSession(deps.ctx.selfId, s.id) ? '*' : ' ',
+ s.id.slice(0, 8),
+ s.mode ?? '?',
+ s.status ?? '?',
+ s.name || s.workingDir || '',
+ ]);
+ deps.io.out(table([[' ', 'ID', 'MODE', 'STATUS', 'NAME'], ...rows], { gap: 2 }));
+ deps.io.out(
+ palette.muted(`* = this session (${deps.ctx.selfId.slice(0, 8)}). status is a UI hint, never a sync signal.`)
+ );
+ return EXIT.ok;
+}
+
+export interface SpawnOptions {
+ caseName: string;
+ mode: string;
+ name?: string;
+ /** Wait for the composer before returning, where the registry gives the mode a ready mark. */
+ ready: boolean;
+ timeoutMs: number;
+}
+
+/**
+ * Agent-scratch label for a case directory a spawn CREATES (the server applies it only
+ * when quick-start makes the directory). The Add Case UI offers a recursive delete for
+ * such directories, so this header must never ride any other request: mislabelling a
+ * real repo there is the one failure in this area that costs actual work.
+ */
+export const AGENT_ORIGIN_HEADER = { 'X-Codeman-Agent-Origin': 'codeman-agent-cli' } as const;
+
+/**
+ * What the mode's TUI draws once its composer can take a prompt, from the CLI registry
+ * (`capabilities.composerReadyMark`); undefined means the mode has no readiness wait.
+ */
+export function composerReadyMark(mode: string): string | undefined {
+ return getCli(mode)?.capabilities.composerReadyMark;
+}
+/** `agent spawn` — quick-start with lineage, then the readiness ladder where the mode has one. */
+export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promise {
+ const body: Record = {
+ caseName: options.caseName,
+ mode: options.mode,
+ parentSessionId: deps.ctx.selfId,
+ };
+ if (options.name) body.sessionName = options.name;
+ const res = await deps.request(deps.ctx, {
+ method: 'POST',
+ path: '/api/v1/quick-start',
+ body,
+ headers: { ...AGENT_ORIGIN_HEADER },
+ });
+ const data = res.json?.data as { sessionId?: string; caseName?: string; casePath?: string } | undefined;
+ if (!res.json?.success || !data?.sessionId) return fail(deps, describeFailure(res));
+ const sid = data.sessionId;
+
+ let ready: boolean | undefined;
+ let readinessError: string | undefined;
+ let dead = false;
+ const mark = composerReadyMark(options.mode);
+ if (options.ready && mark) {
+ const wait = await deps.request(deps.ctx, {
+ method: 'GET',
+ path: `/api/v1/sessions/${encodeURIComponent(sid)}/wait-output`,
+ query: { match: mark, from: 'buffer', timeout: options.timeoutMs },
+ timeoutMs: options.timeoutMs + 10_000,
+ });
+ // A failed readiness call (waiter cap, 400, network) is its own error, not "the
+ // composer never showed up": report the real reason instead of the trust-dialog hint.
+ if (!wait.json?.success) readinessError = describeFailure(wait);
+ else {
+ const result = (wait.json.data as { wait?: WaitResult } | undefined)?.wait;
+ // A worker that died while we waited is exit 3 like every other wait, not a
+ // "composer not seen" timeout that sends the caller looking for a dialog.
+ dead = waitExitCode(result) === EXIT.dead;
+ ready = !dead && Boolean(result?.matched);
+ }
+ }
+
+ if (deps.json) {
+ emitJson(deps, { ...data, ready, readinessError });
+ } else {
+ // Human lines go to stderr so `SID=$(codeman agent spawn …)` captures the id alone.
+ const say = (line: string) => deps.io.err(line);
+ say(palette.ok(`${GLYPH.ok} spawned ${sid} (${options.mode}, case ${data.caseName ?? options.caseName})`));
+ if (ready === true) say(palette.muted(' composer up: the worker can take a prompt'));
+ if (dead) say(palette.err(`${GLYPH.fail} the worker exited during the readiness wait`));
+ if (ready === false && !dead) {
+ say(
+ palette.warn(
+ `${GLYPH.warn} composer not seen within ${options.timeoutMs} ms — read \`agent read ${sid.slice(0, 8)} --tail 2000\` before sending (a startup dialog?)`
+ )
+ );
+ }
+ if (readinessError) say(palette.err(`${GLYPH.fail} readiness check failed: ${readinessError}`));
+ if (ready === undefined && !readinessError && options.ready) {
+ say(
+ palette.muted(
+ ` ${options.mode} has no readiness mark; give it a moment, then use --match markers to synchronize`
+ )
+ );
+ }
+ deps.io.out(sid);
+ }
+ if (readinessError) return EXIT.error;
+ if (dead) return EXIT.dead;
+ return ready === false ? EXIT.timeout : EXIT.ok;
+}
+
+export interface SendOptions {
+ id: string;
+ text: string;
+ enter: boolean;
+ /** `undefined` = fire-and-forget; `true` = default signal set; string = comma list. */
+ wait?: string | true;
+ timeoutMs?: number;
+ clientId?: string;
+ seq?: number;
+}
+
+/** `agent send` — printable text plus `\r`, exactly-once, optionally blocking on end of turn. */
+export async function agentSend(deps: AgentDeps, options: SendOptions): Promise {
+ if (isSelfSession(deps.ctx.selfId, options.id)) {
+ return fail(deps, `refusing: ${options.id} is me — typing into my own composer is not a message`, EXIT.refused);
+ }
+ const refusal = inputRefusal(options.text);
+ if (refusal) return fail(deps, refusal, EXIT.refused);
+ const target = await resolveSessionId(deps, options.id);
+ if ('error' in target) return fail(deps, target.error);
+ const body = buildSendBody(options.text, {
+ enter: options.enter,
+ clientId: options.clientId ?? defaultClientId(deps.ctx.selfId),
+ seq: options.seq ?? (deps.now ?? Date.now)(),
+ wait: options.wait,
+ waitTimeout: options.wait !== undefined ? options.timeoutMs : undefined,
+ });
+ const res = await deps.request(deps.ctx, {
+ method: 'POST',
+ path: `/api/v1/sessions/${encodeURIComponent(target.id)}/input`,
+ body,
+ timeoutMs: (options.timeoutMs ?? 60_000) + 10_000,
+ });
+ if (!res.json?.success) return fail(deps, describeFailure(res));
+ const data = res.json.data as { delivered?: boolean; duplicate?: boolean; wait?: WaitResult } | undefined;
+ if (deps.json) emitJson(deps, data ?? {});
+ // `delivered:false` without `duplicate` is the route's "the bytes went nowhere":
+ // the PTY exited or send-keys hit a dead pane. The field exists so a client does not
+ // say "wait longer" when the truth is "restart the worker" — so it is a failure here.
+ if (data?.delivered === false && !data.duplicate) {
+ if (!deps.json) {
+ deps.io.err(
+ palette.err(`${GLYPH.fail} not delivered: ${target.id} has no live worker (pane exited) — restart it`)
+ );
+ }
+ return EXIT.dead;
+ }
+ if (!deps.json) {
+ const noEnter = options.enter ? '' : ' (no Enter)';
+ if (data?.duplicate) {
+ deps.io.out(palette.warn(`${GLYPH.warn} duplicate (clientId/seq already applied): nothing typed`));
+ } else if (data?.delivered === true) {
+ deps.io.out(palette.ok(`${GLYPH.ok} delivered to ${target.id}${noEnter}`));
+ } else {
+ // Fire-and-forget answers before the write, so there is no delivery report here.
+ deps.io.out(palette.ok(`${GLYPH.ok} accepted for ${target.id}${noEnter} (no delivery report without --wait)`));
+ }
+ if (data?.wait) deps.io.out(describeWait(data.wait));
+ }
+ if (options.wait === undefined) return EXIT.ok;
+ return waitExitCode(data?.wait);
+}
+
+function describeWait(wait: WaitResult): string {
+ if (wait.signal === 'exit') return palette.err(`${GLYPH.fail} the session exited`);
+ if (wait.ended) {
+ return palette.err(
+ `${GLYPH.fail} the wait ended without an answer: the session went away (dead worker, deleted, or nothing was written)`
+ );
+ }
+ // A timeout is a 200 with `timedOut`, an answer rather than a failure: exit 2 says it,
+ // so the line stays neutral instead of looking like an error to whoever reads the log.
+ if (wait.timedOut) return palette.muted(`timed out after ${wait.timeoutMs ?? '?'} ms (exit 2)`);
+ if (wait.matched !== undefined) {
+ return wait.matched
+ ? palette.ok(`${GLYPH.ok} matched "${wait.match}"${wait.snippet ? `: ${wait.snippet}` : ''}`)
+ : palette.muted('not matched (exit 2)');
+ }
+ return palette.ok(
+ `${GLYPH.ok} signal: ${wait.signal}${wait.immediate ? ' (immediate: current state, not a transition)' : ''}`
+ );
+}
+
+export interface WaitOptions {
+ id: string;
+ until?: string;
+ match?: string;
+ from?: 'buffer' | 'now';
+ fresh?: boolean;
+ nocase?: boolean;
+ timeoutMs: number;
+}
+
+/** `agent wait` — a signal (`--until`) or a literal output marker (`--match`). */
+export async function agentWait(deps: AgentDeps, options: WaitOptions): Promise {
+ if (options.until && options.match)
+ return fail(deps, 'use either --until or --match , not both', EXIT.refused);
+ const target = await resolveSessionId(deps, options.id);
+ if ('error' in target) return fail(deps, target.error);
+ const sid = encodeURIComponent(target.id);
+ const res = options.match
+ ? await deps.request(deps.ctx, {
+ method: 'GET',
+ path: `/api/v1/sessions/${sid}/wait-output`,
+ query: {
+ match: options.match,
+ from: options.from ?? 'buffer',
+ nocase: options.nocase ? 1 : undefined,
+ timeout: options.timeoutMs,
+ },
+ timeoutMs: options.timeoutMs + 10_000,
+ })
+ : await deps.request(deps.ctx, {
+ method: 'GET',
+ path: `/api/v1/sessions/${sid}/wait`,
+ query: { until: options.until, fresh: options.fresh ? 1 : undefined, timeout: options.timeoutMs },
+ timeoutMs: options.timeoutMs + 10_000,
+ });
+ // A 400 here is the server saying "this mode has no such signal" (until=stop on an
+ // external CLI). Passed through, never papered over: the marker path is the answer.
+ if (!res.json?.success) return fail(deps, describeFailure(res));
+ const data = res.json.data as { wait?: WaitResult; status?: string; limitPaused?: boolean } | undefined;
+ if (deps.json) {
+ emitJson(deps, data ?? {});
+ } else if (data?.wait) {
+ deps.io.out(describeWait(data.wait));
+ if (data.limitPaused)
+ deps.io.out(palette.warn(`${GLYPH.warn} session is paused on a usage limit; a timeout is expected`));
+ }
+ return waitExitCode(data?.wait);
+}
+
+export interface ReadOptions {
+ id: string;
+ /** Bytes of raw terminal to fetch; ANSI is stripped for humans. */
+ tail?: number;
+ /** Whole conversation (`context=full`) instead of the last assistant message. */
+ full?: boolean;
+}
+
+/** `agent read` — the last answer (the route picks the transcript reader or the pane segmenter) or a terminal tail. */
+export async function agentRead(deps: AgentDeps, options: ReadOptions): Promise {
+ const target = await resolveSessionId(deps, options.id);
+ if ('error' in target) return fail(deps, target.error);
+ const sid = encodeURIComponent(target.id);
+ if (options.tail !== undefined) {
+ const res = await deps.request(deps.ctx, {
+ method: 'GET',
+ path: `/api/v1/sessions/${sid}/terminal`,
+ query: { tail: options.tail },
+ });
+ if (!res.json?.success) return fail(deps, describeFailure(res));
+ const buffer = (res.json.data as { terminalBuffer?: string } | undefined)?.terminalBuffer ?? '';
+ if (deps.json) emitJson(deps, res.json.data);
+ else deps.io.out(stripAnsi(buffer));
+ return EXIT.ok;
+ }
+ const res = await deps.request(deps.ctx, {
+ method: 'GET',
+ path: `/api/v1/sessions/${sid}/last-response`,
+ query: { context: options.full ? 'full' : undefined },
+ });
+ if (!res.json?.success) return fail(deps, describeFailure(res));
+ const data = res.json.data as
+ | { text?: string; timestamp?: string; messages?: Array<{ role: string; text: string }> }
+ | undefined;
+ if (deps.json) {
+ emitJson(deps, data ?? {});
+ return EXIT.ok;
+ }
+ if (options.full && data?.messages) {
+ for (const m of data.messages) deps.io.out(`${palette.emph(m.role)}: ${m.text}`);
+ return EXIT.ok;
+ }
+ const text = data?.text ?? '';
+ if (!text) {
+ deps.io.err(
+ palette.muted('(empty: nothing answered yet, or nothing the server could segment as an answer; try --tail 3000)')
+ );
+ return EXIT.ok;
+ }
+ deps.io.out(text);
+ return EXIT.ok;
+}
+
+/** `agent interrupt` — a bare ESC keypress, no Enter, conversation intact. */
+export async function agentInterrupt(deps: AgentDeps, options: { id: string }): Promise {
+ if (isSelfSession(deps.ctx.selfId, options.id)) return fail(deps, `refusing: ${options.id} is me`, EXIT.refused);
+ const target = await resolveSessionId(deps, options.id);
+ if ('error' in target) return fail(deps, target.error);
+ const body = buildInterruptBody(defaultClientId(deps.ctx.selfId, 'interrupt'), (deps.now ?? Date.now)());
+ const res = await deps.request(deps.ctx, {
+ method: 'POST',
+ path: `/api/v1/sessions/${encodeURIComponent(target.id)}/input`,
+ body,
+ });
+ if (!res.json?.success) return fail(deps, describeFailure(res));
+ if (deps.json) emitJson(deps, res.json.data ?? {});
+ else
+ deps.io.out(
+ palette.ok(
+ `${GLYPH.ok} ESC sent to ${target.id} — one Esc does not always land; read the tail before the next prompt`
+ )
+ );
+ return EXIT.ok;
+}
+
+/** `agent rm` — delete a session that is provably not this one. */
+export async function agentRm(deps: AgentDeps, options: { id: string }): Promise {
+ const refusal = deleteRefusal(deps.ctx.selfId, options.id);
+ if (refusal) return fail(deps, refusal, EXIT.refused);
+ const target = await resolveSessionId(deps, options.id);
+ if ('error' in target) return fail(deps, target.error);
+ // The guard again on the RESOLVED id: a prefix that is not me can still resolve
+ // to me only if the list is lying, but a delete is the one call worth the paranoia.
+ const resolvedRefusal = deleteRefusal(deps.ctx.selfId, target.id);
+ if (resolvedRefusal) return fail(deps, resolvedRefusal, EXIT.refused);
+ const res = await deps.request(deps.ctx, {
+ method: 'DELETE',
+ path: `/api/v1/sessions/${encodeURIComponent(target.id)}`,
+ });
+ if (!res.json?.success) return fail(deps, describeFailure(res));
+ if (deps.json) emitJson(deps, res.json.data ?? {});
+ else deps.io.out(palette.ok(`${GLYPH.ok} deleted ${target.id} (its case directory stays on disk)`));
+ return EXIT.ok;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Commander wiring
+// ─────────────────────────────────────────────────────────────────────────────
+
+const DEFAULT_WAIT_MS = 60_000;
+
+/** Build deps from the live environment; the guard's message is the only thing a non-session caller sees. */
+function liveDeps(json: boolean): AgentDeps | undefined {
+ try {
+ return {
+ ctx: resolveAgentContext(),
+ request: httpRequest,
+ json,
+ io: { out: (line) => console.log(line), err: (line) => console.error(line) },
+ };
+ } catch (err) {
+ if (err instanceof AgentGuardError) {
+ console.error(palette.err(`${GLYPH.fail} ${err.message}`));
+ return undefined;
+ }
+ throw err;
+ }
+}
+
+/** Run a verb with the live transport and turn its exit code into the process exit. */
+async function run(json: boolean, verb: (deps: AgentDeps) => Promise): Promise {
+ const deps = liveDeps(json);
+ if (!deps) {
+ process.exitCode = EXIT.refused;
+ return;
+ }
+ try {
+ process.exitCode = await verb(deps);
+ } catch (err) {
+ console.error(palette.err(`${GLYPH.fail} ${getErrorMessage(err)}`));
+ process.exitCode = EXIT.error;
+ }
+}
+
+/** Register `codeman agent …` on the program. */
+export function registerAgentCommands(program: Command): Command {
+ const agent = program
+ .command('agent')
+ .description('Talk to other sessions from inside one (any CLI mode): list, spawn, send, wait, read, interrupt, rm');
+
+ agent
+ .command('ls')
+ .alias('list')
+ .description('List sessions; * marks this one')
+ .option('--json', 'Machine-readable output')
+ .action((options: { json?: boolean }) => run(Boolean(options.json), agentLs));
+
+ agent
+ .command('spawn ')
+ .description(
+ 'Start a worker session in a case (created if missing) and wait for its composer where the mode draws one'
+ )
+ .option('-m, --mode ', 'Run mode id, as the Run menu names it', 'claude')
+ .option('-n, --name ', 'Session name shown in the UI')
+ .option('--no-ready', 'Return as soon as the session exists, without the readiness wait')
+ .option('-t, --timeout ', 'Readiness budget in ms', String(DEFAULT_WAIT_MS))
+ .option('--json', 'Machine-readable output')
+ .action(
+ (caseName: string, options: { mode: string; name?: string; ready: boolean; timeout?: string; json?: boolean }) =>
+ run(Boolean(options.json), (deps) =>
+ agentSpawn(deps, {
+ caseName,
+ mode: options.mode,
+ name: options.name,
+ ready: options.ready,
+ timeoutMs: parsePositiveInt(options.timeout, DEFAULT_WAIT_MS),
+ })
+ )
+ );
+
+ agent
+ .command('send ')
+ .description('Type a prompt into another session and press Enter (ONE quoted argument, printable text only)')
+ .option('-w, --wait', 'Block until end of turn (the default signal set; see --until)')
+ .option('-u, --until ', 'Signals to wait for, comma list such as stop,exit (implies --wait)')
+ .option('-t, --timeout ', 'Wait budget in ms (with --wait)', String(DEFAULT_WAIT_MS))
+ .option('--no-enter', 'Type the text without submitting it')
+ .option('--client-id ', 'Exactly-once tag (default: one per calling session)')
+ .option(
+ '--seq ',
+ 'Sequence number for the tag (default: the current epoch ms). Must stay monotonic per client id: a reused or lower value is a silent duplicate, nothing is typed'
+ )
+ .option('--json', 'Machine-readable output')
+ .action(
+ (
+ id: string,
+ words: string[],
+ options: {
+ wait?: boolean;
+ until?: string;
+ timeout?: string;
+ enter: boolean;
+ clientId?: string;
+ seq?: string;
+ json?: boolean;
+ }
+ ) =>
+ run(Boolean(options.json), (deps) => {
+ const prompt = sendPromptFromArgs(words);
+ if ('error' in prompt) return Promise.resolve(fail(deps, prompt.error, EXIT.refused));
+ return agentSend(deps, {
+ id,
+ text: prompt.text,
+ enter: options.enter,
+ wait: options.until ?? (options.wait ? true : undefined),
+ timeoutMs: parsePositiveInt(options.timeout, DEFAULT_WAIT_MS),
+ clientId: options.clientId,
+ seq: options.seq === undefined ? undefined : parsePositiveInt(options.seq, 1, '--seq'),
+ });
+ })
+ );
+
+ agent
+ .command('wait ')
+ .description(
+ 'Block until a signal (--until) or an output marker (--match) — timeout exits 2, a dead worker exits 3'
+ )
+ .option(
+ '-u, --until ',
+ 'Comma list: stop,idle,exit,working,blocked (stop/blocked need hook signals for the session; where there are none the server answers 400, passed through)'
+ )
+ .option('-m, --match ', 'Literal substring to wait for in the output (ANSI-stripped, no regex)')
+ .option('--from ', 'buffer (scan existing output first, the default) or now', 'buffer')
+ .option('--nocase', 'Case-insensitive --match')
+ .option('--fresh', 'Require an actual transition (--until only)')
+ .option('-t, --timeout ', 'Budget in ms', String(DEFAULT_WAIT_MS))
+ .option('--json', 'Machine-readable output')
+ .action(
+ (
+ id: string,
+ options: {
+ until?: string;
+ match?: string;
+ from: string;
+ nocase?: boolean;
+ fresh?: boolean;
+ timeout?: string;
+ json?: boolean;
+ }
+ ) =>
+ run(Boolean(options.json), (deps) =>
+ agentWait(deps, {
+ id,
+ until: options.until,
+ match: options.match,
+ from: options.from === 'now' ? 'now' : 'buffer',
+ nocase: options.nocase,
+ fresh: options.fresh,
+ timeoutMs: parsePositiveInt(options.timeout, DEFAULT_WAIT_MS),
+ })
+ )
+ );
+
+ agent
+ .command('read ')
+ .description("Print a session's last answer (as the server reads it for that mode) or, with --tail, its terminal")
+ .option('--tail ', 'Raw terminal tail in bytes, ANSI stripped (works in every mode)')
+ .option('--full', 'The whole conversation instead of the last assistant message')
+ .option('--json', 'Machine-readable output')
+ .action((id: string, options: { tail?: string; full?: boolean; json?: boolean }) =>
+ run(Boolean(options.json), (deps) =>
+ agentRead(deps, {
+ id,
+ tail: options.tail === undefined ? undefined : parsePositiveInt(options.tail, 3000, '--tail'),
+ full: options.full,
+ })
+ )
+ );
+
+ agent
+ .command('interrupt ')
+ .description('Send a bare ESC to stop the current turn (the conversation survives; deleting would not)')
+ .option('--json', 'Machine-readable output')
+ .action((id: string, options: { json?: boolean }) =>
+ run(Boolean(options.json), (deps) => agentInterrupt(deps, { id }))
+ );
+
+ agent
+ .command('rm ')
+ .description('Delete a session you created (refuses your own id)')
+ .option('--json', 'Machine-readable output')
+ .action((id: string, options: { json?: boolean }) => run(Boolean(options.json), (deps) => agentRm(deps, { id })));
+
+ return agent;
+}
diff --git a/src/cli.ts b/src/cli.ts
index 0622087e..03692780 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -19,6 +19,7 @@ import { readCodemanCredentials } from './codeman-credentials.js';
import { casePath } from './config/cases-dir.js';
import { assertValidBasePath } from './config/base-path.js';
import { installAgentSkillInto, removeAgentSkillFrom, type AgentSkillApplyResult } from './hooks-config.js';
+import { registerAgentCommands } from './cli-agent.js';
import { getSessionManager } from './session-manager.js';
import { getTaskQueue } from './task-queue.js';
import { getRalphLoop } from './ralph-loop.js';
@@ -229,6 +230,10 @@ skillCmd
}
});
+// ============ Agent Commands (session-to-session, any CLI mode) ============
+
+registerAgentCommands(program);
+
// ============ Session Commands ============
const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions');
diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts
index 5159a203..3f795196 100644
--- a/src/config/cli-registry/schema.ts
+++ b/src/config/cli-registry/schema.ts
@@ -320,6 +320,8 @@ const capabilitiesSchema = z
// A declared width cannot do either. Absent means no strip, so a CLI whose
// transcript layout nobody has measured is never touched.
transcriptGutter: z.number().int().min(1).max(8).optional(),
+ // Literal text matched by a `wait-output` long-poll, never compiled as a regex.
+ composerReadyMark: z.string().min(1).max(64).optional(),
workDetect: z
.object({
promptGlyph: z.string().min(1).max(8),
diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts
index 35b042e9..ae497207 100644
--- a/src/config/cli-registry/stock.ts
+++ b/src/config/cli-registry/stock.ts
@@ -218,6 +218,9 @@ const CLAUDE: CliEntry = {
// in them, so a copy can drop two and paste flush. Claude and codex are the only
// entries that declare this, because theirs are the only gutters that have been measured.
transcriptGutter: 2,
+ // The composer's own hint text (`⏵⏵ … (shift+tab to cycle)`), not `❯`, which the
+ // trust dialog's selected row also carries. Measured by the agent skill's spawn_worker.
+ composerReadyMark: 'shift+tab',
// The historical hard-coded pair, now stated as data. `workingLine` matches both the
// `✻ Actualizing… (39s · ↓ 2.0k tokens)` status line and the bare `esc to interrupt`
// footer, because tmux repaints partially and only one of the two may land in a chunk.
@@ -1342,6 +1345,8 @@ const DEEPSEEK: CliEntry = {
// supervisor and Codeman is that supervisor. 'supervised' rather than 'always' because
// the session can disarm the bridge, and docker/remote cannot reach it at all.
hooks: 'supervised',
+ // dsh's composer glyph, drawn once the harness TUI can take a prompt.
+ composerReadyMark: '❯',
transcript: 'deepseek-zstd',
altScreen: 'strip-mux-only',
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts
index 09cc3136..af15865a 100644
--- a/src/config/cli-registry/types.ts
+++ b/src/config/cli-registry/types.ts
@@ -416,6 +416,17 @@ export interface CliCapabilities {
* Absent means no strip at all, the same fail-safe direction `workDetect` takes.
*/
transcriptGutter?: number;
+ /**
+ * Literal text the TUI draws once its composer can take a prompt — what `codeman agent
+ * spawn` waits for (a `wait-output` match) before it calls a worker ready.
+ *
+ * Deliberately NOT `workDetect.promptGlyph`: claude's `❯` also marks the selected row
+ * of its workspace-trust dialog, which is exactly the screen a readiness wait must not
+ * mistake for a composer, so claude declares its composer's own hint text instead.
+ * Absent means no readiness wait: a spawn returns as soon as the session exists, and
+ * the caller synchronizes on `wait-output` markers.
+ */
+ composerReadyMark?: string;
/** No direct-PTY fallback: the CLI must run inside tmux (secrets ride tmux setenv). */
requiresMux: boolean;
/**
diff --git a/test/cli-agent.test.ts b/test/cli-agent.test.ts
new file mode 100644
index 00000000..d89ccbb4
--- /dev/null
+++ b/test/cli-agent.test.ts
@@ -0,0 +1,646 @@
+/**
+ * @fileoverview `codeman agent …` — the three invariants from `src/cli-agent.ts`
+ * plus every verb against a recording fake transport, and the real HTTP transport
+ * against a local server (headers, auth, query encoding).
+ */
+
+import http from 'node:http';
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import {
+ AgentGuardError,
+ EXIT,
+ agentInterrupt,
+ agentLs,
+ agentRead,
+ agentRm,
+ agentSend,
+ agentSpawn,
+ agentWait,
+ baseHeaders,
+ sendPromptFromArgs,
+ composerReadyMark,
+ buildInterruptBody,
+ buildSendBody,
+ deleteRefusal,
+ describeFailure,
+ httpRequest,
+ inputRefusal,
+ isSelfSession,
+ parsePositiveInt,
+ resolveAgentContext,
+ stripAnsi,
+ waitExitCode,
+ type AgentContext,
+ type AgentDeps,
+ type ApiResponse,
+ type RequestOptions,
+} from '../src/cli-agent.js';
+import { readCodemanEnvFile } from '../src/codeman-credentials.js';
+
+const SELF = '058ee7b5-b2aa-4c33-8cc1-e900eb0b28af';
+const OTHER = '94990c6d-e461-4a29-aa83-89275327732c';
+
+function ctx(overrides: Partial = {}): AgentContext {
+ return { apiUrl: 'http://127.0.0.1:1', selfId: SELF, ...overrides };
+}
+
+/** Recording transport: answers from a queue (or a resolver) and keeps every call. */
+function fakeDeps(
+ answer: ((options: RequestOptions) => ApiResponse) | ApiResponse[],
+ json = false
+): AgentDeps & { calls: RequestOptions[]; out: string[]; err: string[] } {
+ const calls: RequestOptions[] = [];
+ const out: string[] = [];
+ const err: string[] = [];
+ const queue = Array.isArray(answer) ? [...answer] : undefined;
+ return {
+ ctx: ctx(),
+ calls,
+ out,
+ err,
+ json,
+ now: () => 1_700_000_000_000,
+ io: { out: (l) => out.push(l), err: (l) => err.push(l) },
+ request: async (_c, options) => {
+ calls.push(options);
+ if (queue) {
+ const next = queue.shift();
+ if (!next) throw new Error('fake transport: no answer queued');
+ return next;
+ }
+ return (answer as (o: RequestOptions) => ApiResponse)(options);
+ },
+ };
+}
+
+function ok(data: unknown): ApiResponse {
+ return { status: 200, json: { success: true, data }, text: '' };
+}
+
+function apiError(status: number, errorCode: string, error: string): ApiResponse {
+ return { status, json: { success: false, errorCode, error }, text: '' };
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Invariant 1: the guard
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('resolveAgentContext (guard)', () => {
+ const inside = { CODEMAN_MUX: '1', CODEMAN_API_URL: 'http://127.0.0.1:3459', CODEMAN_SESSION_ID: SELF };
+
+ it('refuses outside a Codeman session', () => {
+ expect(() => resolveAgentContext({}, () => ({}))).toThrow(AgentGuardError);
+ expect(() => resolveAgentContext({ ...inside, CODEMAN_MUX: '0' }, () => ({}))).toThrow(/CODEMAN_MUX/);
+ });
+
+ it('never guesses an API URL', () => {
+ expect(() => resolveAgentContext({ ...inside, CODEMAN_API_URL: '' }, () => ({}))).toThrow(/refusing to guess/);
+ expect(() => resolveAgentContext({ ...inside, CODEMAN_API_URL: undefined }, () => ({}))).toThrow(AgentGuardError);
+ });
+
+ it('needs its own session id to tell self from others', () => {
+ expect(() => resolveAgentContext({ ...inside, CODEMAN_SESSION_ID: '' }, () => ({}))).toThrow(/CODEMAN_SESSION_ID/);
+ });
+
+ it('takes the password from the environment first, the .env file second, and none means open', () => {
+ expect(
+ resolveAgentContext({ ...inside, CODEMAN_PASSWORD: 'pw' }, () => ({ CODEMAN_PASSWORD: 'file' })).auth
+ ).toEqual({
+ username: 'admin',
+ password: 'pw',
+ });
+ expect(resolveAgentContext(inside, () => ({ CODEMAN_USERNAME: 'joe', CODEMAN_PASSWORD: 'file' })).auth).toEqual({
+ username: 'joe',
+ password: 'file',
+ });
+ expect(resolveAgentContext(inside, () => ({})).auth).toBeUndefined();
+ });
+
+ it('resolves each field the way attach and the TUI do (one shared order)', () => {
+ // Password from the environment, username from the file: joe, not admin.
+ expect(
+ resolveAgentContext({ ...inside, CODEMAN_PASSWORD: 'pw' }, () => ({ CODEMAN_USERNAME: 'joe' })).auth
+ ).toEqual({ username: 'joe', password: 'pw' });
+ });
+
+ it('reads a hand-authored .env with quotes and export prefixes', () => {
+ const dir = mkdtempSync(join(tmpdir(), 'codeman-agent-env-'));
+ try {
+ const file = join(dir, '.env');
+ writeFileSync(file, '# comment\nexport CODEMAN_USERNAME="joe"\nCODEMAN_PASSWORD=\'s3cret\'\nnot a line\n');
+ expect(readCodemanEnvFile(file)).toEqual({ CODEMAN_USERNAME: 'joe', CODEMAN_PASSWORD: 's3cret' });
+ expect(readCodemanEnvFile(join(dir, 'missing'))).toEqual({});
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Invariant 2: send is printable text + \r; ESC lives only in interrupt
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('send transmits printable text only', () => {
+ it('refuses every control byte and DEL, naming it', () => {
+ expect(inputRefusal('\u0003')).toMatch(/0x03/); // Ctrl+C: opencode's app_exit
+ expect(inputRefusal('ls\u001b')).toMatch(/0x1b/);
+ expect(inputRefusal('a\u007fb')).toMatch(/0x7f/);
+ expect(inputRefusal('two\nlines')).toMatch(/single line/); // not the ESC hint
+ expect(inputRefusal('a\tb')).toMatch(/single line/);
+ expect(inputRefusal('x\u009bmy')).toMatch(/0x9b/); // 8-bit CSI
+ expect(inputRefusal('')).toMatch(/empty/);
+ });
+
+ it('accepts ordinary prompts, including unicode', () => {
+ expect(inputRefusal('review the diff in src/, then say DONE_4711')).toBeUndefined();
+ expect(inputRefusal('prüfe die Ändërung ❯ ok')).toBeUndefined();
+ });
+
+ it('appends exactly one \\r, or nothing with --no-enter, and never anything else', () => {
+ const base = { clientId: 'c', seq: 1 };
+ expect(buildSendBody('hi', { ...base, enter: true }).input).toBe('hi\r');
+ expect(buildSendBody('hi', { ...base, enter: false }).input).toBe('hi');
+ const body = buildSendBody('hi', { ...base, enter: true, wait: 'stop,exit', waitTimeout: 5000 });
+ expect(body).toEqual({ input: 'hi\r', useMux: true, clientId: 'c', seq: 1, wait: 'stop,exit', waitTimeout: 5000 });
+ expect(buildSendBody('hi', { ...base, enter: true })).not.toHaveProperty('wait');
+ });
+
+ it('interrupt is a bare ESC with no Enter', () => {
+ const body = buildInterruptBody('c-interrupt', 7);
+ expect(body.input).toBe('\u001b');
+ expect(String(body.input)).not.toContain('\r');
+ expect(body).toEqual({ input: '\u001b', useMux: true, clientId: 'c-interrupt', seq: 7 });
+ });
+
+ it('agentSend refuses control bytes BEFORE touching the transport', async () => {
+ const deps = fakeDeps([]);
+ expect(await agentSend(deps, { id: OTHER, text: 'q\u0003', enter: true })).toBe(EXIT.refused);
+ expect(deps.calls).toEqual([]);
+ });
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Invariant 3: rm fails closed
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('rm fails closed', () => {
+ it('refuses an empty id, a short self id, and a prefix match in either direction', () => {
+ expect(deleteRefusal(SELF, '')).toMatch(/empty/);
+ expect(deleteRefusal('058ee7', OTHER)).toMatch(/too short/);
+ expect(deleteRefusal(SELF, SELF)).toMatch(/is me/);
+ expect(deleteRefusal(SELF, SELF.slice(0, 8))).toMatch(/is me/); // 8-char form of me
+ expect(deleteRefusal(SELF.slice(0, 8), SELF)).toMatch(/is me/); // Docker's truncated $SELF
+ expect(deleteRefusal(SELF, OTHER)).toBeUndefined();
+ expect(deleteRefusal(SELF, OTHER.slice(0, 8))).toBeUndefined();
+ });
+
+ it('isSelfSession treats an unprovable self as "maybe me"', () => {
+ expect(isSelfSession('short', OTHER)).toBe(true);
+ expect(isSelfSession(SELF, '')).toBe(true);
+ expect(isSelfSession(SELF, OTHER)).toBe(false);
+ });
+
+ it('agentRm never calls DELETE on a refusal', async () => {
+ const deps = fakeDeps([]);
+ expect(await agentRm(deps, { id: SELF.slice(0, 8) })).toBe(EXIT.refused);
+ expect(deps.calls).toEqual([]);
+ });
+
+ it('agentRm deletes a foreign id plainly (killMux default, no query)', async () => {
+ const deps = fakeDeps([ok({})]);
+ expect(await agentRm(deps, { id: OTHER })).toBe(EXIT.ok);
+ expect(deps.calls[0]).toMatchObject({ method: 'DELETE', path: `/api/v1/sessions/${OTHER}` });
+ expect(deps.calls[0].query).toBeUndefined();
+ });
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Verbs against the fake transport
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('agent ls', () => {
+ const sessions = [
+ { id: SELF, mode: 'claude', status: 'busy', name: 'w1-Codeman' },
+ { id: OTHER, mode: 'opencode', status: 'idle', workingDir: '/home/joe/wiki' },
+ ];
+
+ it('marks this session and falls back to workingDir for the name', async () => {
+ const deps = fakeDeps([ok(sessions)]);
+ expect(await agentLs(deps)).toBe(EXIT.ok);
+ const text = deps.out.join('\n');
+ expect(text).toMatch(/\*\s+058ee7b5\s+claude\s+busy\s+w1-Codeman/);
+ expect(text).toMatch(/94990c6d\s+opencode\s+idle\s+\/home\/joe\/wiki/);
+ });
+
+ it('--json is the envelope data plus a self flag', async () => {
+ const deps = fakeDeps([ok(sessions)], true);
+ await agentLs(deps);
+ const parsed = JSON.parse(deps.out.join('')) as Array<{ id: string; self: boolean }>;
+ expect(parsed.map((s) => [s.id.slice(0, 8), s.self])).toEqual([
+ ['058ee7b5', true],
+ ['94990c6d', false],
+ ]);
+ });
+
+ it('surfaces a plain-text 401 as a credentials hint, not a parse error', async () => {
+ const deps = fakeDeps([{ status: 401, text: 'Unauthorized' }]);
+ expect(await agentLs(deps)).toBe(EXIT.error);
+ expect(deps.err.join('')).toMatch(/401.*password/);
+ });
+});
+
+describe('agent send', () => {
+ it('refuses to type into its own composer', async () => {
+ const deps = fakeDeps([]);
+ expect(await agentSend(deps, { id: SELF.slice(0, 8), text: 'hi', enter: true })).toBe(EXIT.refused);
+ expect(deps.calls).toEqual([]);
+ });
+
+ it('fire-and-forget: input + \\r, a fixed clientId per caller, seq from the clock', async () => {
+ const deps = fakeDeps([ok({ delivered: true })]);
+ expect(await agentSend(deps, { id: OTHER, text: 'say DONE_1', enter: true })).toBe(EXIT.ok);
+ expect(deps.calls[0]).toMatchObject({
+ method: 'POST',
+ path: `/api/v1/sessions/${OTHER}/input`,
+ body: { input: 'say DONE_1\r', useMux: true, clientId: 'codeman-agent-cli-058ee7b5', seq: 1_700_000_000_000 },
+ });
+ expect(deps.calls[0].body).not.toHaveProperty('wait');
+ });
+
+ it('--wait passes the signal list and timeout through and maps the result to an exit code', async () => {
+ const stop = fakeDeps([ok({ delivered: true, wait: { signal: 'stop', timedOut: false } })]);
+ expect(await agentSend(stop, { id: OTHER, text: 'go', enter: true, wait: 'stop,exit', timeoutMs: 5000 })).toBe(
+ EXIT.ok
+ );
+ expect(stop.calls[0].body).toMatchObject({ wait: 'stop,exit', waitTimeout: 5000 });
+
+ const timeout = fakeDeps([ok({ delivered: true, wait: { timedOut: true, timeoutMs: 5000 } })]);
+ expect(await agentSend(timeout, { id: OTHER, text: 'go', enter: true, wait: true, timeoutMs: 5000 })).toBe(
+ EXIT.timeout
+ );
+
+ const dead = fakeDeps([ok({ delivered: true, wait: { signal: 'exit' } })]);
+ expect(await agentSend(dead, { id: OTHER, text: 'go', enter: true, wait: true })).toBe(EXIT.dead);
+ });
+
+ it('delivered:false without duplicate is "the bytes went nowhere": exit 3, never a ✓', async () => {
+ const deps = fakeDeps([ok({ delivered: false, duplicate: false, wait: { ended: true, signal: null } })]);
+ expect(await agentSend(deps, { id: OTHER, text: 'go', enter: true, wait: true })).toBe(EXIT.dead);
+ expect(deps.out.join('')).not.toMatch(/delivered to/);
+ expect(deps.err.join('')).toMatch(/not delivered.*restart/);
+ });
+
+ it('fire-and-forget says "accepted", not "delivered" (the route answers before the write)', async () => {
+ const deps = fakeDeps([ok({})]);
+ expect(await agentSend(deps, { id: OTHER, text: 'go', enter: true })).toBe(EXIT.ok);
+ expect(deps.out.join('')).toMatch(/accepted for/);
+ expect(deps.out.join('')).not.toMatch(/delivered to/);
+ });
+
+ it('reports a tagged duplicate instead of claiming delivery', async () => {
+ const deps = fakeDeps([ok({ delivered: false, duplicate: true })]);
+ await agentSend(deps, { id: OTHER, text: 'go', enter: true });
+ expect(deps.out.join('')).toMatch(/duplicate/);
+ });
+});
+
+describe('agent wait', () => {
+ it('--until goes to /wait and a 400 for a hook-less mode is passed through, not papered over', async () => {
+ const deps = fakeDeps([apiError(400, 'INVALID_INPUT', 'until=stop is not available for mode opencode')]);
+ expect(await agentWait(deps, { id: OTHER, until: 'stop', timeoutMs: 1000 })).toBe(EXIT.error);
+ expect(deps.calls[0]).toMatchObject({
+ method: 'GET',
+ path: `/api/v1/sessions/${OTHER}/wait`,
+ query: { until: 'stop', timeout: 1000 },
+ });
+ expect(deps.err.join('')).toMatch(/INVALID_INPUT.*opencode/);
+ });
+
+ it('--match goes to /wait-output with from=buffer by default', async () => {
+ const deps = fakeDeps([ok({ wait: { matched: true, match: 'DONE_1', snippet: 'DONE_1' } })]);
+ expect(await agentWait(deps, { id: OTHER, match: 'DONE_1', timeoutMs: 1000 })).toBe(EXIT.ok);
+ expect(deps.calls[0]).toMatchObject({
+ path: `/api/v1/sessions/${OTHER}/wait-output`,
+ query: { match: 'DONE_1', from: 'buffer', timeout: 1000 },
+ });
+ });
+
+ it('refuses --until together with --match', async () => {
+ const deps = fakeDeps([]);
+ expect(await agentWait(deps, { id: OTHER, until: 'idle', match: 'x', timeoutMs: 1000 })).toBe(EXIT.refused);
+ expect(deps.calls).toEqual([]);
+ });
+
+ it('a wait that ended without an answer is reported as dead, not as `signal: null`', async () => {
+ const deps = fakeDeps([ok({ wait: { ended: true, signal: null, timedOut: false } })]);
+ expect(await agentWait(deps, { id: OTHER, until: 'stop', timeoutMs: 1000 })).toBe(EXIT.dead);
+ expect(deps.out.join('')).toMatch(/went away/);
+ expect(deps.out.join('')).not.toMatch(/signal: null/);
+ });
+
+ it('exit codes: matched/signal 0, timeout 2, exit 3', () => {
+ expect(waitExitCode({ signal: 'stop' })).toBe(EXIT.ok);
+ expect(waitExitCode({ matched: true })).toBe(EXIT.ok);
+ expect(waitExitCode({ matched: false, timedOut: true })).toBe(EXIT.timeout);
+ expect(waitExitCode({ timedOut: true })).toBe(EXIT.timeout);
+ expect(waitExitCode({ signal: 'exit' })).toBe(EXIT.dead);
+ // A worker that dies during --until stop: the registry only satisfies waiters that
+ // listed `exit`, then cancels the rest → ended:true, signal:null. Never "done".
+ expect(waitExitCode({ ended: true, signal: null, timedOut: false })).toBe(EXIT.dead);
+ expect(waitExitCode({ ended: true, matched: false, timedOut: false })).toBe(EXIT.dead);
+ expect(waitExitCode(undefined)).toBe(EXIT.error);
+ });
+});
+
+describe('agent read', () => {
+ it('defaults to last-response and prints the text', async () => {
+ const deps = fakeDeps([ok({ text: 'the answer', timestamp: 't' })]);
+ expect(await agentRead(deps, { id: OTHER })).toBe(EXIT.ok);
+ expect(deps.calls[0].path).toBe(`/api/v1/sessions/${OTHER}/last-response`);
+ expect(deps.out).toEqual(['the answer']);
+ });
+
+ it('says why an empty transcript is empty instead of printing nothing', async () => {
+ const deps = fakeDeps([ok({ text: '' })]);
+ await agentRead(deps, { id: OTHER });
+ expect(deps.err.join('')).toMatch(/nothing answered yet.*--tail 3000/);
+ });
+
+ it('--tail fetches the terminal and strips ANSI', async () => {
+ const deps = fakeDeps([ok({ terminalBuffer: '\u001b[32m❯\u001b[0m ready \u001b(B' })]);
+ expect(await agentRead(deps, { id: OTHER, tail: 500 })).toBe(EXIT.ok);
+ expect(deps.calls[0]).toMatchObject({ path: `/api/v1/sessions/${OTHER}/terminal`, query: { tail: 500 } });
+ expect(deps.out).toEqual(['❯ ready ']);
+ });
+
+ it('--full prints every message with its role', async () => {
+ const deps = fakeDeps([
+ ok({
+ text: 'b',
+ messages: [
+ { role: 'user', text: 'a' },
+ { role: 'assistant', text: 'b' },
+ ],
+ }),
+ ]);
+ await agentRead(deps, { id: OTHER, full: true });
+ expect(deps.calls[0].query).toMatchObject({ context: 'full' });
+ expect(deps.out.map(stripAnsi)).toEqual(['user: a', 'assistant: b']);
+ });
+});
+
+describe('agent interrupt', () => {
+ it('sends the bare ESC body under its own clientId, never to itself', async () => {
+ const deps = fakeDeps([ok({ delivered: true })]);
+ expect(await agentInterrupt(deps, { id: OTHER })).toBe(EXIT.ok);
+ expect(deps.calls[0].body).toEqual({
+ input: '\u001b',
+ useMux: true,
+ clientId: 'codeman-agent-cli-058ee7b5-interrupt',
+ seq: 1_700_000_000_000,
+ });
+ const self = fakeDeps([]);
+ expect(await agentInterrupt(self, { id: SELF })).toBe(EXIT.refused);
+ expect(self.calls).toEqual([]);
+ });
+});
+
+describe('send takes the prompt as ONE argument', () => {
+ it('refuses several words, which an unquoted multi-line $(…) becomes after word splitting', () => {
+ expect(sendPromptFromArgs(['review src/, then say DONE'])).toEqual({ text: 'review src/, then say DONE' });
+ expect(sendPromptFromArgs(['line', 'one', 'line', 'two'])).toMatchObject({
+ error: expect.stringMatching(/ONE argument, got 4/),
+ });
+ });
+});
+
+describe('agent spawn: a worker that dies during the readiness wait', () => {
+ it('is exit 3 with its own line, not the composer-timeout hint', async () => {
+ const deps = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' }), ok({ wait: { ended: true, matched: false } })]);
+ expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.dead);
+ expect(deps.err.join('')).toMatch(/exited during the readiness wait/);
+ expect(deps.err.join('')).not.toMatch(/composer not seen/);
+ });
+});
+
+describe('agent wait: a timeout is an answer, not a failure', () => {
+ it('prints a neutral line and exits 2', async () => {
+ const deps = fakeDeps([ok({ wait: { timedOut: true, timeoutMs: 1000 } })]);
+ expect(await agentWait(deps, { id: OTHER, until: 'stop', from: 'buffer', timeoutMs: 1000 })).toBe(EXIT.timeout);
+ const line = deps.out.join('\n').replace(/\x1b\[[0-9;]*m/g, '');
+ expect(line).toBe('timed out after 1000 ms (exit 2)');
+ expect(deps.err).toEqual([]);
+ });
+});
+
+describe('agent spawn', () => {
+ it('quick-starts with lineage and waits for the claude composer', async () => {
+ const deps = fakeDeps([
+ ok({ sessionId: OTHER, caseName: 'scratch-1', casePath: '/x' }),
+ ok({ wait: { matched: true } }),
+ ]);
+ expect(await agentSpawn(deps, { caseName: 'scratch-1', mode: 'claude', ready: true, timeoutMs: 2000 })).toBe(
+ EXIT.ok
+ );
+ expect(deps.calls[0]).toMatchObject({
+ method: 'POST',
+ path: '/api/v1/quick-start',
+ body: { caseName: 'scratch-1', mode: 'claude', parentSessionId: SELF },
+ });
+ expect(deps.calls[1]).toMatchObject({
+ path: `/api/v1/sessions/${OTHER}/wait-output`,
+ query: { match: 'shift+tab', from: 'buffer', timeout: 2000 },
+ });
+ expect(deps.out).toEqual([OTHER]); // stdout is the id ALONE, so `SID=$(…)` works; prose goes to stderr
+ expect(deps.err.join('')).toMatch(/spawned .*composer up/s);
+ });
+
+ it('labels the case as agent scratch on the spawn request, and on no other request', async () => {
+ // The label drives a recursive-delete affordance in the Add Case UI: it may only ride
+ // the request that can CREATE a case directory (quick-start), never anything else.
+ const spawn = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' }), ok({ wait: { matched: true } })]);
+ await agentSpawn(spawn, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 });
+ expect(spawn.calls[0].headers).toEqual({ 'X-Codeman-Agent-Origin': 'codeman-agent-cli' });
+ expect(spawn.calls[1].headers?.['X-Codeman-Agent-Origin']).toBeUndefined(); // the readiness wait
+
+ const everyOther = fakeDeps((o) =>
+ o.path === '/api/v1/sessions' ? ok([]) : ok({ wait: { signal: 'stop' }, text: '' })
+ );
+ await agentLs(everyOther);
+ await agentSend(everyOther, { id: OTHER, text: 'hi', enter: true });
+ await agentWait(everyOther, { id: OTHER, until: 'stop', from: 'buffer', timeoutMs: 1000 });
+ await agentRead(everyOther, { id: OTHER });
+ await agentInterrupt(everyOther, { id: OTHER });
+ await agentRm(everyOther, { id: OTHER });
+ expect(everyOther.calls.length).toBeGreaterThan(5);
+ for (const call of everyOther.calls) expect(call.headers?.['X-Codeman-Agent-Origin'], call.path).toBeUndefined();
+ expect(baseHeaders(ctx())).not.toHaveProperty('X-Codeman-Agent-Origin');
+ });
+
+ it('takes the readiness mark from the CLI registry, not from a mode list', () => {
+ expect(composerReadyMark('claude')).toBe('shift+tab');
+ expect(composerReadyMark('deepseek')).toBe('❯');
+ expect(composerReadyMark('pi')).toBeUndefined();
+ expect(composerReadyMark('no-such-cli')).toBeUndefined();
+ });
+
+ it('a composer that never shows up is exit 2 and the session is left for inspection, not deleted', async () => {
+ const deps = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' }), ok({ wait: { matched: false, timedOut: true } })]);
+ expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.timeout);
+ expect(deps.calls.map((c) => c.method)).toEqual(['POST', 'GET']);
+ expect(deps.err.join('')).toMatch(/startup dialog/);
+ });
+
+ it('a mode without a readiness mark returns after the create, and --no-ready skips the wait everywhere', async () => {
+ const pi = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' })]);
+ expect(await agentSpawn(pi, { caseName: 'c', mode: 'pi', ready: true, timeoutMs: 1000 })).toBe(EXIT.ok);
+ expect(pi.calls).toHaveLength(1);
+ const noReady = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' })]);
+ expect(await agentSpawn(noReady, { caseName: 'c', mode: 'claude', ready: false, timeoutMs: 1000 })).toBe(EXIT.ok);
+ expect(noReady.calls).toHaveLength(1);
+ });
+
+ it('a failed readiness call reports its own reason instead of the trust-dialog hint', async () => {
+ const deps = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' }), apiError(429, 'RATE_LIMITED', 'waiter pool full')]);
+ expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.error);
+ expect(deps.err.join('')).toMatch(/readiness check failed: RATE_LIMITED/);
+ expect(deps.err.join('')).not.toMatch(/trust dialog/);
+ expect(deps.out).toEqual([OTHER]); // the session exists; the id is still handed back
+ });
+
+ it('a failed quick-start is terminal: the error code is shown and nothing else is called', async () => {
+ const deps = fakeDeps([apiError(409, 'SESSION_BUSY', 'session cap reached')]);
+ expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.error);
+ expect(deps.calls).toHaveLength(1);
+ expect(deps.err.join('')).toMatch(/SESSION_BUSY/);
+ });
+});
+
+describe('session id prefixes', () => {
+ const THIRD = '94990c6d-ffff-4000-8000-000000000000';
+ const list = ok([{ id: SELF }, { id: OTHER }, { id: THIRD }]);
+
+ it('a full id goes straight to the route, no list call', async () => {
+ const deps = fakeDeps([ok({ text: 'x' })]);
+ await agentRead(deps, { id: OTHER });
+ expect(deps.calls.map((c) => c.path)).toEqual([`/api/v1/sessions/${OTHER}/last-response`]);
+ });
+
+ it('a unique prefix (what `ls` prints) resolves through the list — the routes 404 on prefixes', async () => {
+ const deps = fakeDeps([ok([{ id: SELF }, { id: OTHER }]), ok({ text: 'x' })]);
+ expect(await agentRead(deps, { id: '94990c6d' })).toBe(EXIT.ok);
+ expect(deps.calls.map((c) => c.path)).toEqual(['/api/v1/sessions', `/api/v1/sessions/${OTHER}/last-response`]);
+ });
+
+ it('an ambiguous prefix refuses instead of picking one', async () => {
+ const deps = fakeDeps([list]);
+ expect(await agentRead(deps, { id: '94990c6d' })).toBe(EXIT.error);
+ expect(deps.err.join('')).toMatch(/ambiguous.*94990c6d-e461.*94990c6d-ffff/);
+ expect(deps.calls).toHaveLength(1);
+ });
+
+ it('an unknown prefix names the problem', async () => {
+ const deps = fakeDeps([list]);
+ expect(await agentRm(deps, { id: 'deadbeef' })).toBe(EXIT.error);
+ expect(deps.err.join('')).toMatch(/no session starts with "deadbeef"/);
+ expect(deps.calls.map((c) => c.method)).toEqual(['GET']);
+ });
+
+ it('rm runs the self guard before the list and again on the resolved id', async () => {
+ const first = fakeDeps([ok([{ id: SELF }])]);
+ expect(await agentRm(first, { id: SELF.slice(0, 8) })).toBe(EXIT.refused);
+ expect(first.calls).toEqual([]); // refused before any request
+ const resolved = fakeDeps([ok([{ id: SELF }, { id: OTHER }])]);
+ expect(await agentRm(resolved, { id: 'deadbeef' })).toBe(EXIT.error); // nothing to delete
+ expect(resolved.calls.map((c) => c.method)).toEqual(['GET']);
+ });
+});
+
+describe('option parsing', () => {
+ it('positive integers only, the server rejects the rest', () => {
+ expect(parsePositiveInt(undefined, 60000)).toBe(60000);
+ expect(parsePositiveInt('1500', 1)).toBe(1500);
+ for (const bad of ['0', '-1', '1.5', '30s', '']) expect(() => parsePositiveInt(bad, 1)).toThrow(/positive integer/);
+ });
+
+ it('describeFailure prefers the envelope and falls back to the status line', () => {
+ expect(describeFailure(apiError(404, 'NOT_FOUND', 'no such session'))).toBe(
+ 'NOT_FOUND: no such session (HTTP 404)'
+ );
+ expect(describeFailure({ status: 403, text: 'Forbidden: host not allowed' })).toBe(
+ 'HTTP 403 Forbidden: host not allowed'
+ );
+ });
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// The real transport against a local server
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('httpRequest', () => {
+ let server: http.Server;
+ let apiUrl: string;
+ const seen: Array<{ method?: string; url?: string; headers: http.IncomingHttpHeaders; body: string }> = [];
+
+ beforeAll(async () => {
+ server = http.createServer((req, res) => {
+ const chunks: Buffer[] = [];
+ req.on('data', (c: Buffer) => chunks.push(c));
+ req.on('end', () => {
+ seen.push({ method: req.method, url: req.url, headers: req.headers, body: Buffer.concat(chunks).toString() });
+ if (req.url?.startsWith('/plain')) {
+ res.writeHead(401, { 'Content-Type': 'text/plain' }).end('Unauthorized');
+ return;
+ }
+ res
+ .writeHead(200, { 'Content-Type': 'application/json' })
+ .end(JSON.stringify({ success: true, data: { echo: true } }));
+ });
+ });
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ const address = server.address() as { port: number };
+ apiUrl = `http://127.0.0.1:${address.port}`;
+ });
+
+ afterAll(async () => {
+ await new Promise((resolve) => server.close(() => resolve()));
+ });
+
+ it('carries lineage headers, Basic auth and a urlencoded query (the + in shift+tab survives)', async () => {
+ const res = await httpRequest(ctx({ apiUrl, auth: { username: 'joe', password: 'pw' } }), {
+ method: 'GET',
+ path: '/api/v1/sessions/x/wait-output',
+ query: { match: 'shift+tab', from: 'buffer', timeout: 1000, nocase: undefined },
+ });
+ expect(res.status).toBe(200);
+ expect(res.json).toEqual({ success: true, data: { echo: true } });
+ const last = seen.at(-1)!;
+ expect(last.url).toBe('/api/v1/sessions/x/wait-output?match=shift%2Btab&from=buffer&timeout=1000');
+ expect(last.headers['x-codeman-parent-session']).toBe(SELF);
+ expect(last.headers['x-codeman-agent-origin']).toBeUndefined(); // only spawn's quick-start carries it
+ expect(last.headers.authorization).toBe(`Basic ${Buffer.from('joe:pw').toString('base64')}`);
+ });
+
+ it('posts JSON bodies with a length, and no auth header when the server is open', async () => {
+ await httpRequest(ctx({ apiUrl }), {
+ method: 'POST',
+ path: '/api/v1/sessions/x/input',
+ body: { input: 'hi\r', seq: 1 },
+ });
+ const last = seen.at(-1)!;
+ expect(last.method).toBe('POST');
+ expect(JSON.parse(last.body)).toEqual({ input: 'hi\r', seq: 1 });
+ expect(last.headers['content-type']).toBe('application/json');
+ expect(last.headers.authorization).toBeUndefined();
+ });
+
+ it('keeps a plain-text body when the answer is not JSON', async () => {
+ const res = await httpRequest(ctx({ apiUrl }), { method: 'GET', path: '/plain' });
+ expect(res.status).toBe(401);
+ expect(res.json).toBeUndefined();
+ expect(res.text).toBe('Unauthorized');
+ });
+});
diff --git a/test/cli-commands.test.ts b/test/cli-commands.test.ts
index 6298f451..9714313e 100644
--- a/test/cli-commands.test.ts
+++ b/test/cli-commands.test.ts
@@ -33,6 +33,7 @@ function walk(cmd: Command, path: string[] = []): Array<{ path: string[]; cmd: C
const TOP_LEVEL: Record = {
attach: [],
skill: [],
+ agent: [],
session: ['s'],
task: ['t'],
ralph: ['r'],
@@ -53,6 +54,7 @@ const SUBCOMMANDS: Record> = {
task: { add: [], list: ['ls'], status: [], remove: ['rm'], clear: [] },
ralph: { start: [], stop: [], status: [] },
skill: { install: [], uninstall: [] },
+ agent: { ls: ['list'], spawn: [], send: [], wait: [], read: [], interrupt: [], rm: [] },
service: { install: [], uninstall: [], status: [] },
users: { add: [], passwd: [], list: ['ls'], rm: [] },
};
diff --git a/test/routes/agent-case-marker-routes.test.ts b/test/routes/agent-case-marker-routes.test.ts
index 037b33a2..473c49b5 100644
--- a/test/routes/agent-case-marker-routes.test.ts
+++ b/test/routes/agent-case-marker-routes.test.ts
@@ -155,6 +155,37 @@ describe('agent-created case marker', () => {
expect(await readMarker(name)).toBeNull();
});
+ it('never labels the working directory of a POST /api/sessions, header or not', async () => {
+ // `codeman agent` sends the origin only on quick-start, but the server must not
+ // trust that: a session created on an EXISTING workingDir (somebody's real repo)
+ // is never marked as agent scratch, whatever the request carries.
+ const name = 'existingrepo1';
+ created.push(name);
+ const dir = join(getCasesDir(), name);
+ await mkdir(dir, { recursive: true });
+
+ const res = await harness.app.inject({
+ method: 'POST',
+ url: '/api/sessions',
+ headers: { 'x-codeman-agent-origin': 'codeman-agent-cli', 'x-codeman-parent-session': PARENT_ID },
+ payload: { workingDir: dir, mode: 'claude' },
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(await readMarker(name)).toBeNull();
+ });
+
+ it('labels a case quick-start creates for `codeman agent spawn`, and not one that existed', async () => {
+ await quickStart('agentclicase1', { headers: { 'x-codeman-agent-origin': 'codeman-agent-cli' } });
+ expect(await readMarker('agentclicase1')).toMatchObject({ createdBy: 'codeman-agent-cli' });
+
+ const name = 'preexisting2';
+ created.push(name);
+ await mkdir(join(getCasesDir(), name), { recursive: true });
+ await quickStart(name, { headers: { 'x-codeman-agent-origin': 'codeman-agent-cli' } });
+ expect(await readMarker(name)).toBeNull();
+ });
+
it('drops an unrecognised origin token rather than storing it', async () => {
await quickStart('agentcase4', { payload: { agentOrigin: '' } });
From 24e51a3b8e8d38b0999c01dd8ff4429f45146817 Mon Sep 17 00:00:00 2001
From: Randalix
Date: Fri, 9 Oct 2026 12:59:47 +0200
Subject: [PATCH 07/76] =?UTF-8?q?fix(cli):=20review=20=E2=80=94=208-char?=
=?UTF-8?q?=20id=20floor,=20marker=20echo,=20wake=20answers=20on=20send?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
From the review of #557:
- An id shorter than 8 characters refuses with exit 4 before any request,
on every verb. `rm 9` resolved to whichever session was alone with that
first character (the user's own tab included) and deleted it. Same floor
as the server's PARENT_SESSION_ID_MIN_PREFIX. `rm` no longer claims a
lineage check: "Delete any session except this one".
- `wait --match` help and the README example say the marker must not appear
verbatim in the prompt (its echo matches at once) and show the split form.
- `send` reads the route's wake-on-LAN answers: `buffered` gets its own
line (exit 0), `dropped` exits 1 instead of printing "accepted".
- `--` for a prompt that starts with "-", in the `send` description and in
the one-argument refusal.
- `stripAnsi` builds on the shared one (OSC sequences go too); the
inputRefusal JSDoc sits above inputRefusal again.
- docs/wiki/Driving-Codeman-From-An-Agent.md gets a `codeman agent` section.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
README.md | 7 +-
docs/api-reference.md | 2 +-
docs/wiki/Driving-Codeman-From-An-Agent.md | 33 +++++++-
src/cli-agent.ts | 97 +++++++++++++++++-----
test/cli-agent.test.ts | 67 ++++++++++++++-
5 files changed, 177 insertions(+), 29 deletions(-)
diff --git a/README.md b/README.md
index b9700a2f..5a06185f 100644
--- a/README.md
+++ b/README.md
@@ -954,12 +954,13 @@ SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait f
codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000 # --wait = default signal set
codeman agent read "$SID" # last answer (as the server reads it for that mode)
codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode)
-codeman agent wait "$SID" --match DONE_4711 # marker wait for hook-less modes (opencode, pi, …)
+codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes (opencode, pi, …): ask for the marker in halves …
+codeman agent wait "$SID" --match WORKDONE_4711 # … and wait on the joined form, which the prompt's echo never contains
codeman agent interrupt "$SID" # a bare ESC, conversation intact
-codeman agent rm "$SID" # refuses your own id
+codeman agent rm "$SID" # any session except this one
```
-Rules the commands enforce rather than document: they refuse outside a Codeman session and never guess a URL; `send` transmits printable text plus Enter only (a control byte such as `Ctrl+C` is `app_exit` in opencode — ESC exists solely as `interrupt`, which never appends Enter); `rm` refuses an empty id, an unprovable self id and a prefix match in either direction. Ids may be the 8-character prefixes `ls` prints (resolved through the list; an ambiguous prefix refuses). Exit codes: `0` delivered/matched/signal, `1` error, `2` timeout, `3` the worker exited or the wait ended without an answer (`delivered:false`, `ended:true`), `4` refused. `spawn` prints the id alone on stdout (prose goes to stderr), so `SID=$(…)` captures exactly the id. `--json` prints the envelope's `data` for every verb. `--until stop` on a mode without hook signals is the server's 400, passed through — the marker path (`--match`) is the answer there, exactly as for the skill.
+Rules the commands enforce rather than document: they refuse outside a Codeman session and never guess a URL; `send` transmits printable text plus Enter only (a control byte such as `Ctrl+C` is `app_exit` in opencode — ESC exists solely as `interrupt`, which never appends Enter); `rm` refuses an empty id, an unprovable self id and a prefix match in either direction. Ids may be the 8-character prefixes `ls` prints (resolved through the list; an ambiguous prefix refuses, anything shorter than 8 characters refuses on every verb). A prompt that starts with `-` goes after `--` (`send "$SID" -- "- fix the bug"`). The echo of the prompt you sent is output too: a `--match` marker that appears verbatim in the prompt matches at once, before the worker has done anything, so the prompt asks for it in halves. A remote session whose host is asleep answers a fire-and-forget `send` with `buffered` (Codeman wakes the host and types the prompt once the pane is back) or `dropped` (over the wake buffer's cap, nothing will be typed: exit `1`). Exit codes: `0` delivered/matched/signal, `1` error, `2` timeout, `3` the worker exited or the wait ended without an answer (`delivered:false`, `ended:true`), `4` refused. `spawn` prints the id alone on stdout (prose goes to stderr), so `SID=$(…)` captures exactly the id. `--json` prints the envelope's `data` for every verb. `--until stop` on a mode without hook signals is the server's 400, passed through — the marker path (`--match`) is the answer there, exactly as for the skill.
### Hooks (events flowing _back_ to Codeman)
diff --git a/docs/api-reference.md b/docs/api-reference.md
index 09022fc5..29fd5e06 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -468,7 +468,7 @@ geometry was read. The capture runs synchronous tmux calls on the server; the
## The `codeman agent` CLI (client over these endpoints)
-`codeman agent ls|spawn|send|wait|read|interrupt|rm` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents in modes that never receive the claude-only skill preamble. It adds no route: `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until `; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour.
+`codeman agent ls|spawn|send|wait|read|interrupt|rm` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents in modes that never receive the claude-only skill preamble. It adds no route: `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until `; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour.
## Session lineage (`parentSessionId`)
diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md
index 2fa41b9e..1a5b16a2 100644
--- a/docs/wiki/Driving-Codeman-From-An-Agent.md
+++ b/docs/wiki/Driving-Codeman-From-An-Agent.md
@@ -4,7 +4,8 @@ Everything the dashboard does is HTTP, so an agent can do it too. This page is f
that makes Codeman interesting: **Claude Code running inside a Codeman session, spawning and
supervising other sessions.**
-Two routes. Start with the skill.
+Three routes. In a Claude session, start with the skill. In any other CLI mode, use the
+`codeman agent` commands. Raw HTTP is there for everything else.
## The agent skill
@@ -59,6 +60,36 @@ DeepSeek Harness workers the same way it drives Claude ones (`spawn_workers alph
beta:deepseek` is a mixed fleet in one call), since those are the two modes with real
completion signals.
+## The `codeman agent` commands
+
+The skill is Claude-shaped: Codeman seeds its preamble for Claude sessions only. An
+`opencode`, `codex`, `pi` or `gemini` agent runs in the same environment but has nothing
+that teaches it the API, so `codeman agent` packages the same verbs as shell commands. It is
+a thin client over the endpoints in [the manual path](#the-manual-path), so auth and
+ownership apply unchanged, and it refuses to act outside a Codeman session. One line in a
+case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*.
+
+```bash
+codeman agent ls # sessions; * marks this one
+SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait for the composer where the mode has a ready mark
+codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000
+codeman agent read "$SID" # last answer (as the server reads it for that mode)
+codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode)
+codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes: the marker in halves …
+codeman agent wait "$SID" --match WORKDONE_4711 # … and the wait on the joined form
+codeman agent interrupt "$SID" # a bare ESC, conversation intact
+codeman agent rm "$SID" # any session except this one
+```
+
+- **Ids** may be the 8-character form `ls` prints. Anything shorter refuses, and so does an
+ ambiguous prefix.
+- **`send`** takes ONE quoted argument of printable text and presses Enter. A prompt that
+ starts with `-` goes after `--`: `codeman agent send "$SID" -- "- fix the bug"`.
+- **Markers** follow [the split-marker trick](#the-split-marker-trick): the echo of your own
+ prompt is output too, so ask for the marker in halves and wait on the joined form.
+- **Exit codes** are the same for every verb: `0` done, `1` error, `2` timeout, `3` the
+ worker exited, `4` refused. `--json` prints the response's `data`.
+
## The manual path
The same operations as raw HTTP, for a CI bot, a shell script, or an agent without skill
diff --git a/src/cli-agent.ts b/src/cli-agent.ts
index 7b73e1fd..4a295af5 100644
--- a/src/cli-agent.ts
+++ b/src/cli-agent.ts
@@ -19,6 +19,9 @@
* 3. `rm` fails closed: empty id, a short self id, or a prefix match in EITHER
* direction refuses. Ids appear in full and 8-char form, so equality alone
* misses a real combination — and the miss deletes the caller.
+ * 4. An id shorter than 8 characters refuses (exit 4) before any request, on every
+ * verb. `9` would resolve to whichever session is alone with that first
+ * character, the user's own interactive tab included.
*
* Commands live here as functions returning an exit code, not calling
* `process.exit`, so the whole surface is unit-testable against a fake server.
@@ -37,6 +40,7 @@ import {
import { getCli } from './config/cli-registry/registry.js';
import { GLYPH, palette, table } from './cli-style.js';
import { getErrorMessage } from './types.js';
+import { stripAnsi as stripAnsiSequences } from './utils/regex-patterns.js';
// ─────────────────────────────────────────────────────────────────────────────
// Context and guard
@@ -112,11 +116,6 @@ export function deleteRefusal(selfId: string, id: string): string | undefined {
return undefined;
}
-/**
- * Why `send` refuses this text, or `undefined` when it is printable. The composer
- * takes one line; the server strips `\r`/`\n` but everything else below 0x20 (and
- * DEL) reaches the pane as a keypress. None of that is a prompt.
- */
/**
* The prompt `send` was given, which must be ONE argument. Joining several with spaces
* would turn an unquoted `$(cat notes.txt)`, which the shell splits on every newline,
@@ -125,10 +124,15 @@ export function deleteRefusal(selfId: string, id: string): string | undefined {
export function sendPromptFromArgs(words: readonly string[]): { text: string } | { error: string } {
if (words.length === 1) return { text: words[0] };
return {
- error: `refusing: the prompt must be ONE argument, got ${words.length} — quote it (\`send "…"\`)`,
+ error: `refusing: the prompt must be ONE argument, got ${words.length} — quote it (\`send "…"\`; a prompt that starts with "-" goes after --: \`send -- "- fix the bug"\`)`,
};
}
+/**
+ * Why `send` refuses this text, or `undefined` when it is printable. The composer
+ * takes one line; the server strips `\r`/`\n` but everything else below 0x20 (and
+ * DEL) reaches the pane as a keypress. None of that is a prompt.
+ */
export function inputRefusal(text: string): string | undefined {
if (text.length === 0) return 'refusing: empty input (use `interrupt` for ESC, `send ""` is never a prompt)';
// The composer is one line: the server strips newlines, which silently joins the
@@ -175,10 +179,13 @@ export function defaultClientId(selfId: string, suffix = ''): string {
return `codeman-agent-cli-${selfId.slice(0, 8)}${suffix ? `-${suffix}` : ''}`;
}
-/** Strip ANSI CSI/charset sequences from a terminal buffer (GNU and BSD alike). */
+/**
+ * Strip a terminal buffer for humans: the shared ANSI strip (CSI, OSC such as window
+ * titles, keypad modes) plus the charset designators (`ESC ( B`) it leaves in.
+ */
export function stripAnsi(text: string): string {
// eslint-disable-next-line no-control-regex
- return text.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '').replace(/\x1b[()][AB0]/g, '');
+ return stripAnsiSequences(text).replace(/\x1b[()][AB0]/g, '');
}
/** Parse a positive-integer option (`--timeout` ms, `--tail` bytes); the server rejects anything else. */
@@ -368,21 +375,38 @@ interface SessionRow {
/** A full session id (the only form the routes accept); `ls` prints the 8-char prefix. */
const FULL_ID_LENGTH = 36;
+/**
+ * Shortest prefix that may name a session: the 8-char form `ls` prints, and the floor
+ * the server's own resolver uses (`PARENT_SESSION_ID_MIN_PREFIX`, route-helpers.ts).
+ */
+export const MIN_ID_PREFIX_LENGTH = 8;
+
/**
* Turn the id a human typed into the one the routes accept. `ls` prints 8-char
* prefixes and the routes answer 404 to those (measured live), so anything shorter
* than a full id resolves through the session list; an ambiguous prefix refuses
- * rather than picking one.
+ * rather than picking one. Below 8 characters it refuses before the list: "unique"
+ * means nothing for `9` — it names whatever session happens to be alone with that
+ * first character, and `rm`/`send` would act on it.
*/
-export async function resolveSessionId(deps: AgentDeps, id: string): Promise<{ id: string } | { error: string }> {
- if (!id) return { error: 'refusing: empty session id' };
+export async function resolveSessionId(
+ deps: AgentDeps,
+ id: string
+): Promise<{ id: string } | { error: string; code: number }> {
+ if (!id) return { error: 'refusing: empty session id', code: EXIT.refused };
+ if (id.length < MIN_ID_PREFIX_LENGTH) {
+ return {
+ error: `refusing: "${id}" is shorter than ${MIN_ID_PREFIX_LENGTH} characters — use the 8-character id \`agent ls\` prints, or the full id`,
+ code: EXIT.refused,
+ };
+ }
if (id.length >= FULL_ID_LENGTH) return { id };
const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' });
- if (!res.json?.success) return { error: describeFailure(res) };
+ if (!res.json?.success) return { error: describeFailure(res), code: EXIT.error };
const matches = ((res.json.data as SessionRow[] | undefined) ?? []).filter((s) => s.id.startsWith(id));
if (matches.length === 1) return { id: matches[0].id };
- if (matches.length === 0) return { error: `no session starts with "${id}" (see \`agent ls\`)` };
- return { error: `"${id}" is ambiguous: ${matches.map((s) => s.id.slice(0, 13)).join(', ')}` };
+ if (matches.length === 0) return { error: `no session starts with "${id}" (see \`agent ls\`)`, code: EXIT.error };
+ return { error: `"${id}" is ambiguous: ${matches.map((s) => s.id.slice(0, 13)).join(', ')}`, code: EXIT.error };
}
/** `agent ls` — every session the caller can see, self marked. */
@@ -529,7 +553,7 @@ export async function agentSend(deps: AgentDeps, options: SendOptions): Promise<
const refusal = inputRefusal(options.text);
if (refusal) return fail(deps, refusal, EXIT.refused);
const target = await resolveSessionId(deps, options.id);
- if ('error' in target) return fail(deps, target.error);
+ if ('error' in target) return fail(deps, target.error, target.code);
const body = buildSendBody(options.text, {
enter: options.enter,
clientId: options.clientId ?? defaultClientId(deps.ctx.selfId),
@@ -544,8 +568,24 @@ export async function agentSend(deps: AgentDeps, options: SendOptions): Promise<
timeoutMs: (options.timeoutMs ?? 60_000) + 10_000,
});
if (!res.json?.success) return fail(deps, describeFailure(res));
- const data = res.json.data as { delivered?: boolean; duplicate?: boolean; wait?: WaitResult } | undefined;
+ const data = res.json.data as
+ | { delivered?: boolean; duplicate?: boolean; buffered?: boolean; dropped?: boolean; wait?: WaitResult }
+ | undefined;
if (deps.json) emitJson(deps, data ?? {});
+ // Fire-and-forget to a remote session whose host is asleep (wake-on-LAN): the server
+ // holds the chunk and types it once the pane is back (`buffered`), or the chunk was
+ // over the wake buffer's cap and is gone (`dropped`). The seq is spent either way,
+ // so a retry needs a new one (the default, the clock, gives it that).
+ if (data?.dropped) {
+ if (!deps.json) {
+ deps.io.err(
+ palette.err(
+ `${GLYPH.fail} dropped: ${target.id}'s host is waking and its input buffer is full — nothing will be typed; send again once it is back`
+ )
+ );
+ }
+ return EXIT.error;
+ }
// `delivered:false` without `duplicate` is the route's "the bytes went nowhere":
// the PTY exited or send-keys hit a dead pane. The field exists so a client does not
// say "wait longer" when the truth is "restart the worker" — so it is a failure here.
@@ -561,6 +601,12 @@ export async function agentSend(deps: AgentDeps, options: SendOptions): Promise<
const noEnter = options.enter ? '' : ' (no Enter)';
if (data?.duplicate) {
deps.io.out(palette.warn(`${GLYPH.warn} duplicate (clientId/seq already applied): nothing typed`));
+ } else if (data?.buffered) {
+ deps.io.out(
+ palette.ok(
+ `${GLYPH.ok} buffered for ${target.id}${noEnter}: its host is asleep; Codeman is waking it and types this once the pane is back`
+ )
+ );
} else if (data?.delivered === true) {
deps.io.out(palette.ok(`${GLYPH.ok} delivered to ${target.id}${noEnter}`));
} else {
@@ -608,7 +654,7 @@ export async function agentWait(deps: AgentDeps, options: WaitOptions): Promise<
if (options.until && options.match)
return fail(deps, 'use either --until or --match , not both', EXIT.refused);
const target = await resolveSessionId(deps, options.id);
- if ('error' in target) return fail(deps, target.error);
+ if ('error' in target) return fail(deps, target.error, target.code);
const sid = encodeURIComponent(target.id);
const res = options.match
? await deps.request(deps.ctx, {
@@ -653,7 +699,7 @@ export interface ReadOptions {
/** `agent read` — the last answer (the route picks the transcript reader or the pane segmenter) or a terminal tail. */
export async function agentRead(deps: AgentDeps, options: ReadOptions): Promise {
const target = await resolveSessionId(deps, options.id);
- if ('error' in target) return fail(deps, target.error);
+ if ('error' in target) return fail(deps, target.error, target.code);
const sid = encodeURIComponent(target.id);
if (options.tail !== undefined) {
const res = await deps.request(deps.ctx, {
@@ -699,7 +745,7 @@ export async function agentRead(deps: AgentDeps, options: ReadOptions): Promise<
export async function agentInterrupt(deps: AgentDeps, options: { id: string }): Promise {
if (isSelfSession(deps.ctx.selfId, options.id)) return fail(deps, `refusing: ${options.id} is me`, EXIT.refused);
const target = await resolveSessionId(deps, options.id);
- if ('error' in target) return fail(deps, target.error);
+ if ('error' in target) return fail(deps, target.error, target.code);
const body = buildInterruptBody(defaultClientId(deps.ctx.selfId, 'interrupt'), (deps.now ?? Date.now)());
const res = await deps.request(deps.ctx, {
method: 'POST',
@@ -722,7 +768,7 @@ export async function agentRm(deps: AgentDeps, options: { id: string }): Promise
const refusal = deleteRefusal(deps.ctx.selfId, options.id);
if (refusal) return fail(deps, refusal, EXIT.refused);
const target = await resolveSessionId(deps, options.id);
- if ('error' in target) return fail(deps, target.error);
+ if ('error' in target) return fail(deps, target.error, target.code);
// The guard again on the RESOLVED id: a prefix that is not me can still resolve
// to me only if the list is lying, but a delete is the one call worth the paranoia.
const resolvedRefusal = deleteRefusal(deps.ctx.selfId, target.id);
@@ -814,7 +860,9 @@ export function registerAgentCommands(program: Command): Command {
agent
.command('send ')
- .description('Type a prompt into another session and press Enter (ONE quoted argument, printable text only)')
+ .description(
+ 'Type a prompt into another session and press Enter (ONE quoted argument, printable text only; a prompt that starts with "-" goes after --: send -- "- fix the bug")'
+ )
.option('-w, --wait', 'Block until end of turn (the default signal set; see --until)')
.option('-u, --until ', 'Signals to wait for, comma list such as stop,exit (implies --wait)')
.option('-t, --timeout ', 'Wait budget in ms (with --wait)', String(DEFAULT_WAIT_MS))
@@ -863,7 +911,10 @@ export function registerAgentCommands(program: Command): Command {
'-u, --until ',
'Comma list: stop,idle,exit,working,blocked (stop/blocked need hook signals for the session; where there are none the server answers 400, passed through)'
)
- .option('-m, --match ', 'Literal substring to wait for in the output (ANSI-stripped, no regex)')
+ .option(
+ '-m, --match ',
+ 'Literal substring to wait for in the output (ANSI-stripped, no regex). The echo of your own prompt is output too, so never put the marker verbatim in the prompt: ask for it in halves ("print WORKDONE followed by _4711") and wait on the joined form (WORKDONE_4711)'
+ )
.option('--from ', 'buffer (scan existing output first, the default) or now', 'buffer')
.option('--nocase', 'Case-insensitive --match')
.option('--fresh', 'Require an actual transition (--until only)')
@@ -921,7 +972,7 @@ export function registerAgentCommands(program: Command): Command {
agent
.command('rm ')
- .description('Delete a session you created (refuses your own id)')
+ .description('Delete any session except this one (refuses your own id)')
.option('--json', 'Machine-readable output')
.action((id: string, options: { json?: boolean }) => run(Boolean(options.json), (deps) => agentRm(deps, { id })));
diff --git a/test/cli-agent.test.ts b/test/cli-agent.test.ts
index d89ccbb4..84c635be 100644
--- a/test/cli-agent.test.ts
+++ b/test/cli-agent.test.ts
@@ -8,6 +8,7 @@ import http from 'node:http';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
+import { Command } from 'commander';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
AgentGuardError,
@@ -29,7 +30,9 @@ import {
httpRequest,
inputRefusal,
isSelfSession,
+ MIN_ID_PREFIX_LENGTH,
parsePositiveInt,
+ registerAgentCommands,
resolveAgentContext,
stripAnsi,
waitExitCode,
@@ -300,6 +303,24 @@ describe('agent send', () => {
expect(deps.out.join('')).not.toMatch(/delivered to/);
});
+ it('a sleeping remote host: `buffered` gets its own line and exit 0, never "accepted"', async () => {
+ const deps = fakeDeps([ok({ buffered: true })]);
+ expect(await agentSend(deps, { id: OTHER, text: 'go', enter: true })).toBe(EXIT.ok);
+ expect(deps.out.join('')).toMatch(/buffered for .*asleep/);
+ expect(deps.out.join('')).not.toMatch(/accepted for/);
+ });
+
+ it('`dropped` (over the wake buffer cap) is a failure: exit 1, nothing claims success', async () => {
+ const deps = fakeDeps([ok({ buffered: true, dropped: true })]);
+ expect(await agentSend(deps, { id: OTHER, text: 'go', enter: true })).toBe(EXIT.error);
+ expect(deps.err.join('')).toMatch(/dropped: .*nothing will be typed/);
+ expect(deps.out).toEqual([]);
+
+ const json = fakeDeps([ok({ buffered: true, dropped: true })], true);
+ expect(await agentSend(json, { id: OTHER, text: 'go', enter: true })).toBe(EXIT.error);
+ expect(JSON.parse(json.out.join(''))).toEqual({ buffered: true, dropped: true });
+ });
+
it('reports a tagged duplicate instead of claiming delivery', async () => {
const deps = fakeDeps([ok({ delivered: false, duplicate: true })]);
await agentSend(deps, { id: OTHER, text: 'go', enter: true });
@@ -370,7 +391,7 @@ describe('agent read', () => {
});
it('--tail fetches the terminal and strips ANSI', async () => {
- const deps = fakeDeps([ok({ terminalBuffer: '\u001b[32m❯\u001b[0m ready \u001b(B' })]);
+ const deps = fakeDeps([ok({ terminalBuffer: '\u001b]0;w1 title\u0007\u001b[32m❯\u001b[0m ready \u001b(B' })]);
expect(await agentRead(deps, { id: OTHER, tail: 500 })).toBe(EXIT.ok);
expect(deps.calls[0]).toMatchObject({ path: `/api/v1/sessions/${OTHER}/terminal`, query: { tail: 500 } });
expect(deps.out).toEqual(['❯ ready ']);
@@ -414,6 +435,8 @@ describe('send takes the prompt as ONE argument', () => {
expect(sendPromptFromArgs(['line', 'one', 'line', 'two'])).toMatchObject({
error: expect.stringMatching(/ONE argument, got 4/),
});
+ // A leading "-" is commander's option syntax, so the hint names the escape.
+ expect(sendPromptFromArgs(['a', 'b'])).toMatchObject({ error: expect.stringContaining('send -- "- fix') });
});
});
@@ -549,6 +572,29 @@ describe('session id prefixes', () => {
expect(deps.calls.map((c) => c.method)).toEqual(['GET']);
});
+ it('a prefix shorter than 8 characters refuses (exit 4) before any request, on every verb', async () => {
+ expect(MIN_ID_PREFIX_LENGTH).toBe(8);
+ // The maintainer's repro: `rm 9` with one other session starting with 9 deleted it.
+ const rm = fakeDeps([ok([{ id: SELF }, { id: OTHER }]), ok({})]);
+ expect(await agentRm(rm, { id: '9' })).toBe(EXIT.refused);
+ expect(rm.calls).toEqual([]);
+ expect(rm.err.join('')).toMatch(/"9" is shorter than 8 characters.*8-character id `agent ls` prints/);
+
+ const short = OTHER.slice(0, 7);
+ const verbs: Array<[string, (deps: AgentDeps) => Promise]> = [
+ ['send', (d) => agentSend(d, { id: short, text: 'go', enter: true })],
+ ['wait', (d) => agentWait(d, { id: short, until: 'idle', timeoutMs: 1000 })],
+ ['read', (d) => agentRead(d, { id: short })],
+ ['interrupt', (d) => agentInterrupt(d, { id: short })],
+ ['rm', (d) => agentRm(d, { id: short })],
+ ];
+ for (const [verb, call] of verbs) {
+ const deps = fakeDeps([ok([{ id: SELF }, { id: OTHER }]), ok({ delivered: true })]);
+ expect(await call(deps), verb).toBe(EXIT.refused);
+ expect(deps.calls, verb).toEqual([]);
+ }
+ });
+
it('rm runs the self guard before the list and again on the resolved id', async () => {
const first = fakeDeps([ok([{ id: SELF }])]);
expect(await agentRm(first, { id: SELF.slice(0, 8) })).toBe(EXIT.refused);
@@ -559,6 +605,25 @@ describe('session id prefixes', () => {
});
});
+describe('help texts carry the traps the skill documents', () => {
+ const agent = registerAgentCommands(new Command());
+ const sub = (name: string) => agent.commands.find((c) => c.name() === name)!;
+
+ it('--match says the prompt must not contain the marker verbatim, and how to split it', () => {
+ const match = sub('wait').options.find((o) => o.long === '--match')!;
+ expect(match.description).toMatch(/never put the marker verbatim in the prompt/);
+ expect(match.description).toMatch(/WORKDONE followed by _4711.*WORKDONE_4711/);
+ });
+
+ it('send names the -- escape for a prompt that starts with "-"', () => {
+ expect(sub('send').description()).toContain('send -- "- fix the bug"');
+ });
+
+ it('rm does not claim a lineage check it does not make', () => {
+ expect(sub('rm').description()).toMatch(/^Delete any session except this one/);
+ });
+});
+
describe('option parsing', () => {
it('positive integers only, the server rejects the rest', () => {
expect(parsePositiveInt(undefined, 60000)).toBe(60000);
From c2e55fc21006e7f8d9ac9fd7c50181f2be1de23e Mon Sep 17 00:00:00 2001
From: Devvyn <22340871+opticon454@users.noreply.github.com>
Date: Fri, 9 Oct 2026 19:42:06 +0800
Subject: [PATCH 08/76] fix(cli-install): address review: drop every
npm_config_prefix spelling, probe async
- Delete every spelling of npm_config_prefix before setting NPM_CONFIG_PREFIX, in the Compose
branch too: npm run exports the lowercase key and a sorting /bin/sh let it win. The
operator guard stays on the uppercase key only.
- A prefix that does not exist yet is judged by its nearest existing ancestor.
- The probe is async (promisified execFile, fs.promises.access, SIGKILL on timeout), awaited
before the spawn and only for commands that run npm.
- Tests: lowercase/mixed-case keys, and the decision logic against a fake npm on PATH
(writable, read-only, not yet created, npm missing). Docs: one clause in cli-registry.md.
---
docs/cli-registry.md | 2 +-
src/web/routes/cli-registry-routes.ts | 71 ++++++++++++++++++------
test/routes/cli-registry-routes.test.ts | 72 ++++++++++++++++++++++---
3 files changed, 123 insertions(+), 22 deletions(-)
diff --git a/docs/cli-registry.md b/docs/cli-registry.md
index 10e49c6b..e9ef4bcb 100644
--- a/docs/cli-registry.md
+++ b/docs/cli-registry.md
@@ -25,7 +25,7 @@ Every run mode Codeman can launch — Claude Code, Terminal/Shell, OpenCode, Cod
App Settings → Agents & CLIs → **CLI management** (`cliManagementEnabled`, default OFF; admin-only in multi-user mode) lists every entry with an installed/not-installed badge and:
- toggles any entry on or off. A `kind: 'shell'` entry cannot be disabled, and the row shows no switch for it. A disabled CLI disappears from the Run menu, the welcome screen and the phone overview, and new session requests for it are rejected.
-- installs a missing **stock** CLI by running its shipped install command, after a confirm that names the exact command. Only one install per CLI runs at a time, and the command runs without any `CODEMAN_*` variable in its environment. A custom entry's install command is never executed.
+- installs a missing **stock** CLI by running its shipped install command, after a confirm that names the exact command. Only one install per CLI runs at a time, and the command runs without any `CODEMAN_*` variable in its environment. An `npm install -g` command is pointed at `~/.local` (which every resolver searches) when the npm global prefix is not writable by the server user, for example a system node under `/usr`; a writable prefix, a prefix that does not exist yet but could be created, and an explicit `NPM_CONFIG_PREFIX` are left alone. A custom entry's install command is never executed.
- adds, edits and deletes **custom** entries (id, label, badge, binaries, launch argv). The server re-validates the whole assembled entry through `CliEntrySchema`, so the form cannot bypass the load-time rules.
These are the only writes to `clis.json`. They are serialized, and a file that does not parse or has unsafe permissions is refused rather than overwritten; fix it (or `chmod 600` it) and retry. The HTTP routes are listed in `docs/api-reference.md` under *CLI management*.
diff --git a/src/web/routes/cli-registry-routes.ts b/src/web/routes/cli-registry-routes.ts
index 88f076ee..d0a9f116 100644
--- a/src/web/routes/cli-registry-routes.ts
+++ b/src/web/routes/cli-registry-routes.ts
@@ -29,9 +29,11 @@
* write path (`registry-writer.ts` mirrors `custom-model-hosts.ts`).
*/
-import { execFileSync, spawn } from 'node:child_process';
-import { accessSync, constants as fsConstants } from 'node:fs';
-import { join } from 'node:path';
+import { execFile, spawn } from 'node:child_process';
+import { constants as fsConstants } from 'node:fs';
+import { access } from 'node:fs/promises';
+import { dirname, join } from 'node:path';
+import { promisify } from 'node:util';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { getAuthUser, isAdmin, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
@@ -209,31 +211,42 @@ const CLI_INSTALL_TIMEOUT_MS = 300_000;
*/
const installsInFlight = new Set();
+const execFileAsync = promisify(execFile);
+
/**
* True when `npm install -g` can write to this process's npm global prefix, or when that cannot
* be determined (then nothing is redirected: a wrong guess would move installs somewhere the
* user did not choose). `npm config get prefix` is asked rather than guessed from `process.execPath`
* because a user `.npmrc` / `NPM_CONFIG_PREFIX` can point it anywhere.
+ *
+ * Async so the server keeps serving while npm boots (130 to 240 ms), and killed with SIGKILL on
+ * timeout because `SIGTERM` alone leaves the wait running. A prefix that does not exist yet is
+ * judged by the nearest ancestor that does: npm creates the missing directories, so a user
+ * `.npmrc` pointing at `~/.npm-global` before it was made is not moved.
*/
-export function npmGlobalPrefixWritable(source: NodeJS.ProcessEnv): boolean {
+export async function npmGlobalPrefixWritable(source: NodeJS.ProcessEnv): Promise {
try {
- const prefix = execFileSync('npm', ['config', 'get', 'prefix'], {
+ const { stdout } = await execFileAsync('npm', ['config', 'get', 'prefix'], {
env: source,
encoding: 'utf8',
timeout: 5_000,
- stdio: ['ignore', 'pipe', 'ignore'],
- }).trim();
+ killSignal: 'SIGKILL',
+ });
+ const prefix = stdout.trim();
if (!prefix) return true;
- // npm creates lib/node_modules under the prefix; check that directory when it exists, else the prefix.
- for (const dir of [join(prefix, 'lib', 'node_modules'), prefix]) {
+ // npm creates lib/node_modules under the prefix; walk up to the first directory that exists.
+ let dir = join(prefix, 'lib', 'node_modules');
+ for (;;) {
try {
- accessSync(dir, fsConstants.W_OK);
+ await access(dir, fsConstants.W_OK);
return true;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return false;
+ const parent = dirname(dir);
+ if (parent === dir) return false;
+ dir = parent;
}
}
- return false;
} catch {
return true;
}
@@ -255,7 +268,7 @@ export function npmGlobalPrefixWritable(source: NodeJS.ProcessEnv): boolean {
*/
export function installEnv(
source: NodeJS.ProcessEnv = process.env,
- prefixWritable: (env: NodeJS.ProcessEnv) => boolean = npmGlobalPrefixWritable
+ prefixWritable: (env: NodeJS.ProcessEnv) => boolean = () => true
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
for (const [key, value] of Object.entries(source)) {
@@ -266,13 +279,40 @@ export function installEnv(
// vanishes. HOME is the persistent bind mount and `~/.local/bin` is already on every resolver's search
// list, so npm-based installs are redirected there. curl|bash installers already target HOME.
if (source.CODEMAN_IN_CONTAINER === '1' && source.HOME) {
- env.NPM_CONFIG_PREFIX = `${source.HOME}/.local`;
+ redirectNpmPrefix(env, source.HOME);
} else if (process.platform !== 'win32' && source.HOME && !source.NPM_CONFIG_PREFIX && !prefixWritable(env)) {
- env.NPM_CONFIG_PREFIX = `${source.HOME}/.local`;
+ redirectNpmPrefix(env, source.HOME);
}
return env;
}
+/**
+ * Point npm at `$HOME/.local`, dropping every spelling of the prefix key first. `npm run` exports a
+ * lowercase `npm_config_prefix`, npm reads `npm_config_*` case-insensitively, and when both spellings
+ * are present a `/bin/sh` that sorts its environment (bash) lets the older value win. The explicit
+ * operator guard in `installEnv` stays on the uppercase key only: npm always injects the lowercase one.
+ */
+function redirectNpmPrefix(env: NodeJS.ProcessEnv, home: string): void {
+ for (const key of Object.keys(env)) if (/^npm_config_prefix$/i.test(key)) delete env[key];
+ env.NPM_CONFIG_PREFIX = `${home}/.local`;
+}
+
+/**
+ * `installEnv` for this process, with the (async) npm prefix probe done first and only when the
+ * command runs npm at all: a `curl | bash` installer never pays for it.
+ */
+async function installEnvFor(command: string, source: NodeJS.ProcessEnv = process.env): Promise {
+ const usesNpm = /\bnpm\b/.test(command);
+ const probeNeeded =
+ usesNpm &&
+ source.CODEMAN_IN_CONTAINER !== '1' &&
+ process.platform !== 'win32' &&
+ !!source.HOME &&
+ !source.NPM_CONFIG_PREFIX;
+ const writable = probeNeeded ? await npmGlobalPrefixWritable(installEnv(source)) : true;
+ return installEnv(source, () => writable);
+}
+
interface InstallResult {
code: number | null;
output: string;
@@ -288,6 +328,7 @@ interface InstallResult {
* CUSTOM entry can never reach this function at all — see the route's own guard below.
*/
async function runInstallCommand(command: string): Promise {
+ const env = await installEnvFor(command);
return new Promise((resolve) => {
let child: ReturnType;
try {
@@ -299,7 +340,7 @@ async function runInstallCommand(command: string): Promise {
// out into package-manager children, and spawn's own `timeout` option signals
// only the direct child, leaving survivors holding the pipes open forever.
detached: true,
- env: installEnv(),
+ env,
});
} catch (err) {
resolve({ code: null, output: `spawn failed: ${getErrorMessage(err)}`, timedOut: false });
diff --git a/test/routes/cli-registry-routes.test.ts b/test/routes/cli-registry-routes.test.ts
index 03fe2650..ba5ec7d9 100644
--- a/test/routes/cli-registry-routes.test.ts
+++ b/test/routes/cli-registry-routes.test.ts
@@ -12,7 +12,8 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EventEmitter } from 'node:events';
-import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync, statSync } from 'node:fs';
+import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, statSync } from 'node:fs';
+import { tmpdir } from 'node:os';
import { dirname } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import {
@@ -744,11 +745,70 @@ describe('registry writes are serialized and never clobber a file the reader wou
expect(installEnv({ PATH: '/usr/bin' }, () => false)).toEqual({ PATH: '/usr/bin' });
});
- it('npmGlobalPrefixWritable follows npm config and treats a probe failure as writable', () => {
- // Real probe against this machine: must return a boolean and never throw.
- expect(typeof npmGlobalPrefixWritable({ PATH: process.env.PATH })).toBe('boolean');
- // npm not found on PATH: cannot tell, so do not redirect.
- expect(npmGlobalPrefixWritable({ PATH: '/nonexistent' })).toBe(true);
+ it('drops every spelling of npm_config_prefix when it redirects (npm run exports the lowercase one)', () => {
+ // npm reads npm_config_* case-insensitively and a sorting /bin/sh lets the older key win.
+ expect(installEnv({ HOME: '/h', npm_config_prefix: '/usr' }, () => false)).toEqual({
+ HOME: '/h',
+ NPM_CONFIG_PREFIX: '/h/.local',
+ });
+ expect(installEnv({ HOME: '/h', Npm_Config_Prefix: '/usr', NPM_CONFIG_PREFIX: '' }, () => false)).toEqual({
+ HOME: '/h',
+ NPM_CONFIG_PREFIX: '/h/.local',
+ });
+ expect(
+ installEnv({ CODEMAN_IN_CONTAINER: '1', HOME: '/h', npm_config_prefix: '/usr', NPM_CONFIG_PREFIX: '/opt/x' })
+ ).toEqual({ HOME: '/h', NPM_CONFIG_PREFIX: '/h/.local' });
+ // The operator guard is the uppercase key only: npm run always injects the lowercase one.
+ expect(installEnv({ HOME: '/h', npm_config_prefix: '/usr' }, () => true)).toEqual({
+ HOME: '/h',
+ npm_config_prefix: '/usr',
+ });
+ });
+
+ describe('npmGlobalPrefixWritable', () => {
+ let dir: string;
+ beforeEach(() => {
+ dir = mkdtempSync(`${tmpdir()}/npm-prefix-`);
+ });
+ afterEach(() => {
+ chmodSync(dir, 0o755);
+ rmSync(dir, { recursive: true, force: true });
+ });
+
+ /** A fake `npm` on PATH that prints `prefix` for `npm config get prefix`. */
+ function fakeNpm(prefix: string): NodeJS.ProcessEnv {
+ const bin = `${dir}/bin`;
+ mkdirSync(bin, { recursive: true });
+ writeFileSync(`${bin}/npm`, `#!/bin/sh\necho '${prefix}'\n`, { mode: 0o755 });
+ return { PATH: `${bin}:/usr/bin:/bin` };
+ }
+
+ it('is true for a writable prefix, whether or not lib/node_modules exists', async () => {
+ mkdirSync(`${dir}/w`);
+ expect(await npmGlobalPrefixWritable(fakeNpm(`${dir}/w`))).toBe(true);
+ mkdirSync(`${dir}/w/lib/node_modules`, { recursive: true });
+ expect(await npmGlobalPrefixWritable(fakeNpm(`${dir}/w`))).toBe(true);
+ });
+
+ it('is false for a prefix the server user cannot write', async () => {
+ if (process.getuid?.() === 0) return; // root can write anywhere
+ mkdirSync(`${dir}/ro`);
+ chmodSync(`${dir}/ro`, 0o555);
+ expect(await npmGlobalPrefixWritable(fakeNpm(`${dir}/ro`))).toBe(false);
+ });
+
+ it('judges a prefix that does not exist yet by its nearest existing ancestor', async () => {
+ // A user .npmrc with prefix=~/.npm-global before it was created: npm makes it, so do not move it.
+ expect(await npmGlobalPrefixWritable(fakeNpm(`${dir}/not/yet/made`))).toBe(true);
+ if (process.getuid?.() === 0) return;
+ mkdirSync(`${dir}/ro2`);
+ chmodSync(`${dir}/ro2`, 0o555);
+ expect(await npmGlobalPrefixWritable(fakeNpm(`${dir}/ro2/not/yet`))).toBe(false);
+ });
+
+ it('treats a probe failure (npm missing) as writable, and never throws', async () => {
+ expect(await npmGlobalPrefixWritable({ PATH: '/nonexistent' })).toBe(true);
+ });
});
});
From c75d62ce043d0b3a50dfaacef79900e8563a4467 Mon Sep 17 00:00:00 2001
From: Devvyn <22340871+opticon454@users.noreply.github.com>
Date: Fri, 9 Oct 2026 19:46:09 +0800
Subject: [PATCH 09/76] fix(settings): Apply cannot keep a later Save open,
refreshes only after a saved PUT (review)
- Split the one-shot keep-open intent from the in-flight guard and consume it before the
first await, so a Save clicked while an Apply is in flight closes the modal.
- Refresh the dependent groups only when the settings PUT returned ok (_apiPut answers null
or a non-ok response instead of throwing), and also when only the webhook save failed.
- Find the 'Apply or Save' hint by a data marker, not its English text; add zh-CN strings
for the new toast and title.
- Tests run the real saveAppSettings() (Apply then Save mid-flight, a failed PUT, a webhook-only
failure, a plain Save).
- Narrow the changeset and JSDoc to MCP sync and CLI management; touch the tray comment, the
architecture note and the wiki.
---
.changeset/settings-apply-button.md | 2 +-
docs/architecture-invariants.md | 2 +-
docs/wiki/Settings-Reference.md | 2 +-
src/web/public/i18n.js | 2 +
src/web/public/mobile.css | 2 +-
src/web/public/settings-ui.js | 45 ++++++----
test/settings-apply-button.test.ts | 133 +++++++++++++++++++++++++---
7 files changed, 157 insertions(+), 31 deletions(-)
diff --git a/.changeset/settings-apply-button.md b/.changeset/settings-apply-button.md
index 950394df..80be3175 100644
--- a/.changeset/settings-apply-button.md
+++ b/.changeset/settings-apply-button.md
@@ -2,4 +2,4 @@
'aicodeman': minor
---
-Settings has an Apply button next to Save. It saves the same way but keeps Settings open and refreshes the groups that depend on a saved value, so switching on MCP server sync (or custom model endpoints, or CLI management) brings up its controls straight away instead of needing a close-and-reopen. The MCP sync hint now says to Apply or Save first.
+Settings has an Apply button next to Save. It saves the same way but keeps Settings open, so switching on MCP server sync makes its Preview and Sync usable straight away, and CLI management's add, enable and disable work without a close-and-reopen. The MCP sync hint now says to Apply or Save first.
diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md
index fa4fb87c..347ca5d0 100644
--- a/docs/architecture-invariants.md
+++ b/docs/architecture-invariants.md
@@ -728,7 +728,7 @@ The guard is a one-shot flag on each trap rather than a browser test or a readin
**One visual language, three modals.** `#appSettingsModal`, `#sessionOptionsModal` and `#createCaseModal` share the `set-*` surface (left rail, sections of grouped row cards, label + description on the left, control pinned right) through a single `:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal)` scope in `styles.css`. An `:is()` list takes the specificity of its **most specific argument**, and all three arguments are ids, so every rule kept exactly the weight it had when the block was `#appSettingsModal`-only: nothing downstream shifted in the cascade. That property is what let the surface absorb Session Options and then Add Case in two separate commits without a cascade audit each time.
-Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.set-body` (grid of `.set-rail` and `.set-doc`). The rail holds `.set-search`, `.set-rail-items` and `.set-rail-foot`; the document holds `.set-section`s, each with `.set-section-head` + `.set-section-blurb` + `.set-group`s of `.set-row`s (`.set-row-text` = `.set-row-label` + `.set-row-desc`; controls sit in `.set-row-actions`). ⚠️ Close is deliberately **first in the DOM** inside `.set-head-actions` so the focus trap lands on it rather than on Save; `row-reverse` is what puts Save to its left visually on phones.
+Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.set-body` (grid of `.set-rail` and `.set-doc`). The rail holds `.set-search`, `.set-rail-items` and `.set-rail-foot`; the document holds `.set-section`s, each with `.set-section-head` + `.set-section-blurb` + `.set-group`s of `.set-row`s (`.set-row-text` = `.set-row-label` + `.set-row-desc`; controls sit in `.set-row-actions`). ⚠️ Close is deliberately **first in the DOM** inside `.set-head-actions` so the focus trap lands on it rather than on Save; `row-reverse` is what puts Save to its left visually on phones. Apply (`applyAppSettings()`) sits beside Save in the same actions and saves without closing; it only sets a one-shot flag that `saveAppSettings()` consumes before its first await, so a Save clicked mid-Apply still closes the sheet.
**The rail means two different things, and that is deliberate:**
diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md
index ff31d1d3..d8810ba6 100644
--- a/docs/wiki/Settings-Reference.md
+++ b/docs/wiki/Settings-Reference.md
@@ -142,7 +142,7 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
| Default Codex reasoning effort | Reasoning level for new local Codex sessions; empty uses Codex's own config. |
| Bypass approvals and sandbox | Starts new Codex sessions with `--dangerously-bypass-approvals-and-sandbox`. Read [Agent CLIs](Agent-CLIs) before enabling. |
| Animated status effects | Cosmetic. |
-| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and save, then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME` or `GEMINI_CLI_HOME` in Codeman's own environment is followed. |
+| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and **Apply** or **Save** (Apply keeps Settings open), then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME` or `GEMINI_CLI_HOME` in Codeman's own environment is followed. |
### Notifications
diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js
index ffdd8d86..ecaaa566 100644
--- a/src/web/public/i18n.js
+++ b/src/web/public/i18n.js
@@ -718,6 +718,8 @@
// Dynamic common status / toasts
'Settings saved': '设置已保存',
+ 'Settings applied': '设置已应用',
+ 'Save and keep Settings open': '保存并保持设置打开',
'Settings saved locally': '设置已保存到本机',
'Tunnel active': '隧道已启用',
'Tunnel starting — QR code will appear when ready...': '隧道正在启动,准备好后将显示二维码…',
diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css
index b348313c..2459b548 100644
--- a/src/web/public/mobile.css
+++ b/src/web/public/mobile.css
@@ -3552,7 +3552,7 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
}
/* Save + Close are the two ways out of the sheet (save-and-close vs
- discard-and-close), hit in the same corner with the same thumb, so here —
+ discard-and-close; Apply saves but keeps the sheet open), hit in the same corner with the same thumb, so here —
and only here, since Save is header-only below 860px — they share a
recessed tray and matching pill geometry instead of reading as a fat
accent pill parked beside a stray × glyph. Tray colors come from skin
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js
index a7c2f77a..cc05115d 100644
--- a/src/web/public/settings-ui.js
+++ b/src/web/public/settings-ui.js
@@ -1220,12 +1220,12 @@ Object.assign(CodemanApp.prototype, {
/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
async mcpSync(apply) {
const out = this.$('mcpSyncResult');
- const show = (html) => {
- if (out) { out.style.display = 'block'; out.innerHTML = html; }
+ const show = (html, hint = '') => {
+ if (out) { out.style.display = 'block'; out.innerHTML = html; out.dataset.hint = hint; }
};
// Switched on in this modal but not saved yet: the routes would only answer "disabled".
if (!this._mcpSyncSavedOn) {
- show('Apply or Save settings to turn MCP sync on first, then preview or sync.');
+ show('Apply or Save settings to turn MCP sync on first, then preview or sync.', 'save-first');
return;
}
if (apply && !confirm('Add missing MCP servers to every installed, enabled CLI\'s config file? Env values and headers on those servers are copied too.')) return;
@@ -2507,24 +2507,30 @@ Object.assign(CodemanApp.prototype, {
},
/**
- * Apply button: the same save as Save, but the modal stays open and the groups that depend on
- * a saved value (MCP sync, custom model endpoints, CLI management) are refreshed in place, so
- * a switch that unlocks more settings needs no close-and-reopen. It is a wrapper rather than
- * an option on saveAppSettings() so that function's signature (which tests locate by text)
- * stays as it was.
+ * Apply button: the same save as Save, but the modal stays open and the MCP sync group (its
+ * Preview and Sync need the saved flag) and the CLI management writes are refreshed in place, so
+ * turning either on needs no close-and-reopen. It is a wrapper rather than an option on
+ * saveAppSettings() so that function's signature (which tests locate by text) stays as it was.
+ *
+ * `_keepSettingsOpenOnce` is the one-shot intent and `_applyInFlight` the double-click guard:
+ * saveAppSettings() consumes the intent before its first await, so a Save clicked while an Apply
+ * is still in flight is an ordinary Save and closes the modal.
*/
async applyAppSettings() {
- if (this._applyingSettings) return;
- this._applyingSettings = true;
+ if (this._applyInFlight) return;
+ this._applyInFlight = true;
+ this._keepSettingsOpenOnce = true;
try {
await this.saveAppSettings();
} finally {
- this._applyingSettings = false;
+ this._applyInFlight = false;
+ this._keepSettingsOpenOnce = false;
}
},
async saveAppSettings() {
- const keepOpen = this._applyingSettings === true;
+ const keepOpen = this._keepSettingsOpenOnce === true;
+ this._keepSettingsOpenOnce = false;
// Gesture overlay is injected at page render (server-side), so a change to it
// only takes effect on reload — remember the prior value to decide below.
const _prev = this.loadAppSettingsFromStorage();
@@ -2867,6 +2873,7 @@ Object.assign(CodemanApp.prototype, {
...serverSettings
} = settings;
let webhookError = '';
+ let serverSaved = false;
try {
const res = await this._apiPut('/api/settings', {
...serverSettings,
@@ -2888,6 +2895,10 @@ Object.assign(CodemanApp.prototype, {
return;
}
+ // `_apiPut` answers null or a non-ok response instead of throwing, so this is the only
+ // evidence the server kept the flags the Apply refresh below reads.
+ serverSaved = !!res?.ok;
+
// Save model configuration separately
await this.saveModelConfigFromSettings();
@@ -2911,11 +2922,13 @@ Object.assign(CodemanApp.prototype, {
this.showToast('Settings saved locally', 'warning');
}
+ // Only when the settings PUT landed: after a 400 or a dropped connection the server still has the
+ // old flags, and a webhook-only failure still saved the rest, so this runs ahead of that branch.
+ if (keepOpen && serverSaved) this._refreshSettingsAfterApply(settings);
+
if (webhookError) {
document.getElementById('webhookGroup')?.scrollIntoView({ block: 'center' });
- } else if (keepOpen) {
- this._refreshSettingsAfterApply(settings);
- } else {
+ } else if (!keepOpen) {
this.closeAppSettings();
}
@@ -2945,7 +2958,7 @@ Object.assign(CodemanApp.prototype, {
// The MCP routes read the saved flag, so switching it on is only usable from now.
this._mcpSyncSavedOn = settings.mcpSyncEnabled === true;
const out = this.$('mcpSyncResult');
- if (this._mcpSyncSavedOn && out && out.textContent.startsWith('Apply or Save settings')) {
+ if (this._mcpSyncSavedOn && out && out.dataset?.hint === 'save-first') {
out.style.display = 'none';
out.innerHTML = '';
}
diff --git a/test/settings-apply-button.test.ts b/test/settings-apply-button.test.ts
index c8e989e1..2bc1d826 100644
--- a/test/settings-apply-button.test.ts
+++ b/test/settings-apply-button.test.ts
@@ -32,15 +32,16 @@ describe('Settings Apply button', () => {
expect(modal.match(/onclick="app\.saveAppSettings\(\)"/g)).toHaveLength(2);
});
- it('flags the save as keep-open while it runs and clears the flag afterwards', async () => {
+ it('raises the keep-open intent for the save it wraps and clears it afterwards', async () => {
const app = loadApp();
const seen: boolean[] = [];
app.saveAppSettings = vi.fn(async () => {
- seen.push(app._applyingSettings);
+ seen.push(app._keepSettingsOpenOnce);
});
await app.applyAppSettings();
expect(seen).toEqual([true]);
- expect(app._applyingSettings).toBe(false);
+ expect(app._keepSettingsOpenOnce).toBe(false);
+ expect(app._applyInFlight).toBe(false);
});
it('clears the flag even when the save throws, and ignores a second click mid-save', async () => {
@@ -57,13 +58,15 @@ describe('Settings Apply button', () => {
throw new Error('boom');
});
await expect(app.applyAppSettings()).rejects.toThrow('boom');
- expect(app._applyingSettings).toBe(false);
+ expect(app._applyInFlight).toBe(false);
+ expect(app._keepSettingsOpenOnce).toBe(false);
});
it('refreshes the dependent groups from the saved values', () => {
const app = loadApp();
const out = {
- textContent: 'Apply or Save settings to turn MCP sync on first',
+ textContent: 'Anything: the hint is found by its marker, not its (translatable) text',
+ dataset: { hint: 'save-first' },
style: { display: 'block' },
innerHTML: 'x',
};
@@ -83,11 +86,119 @@ describe('Settings Apply button', () => {
expect(app.applyCliManagementVisibility).toHaveBeenCalled();
});
- it('only closes the modal on a plain Save', () => {
- const body = settingsUi.slice(settingsUi.indexOf('\n async saveAppSettings() {'));
- expect(body).toContain('const keepOpen = this._applyingSettings === true;');
- expect(body).toMatch(
- /else if \(keepOpen\) \{\s*this\._refreshSettingsAfterApply\(settings\);\s*\} else \{\s*this\.closeAppSettings\(\);/
- );
+ describe('the real saveAppSettings()', () => {
+ /** An element that answers any property read, call or write: enough for a DOM-heavy function to run. */
+ function genericElement(): any {
+ const el: any = new Proxy(function () {}, {
+ get: (_t, prop) => {
+ if (prop === 'value') return '';
+ if (prop === 'checked') return false;
+ if (prop === Symbol.toPrimitive) return () => '';
+ if (prop === 'then') return undefined;
+ return el;
+ },
+ set: () => true,
+ apply: () => el,
+ });
+ return el;
+ }
+
+ function loadRealApp(put: () => Promise) {
+ const CodemanApp = function CodemanApp() {} as unknown as { prototype: Record };
+ const el = genericElement();
+ const context = vm.createContext({
+ CodemanApp,
+ localStorage: { getItem: () => null, setItem: () => {} },
+ document: new Proxy(
+ {
+ getElementById: () => el,
+ querySelectorAll: () => [],
+ querySelector: () => el,
+ addEventListener: () => {},
+ body: el,
+ documentElement: el,
+ },
+ { get: (t: any, prop: string) => (prop in t ? t[prop] : el) }
+ ),
+ window: {},
+ location: { reload: vi.fn() },
+ VoiceInput: new Proxy({}, { get: () => vi.fn(() => ({})) }),
+ KeyboardAccessoryBar: new Proxy({}, { get: () => vi.fn(() => ({})) }),
+ MobileDetection: new Proxy({}, { get: () => vi.fn(() => false) }),
+ setTimeout,
+ console,
+ });
+ vm.runInContext(settingsUi, context, { filename: 'settings-ui.js' });
+ const target = new (CodemanApp as unknown as new () => Record)();
+ // saveAppSettings() leans on helpers from the other settings mixins; anything it asks for
+ // that is not defined is a harmless stub, while the state the Apply logic owns stays real.
+ const state = new Set(['_keepSettingsOpenOnce', '_applyInFlight', '_mcpSyncSavedOn']);
+ const app: Record = new Proxy(target, {
+ get: (t, prop) =>
+ prop in t || typeof prop !== 'string' || state.has(prop) || prop === 'then'
+ ? t[prop as string]
+ : vi.fn(() => 14),
+ });
+ app.loadAppSettingsFromStorage = () => ({});
+ app.saveAppSettingsToStorage = vi.fn();
+ app.notificationManager = new Proxy(
+ { preferences: {}, normalizePreferences: (p: unknown) => p, getToastDurationMs: () => 3000 },
+ { get: (t: any, prop: string) => (prop in t ? t[prop] : vi.fn()) }
+ );
+ app._apiPut = vi.fn(put);
+ app._handleTunnelEnableRefusal = vi.fn(async () => false);
+ app.saveModelConfigFromSettings = vi.fn(async () => {});
+ app._webhookPending = () => false;
+ app.showToast = vi.fn();
+ app.closeAppSettings = vi.fn();
+ app._refreshSettingsAfterApply = vi.fn();
+ // The apply*() helpers re-skin and re-lay-out the page from real DOM geometry; that is not what
+ // these tests are about, so they are stubbed. applyAppSettings() itself stays real.
+ for (const name of Object.keys(CodemanApp.prototype)) {
+ if (/^apply[A-Z]/.test(name) && name !== 'applyAppSettings') app[name] = vi.fn();
+ }
+ return app;
+ }
+
+ it('a Save clicked while an Apply is in flight closes the modal, and the Apply keeps it open', async () => {
+ let resolvePut!: (v: unknown) => void;
+ const first = new Promise((r) => (resolvePut = r));
+ const app = loadRealApp(() => first);
+ const apply = app.applyAppSettings();
+ // The Apply's PUT is still pending; the user clicks Save now.
+ const save = app.saveAppSettings();
+ resolvePut({ ok: true });
+ await Promise.all([apply, save]);
+ expect(app.closeAppSettings).toHaveBeenCalledTimes(1);
+ expect(app._refreshSettingsAfterApply).toHaveBeenCalledTimes(1);
+ const toasts = app.showToast.mock.calls.map((c: unknown[]) => c[0]);
+ expect(toasts).toContain('Settings applied');
+ expect(toasts).toContain('Settings saved');
+ });
+
+ it('does not refresh the dependent groups when the settings PUT failed', async () => {
+ for (const failure of [{ ok: false }, null]) {
+ const app = loadRealApp(async () => failure);
+ await app.applyAppSettings();
+ expect(app._refreshSettingsAfterApply, JSON.stringify(failure)).not.toHaveBeenCalled();
+ expect(app.closeAppSettings).not.toHaveBeenCalled();
+ }
+ });
+
+ it('still refreshes when only the webhook failed (the rest was saved)', async () => {
+ const app = loadRealApp(async () => ({ ok: true }));
+ app._webhookPending = () => true;
+ app.saveWebhook = vi.fn(async () => 'bad URL');
+ await app.applyAppSettings();
+ expect(app._refreshSettingsAfterApply).toHaveBeenCalledTimes(1);
+ expect(app.closeAppSettings).not.toHaveBeenCalled();
+ });
+
+ it('a plain Save closes the modal and never refreshes in place', async () => {
+ const app = loadRealApp(async () => ({ ok: true }));
+ await app.saveAppSettings();
+ expect(app.closeAppSettings).toHaveBeenCalledTimes(1);
+ expect(app._refreshSettingsAfterApply).not.toHaveBeenCalled();
+ });
});
});
From aa4217b8b1921fc62193e6879f230c85370cdd5f Mon Sep 17 00:00:00 2001
From: Randalix
Date: Fri, 9 Oct 2026 16:28:26 +0200
Subject: [PATCH 10/76] test: build every in-process WebServer on port 0 (#440)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The files on the guard's legacy list build new WebServer(0, …) and read server.boundPort after start(), as sse-tile-grid-filter does. Files with several servers read each server's own port; quick-start's local helper lost its port parameter so the literal 0 sits where the server is built. Header comments that named a port say "ephemeral". No assertion changed except where it embedded the port.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
test/admin-routes.test.ts | 7 +++---
test/base-path-server.test.ts | 8 +++----
test/capture-geometry-retry.browser.test.ts | 10 ++++----
test/capture-load-window.browser.test.ts | 10 ++++----
test/case-custom-path.browser.test.ts | 6 ++---
test/doctor-settings.browser.test.ts | 6 ++---
test/edge-cases.test.ts | 13 +++++-----
test/file-link-click.test.ts | 8 +++----
test/git-status.browser.test.ts | 5 ++--
test/hooks-config.test.ts | 12 ++++------
test/http-contract.test.ts | 7 +++---
test/inline-rename.test.ts | 24 ++++++++-----------
test/integration-flows.test.ts | 9 ++++---
test/key-tester.browser.test.ts | 6 ++---
test/opencode-resize.test.ts | 10 ++++----
test/operation-lightspeed.test.ts | 5 ++--
test/ownership-scoping.test.ts | 7 +++---
test/pane-exit-sweep.test.ts | 9 ++++---
test/paste-image-dir-shared.test.ts | 9 ++++---
test/perf-browser.test.ts | 16 ++++++-------
test/quick-start.test.ts | 21 ++++++++--------
test/ralph-integration.test.ts | 7 +++---
test/scheduled-runs.test.ts | 10 ++++----
test/security-regression.test.ts | 14 +++++------
test/session-cleanup.test.ts | 9 ++++---
test/session-pane-exit.test.ts | 6 ++---
test/session.test.ts | 6 ++---
test/shift-enter-keypress.browser.test.ts | 10 ++++----
test/split-pane-auto-collapse.browser.test.ts | 8 +++----
test/split-pane-orchestration.browser.test.ts | 8 +++----
test/split-pane-terminal.browser.test.ts | 8 +++----
test/sse-cors-headers.test.ts | 5 ++--
test/sse-events.test.ts | 10 ++++----
test/sse-routing-remote.test.ts | 2 +-
test/sse-subscription-filter.test.ts | 6 ++---
test/static-cache-headers.test.ts | 6 ++---
test/terminal-copy-shortcut.test.ts | 12 +++++-----
...rminal-keycode229-recovery.browser.test.ts | 10 ++++----
test/webgl-fallback.test.ts | 10 ++++----
test/webhook-settings.browser.test.ts | 5 ++--
test/webview-lost-root-frame.test.ts | 5 ++--
test/webview-sse.test.ts | 2 +-
42 files changed, 163 insertions(+), 204 deletions(-)
diff --git a/test/admin-routes.test.ts b/test/admin-routes.test.ts
index 6dcb89be..e2ea176a 100644
--- a/test/admin-routes.test.ts
+++ b/test/admin-routes.test.ts
@@ -1,5 +1,5 @@
/**
- * @fileoverview Phase 5 admin API tests (live server, port 3173).
+ * @fileoverview Phase 5 admin API tests (live server, ephemeral port).
*
* Covers the admin user-management endpoints: multi-user gate, requireAdmin,
* create (one-time password), patch + last-admin invariant, reset-password,
@@ -16,9 +16,8 @@ import { createUser, invalidateUsersCache } from '../src/user-store.js';
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
-const PORT = 3173;
const basic = (u: string, p: string) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');
-const url = (p: string) => `http://localhost:${PORT}${p}`;
+const url = (p: string) => `http://localhost:${server.boundPort}${p}`;
const admin = { Authorization: basic('root', 'rootpass123'), 'Content-Type': 'application/json' };
const adminNoBody = { Authorization: basic('root', 'rootpass123') };
const regular = { Authorization: basic('joe', 'joepass1234'), 'Content-Type': 'application/json' };
@@ -48,7 +47,7 @@ beforeAll(async () => {
invalidateUsersCache();
await createUser({ username: 'root', role: 'admin', password: 'rootpass123' });
await createUser({ username: 'joe', role: 'user', password: 'joepass1234' });
- server = new WebServer(PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
});
diff --git a/test/base-path-server.test.ts b/test/base-path-server.test.ts
index 38231d98..01d87eba 100644
--- a/test/base-path-server.test.ts
+++ b/test/base-path-server.test.ts
@@ -8,14 +8,12 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { WebServer } from '../src/web/server.js';
-const PORT = 3197;
-
describe('reverse-proxy base path: server wiring', () => {
let server: WebServer;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let app: any;
beforeAll(async () => {
- server = new WebServer(PORT, false, true, '127.0.0.1', undefined, false, '/codeman');
+ server = new WebServer(0, false, true, '127.0.0.1', undefined, false, '/codeman');
await server.start();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
app = (server as any).app;
@@ -66,7 +64,9 @@ describe('reverse-proxy base path: server wiring', () => {
const { WebSocket } = await import('ws');
const close = (path: string) =>
new Promise<{ code: number; reason: string }>((resolve) => {
- const ws = new WebSocket(`ws://127.0.0.1:${PORT}${path}`, { headers: { origin: `http://127.0.0.1:${PORT}` } });
+ const ws = new WebSocket(`ws://127.0.0.1:${server.boundPort}${path}`, {
+ headers: { origin: `http://127.0.0.1:${server.boundPort}` },
+ });
ws.on('close', (code, reason) => resolve({ code, reason: reason.toString() }));
ws.on('error', (e) => resolve({ code: -1, reason: String(e) }));
});
diff --git a/test/capture-geometry-retry.browser.test.ts b/test/capture-geometry-retry.browser.test.ts
index 2a127e78..f40e0bfe 100644
--- a/test/capture-geometry-retry.browser.test.ts
+++ b/test/capture-geometry-retry.browser.test.ts
@@ -20,7 +20,7 @@
* because the mismatch itself needs two viewports to stage against live tmux.
* Without the fix the first assertion below sees one fetch instead of two.
*
- * Port: 3252 (capture geometry retry)
+ * Port: ephemeral
*
* Run: npx vitest run --config config/vitest.browser.config.ts test/capture-geometry-retry.browser.test.ts
*/
@@ -29,15 +29,15 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
-const PORT = 3252;
-const BASE_URL = `http://localhost:${PORT}`;
+let baseUrl: string;
let server: WebServer;
let browser: Browser;
beforeAll(async () => {
- server = new WebServer(PORT, false, true); // testMode
+ server = new WebServer(0, false, true); // testMode
await server.start();
+ baseUrl = `http://localhost:${server.boundPort}`;
browser = await chromium.launch({ headless: true });
}, 60_000);
@@ -176,7 +176,7 @@ async function stubTerminalAtRequestedSize(page: Page, counter: { n: number; url
const WIDER_THAN_ANY_TERMINAL_COLS = 500;
async function openSession(page: Page): Promise {
- await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
+ await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10_000 });
// xterm is loaded from /vendor, so the terminal appears a beat after the app.
// Without it `app.terminal.rows` reads 0 and every height comparison below
diff --git a/test/capture-load-window.browser.test.ts b/test/capture-load-window.browser.test.ts
index 8cea77ef..701b4ab9 100644
--- a/test/capture-load-window.browser.test.ts
+++ b/test/capture-load-window.browser.test.ts
@@ -14,7 +14,7 @@
* own `json()` call, which is the one place guaranteed to land after the
* headers and before the chunked write.
*
- * Port: 3256 (capture load window)
+ * Port: ephemeral
*
* Run: npx vitest run --config config/vitest.browser.config.ts test/capture-load-window.browser.test.ts
*/
@@ -23,16 +23,16 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
-const PORT = 3256;
-const BASE_URL = `http://localhost:${PORT}`;
+let baseUrl: string;
const MARKER = 'ARRIVED-AFTER-THE-CAPTURE';
let server: WebServer;
let browser: Browser;
beforeAll(async () => {
- server = new WebServer(PORT, false, true); // testMode
+ server = new WebServer(0, false, true); // testMode
await server.start();
+ baseUrl = `http://localhost:${server.boundPort}`;
browser = await chromium.launch({ headless: true });
}, 60_000);
@@ -115,7 +115,7 @@ async function runLoad(page: Page, sessionId: string, source: string): Promise {
- await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
+ await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10_000 });
// xterm loads from /vendor, so the terminal appears a beat after the app.
// Without it every buffer assertion below would throw rather than compare.
diff --git a/test/case-custom-path.browser.test.ts b/test/case-custom-path.browser.test.ts
index dcaf0a3d..8cd89ca4 100644
--- a/test/case-custom-path.browser.test.ts
+++ b/test/case-custom-path.browser.test.ts
@@ -8,8 +8,6 @@ import { WebServer } from '../src/web/server.js';
declare const PathPicker: any; // evaluated inside the page, where it is a global
-const PORT = 3193;
-
describe('Create a case in a custom folder', () => {
let server: WebServer;
let browser: Browser;
@@ -18,11 +16,11 @@ describe('Create a case in a custom folder', () => {
beforeAll(async () => {
parent = mkdtempSync(join(homedir(), 'custom-case-'));
- server = new WebServer(PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
- await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
+ await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
}, 90000);
diff --git a/test/doctor-settings.browser.test.ts b/test/doctor-settings.browser.test.ts
index d33ff17c..c576f06a 100644
--- a/test/doctor-settings.browser.test.ts
+++ b/test/doctor-settings.browser.test.ts
@@ -3,8 +3,6 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
-const PORT = 3196;
-
const REPORT = {
platform: { environment: 'linux' },
summary: { ok: 1, requiredMissing: 1, optionalMissing: 0, exitCode: 1 },
@@ -46,13 +44,13 @@ describe('Diagnostics panel in a real browser', () => {
let page: Page;
beforeAll(async () => {
- server = new WebServer(PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
// A controlling service worker can swallow requests before page.route() sees them, letting the
// real /api/doctor (a forked Node process) answer instead; block it so the stub is reliable.
page = await (await browser.newContext({ serviceWorkers: 'block' })).newPage();
- await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
+ await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
await page.evaluate(() => (window as any).app.openAppSettings());
}, 90000);
diff --git a/test/edge-cases.test.ts b/test/edge-cases.test.ts
index 576b442a..a3724705 100644
--- a/test/edge-cases.test.ts
+++ b/test/edge-cases.test.ts
@@ -4,7 +4,6 @@ import { join } from 'node:path';
import { homedir } from 'node:os';
import { safeRmHomeTree } from './mocks/index.js';
-const TEST_PORT = 3110;
const CASES_DIR = join(homedir(), 'codeman-cases');
describe('Edge Cases and Error Handling', () => {
@@ -13,9 +12,9 @@ describe('Edge Cases and Error Handling', () => {
const createdCases: string[] = [];
beforeAll(async () => {
- server = new WebServer(TEST_PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
- baseUrl = `http://localhost:${TEST_PORT}`;
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterEach(() => {
@@ -263,9 +262,9 @@ describe('Concurrent Session Handling', () => {
let baseUrl: string;
beforeAll(async () => {
- server = new WebServer(TEST_PORT + 1, false, true);
+ server = new WebServer(0, false, true);
await server.start();
- baseUrl = `http://localhost:${TEST_PORT + 1}`;
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -360,9 +359,9 @@ describe('API Request Validation', () => {
let baseUrl: string;
beforeAll(async () => {
- server = new WebServer(TEST_PORT + 2, false, true);
+ server = new WebServer(0, false, true);
await server.start();
- baseUrl = `http://localhost:${TEST_PORT + 2}`;
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
diff --git a/test/file-link-click.test.ts b/test/file-link-click.test.ts
index 0cdd745a..17577e18 100644
--- a/test/file-link-click.test.ts
+++ b/test/file-link-click.test.ts
@@ -4,7 +4,7 @@
* Tests that file paths displayed in terminal output are clickable
* and open the log viewer window correctly.
*
- * Port allocation: 3154 (see CLAUDE.md test port table)
+ * Port allocation: ephemeral port
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
@@ -14,8 +14,7 @@ import { writeFileSync, mkdirSync, rmSync, appendFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
-const TEST_PORT = 3154;
-const baseUrl = `http://localhost:${TEST_PORT}`;
+let baseUrl: string;
const BROWSER_TIMEOUT = 30000;
// Helper to run agent-browser commands
@@ -97,8 +96,9 @@ describe('File Link Click Tests', () => {
testLogFile = join(testDir, 'test.log');
writeFileSync(testLogFile, '=== Test Log Started ===\n');
- server = new WebServer(TEST_PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
+ baseUrl = `http://localhost:${server.boundPort}`;
await new Promise((r) => setTimeout(r, 1000));
// Test if browser is available
diff --git a/test/git-status.browser.test.ts b/test/git-status.browser.test.ts
index e86b5953..e8c06e9b 100644
--- a/test/git-status.browser.test.ts
+++ b/test/git-status.browser.test.ts
@@ -7,7 +7,6 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
-const PORT = 3192;
const ENV = {
...process.env,
GIT_AUTHOR_NAME: 'T',
@@ -85,7 +84,7 @@ describe('Git status indicator in a real browser', () => {
write('a.txt', '2\n');
write('new file.txt', 'n\n');
- server = new WebServer(PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
@@ -96,7 +95,7 @@ describe('Git status indicator in a real browser', () => {
page.on('response', (r) => {
if (r.request().method() === 'PUT' && r.url().endsWith('/api/settings')) settingsPutStatuses.push(r.status());
});
- await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
+ await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
repoSession = await createSession(repo);
plainSession = await createSession(plain);
diff --git a/test/hooks-config.test.ts b/test/hooks-config.test.ts
index 502a21b2..9077fc5d 100644
--- a/test/hooks-config.test.ts
+++ b/test/hooks-config.test.ts
@@ -782,21 +782,19 @@ describe('subagent stop guard helper', () => {
});
// ========== Hook Event API Integration Tests ==========
-// Port 3130 reserved for hooks integration tests
+// Hooks integration tests use an ephemeral port
import { WebServer } from '../src/web/server.js';
-const TEST_PORT = 3130;
-
describe('Hook Event API', () => {
let server: WebServer;
let baseUrl: string;
let testSessionId: string;
beforeAll(async () => {
- server = new WebServer(TEST_PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
- baseUrl = `http://localhost:${TEST_PORT}`;
+ baseUrl = `http://localhost:${server.boundPort}`;
// Create a test session
const createRes = await fetch(`${baseUrl}/api/sessions`, {
@@ -976,9 +974,9 @@ describe('Hook Data Sanitization', () => {
let testSessionId: string;
beforeAll(async () => {
- server = new WebServer(TEST_PORT + 1, false, true); // Port 3131
+ server = new WebServer(0, false, true);
await server.start();
- baseUrl = `http://localhost:${TEST_PORT + 1}`;
+ baseUrl = `http://localhost:${server.boundPort}`;
// Create a test session
const createRes = await fetch(`${baseUrl}/api/sessions`, {
diff --git a/test/http-contract.test.ts b/test/http-contract.test.ts
index 11dcdd60..55e68594 100644
--- a/test/http-contract.test.ts
+++ b/test/http-contract.test.ts
@@ -11,15 +11,14 @@ import { flattenOwnerSessionOrder, type TabLayout } from '../src/tab-layout.js';
import { WebServer } from '../src/web/server.js';
import { SseEvent } from '../src/web/sse-events.js';
-const PORT = 3168;
-
describe('Stable HTTP contract (live server)', () => {
let server: WebServer;
- const base = `http://localhost:${PORT}`;
+ let base: string;
beforeAll(async () => {
- server = new WebServer(PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
+ base = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
diff --git a/test/inline-rename.test.ts b/test/inline-rename.test.ts
index aa8c7c17..aeecb6f5 100644
--- a/test/inline-rename.test.ts
+++ b/test/inline-rename.test.ts
@@ -13,18 +13,14 @@
* Strategy: stub a synthetic .tab-name node and a fake session entry, then
* drive the rename function directly via page.evaluate(). No real PTY/tmux.
*
- * Ports: 3164, plus 3165 and 3192 for the two server-backed describes below
- * (per MEMORY.md, ports 3150+ for tests)
+ * Ports: ephemeral, for this and the two server-backed describes below
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
-const PORT = 3164;
-const ORDERING_PORT = 3165;
-const LONG_PREFIX_PORT = 3192;
-const BASE_URL = `http://localhost:${PORT}`;
+let baseUrl: string;
describe('Inline rename input', () => {
let server: WebServer;
@@ -32,11 +28,12 @@ describe('Inline rename input', () => {
let page: Page;
beforeAll(async () => {
- server = new WebServer(PORT, false, true); // testMode = true
+ server = new WebServer(0, false, true); // testMode = true
await server.start();
+ baseUrl = `http://localhost:${server.boundPort}`;
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
- await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
+ await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
// Wait for app.js to expose window.app and finish constructor init.
await page.waitForFunction(
() =>
@@ -667,11 +664,11 @@ describe('Inline rename write ordering', () => {
type Pending = { body: string; resolve: (response: Response) => void };
beforeAll(async () => {
- server = new WebServer(ORDERING_PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
- await page.goto(`http://localhost:${ORDERING_PORT}`, { waitUntil: 'domcontentloaded' });
+ await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
typeof (window as { app?: unknown }).app !== 'undefined' &&
@@ -1010,14 +1007,13 @@ describe('Inline rename write ordering', () => {
describe('Vertical rail rename editor with a long prefix', () => {
let server: WebServer;
let browser: Browser;
- const port = LONG_PREFIX_PORT;
const NAME = 'w3-this_is_a_very_long_valid_prefix: charlie';
let sessionId = '';
beforeAll(async () => {
- server = new WebServer(port, false, true);
+ server = new WebServer(0, false, true);
await server.start();
- const res = await fetch(`http://localhost:${port}/api/sessions`, {
+ const res = await fetch(`http://localhost:${server.boundPort}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: NAME, mode: 'shell' }),
@@ -1048,7 +1044,7 @@ describe('Vertical rail rename editor with a long prefix', () => {
settings
);
const page = await context.newPage();
- await page.goto(`http://localhost:${port}`, { waitUntil: 'domcontentloaded' });
+ await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
// One #sessionTabs list, moved into the rail or the sidebar by the layout.
const row = page.locator(`#sessionTabs .session-tab[data-id="${sessionId}"]`);
await row.waitFor({ state: 'visible', timeout: 15000 });
diff --git a/test/integration-flows.test.ts b/test/integration-flows.test.ts
index 2d38aa89..9b78c015 100644
--- a/test/integration-flows.test.ts
+++ b/test/integration-flows.test.ts
@@ -4,7 +4,6 @@ import { join } from 'node:path';
import { homedir } from 'node:os';
import { safeRmHomeTree } from './mocks/index.js';
-const TEST_PORT = 3115;
const CASES_DIR = join(homedir(), 'codeman-cases');
/**
@@ -18,9 +17,9 @@ describe('Integration Flows', () => {
const createdSessions: string[] = [];
beforeAll(async () => {
- server = new WebServer(TEST_PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
- baseUrl = `http://localhost:${TEST_PORT}`;
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterEach(() => {
@@ -282,9 +281,9 @@ describe('SSE Event Flow', () => {
const createdSessions: string[] = [];
beforeAll(async () => {
- server = new WebServer(TEST_PORT + 1, false, true);
+ server = new WebServer(0, false, true);
await server.start();
- baseUrl = `http://localhost:${TEST_PORT + 1}`;
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
diff --git a/test/key-tester.browser.test.ts b/test/key-tester.browser.test.ts
index edc6eb5e..d6824c87 100644
--- a/test/key-tester.browser.test.ts
+++ b/test/key-tester.browser.test.ts
@@ -3,19 +3,17 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
-const PORT = 3194;
-
describe('Key tester in a real browser', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
beforeAll(async () => {
- server = new WebServer(PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
- await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
+ await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
await page.evaluate(() => (window as any).app.openAppSettings());
await page.focus('#keyTesterInput');
diff --git a/test/opencode-resize.test.ts b/test/opencode-resize.test.ts
index 5a2cb696..6dac0007 100644
--- a/test/opencode-resize.test.ts
+++ b/test/opencode-resize.test.ts
@@ -6,7 +6,7 @@
* - Close modal shows "Kill Tmux & OpenCode" (not "Claude Code")
* - needsRefresh handler sends resize
*
- * Port: 3211 (opencode UI tests)
+ * Port: ephemeral
*
* Run: npx vitest run test/opencode-resize.test.ts
*/
@@ -16,8 +16,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
-const PORT = 3211;
-const BASE_URL = `http://localhost:${PORT}`;
+let baseUrl: string;
const HAS_OPENCODE = (() => {
try {
@@ -40,15 +39,16 @@ async function freshPage(): Promise<{ context: BrowserContext; page: Page }> {
}
async function navigateAndWait(page: Page): Promise {
- await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
+ await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), {
timeout: 5000,
});
}
beforeAll(async () => {
- server = new WebServer(PORT, false, true); // testMode
+ server = new WebServer(0, false, true); // testMode
await server.start();
+ baseUrl = `http://localhost:${server.boundPort}`;
browser = await chromium.launch({ headless: true });
}, 30_000);
diff --git a/test/operation-lightspeed.test.ts b/test/operation-lightspeed.test.ts
index 2f59cdb1..0e93da5c 100644
--- a/test/operation-lightspeed.test.ts
+++ b/test/operation-lightspeed.test.ts
@@ -15,7 +15,6 @@ import { WebServer } from '../src/web/server.js';
import { safeRmHomeTree } from './mocks/index.js';
import { homedir } from 'node:os';
import { join } from 'node:path';
-const TEST_PORT = 3215;
// Helper to parse SSE events from raw text
function parseSSEEvents(text: string): Array<{ event: string; data: unknown }> {
@@ -106,9 +105,9 @@ describe('Operation Lightspeed', () => {
let baseUrl: string;
beforeAll(async () => {
- server = new WebServer(TEST_PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
- baseUrl = `http://localhost:${TEST_PORT}`;
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
diff --git a/test/ownership-scoping.test.ts b/test/ownership-scoping.test.ts
index aa6ad7df..986e9500 100644
--- a/test/ownership-scoping.test.ts
+++ b/test/ownership-scoping.test.ts
@@ -1,5 +1,5 @@
/**
- * @fileoverview Phase 3 ownership-scoping tests (live server, port 3172).
+ * @fileoverview Phase 3 ownership-scoping tests (live server, ephemeral port).
*
* Verifies multi-user isolation at the API level: case lists are disjoint per user,
* a non-admin cannot read/kill another user's session, workingDir confinement +
@@ -17,14 +17,13 @@ import { canAccessOwned, findSessionOrFail, sessionCapacityState } from '../src/
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
-const PORT = 3172;
const basic = (u: string, p: string) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');
let server: WebServer;
let dataDir: string;
let spacesDir: string;
const saved: Record = {};
-const url = (p: string) => `http://localhost:${PORT}${p}`;
+const url = (p: string) => `http://localhost:${server.boundPort}${p}`;
// Route returns are wrapped in the {success,data} envelope; unwrap to the payload.
async function getJson(p: string, headers: Record): Promise {
@@ -58,7 +57,7 @@ beforeAll(async () => {
await createUser({ username: 'alice', role: 'user', password: 'alicepass1' });
await createUser({ username: 'bob', role: 'user', password: 'bobpass1234' });
- server = new WebServer(PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
});
diff --git a/test/pane-exit-sweep.test.ts b/test/pane-exit-sweep.test.ts
index 6839e846..d9aed019 100644
--- a/test/pane-exit-sweep.test.ts
+++ b/test/pane-exit-sweep.test.ts
@@ -18,7 +18,7 @@
* 4. **Only local mux-backed sessions.** A remote session's local pane is its
* ssh client, whose death may be a transport drop.
*
- * Port: 3189
+ * Port: ephemeral
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Session } from '../src/session.js';
@@ -33,7 +33,6 @@ import {
shouldCloseCleanlyExitedSession,
} from '../src/pane-exit-sweep.js';
-const PORT = 3189;
const AT = 1_700_000_000_000;
describe('isCleanPaneExit', () => {
@@ -144,7 +143,7 @@ describe('the sweep on a real server', () => {
/** A server whose mux reports `exit` for every pane, seen by `reads` reads. */
const build = (exit: PaneExit | undefined, reads: number) => {
- const web = new WebServer(PORT, false, true);
+ const web = new WebServer(0, false, true);
server = web;
const mux = (web as unknown as { mux: Record }).mux;
const state = { exit, reads };
@@ -245,7 +244,7 @@ describe('the sweep on a real server', () => {
it('removes the session for real through cleanupSession', async () => {
// No spy on cleanupSession here: the close runs the same path as the X
// button, and the session leaves the server's map.
- const web = new WebServer(PORT, false, true);
+ const web = new WebServer(0, false, true);
server = web;
const mux = (web as unknown as { mux: Record }).mux;
mux.getPaneExit = () => ({ status: 0, at: AT });
@@ -276,7 +275,7 @@ describe('a session the server is closing', () => {
});
it('is cleared again when the server gives the close up', async () => {
- const web = new WebServer(PORT, false, true);
+ const web = new WebServer(0, false, true);
try {
const s = session();
const sessions = (web as unknown as { sessions: Map }).sessions;
diff --git a/test/paste-image-dir-shared.test.ts b/test/paste-image-dir-shared.test.ts
index 1adc5a53..3ac66e93 100644
--- a/test/paste-image-dir-shared.test.ts
+++ b/test/paste-image-dir-shared.test.ts
@@ -9,7 +9,7 @@
* closes sessions unattended, which turns that from an occasional loss into a
* routine one.
*
- * Port: 3188
+ * Port: ephemeral
*/
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
@@ -18,8 +18,6 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { pasteImageDirInUseByOtherSession } from '../src/web/paste-image-gc.js';
-const PORT = 3188;
-
describe('pasteImageDirInUseByOtherSession', () => {
const none = new Set();
const check = (
@@ -108,12 +106,13 @@ describe('pasteImageDirInUseByOtherSession', () => {
describe('deleting a session that shares its working directory', () => {
let server: WebServer;
let workingDir: string;
- const base = `http://localhost:${PORT}`;
+ let base: string;
beforeAll(async () => {
workingDir = mkdtempSync(join(tmpdir(), 'codeman-paste-shared-'));
- server = new WebServer(PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
+ base = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
diff --git a/test/perf-browser.test.ts b/test/perf-browser.test.ts
index 0c5a2c92..9a2a4ed0 100644
--- a/test/perf-browser.test.ts
+++ b/test/perf-browser.test.ts
@@ -5,7 +5,7 @@
* terminal input latency, subagent window management, settings modal,
* SSE connection speed, and rendering under load.
*
- * Port: 3210 (perf-browser tests)
+ * Port: ephemeral
*
* Run: npx vitest run test/perf-browser.test.ts
*/
@@ -14,8 +14,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
-const PORT = 3210;
-const BASE_URL = `http://localhost:${PORT}`;
+let baseUrl: string;
// Thresholds (ms)
const THRESHOLDS = {
@@ -53,7 +52,7 @@ async function freshPage(): Promise<{ context: BrowserContext; page: Page }> {
}
async function navigateAndWait(page: Page): Promise {
- await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
+ await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
// Wait for app.js to initialize (app-loaded class added to body)
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), {
timeout: 5000,
@@ -120,8 +119,9 @@ async function getHeapMB(page: Page): Promise {
// ─── Setup / Teardown ─────────────────────────────────────
beforeAll(async () => {
- server = new WebServer(PORT, false, true); // testMode
+ server = new WebServer(0, false, true); // testMode
await server.start();
+ baseUrl = `http://localhost:${server.boundPort}`;
browser = await chromium.launch({ headless: true });
}, 30_000);
@@ -144,7 +144,7 @@ describe('Page load performance', () => {
({ context, page } = await freshPage());
const wallTiming = await measure(async () => {
- await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
+ await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
});
const navigationTiming = await getBrowserNavigationTiming(page);
@@ -158,7 +158,7 @@ describe('Page load performance', () => {
({ context, page } = await freshPage());
const timing = await measure(async () => {
- await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
+ await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), {
timeout: 5000,
});
@@ -190,7 +190,7 @@ describe('Page load performance', () => {
it('loading skeleton disappears after init', async () => {
({ context, page } = await freshPage());
- await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
+ await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
// Skeleton should be visible initially
const skeletonVisible = await page.locator('.loading-skeleton').isVisible();
diff --git a/test/quick-start.test.ts b/test/quick-start.test.ts
index 3ff346f5..8daeff46 100644
--- a/test/quick-start.test.ts
+++ b/test/quick-start.test.ts
@@ -4,7 +4,6 @@ import { existsSync, rmSync, mkdirSync, mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
-const TEST_PORT = 3299;
const ORIGINAL_HOME = process.env.HOME;
const TEST_HOME = mkdtempSync(join(tmpdir(), 'codeman-quick-start-'));
const CASES_DIR = join(TEST_HOME, 'codeman-cases');
@@ -12,10 +11,10 @@ let webServerModule: Promise | undefined;
process.env.HOME = TEST_HOME;
-async function createTestServer(port: number): Promise {
+async function createTestServer(): Promise {
webServerModule ??= import('../src/web/server.js');
const { WebServer: TestWebServer } = await webServerModule;
- return new TestWebServer(port, false, true);
+ return new TestWebServer(0, false, true);
}
afterAll(() => {
@@ -30,9 +29,9 @@ describe('Quick Start API', () => {
const createdCases: string[] = [];
beforeAll(async () => {
- server = await createTestServer(TEST_PORT);
+ server = await createTestServer();
await server.start();
- baseUrl = `http://localhost:${TEST_PORT}`;
+ baseUrl = `http://localhost:${server.boundPort}`;
}, 30000);
afterEach(() => {
@@ -164,9 +163,9 @@ describe('Session Management', () => {
let baseUrl: string;
beforeAll(async () => {
- server = await createTestServer(TEST_PORT + 1);
+ server = await createTestServer();
await server.start();
- baseUrl = `http://localhost:${TEST_PORT + 1}`;
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -223,9 +222,9 @@ describe('Case Management', () => {
const createdCases: string[] = [];
beforeAll(async () => {
- server = await createTestServer(TEST_PORT + 2);
+ server = await createTestServer();
await server.start();
- baseUrl = `http://localhost:${TEST_PORT + 2}`;
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -339,9 +338,9 @@ describe('Agent skill injection (agentSkillEnabled)', () => {
const createdCases: string[] = [];
beforeAll(async () => {
- server = await createTestServer(TEST_PORT + 4); // 3103
+ server = await createTestServer();
await server.start();
- baseUrl = `http://localhost:${TEST_PORT + 4}`;
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
diff --git a/test/ralph-integration.test.ts b/test/ralph-integration.test.ts
index 597c03e0..09c4eff9 100644
--- a/test/ralph-integration.test.ts
+++ b/test/ralph-integration.test.ts
@@ -8,7 +8,7 @@
* - Todo item detection and management
* - Ralph configuration via API
*
- * Test port: 3125
+ * Test port: ephemeral
*/
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
@@ -17,7 +17,6 @@ import { join } from 'node:path';
import { homedir } from 'node:os';
import { safeRmHomeTree } from './mocks/index.js';
-const TEST_PORT = 3125;
const CASES_DIR = join(homedir(), 'codeman-cases');
describe('Ralph Integration Tests', () => {
@@ -27,9 +26,9 @@ describe('Ralph Integration Tests', () => {
const createdSessions: string[] = [];
beforeAll(async () => {
- server = new WebServer(TEST_PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
- baseUrl = `http://localhost:${TEST_PORT}`;
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterEach(() => {
diff --git a/test/scheduled-runs.test.ts b/test/scheduled-runs.test.ts
index 4e1ee55d..f136e8c1 100644
--- a/test/scheduled-runs.test.ts
+++ b/test/scheduled-runs.test.ts
@@ -1,17 +1,15 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { WebServer } from '../src/web/server.js';
-const TEST_PORT = 3105;
-
describe('Scheduled Runs API', () => {
let server: WebServer;
let baseUrl: string;
const createdRuns: string[] = [];
beforeAll(async () => {
- server = new WebServer(TEST_PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
- baseUrl = `http://localhost:${TEST_PORT}`;
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -181,9 +179,9 @@ describe('Quick Run API', () => {
let baseUrl: string;
beforeAll(async () => {
- server = new WebServer(TEST_PORT + 1, false, true);
+ server = new WebServer(0, false, true);
await server.start();
- baseUrl = `http://localhost:${TEST_PORT + 1}`;
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
diff --git a/test/security-regression.test.ts b/test/security-regression.test.ts
index 099d4648..60e07e87 100644
--- a/test/security-regression.test.ts
+++ b/test/security-regression.test.ts
@@ -11,7 +11,7 @@
* - text/plain bodies kept RAW (the closed "simple request" CSRF vector)
* - WebSocket anti-CSWSH (Origin/Host validated on upgrade → close 4003)
*
- * Port: 3167
+ * Port: ephemeral
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import http from 'node:http';
@@ -19,8 +19,6 @@ import WebSocket from 'ws';
import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
-const PORT = 3167;
-
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
interface RawResponse {
@@ -32,7 +30,7 @@ interface RawResponse {
/** Raw HTTP request with full control over Host/Origin headers (fetch/undici rewrites Host). */
function raw(method: string, path: string, headers: Record = {}, body?: string): Promise {
return new Promise((resolve, reject) => {
- const req = http.request({ host: '127.0.0.1', port: PORT, path, method, headers }, (res) => {
+ const req = http.request({ host: '127.0.0.1', port: server.boundPort, path, method, headers }, (res) => {
let data = '';
res.on('data', (c) => (data += c));
res.on('end', () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body: data }));
@@ -46,7 +44,7 @@ function raw(method: string, path: string, headers: Record = {},
/** Open a WS to `path` with optional Origin and resolve with the close code the server sends. */
function wsCloseCode(path: string, origin?: string): Promise {
return new Promise((resolve, reject) => {
- const ws = new WebSocket(`ws://127.0.0.1:${PORT}${path}`, {
+ const ws = new WebSocket(`ws://127.0.0.1:${server.boundPort}${path}`, {
headers: origin ? { origin } : {},
});
const timer = setTimeout(() => {
@@ -71,7 +69,7 @@ let server: WebServer;
beforeAll(async () => {
delete process.env.CODEMAN_PASSWORD; // guard must work even on the no-auth default
- server = new WebServer(PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
});
@@ -87,7 +85,7 @@ describe('Host-header allowlist (anti DNS-rebinding), wired', () => {
});
it('allows a loopback Host', async () => {
- const res = await raw('GET', '/api/status', { Host: `localhost:${PORT}` });
+ const res = await raw('GET', '/api/status', { Host: `localhost:${server.boundPort}` });
expect(res.status).toBe(200);
});
@@ -114,7 +112,7 @@ describe('cross-site Origin / CSRF guard, wired', () => {
});
it('allows a state-changing request from a same-site Origin', async () => {
- const res = await raw('POST', PROBE, { Origin: `http://localhost:${PORT}` });
+ const res = await raw('POST', PROBE, { Origin: `http://localhost:${server.boundPort}` });
expect(res.status).not.toBe(403);
});
diff --git a/test/session-cleanup.test.ts b/test/session-cleanup.test.ts
index e7676729..8161bd49 100644
--- a/test/session-cleanup.test.ts
+++ b/test/session-cleanup.test.ts
@@ -5,7 +5,6 @@ import { join } from 'node:path';
import { homedir, tmpdir } from 'node:os';
import { safeRmHomeTree } from './mocks/index.js';
-const TEST_PORT = 3120;
const CASES_DIR = join(homedir(), 'codeman-cases');
/**
@@ -22,9 +21,9 @@ describe('Session Cleanup', () => {
beforeAll(async () => {
testWorkingDir = mkdtempSync(join(tmpdir(), 'codeman-cleanup-test-'));
- server = new WebServer(TEST_PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
- baseUrl = `http://localhost:${TEST_PORT}`;
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterEach(() => {
@@ -218,9 +217,9 @@ describe('Resource Management', () => {
const createdCases: string[] = [];
beforeAll(async () => {
- server = new WebServer(TEST_PORT + 1, false, true);
+ server = new WebServer(0, false, true);
await server.start();
- baseUrl = `http://localhost:${TEST_PORT + 1}`;
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
diff --git a/test/session-pane-exit.test.ts b/test/session-pane-exit.test.ts
index afa4f623..c8167836 100644
--- a/test/session-pane-exit.test.ts
+++ b/test/session-pane-exit.test.ts
@@ -24,7 +24,7 @@
* server and the persisted record is the only thing left that can say the
* agent was already gone.
*
- * Port: 3187
+ * Port: ephemeral
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
@@ -37,8 +37,6 @@ import type { PaneExit, SessionRemote, SessionDocker, SessionState } from '../sr
import type { MuxSession, TerminalMultiplexer } from '../src/mux-interface.js';
import { hasObservablePaneSession } from '../src/tmux-manager.js';
-const PORT = 3187;
-
const EXIT: PaneExit = { status: 0, at: 1_700_000_000_000 };
/** The two mux members `Session` reads when it decides whether the field applies. */
@@ -294,7 +292,7 @@ describe('a pane read reaching the session record', () => {
});
const build = () => {
- const web = new WebServer(PORT, false, true);
+ const web = new WebServer(0, false, true);
const mux = (web as unknown as { mux: Record }).mux;
const session = localMuxSession();
(web as unknown as { sessions: Map }).sessions.set(session.id, session);
diff --git a/test/session.test.ts b/test/session.test.ts
index 6d8fe084..1eeaf591 100644
--- a/test/session.test.ts
+++ b/test/session.test.ts
@@ -2,8 +2,6 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { mkdirSync } from 'node:fs';
import { WebServer } from '../src/web/server.js';
-const TEST_PORT = 3102;
-
describe('Interactive Session Lifecycle', () => {
let server: WebServer;
let baseUrl: string;
@@ -12,9 +10,9 @@ describe('Interactive Session Lifecycle', () => {
// The 'custom working directory' test creates a session in /tmp/test; workingDir
// validation requires the dir to exist, so ensure it does (idempotent, CI-safe).
mkdirSync('/tmp/test', { recursive: true });
- server = new WebServer(TEST_PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
- baseUrl = `http://localhost:${TEST_PORT}`;
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
diff --git a/test/shift-enter-keypress.browser.test.ts b/test/shift-enter-keypress.browser.test.ts
index 2a22b2a4..bf87a603 100644
--- a/test/shift-enter-keypress.browser.test.ts
+++ b/test/shift-enter-keypress.browser.test.ts
@@ -18,15 +18,14 @@
* The CI gate's guard on the same handlers is the source check in
* test/shift-enter-keypress-swallowed.test.ts.
*
- * Port: 3267 (per CLAUDE.md, ports 3150+ for tests)
+ * Port: ephemeral
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
-const PORT = 3267;
-const BASE_URL = `http://localhost:${PORT}`;
+let baseUrl: string;
const MAIN_ID = 'shift-enter-probe-main';
const PANE_B_ID = 'shift-enter-probe-pane-b';
@@ -45,11 +44,12 @@ describe('Shift/Ctrl+Enter through the shipped key handlers (keypress must be sw
let page: Page;
beforeAll(async () => {
- server = new WebServer(PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
+ baseUrl = `http://localhost:${server.boundPort}`;
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
- await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
+ await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
await page.evaluate(
({ paneBId }) => {
diff --git a/test/split-pane-auto-collapse.browser.test.ts b/test/split-pane-auto-collapse.browser.test.ts
index c321df56..5215c7bb 100644
--- a/test/split-pane-auto-collapse.browser.test.ts
+++ b/test/split-pane-auto-collapse.browser.test.ts
@@ -4,8 +4,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
-const PORT = 3177;
-const BASE_URL = `http://localhost:${PORT}`;
+let baseUrl: string;
describe('split-pane auto-collapse in a real browser', () => {
let server: WebServer;
@@ -13,11 +12,12 @@ describe('split-pane auto-collapse in a real browser', () => {
let page: Page;
beforeAll(async () => {
- server = new WebServer(PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
+ baseUrl = `http://localhost:${server.boundPort}`;
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
- await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
+ await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
}, 90000);
diff --git a/test/split-pane-orchestration.browser.test.ts b/test/split-pane-orchestration.browser.test.ts
index a8c3190d..aa084f1d 100644
--- a/test/split-pane-orchestration.browser.test.ts
+++ b/test/split-pane-orchestration.browser.test.ts
@@ -3,8 +3,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
-const PORT = 3176;
-const BASE_URL = `http://localhost:${PORT}`;
+let baseUrl: string;
describe('split-pane orchestration in a real browser', () => {
let server: WebServer;
@@ -12,11 +11,12 @@ describe('split-pane orchestration in a real browser', () => {
let page: Page;
beforeAll(async () => {
- server = new WebServer(PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
+ baseUrl = `http://localhost:${server.boundPort}`;
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
- await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
+ await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
}, 90000);
diff --git a/test/split-pane-terminal.browser.test.ts b/test/split-pane-terminal.browser.test.ts
index 5463cef4..c62b14fd 100644
--- a/test/split-pane-terminal.browser.test.ts
+++ b/test/split-pane-terminal.browser.test.ts
@@ -3,8 +3,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
-const PORT = 3175;
-const BASE_URL = `http://localhost:${PORT}`;
+let baseUrl: string;
describe('TerminalTile in a real browser', () => {
let server: WebServer;
@@ -12,11 +11,12 @@ describe('TerminalTile in a real browser', () => {
let page: Page;
beforeAll(async () => {
- server = new WebServer(PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
+ baseUrl = `http://localhost:${server.boundPort}`;
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
- await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
+ await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
}, 90000);
diff --git a/test/sse-cors-headers.test.ts b/test/sse-cors-headers.test.ts
index f0edc206..3a2bf759 100644
--- a/test/sse-cors-headers.test.ts
+++ b/test/sse-cors-headers.test.ts
@@ -17,7 +17,6 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { WebServer } from '../src/web/server.js';
-const TEST_PORT = 3119;
const LOCAL_ORIGIN = 'http://localhost:5173';
/** Open /api/events, read the response headers, then abort — it never ends on its own. */
@@ -42,9 +41,9 @@ describe('GET /api/events header inheritance', () => {
let baseUrl: string;
beforeAll(async () => {
- server = new WebServer(TEST_PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
- baseUrl = `http://localhost:${TEST_PORT}`;
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
diff --git a/test/sse-events.test.ts b/test/sse-events.test.ts
index d602880e..71fecf37 100644
--- a/test/sse-events.test.ts
+++ b/test/sse-events.test.ts
@@ -5,8 +5,6 @@ import { safeRmHomeTree } from './mocks/index.js';
import { homedir } from 'node:os';
import { join } from 'node:path';
-const TEST_PORT = 3107;
-
// Helper to parse SSE events
function parseSSEEvents(text: string): Array<{ event: string; data: unknown }> {
const events: Array<{ event: string; data: unknown }> = [];
@@ -40,9 +38,9 @@ describe('SSE Events', () => {
let baseUrl: string;
beforeAll(async () => {
- server = new WebServer(TEST_PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
- baseUrl = `http://localhost:${TEST_PORT}`;
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -186,9 +184,9 @@ describe('SSE Event Types', () => {
let baseUrl: string;
beforeAll(async () => {
- server = new WebServer(TEST_PORT + 1, false, true);
+ server = new WebServer(0, false, true);
await server.start();
- baseUrl = `http://localhost:${TEST_PORT + 1}`;
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
diff --git a/test/sse-routing-remote.test.ts b/test/sse-routing-remote.test.ts
index ff0a3e09..e62f12da 100644
--- a/test/sse-routing-remote.test.ts
+++ b/test/sse-routing-remote.test.ts
@@ -13,7 +13,7 @@ import { WebServer } from '../src/web/server.js';
type Hint = { owner?: string; username?: string; adminOnly?: boolean; sessionScoped?: boolean } | undefined;
function hintFor(event: string, payload: Record, owners: Record = {}): Hint {
- const server = new WebServer(3999, false, true) as unknown as {
+ const server = new WebServer(0, false, true) as unknown as {
sessions: Map;
deriveSseHint(event: string, data: unknown): Hint;
};
diff --git a/test/sse-subscription-filter.test.ts b/test/sse-subscription-filter.test.ts
index 54514785..e809c91b 100644
--- a/test/sse-subscription-filter.test.ts
+++ b/test/sse-subscription-filter.test.ts
@@ -4,8 +4,6 @@ import { safeRmHomeTree } from './mocks/index.js';
import { homedir } from 'node:os';
import { join } from 'node:path';
-const TEST_PORT = 3212;
-
// Helper to parse SSE events from raw text
function parseSSEEvents(text: string): Array<{ event: string; data: unknown }> {
const events: Array<{ event: string; data: unknown }> = [];
@@ -76,9 +74,9 @@ describe('SSE Subscription Filtering', () => {
let baseUrl: string;
beforeAll(async () => {
- server = new WebServer(TEST_PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
- baseUrl = `http://localhost:${TEST_PORT}`;
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
diff --git a/test/static-cache-headers.test.ts b/test/static-cache-headers.test.ts
index 99ca6f6d..eec9a2fc 100644
--- a/test/static-cache-headers.test.ts
+++ b/test/static-cache-headers.test.ts
@@ -22,8 +22,6 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { WebServer } from '../src/web/server.js';
-const TEST_PORT = 3183;
-
/**
* Fetch and fully drain the body. Both halves matter for teardown: an unconsumed
* body leaves undici holding the socket, and a pooled keep-alive socket makes
@@ -40,9 +38,9 @@ describe('static asset Cache-Control headers', () => {
let baseUrl: string;
beforeAll(async () => {
- server = new WebServer(TEST_PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
- baseUrl = `http://localhost:${TEST_PORT}`;
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
diff --git a/test/terminal-copy-shortcut.test.ts b/test/terminal-copy-shortcut.test.ts
index f719ed3c..a83692d5 100644
--- a/test/terminal-copy-shortcut.test.ts
+++ b/test/terminal-copy-shortcut.test.ts
@@ -14,15 +14,14 @@
* Browser-driven, so it is excluded from `npm run test:ci` like the other
* Playwright suites. Run locally: npm run test:browser -- test/terminal-copy-shortcut.test.ts
*
- * Port: 3174 (per MEMORY.md, ports 3150+ for tests)
+ * Port: ephemeral
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
-const PORT = 3174;
-const BASE_URL = `http://localhost:${PORT}`;
+let baseUrl: string;
const IME_PUNCTUATION = ',。!?;:“”、《》、()';
describe('terminal Ctrl+C smart copy', () => {
@@ -31,12 +30,13 @@ describe('terminal Ctrl+C smart copy', () => {
let page: Page;
beforeAll(async () => {
- server = new WebServer(PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
+ baseUrl = `http://localhost:${server.boundPort}`;
browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ permissions: ['clipboard-read', 'clipboard-write'] });
page = await context.newPage();
- await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
+ await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
// The first write after load can be dropped while the app finishes wiring
// its render pipeline, so poll until one really lands in the buffer.
@@ -226,7 +226,7 @@ describe('terminal Ctrl+C smart copy', () => {
const touchContext = await browser.newContext({ hasTouch: true });
try {
const touchPage = await touchContext.newPage();
- await touchPage.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
+ await touchPage.goto(baseUrl, { waitUntil: 'domcontentloaded' });
const touchChunks = await captureImeInput(touchPage);
expect(touchChunks.join('')).toBe('中文' + IME_PUNCTUATION);
} finally {
diff --git a/test/terminal-keycode229-recovery.browser.test.ts b/test/terminal-keycode229-recovery.browser.test.ts
index c8e78dbe..c17186b1 100644
--- a/test/terminal-keycode229-recovery.browser.test.ts
+++ b/test/terminal-keycode229-recovery.browser.test.ts
@@ -20,15 +20,14 @@
* Playwright suites. Run locally:
* npm run test:browser -- test/terminal-keycode229-recovery.browser.test.ts
*
- * Port: 3186 (per CLAUDE.md, ports 3150+ for tests)
+ * Port: ephemeral
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
-const PORT = 3186;
-const BASE_URL = `http://localhost:${PORT}`;
+let baseUrl: string;
describe('orphaned terminal input recovery wiring', () => {
let server: WebServer;
@@ -36,11 +35,12 @@ describe('orphaned terminal input recovery wiring', () => {
let page: Page;
beforeAll(async () => {
- server = new WebServer(PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
+ baseUrl = `http://localhost:${server.boundPort}`;
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
- await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
+ await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
await page.waitForFunction(() => (window as any).app?._keyCode229Recovery, null, { timeout: 30000 });
}, 90000);
diff --git a/test/webgl-fallback.test.ts b/test/webgl-fallback.test.ts
index 1a8b6514..ac999d9c 100644
--- a/test/webgl-fallback.test.ts
+++ b/test/webgl-fallback.test.ts
@@ -18,15 +18,14 @@
* the trip math is pure and the dispose path is a couple of property
* mutations, both of which run on any page where app.js loaded.
*
- * Port: 3166 (per MEMORY.md, ports 3150+ for tests)
+ * Port: ephemeral
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
-const PORT = 3166;
-const BASE_URL = `http://localhost:${PORT}`;
+let baseUrl: string;
describe('WebGL longtask auto-fallback', () => {
let server: WebServer;
@@ -34,11 +33,12 @@ describe('WebGL longtask auto-fallback', () => {
let page: Page;
beforeAll(async () => {
- server = new WebServer(PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
+ baseUrl = `http://localhost:${server.boundPort}`;
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
- await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
+ await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
// Wait for constants.js + app.js to have loaded — both expose globals.
await page.waitForFunction(
() =>
diff --git a/test/webhook-settings.browser.test.ts b/test/webhook-settings.browser.test.ts
index 7f1ddd98..484920ea 100644
--- a/test/webhook-settings.browser.test.ts
+++ b/test/webhook-settings.browser.test.ts
@@ -5,7 +5,6 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
-const PORT = 3195;
const SECRET = 'SUPERSECRET-topic-123';
describe('Webhook settings in a real browser', () => {
@@ -30,11 +29,11 @@ describe('Webhook settings in a real browser', () => {
await new Promise((r) => receiver.listen(0, '127.0.0.1', r));
receiverPort = (receiver.address() as AddressInfo).port;
- server = new WebServer(PORT, false, true);
+ server = new WebServer(0, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
- await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
+ await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
await page.evaluate(() => (window as any).app.openAppSettings());
await page.waitForSelector('#webhookGroup', { state: 'attached' });
diff --git a/test/webview-lost-root-frame.test.ts b/test/webview-lost-root-frame.test.ts
index a96425ca..e3fb90ca 100644
--- a/test/webview-lost-root-frame.test.ts
+++ b/test/webview-lost-root-frame.test.ts
@@ -12,12 +12,11 @@
* real WebServer in test mode for the passwordless one, where the index route
* itself has to answer.
*
- * Port: 3198
+ * Port: ephemeral
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { WebServer } from '../src/web/server.js';
-const PORT = 3198;
const lostFrame = { 'sec-fetch-dest': 'iframe', 'sec-fetch-mode': 'navigate', accept: 'text/html,*/*;q=0.8' };
describe('landing-page reload of a proxied dashboard, passwordless install', () => {
@@ -25,7 +24,7 @@ describe('landing-page reload of a proxied dashboard, passwordless install', ()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let app: any;
beforeAll(async () => {
- server = new WebServer(PORT);
+ server = new WebServer(0);
await server.start();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
app = (server as any).app;
diff --git a/test/webview-sse.test.ts b/test/webview-sse.test.ts
index bcc47cf9..4f7bbb8c 100644
--- a/test/webview-sse.test.ts
+++ b/test/webview-sse.test.ts
@@ -34,7 +34,7 @@ function client() {
/** The server's real event → routing-hint derivation, without starting the server. */
function serverHint(event: string, data: unknown): SseRoutingHint | undefined {
- const server = new WebServer(3999, false, true) as unknown as {
+ const server = new WebServer(0, false, true) as unknown as {
deriveSseHint(event: string, data: unknown): SseRoutingHint | undefined;
};
return server.deriveSseHint(event, data);
From 6f87186debfd6b488cd601b9a4a185cfcc3985f7 Mon Sep 17 00:00:00 2001
From: Randalix
Date: Fri, 9 Oct 2026 16:29:01 +0200
Subject: [PATCH 11/76] test(mobile): createTestServer() binds an ephemeral
port (#440)
The helper cached servers by port, but every mobile file starts exactly one, so the cache was never shared: it is now a Set that stopAllTestServers() walks. PORTS leaves helpers/constants.ts, the nine tests set baseUrl after start(), and the README drops the port column and the pick-a-port step.
Committed without the pre-commit hook: accessibility, subagent-windows and visual-regression were not prettier-clean on master already; formatting them would bury the port lines.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
test/mobile/README.md | 42 ++++++++++---------
test/mobile/accessibility.test.ts | 26 ++++++------
test/mobile/device-matrix.test.ts | 16 +++----
test/mobile/header-buttons.test.ts | 16 +++----
test/mobile/helpers/constants.ts | 13 ------
test/mobile/helpers/server.ts | 21 ++++------
test/mobile/keyboard.test.ts | 28 ++++++-------
test/mobile/layout.test.ts | 60 +++++++++++++--------------
test/mobile/settings.test.ts | 28 ++++++-------
test/mobile/subagent-windows.test.ts | 28 ++++++-------
test/mobile/tabs.test.ts | 48 ++++++++++-----------
test/mobile/visual-regression.test.ts | 18 ++++----
12 files changed, 164 insertions(+), 180 deletions(-)
diff --git a/test/mobile/README.md b/test/mobile/README.md
index 8c9098d2..1ff30a73 100644
--- a/test/mobile/README.md
+++ b/test/mobile/README.md
@@ -44,16 +44,18 @@ npm run test:mobile -- test/mobile/visual-regression.test.ts
## Test Files
-| File | Port | Description |
-|------|------|-------------|
-| `keyboard.test.ts` | 3200 | Virtual keyboard simulation (3-layer: CDP, mock, DOM) |
-| `tabs.test.ts` | 3201 | Tab switching, swipe navigation, keyboard nav |
-| `subagent-windows.test.ts` | 3202 | Mobile subagent card dimensions, stacking, interactions |
-| `settings.test.ts` | 3203 | Settings modal, mobile defaults, persistence |
-| `layout.test.ts` | 3204 | General mobile layout, fixed elements, device classes |
-| `device-matrix.test.ts` | 3205 | Cross-device parametric tests (138 devices) |
-| `visual-regression.test.ts` | 3206 | Screenshot comparison at key breakpoints |
-| `accessibility.test.ts` | 3207 | WCAG touch targets, zoom, focus, ARIA |
+Each file starts its own server on an ephemeral port (`createTestServer()`, then `server.boundPort`), so files never compete for a port.
+
+| File | Description |
+|------|-------------|
+| `keyboard.test.ts` | Virtual keyboard simulation (3-layer: CDP, mock, DOM) |
+| `tabs.test.ts` | Tab switching, swipe navigation, keyboard nav |
+| `subagent-windows.test.ts` | Mobile subagent card dimensions, stacking, interactions |
+| `settings.test.ts` | Settings modal, mobile defaults, persistence |
+| `layout.test.ts` | General mobile layout, fixed elements, device classes |
+| `device-matrix.test.ts` | Cross-device parametric tests (138 devices) |
+| `visual-regression.test.ts` | Screenshot comparison at key breakpoints |
+| `accessibility.test.ts` | WCAG touch targets, zoom, focus, ARIA |
## Device Matrix
@@ -99,7 +101,7 @@ The phone block is `max-width: 599px` and the tablet block starts at `min-width:
```
Test File
- ├─ helpers/server.ts → WebServer(port, false, testMode=true)
+ ├─ helpers/server.ts → WebServer(0, false, testMode=true), ephemeral port
├─ helpers/browser.ts → Playwright Chromium / WebKit
├─ helpers/cdp.ts → Chrome DevTools Protocol (Chromium only)
├─ helpers/keyboard-sim.ts → 3-layer keyboard simulation
@@ -144,10 +146,10 @@ Snapshots at 10 key breakpoints: 320, 375, 390, 393, 430, 440, 600, 768, 834, 10
## Helpers Reference
### `helpers/constants.ts`
-All magic numbers centralized: ports, CSS selectors, breakpoint thresholds, keyboard constants, swipe parameters, touch target minimums, body CSS classes, localStorage keys.
+All magic numbers centralized: CSS selectors, breakpoint thresholds, keyboard constants, swipe parameters, touch target minimums, body CSS classes, localStorage keys.
### `helpers/server.ts`
-`createTestServer(port)` / `stopTestServer(server)` — wraps `WebServer` with `testMode=true`.
+`createTestServer()` / `stopTestServer(server)` — wraps `WebServer` with `testMode=true` on an ephemeral port; read it from `server.boundPort`.
### `helpers/browser.ts`
`createDevicePage(device, url, engine?)` — creates a Playwright browser context with the device's viewport, DPR, UA, touch support, navigates to the URL.
@@ -196,9 +198,7 @@ Unified `swipe()` / `tap()` with auto CDP/synthetic selection. Also exports per-
## Adding New Tests
-1. Pick a unique port (next: 3208+) — search `const PORT =` across test files
-2. Add the port to `helpers/constants.ts` PORTS object
-3. Use the standard test pattern:
+1. Use the standard test pattern (no port to pick: the server binds port 0):
```typescript
import { createTestServer, stopTestServer } from './helpers/server.js';
@@ -207,12 +207,16 @@ import { REPRESENTATIVE_DEVICES } from './devices.js';
describe('My Test', () => {
let server;
- beforeAll(async () => { server = await createTestServer(MY_PORT); });
+ let baseUrl: string;
+ beforeAll(async () => {
+ server = await createTestServer();
+ baseUrl = `http://localhost:${server.boundPort}`;
+ });
afterAll(async () => { await stopTestServer(server); await closeAllBrowsers(); });
it('works on phone', async () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
- const { page, context } = await createDevicePage(device, `http://localhost:${MY_PORT}`);
+ const { page, context } = await createDevicePage(device, baseUrl);
try {
// ... assertions ...
} finally {
@@ -222,7 +226,7 @@ describe('My Test', () => {
});
```
-4. For new devices: add to `devices.ts` with correct category, viewport, UA, DPR
+2. For new devices: add to `devices.ts` with correct category, viewport, UA, DPR
## Findings Log
diff --git a/test/mobile/accessibility.test.ts b/test/mobile/accessibility.test.ts
index b50c2c40..b96b10a5 100644
--- a/test/mobile/accessibility.test.ts
+++ b/test/mobile/accessibility.test.ts
@@ -1,7 +1,7 @@
-// Port 3207 - Mobile accessibility tests
+// Ephemeral port - Mobile accessibility tests
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import type { Page, BrowserContext } from 'playwright';
-import { PORTS, SELECTORS, KEYBOARD, MIN_TOUCH_TARGET, WAIT } from './helpers/constants.js';
+import { SELECTORS, KEYBOARD, MIN_TOUCH_TARGET, WAIT } from './helpers/constants.js';
import { createTestServer, stopTestServer } from './helpers/server.js';
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
import { showKeyboard, hideKeyboard } from './helpers/keyboard-sim.js';
@@ -12,14 +12,14 @@ import {
import { REPRESENTATIVE_DEVICES } from './devices.js';
import type { WebServer } from '../src/web/server.js';
-const PORT = PORTS.ACCESSIBILITY;
-const BASE_URL = `http://localhost:${PORT}`;
+let baseUrl: string;
describe('Mobile Accessibility', () => {
let server: WebServer;
beforeAll(async () => {
- server = await createTestServer(PORT);
+ server = await createTestServer();
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -39,7 +39,7 @@ describe('Mobile Accessibility', () => {
it('all visible interactive elements >= 44x44px on phones', async () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
- const result = await createDevicePage(device, BASE_URL, 'chromium');
+ const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
@@ -64,7 +64,7 @@ describe('Mobile Accessibility', () => {
it('all visible interactive elements >= 44x44px on tablets', async () => {
const device = REPRESENTATIVE_DEVICES['standard-tablet'];
- const result = await createDevicePage(device, BASE_URL, 'chromium');
+ const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
@@ -83,7 +83,7 @@ describe('Mobile Accessibility', () => {
it('keyboard accessory bar buttons meet size requirement', async () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
- const result = await createDevicePage(device, BASE_URL, 'chromium');
+ const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
@@ -118,7 +118,7 @@ describe('Mobile Accessibility', () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
beforeEach(async () => {
- const result = await createDevicePage(device, BASE_URL, 'chromium');
+ const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
});
@@ -159,7 +159,7 @@ describe('Mobile Accessibility', () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
beforeEach(async () => {
- const result = await createDevicePage(device, BASE_URL, 'chromium');
+ const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
});
@@ -229,7 +229,7 @@ describe('Mobile Accessibility', () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
beforeEach(async () => {
- const result = await createDevicePage(device, BASE_URL, 'chromium');
+ const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
});
@@ -294,7 +294,7 @@ describe('Mobile Accessibility', () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
beforeEach(async () => {
- const result = await createDevicePage(device, BASE_URL, 'chromium');
+ const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
});
@@ -357,7 +357,7 @@ describe('Mobile Accessibility', () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
beforeEach(async () => {
- const result = await createDevicePage(device, BASE_URL, 'chromium');
+ const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
});
diff --git a/test/mobile/device-matrix.test.ts b/test/mobile/device-matrix.test.ts
index 3370b7e2..b0f78fa0 100644
--- a/test/mobile/device-matrix.test.ts
+++ b/test/mobile/device-matrix.test.ts
@@ -1,10 +1,10 @@
-// Port 3205 - Cross-device parametric tests
+// Ephemeral port - Cross-device parametric tests
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { BrowserContext, Page } from 'playwright';
import type { WebServer } from '../src/web/server.js';
import { createTestServer, stopTestServer } from './helpers/server.js';
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
-import { PORTS, SELECTORS, BREAKPOINTS, BODY_CLASSES } from './helpers/constants.js';
+import { SELECTORS, BREAKPOINTS, BODY_CLASSES } from './helpers/constants.js';
import {
assertFixedPosition,
assertHidden,
@@ -17,8 +17,7 @@ import {
} from './helpers/assertions.js';
import { REPRESENTATIVE_DEVICES, DEVICE_REGISTRY, type DeviceEntry, type DeviceCategory } from './devices.js';
-const PORT = PORTS.DEVICE_MATRIX;
-const BASE_URL = `http://localhost:${PORT}`;
+let baseUrl: string;
let server: WebServer;
@@ -36,7 +35,8 @@ const PHONE_ONLY_SELECTORS = [SELECTORS.SETTINGS_MOBILE, SELECTORS.CASE_MOBILE];
describe('Device Matrix', () => {
beforeAll(async () => {
- server = await createTestServer(PORT);
+ server = await createTestServer();
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -53,7 +53,7 @@ describe('Device Matrix', () => {
let page: Page;
beforeAll(async () => {
- ({ context, page } = await createDevicePage(device, BASE_URL));
+ ({ context, page } = await createDevicePage(device, baseUrl));
});
afterAll(async () => {
@@ -143,7 +143,7 @@ describe('Device Matrix', () => {
describe.skipIf(process.env.CI_QUICK === '1')('Full Device Matrix', () => {
for (const device of DEVICE_REGISTRY) {
it(`${device.name} (${device.viewport.width}x${device.viewport.height}): correct layout`, async () => {
- const { context, page } = await createDevicePage(device, BASE_URL);
+ const { context, page } = await createDevicePage(device, baseUrl);
try {
// Verify viewport was applied
const viewportSize = page.viewportSize();
@@ -178,7 +178,7 @@ describe('Device Matrix', () => {
const results: Record = {};
for (const engine of engines) {
- const { context, page } = await createDevicePage(device, BASE_URL, engine);
+ const { context, page } = await createDevicePage(device, baseUrl, engine);
try {
const position = await getCSSProperty(page, SELECTORS.HEADER, 'position');
const top = await getCSSProperty(page, SELECTORS.HEADER, 'top');
diff --git a/test/mobile/header-buttons.test.ts b/test/mobile/header-buttons.test.ts
index c84ba8b5..df69f6c5 100644
--- a/test/mobile/header-buttons.test.ts
+++ b/test/mobile/header-buttons.test.ts
@@ -1,4 +1,4 @@
-// Port 3208 - Header button visibility (real-browser E2E).
+// Ephemeral port - Header button visibility (real-browser E2E).
//
// Companion to the CI static guard (test/mobile-header-buttons-policy.test.ts).
// Renders the real app and asserts ACTUAL computed visibility, catching CSS/layout
@@ -13,16 +13,16 @@ import { createTestServer, stopTestServer } from './helpers/server.js';
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
import { assertHidden, assertVisible } from './helpers/assertions.js';
import { REPRESENTATIVE_DEVICES } from './devices.js';
-import { PORTS, WAIT } from './helpers/constants.js';
+import { WAIT } from './helpers/constants.js';
-const PORT = PORTS.HEADER_BUTTONS;
-const BASE_URL = `http://localhost:${PORT}`;
+let baseUrl: string;
describe('Header button visibility (E2E)', () => {
let server: WebServer;
beforeAll(async () => {
- server = await createTestServer(PORT);
+ server = await createTestServer();
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -31,20 +31,20 @@ describe('Header button visibility (E2E)', () => {
});
it('hides the settings gear + lifecycle log on a standard phone (iPhone 14 Pro)', async () => {
- const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['standard-phone'], BASE_URL);
+ const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['standard-phone'], baseUrl);
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await assertHidden(page, '.btn-icon-header.btn-settings');
await assertHidden(page, '.btn-icon-header.btn-lifecycle-log');
});
it('keeps the opt-in attachments button HIDDEN by default on a desktop-class viewport', async () => {
- const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['large-tablet'], BASE_URL);
+ const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['large-tablet'], baseUrl);
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await assertHidden(page, '#attachmentsHistoryBtn');
});
it('shows the attachments button once the setting is enabled', async () => {
- const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['large-tablet'], BASE_URL);
+ const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['large-tablet'], baseUrl);
// Desktop-class devices use the non-mobile settings blob.
await page.evaluate(() => {
const cur = JSON.parse(localStorage.getItem('codeman-app-settings') || '{}');
diff --git a/test/mobile/helpers/constants.ts b/test/mobile/helpers/constants.ts
index bdd6198b..7acf316b 100644
--- a/test/mobile/helpers/constants.ts
+++ b/test/mobile/helpers/constants.ts
@@ -1,16 +1,3 @@
-// Port assignments
-export const PORTS = {
- KEYBOARD: 3200,
- TABS: 3201,
- SUBAGENT_WINDOWS: 3202,
- SETTINGS: 3203,
- LAYOUT: 3204,
- DEVICE_MATRIX: 3205,
- VISUAL_REGRESSION: 3206,
- ACCESSIBILITY: 3207,
- HEADER_BUTTONS: 3208,
-} as const;
-
// CSS Selectors
export const SELECTORS = {
// Header
diff --git a/test/mobile/helpers/server.ts b/test/mobile/helpers/server.ts
index ffa4e58f..2ec0bb3b 100644
--- a/test/mobile/helpers/server.ts
+++ b/test/mobile/helpers/server.ts
@@ -1,29 +1,22 @@
import { WebServer } from '../../../src/web/server.js';
-let servers: Map = new Map();
+const servers = new Set();
-export async function createTestServer(port: number): Promise {
- if (servers.has(port)) {
- return servers.get(port)!;
- }
- const server = new WebServer(port, false, true); // testMode = true
+/** A test server on an ephemeral port; read the port from `server.boundPort` once this resolves. */
+export async function createTestServer(): Promise {
+ const server = new WebServer(0, false, true); // testMode = true
await server.start();
- servers.set(port, server);
+ servers.add(server);
return server;
}
export async function stopTestServer(server: WebServer): Promise {
await server.stop();
- for (const [port, s] of servers) {
- if (s === server) {
- servers.delete(port);
- break;
- }
- }
+ servers.delete(server);
}
export async function stopAllTestServers(): Promise {
- for (const server of servers.values()) {
+ for (const server of servers) {
await server.stop();
}
servers.clear();
diff --git a/test/mobile/keyboard.test.ts b/test/mobile/keyboard.test.ts
index 9df3ca13..91cf4000 100644
--- a/test/mobile/keyboard.test.ts
+++ b/test/mobile/keyboard.test.ts
@@ -1,7 +1,7 @@
-// Port 3200 - Virtual keyboard simulation tests
+// Ephemeral port - Virtual keyboard simulation tests
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import type { Page, BrowserContext } from 'playwright';
-import { PORTS, KEYBOARD, SELECTORS, BODY_CLASSES, WAIT } from './helpers/constants.js';
+import { KEYBOARD, SELECTORS, BODY_CLASSES, WAIT } from './helpers/constants.js';
import { createTestServer, stopTestServer } from './helpers/server.js';
import { createDevicePage, getBrowser, closeAllBrowsers } from './helpers/browser.js';
import {
@@ -26,8 +26,7 @@ import {
import { REPRESENTATIVE_DEVICES } from './devices.js';
import type { WebServer } from '../src/web/server.js';
-const PORT = PORTS.KEYBOARD;
-const BASE_URL = `http://localhost:${PORT}`;
+let baseUrl: string;
// ─── Page-global access helpers ───
// KeyboardHandler is a `const` in app.js — NOT on `window`.
@@ -59,7 +58,8 @@ describe('Virtual Keyboard', () => {
let server: WebServer;
beforeAll(async () => {
- server = await createTestServer(PORT);
+ server = await createTestServer();
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -75,7 +75,7 @@ describe('Virtual Keyboard', () => {
const device = REPRESENTATIVE_DEVICES['standard-phone']; // iPhone 14 Pro
beforeEach(async () => {
- const result = await createDevicePage(device, BASE_URL, 'chromium');
+ const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
});
@@ -153,7 +153,7 @@ describe('Virtual Keyboard', () => {
const { context, page } = await createDevicePage(device, 'about:blank', 'chromium');
try {
await setupViewportMock(page);
- await page.goto(BASE_URL, { waitUntil: WAIT.DOM_CONTENT_LOADED });
+ await page.goto(baseUrl, { waitUntil: WAIT.DOM_CONTENT_LOADED });
await page.waitForTimeout(2000);
const success = await showKeyboardViaMock(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
@@ -167,7 +167,7 @@ describe('Virtual Keyboard', () => {
const { context, page } = await createDevicePage(device, 'about:blank', 'chromium');
try {
await setupViewportMock(page);
- await page.goto(BASE_URL, { waitUntil: WAIT.DOM_CONTENT_LOADED });
+ await page.goto(baseUrl, { waitUntil: WAIT.DOM_CONTENT_LOADED });
await page.waitForTimeout(2000);
const success = await showKeyboardViaMock(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
@@ -186,7 +186,7 @@ describe('Virtual Keyboard', () => {
const result = await createDevicePage(device, 'about:blank', 'webkit');
context = result.context;
await setupViewportMock(result.page);
- await result.page.goto(BASE_URL, { waitUntil: WAIT.DOM_CONTENT_LOADED });
+ await result.page.goto(baseUrl, { waitUntil: WAIT.DOM_CONTENT_LOADED });
await result.page.waitForTimeout(2000);
const success = await showKeyboardViaMock(result.page, KEYBOARD.TYPICAL_IOS_HEIGHT);
@@ -213,7 +213,7 @@ describe('Virtual Keyboard', () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
beforeEach(async () => {
- const result = await createDevicePage(device, BASE_URL, 'chromium');
+ const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
});
@@ -1469,7 +1469,7 @@ describe('Virtual Keyboard', () => {
let page: Page;
beforeAll(async () => {
- ({ context, page } = await createDevicePage(REPRESENTATIVE_DEVICES['standard-phone'], BASE_URL, 'chromium'));
+ ({ context, page } = await createDevicePage(REPRESENTATIVE_DEVICES['standard-phone'], baseUrl, 'chromium'));
await page.waitForTimeout(WAIT.PAGE_SETTLE);
});
@@ -1638,7 +1638,7 @@ describe('Virtual Keyboard', () => {
describe('Cross-device keyboard behavior', () => {
it('phone: full keyboard handling active', async () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
- const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
+ const { context, page } = await createDevicePage(device, baseUrl, 'chromium');
try {
const state = await getKeyboardState(page);
expect(state.exists).toBe(true);
@@ -1654,7 +1654,7 @@ describe('Virtual Keyboard', () => {
it('tablet: keyboard handling active', async () => {
const device = REPRESENTATIVE_DEVICES['standard-tablet']; // iPad Mini
- const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
+ const { context, page } = await createDevicePage(device, baseUrl, 'chromium');
try {
const state = await getKeyboardState(page);
expect(state.exists).toBe(true);
@@ -1674,7 +1674,7 @@ describe('Virtual Keyboard', () => {
});
const page = await context.newPage();
try {
- await page.goto(BASE_URL, { waitUntil: WAIT.DOM_CONTENT_LOADED });
+ await page.goto(baseUrl, { waitUntil: WAIT.DOM_CONTENT_LOADED });
await page.waitForTimeout(1000);
const state = await getKeyboardState(page);
diff --git a/test/mobile/layout.test.ts b/test/mobile/layout.test.ts
index dcb16951..eb9d325f 100644
--- a/test/mobile/layout.test.ts
+++ b/test/mobile/layout.test.ts
@@ -1,10 +1,10 @@
-// Port 3204 - General mobile layout tests
+// Ephemeral port - General mobile layout tests
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { BrowserContext, Page } from 'playwright';
import type { WebServer } from '../src/web/server.js';
import { createTestServer, stopTestServer } from './helpers/server.js';
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
-import { PORTS, SELECTORS, BREAKPOINTS, BODY_CLASSES, WAIT, MIN_TOUCH_TARGET } from './helpers/constants.js';
+import { SELECTORS, BREAKPOINTS, BODY_CLASSES, WAIT, MIN_TOUCH_TARGET } from './helpers/constants.js';
import {
assertFixedPosition,
assertHidden,
@@ -21,8 +21,7 @@ import {
} from './helpers/assertions.js';
import { REPRESENTATIVE_DEVICES, DEVICE_REGISTRY, type DeviceEntry } from './devices.js';
-const PORT = PORTS.LAYOUT;
-const BASE_URL = `http://localhost:${PORT}`;
+let baseUrl: string;
let server: WebServer;
@@ -33,7 +32,8 @@ const iPadPro = REPRESENTATIVE_DEVICES['large-tablet'];
describe('Mobile Layout', () => {
beforeAll(async () => {
- server = await createTestServer(PORT);
+ server = await createTestServer();
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -48,7 +48,7 @@ describe('Mobile Layout', () => {
let page: Page;
beforeAll(async () => {
- ({ context, page } = await createDevicePage(iPhone14Pro, BASE_URL));
+ ({ context, page } = await createDevicePage(iPhone14Pro, baseUrl));
});
afterAll(async () => {
@@ -98,7 +98,7 @@ describe('Mobile Layout', () => {
let page: Page;
beforeAll(async () => {
- ({ context, page } = await createDevicePage(iPhone14Pro, BASE_URL));
+ ({ context, page } = await createDevicePage(iPhone14Pro, baseUrl));
});
afterAll(async () => {
@@ -124,7 +124,7 @@ describe('Mobile Layout', () => {
describe('Mobile-Only Visible Elements', () => {
it('.btn-settings-mobile visible on phones', async () => {
- const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
+ const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
try {
await assertVisible(page, SELECTORS.SETTINGS_MOBILE);
} finally {
@@ -133,7 +133,7 @@ describe('Mobile Layout', () => {
});
it('.btn-case-mobile visible on phones', async () => {
- const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
+ const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
try {
await assertVisible(page, SELECTORS.CASE_MOBILE);
} finally {
@@ -142,7 +142,7 @@ describe('Mobile Layout', () => {
});
it('.btn-settings-mobile hidden on desktop', async () => {
- const { context, page } = await createDevicePage(iPadPro, BASE_URL);
+ const { context, page } = await createDevicePage(iPadPro, baseUrl);
try {
await assertHidden(page, SELECTORS.SETTINGS_MOBILE);
} finally {
@@ -156,7 +156,7 @@ describe('Mobile Layout', () => {
describe('Toolbar Collision Regression', () => {
it('keeps phone toolbar controls inside the viewport', async () => {
const device = DEVICE_REGISTRY.find((d) => d.name === 'iPhone 8')!;
- const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
+ const { context, page } = await createDevicePage(device, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
const layout = await page.evaluate(() => {
@@ -189,7 +189,7 @@ describe('Mobile Layout', () => {
it('does not render the desktop voice button on a 430px large phone', async () => {
const device = REPRESENTATIVE_DEVICES['large-phone'];
- const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
+ const { context, page } = await createDevicePage(device, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await assertHidden(page, '#voiceInputBtn');
@@ -201,7 +201,7 @@ describe('Mobile Layout', () => {
it('uses phone upload and voice controls without toolbar overlap', async () => {
const device = REPRESENTATIVE_DEVICES['small-phone'];
- const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
+ const { context, page } = await createDevicePage(device, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
expect(await page.locator('.toolbar-center .btn-upload').isVisible()).toBe(false);
@@ -246,7 +246,7 @@ describe('Mobile Layout', () => {
it('keeps the mobile recording mic effect inside the button bounds', async () => {
const device = REPRESENTATIVE_DEVICES['small-phone'];
- const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
+ const { context, page } = await createDevicePage(device, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await page.evaluate(() => {
@@ -266,7 +266,7 @@ describe('Mobile Layout', () => {
});
it('keeps desktop upload and voice controls in one centered row', async () => {
- const { context, page } = await createDevicePage(iPadPro, BASE_URL, 'chromium');
+ const { context, page } = await createDevicePage(iPadPro, baseUrl, 'chromium');
try {
await page.setViewportSize({ width: 1280, height: 800 });
await page.waitForTimeout(WAIT.PAGE_SETTLE);
@@ -294,7 +294,7 @@ describe('Mobile Layout', () => {
describe('Device Classes', () => {
it('iOS user agent adds ios-device class', async () => {
- const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
+ const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
try {
await assertHasClass(page, 'body', BODY_CLASSES.IOS);
} finally {
@@ -304,7 +304,7 @@ describe('Mobile Layout', () => {
it('Android user agent does NOT add ios-device', async () => {
const pixel = DEVICE_REGISTRY.find((d) => d.name === 'Pixel 7')!;
- const { context, page } = await createDevicePage(pixel, BASE_URL);
+ const { context, page } = await createDevicePage(pixel, baseUrl);
try {
await assertNotHasClass(page, 'body', BODY_CLASSES.IOS);
} finally {
@@ -314,7 +314,7 @@ describe('Mobile Layout', () => {
it('Safari user agent adds safari-browser class', async () => {
// iPhone 14 Pro uses Safari UA
- const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
+ const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
try {
await assertHasClass(page, 'body', BODY_CLASSES.SAFARI);
} finally {
@@ -323,7 +323,7 @@ describe('Mobile Layout', () => {
});
it('touch device adds touch-device class', async () => {
- const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
+ const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
try {
await assertHasClass(page, 'body', BODY_CLASSES.TOUCH);
} finally {
@@ -332,7 +332,7 @@ describe('Mobile Layout', () => {
});
it('width < 600 adds device-mobile', async () => {
- const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
+ const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
try {
await assertDeviceClasses(page, iPhone14Pro.viewport.width);
} finally {
@@ -342,7 +342,7 @@ describe('Mobile Layout', () => {
it('width 600-768 adds device-tablet', async () => {
const tablet = REPRESENTATIVE_DEVICES['small-tablet'];
- const { context, page } = await createDevicePage(tablet, BASE_URL);
+ const { context, page } = await createDevicePage(tablet, baseUrl);
try {
await assertDeviceClasses(page, tablet.viewport.width);
} finally {
@@ -351,7 +351,7 @@ describe('Mobile Layout', () => {
});
it('width >= 768 adds device-desktop', async () => {
- const { context, page } = await createDevicePage(iPadPro, BASE_URL);
+ const { context, page } = await createDevicePage(iPadPro, baseUrl);
try {
await assertDeviceClasses(page, iPadPro.viewport.width);
} finally {
@@ -364,7 +364,7 @@ describe('Mobile Layout', () => {
describe('iOS Safe Areas', () => {
it('iOS header has extra padding for safe area', async () => {
- const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
+ const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
try {
// On iOS devices, the header should have padding-top that accounts for safe area
const paddingTop = await getCSSNumericValue(page, SELECTORS.HEADER, 'padding-top');
@@ -378,7 +378,7 @@ describe('Mobile Layout', () => {
});
it('iOS modal content has safe area paddings', async () => {
- const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
+ const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
try {
// Open settings modal to test modal safe areas
const settingsBtn = page.locator(SELECTORS.SETTINGS_MOBILE);
@@ -406,7 +406,7 @@ describe('Mobile Layout', () => {
describe('Touch Targets', () => {
it('all interactive elements meet 44px minimum', async () => {
- const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
+ const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
try {
const violations = await assertAccessibleTouchTargets(page, MIN_TOUCH_TARGET);
if (violations.length > 0) {
@@ -428,7 +428,7 @@ describe('Mobile Layout', () => {
describe('Inputs', () => {
it('text inputs use font-size >= 16px', async () => {
- const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
+ const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
try {
// Check visible text inputs (font-size < 16px causes iOS auto-zoom)
const inputs = await page.$$('input[type="text"], input[type="search"], input:not([type]), textarea');
@@ -457,7 +457,7 @@ describe('Mobile Layout', () => {
describe('Terminal', () => {
it('terminal-container has touch-action: pan-y', async () => {
- const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
+ const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
try {
const exists = await page.$(SELECTORS.TERMINAL_CONTAINER);
if (exists) {
@@ -474,7 +474,7 @@ describe('Mobile Layout', () => {
describe('No Horizontal Overflow', () => {
it('no horizontal scroll on phone viewport', async () => {
- const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
+ const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
try {
await assertNoHorizontalOverflow(page);
} finally {
@@ -487,7 +487,7 @@ describe('Mobile Layout', () => {
describe('Main Content', () => {
it('main has margin-top for fixed header', async () => {
- const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
+ const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
try {
const marginTop = await getCSSNumericValue(page, SELECTORS.MAIN, 'margin-top');
// Main content should have enough margin-top to clear the fixed header (~42px)
@@ -502,7 +502,7 @@ describe('Mobile Layout', () => {
describe('Viewport Meta', () => {
it('zoom is not disabled', async () => {
- const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
+ const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
try {
await assertZoomNotDisabled(page);
} finally {
diff --git a/test/mobile/settings.test.ts b/test/mobile/settings.test.ts
index 8965f0c6..cb6e8b17 100644
--- a/test/mobile/settings.test.ts
+++ b/test/mobile/settings.test.ts
@@ -1,7 +1,7 @@
-// Port 3203 - Settings modal mobile tests
+// Ephemeral port - Settings modal mobile tests
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import type { Page, BrowserContext } from 'playwright';
-import { PORTS, SELECTORS, KEYBOARD, STORAGE_KEYS, BODY_CLASSES, WAIT } from './helpers/constants.js';
+import { SELECTORS, KEYBOARD, STORAGE_KEYS, BODY_CLASSES, WAIT } from './helpers/constants.js';
import { createTestServer, stopTestServer } from './helpers/server.js';
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
import { showKeyboard, hideKeyboard } from './helpers/keyboard-sim.js';
@@ -9,14 +9,14 @@ import { assertVisible, assertHidden, getCSSProperty, getCSSNumericValue } from
import { DEVICE_REGISTRY, REPRESENTATIVE_DEVICES } from './devices.js';
import type { WebServer } from '../src/web/server.js';
-const PORT = PORTS.SETTINGS;
-const BASE_URL = `http://localhost:${PORT}`;
+let baseUrl: string;
describe('Settings Modal', () => {
let server: WebServer;
beforeAll(async () => {
- server = await createTestServer(PORT);
+ server = await createTestServer();
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -32,7 +32,7 @@ describe('Settings Modal', () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
beforeEach(async () => {
- const result = await createDevicePage(device, BASE_URL, 'chromium');
+ const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
});
@@ -105,7 +105,7 @@ describe('Settings Modal', () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
beforeEach(async () => {
- const result = await createDevicePage(device, BASE_URL, 'chromium');
+ const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
});
@@ -210,7 +210,7 @@ describe('Settings Modal', () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
beforeEach(async () => {
- const result = await createDevicePage(device, BASE_URL, 'chromium');
+ const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
@@ -295,7 +295,7 @@ describe('Settings Modal', () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
it('settings survive page reload', async () => {
- const { page, context } = await createDevicePage(device, BASE_URL, 'chromium');
+ const { page, context } = await createDevicePage(device, baseUrl, 'chromium');
try {
// Store a test setting
@@ -330,7 +330,7 @@ describe('Settings Modal', () => {
});
it('mobile notification prefs stored separately from desktop settings', async () => {
- const { page, context } = await createDevicePage(device, BASE_URL, 'chromium');
+ const { page, context } = await createDevicePage(device, baseUrl, 'chromium');
try {
// Store both mobile and desktop settings
@@ -375,7 +375,7 @@ describe('Settings Modal', () => {
describe('Tablet vs Desktop Settings', () => {
it('tablet uses desktop settings key', async () => {
const device = REPRESENTATIVE_DEVICES['standard-tablet'];
- const { page, context } = await createDevicePage(device, BASE_URL, 'chromium');
+ const { page, context } = await createDevicePage(device, baseUrl, 'chromium');
try {
const key = await page.evaluate(() => {
@@ -393,7 +393,7 @@ describe('Settings Modal', () => {
it('settings gear button not in toolbar on desktop', async () => {
const device = REPRESENTATIVE_DEVICES['large-tablet'];
- const { page, context } = await createDevicePage(device, BASE_URL, 'chromium');
+ const { page, context } = await createDevicePage(device, baseUrl, 'chromium');
try {
await assertHidden(page, SELECTORS.SETTINGS_MOBILE);
@@ -411,7 +411,7 @@ describe('Settings Modal', () => {
// test/viewport-shape-change.test.ts; this drives the real resize.
const inner = DEVICE_REGISTRY.find((entry) => entry.name === 'iPhone Duo (inner)')!;
const outer = DEVICE_REGISTRY.find((entry) => entry.name === 'iPhone Duo (outer)')!;
- const { page, context } = await createDevicePage(inner, BASE_URL, 'chromium');
+ const { page, context } = await createDevicePage(inner, baseUrl, 'chromium');
try {
await page.evaluate((key) => {
@@ -444,7 +444,7 @@ describe('Settings Modal', () => {
it('keeps handheld settings when a foldable unfolds past the desktop breakpoint', async () => {
const device = DEVICE_REGISTRY.find((entry) => entry.name === 'OPPO Find N5 (unfolded)')!;
- const { page, context } = await createDevicePage(device, BASE_URL, 'chromium');
+ const { page, context } = await createDevicePage(device, baseUrl, 'chromium');
try {
// Seed the preferences while folded, exactly as a phone user does.
diff --git a/test/mobile/subagent-windows.test.ts b/test/mobile/subagent-windows.test.ts
index 59c5c752..c51a8df2 100644
--- a/test/mobile/subagent-windows.test.ts
+++ b/test/mobile/subagent-windows.test.ts
@@ -1,7 +1,7 @@
-// Port 3202 - Mobile subagent window card tests
+// Ephemeral port - Mobile subagent window card tests
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { Page, BrowserContext } from 'playwright';
-import { PORTS, SELECTORS, SUBAGENT, KEYBOARD, WAIT } from './helpers/constants.js';
+import { SELECTORS, SUBAGENT, KEYBOARD, WAIT } from './helpers/constants.js';
import { createTestServer, stopTestServer } from './helpers/server.js';
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
import { showKeyboard, hideKeyboard } from './helpers/keyboard-sim.js';
@@ -9,8 +9,7 @@ import { getCSSProperty, getCSSNumericValue } from './helpers/assertions.js';
import { REPRESENTATIVE_DEVICES } from './devices.js';
import type { WebServer } from '../src/web/server.js';
-const PORT = PORTS.SUBAGENT_WINDOWS;
-const BASE_URL = `http://localhost:${PORT}`;
+let baseUrl: string;
const standardPhone = REPRESENTATIVE_DEVICES['standard-phone']; // iPhone 14 Pro
@@ -78,7 +77,8 @@ describe('Mobile Subagent Windows', () => {
let server: WebServer;
beforeAll(async () => {
- server = await createTestServer(PORT);
+ server = await createTestServer();
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -93,7 +93,7 @@ describe('Mobile Subagent Windows', () => {
let page: Page;
beforeAll(async () => {
- ({ context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium'));
+ ({ context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium'));
await page.waitForTimeout(WAIT.PAGE_SETTLE);
});
@@ -187,7 +187,7 @@ describe('Mobile Subagent Windows', () => {
describe('Stacking', () => {
it('windows stack from top when keyboard hidden', async () => {
- const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
+ const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
@@ -240,7 +240,7 @@ describe('Mobile Subagent Windows', () => {
});
it('windows stack from bottom when keyboard visible', async () => {
- const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
+ const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
@@ -289,7 +289,7 @@ describe('Mobile Subagent Windows', () => {
});
it('stacking recalculates on keyboard toggle', async () => {
- const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
+ const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
@@ -379,7 +379,7 @@ describe('Mobile Subagent Windows', () => {
describe('Interactions', () => {
it('minimize button hides the window body', async () => {
- const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
+ const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await injectMockSubagentWindow(page, 'minimize-test', 0);
@@ -432,7 +432,7 @@ describe('Mobile Subagent Windows', () => {
});
it('minimize button toggles: second click restores window', async () => {
- const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
+ const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await injectMockSubagentWindow(page, 'toggle-test', 0);
@@ -490,7 +490,7 @@ describe('Mobile Subagent Windows', () => {
let page: Page;
beforeAll(async () => {
- ({ context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium'));
+ ({ context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium'));
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await injectMockSubagentWindow(page, 'content-test-agent', 0);
});
@@ -556,7 +556,7 @@ describe('Mobile Subagent Windows', () => {
describe('Multiple Windows', () => {
it('can render multiple subagent windows simultaneously', async () => {
- const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
+ const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
@@ -583,7 +583,7 @@ describe('Mobile Subagent Windows', () => {
});
it('windows do not overlap when properly stacked', async () => {
- const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
+ const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
diff --git a/test/mobile/tabs.test.ts b/test/mobile/tabs.test.ts
index 25270e99..977421d1 100644
--- a/test/mobile/tabs.test.ts
+++ b/test/mobile/tabs.test.ts
@@ -1,7 +1,7 @@
-// Port 3201 - Tab switching and navigation tests
+// Ephemeral port - Tab switching and navigation tests
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { Page, BrowserContext } from 'playwright';
-import { PORTS, SELECTORS, SWIPE, BODY_CLASSES, WAIT } from './helpers/constants.js';
+import { SELECTORS, SWIPE, BODY_CLASSES, WAIT } from './helpers/constants.js';
import { createTestServer, stopTestServer } from './helpers/server.js';
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
import { swipe, swipeViaCDP, swipeViaSynthetic } from './helpers/touch-sim.js';
@@ -9,8 +9,7 @@ import { assertHidden, assertVisible, getCSSProperty, getCSSNumericValue } from
import { REPRESENTATIVE_DEVICES } from './devices.js';
import type { WebServer } from '../src/web/server.js';
-const PORT = PORTS.TABS;
-const BASE_URL = `http://localhost:${PORT}`;
+let baseUrl: string;
const standardPhone = REPRESENTATIVE_DEVICES['standard-phone']; // iPhone 14 Pro
@@ -18,7 +17,8 @@ describe('Tab Navigation', () => {
let server: WebServer;
beforeAll(async () => {
- server = await createTestServer(PORT);
+ server = await createTestServer();
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -33,7 +33,7 @@ describe('Tab Navigation', () => {
let page: Page;
beforeAll(async () => {
- ({ context, page } = await createDevicePage(standardPhone, BASE_URL));
+ ({ context, page } = await createDevicePage(standardPhone, baseUrl));
// Wait for SSE and JS to fully initialize
await page.waitForTimeout(WAIT.PAGE_SETTLE);
});
@@ -157,7 +157,7 @@ describe('Tab Navigation', () => {
it('tabs remain visible on large phone and tablet headers', async () => {
for (const device of [REPRESENTATIVE_DEVICES['large-phone'], REPRESENTATIVE_DEVICES['small-tablet']]) {
- const { context: deviceContext, page: devicePage } = await createDevicePage(device, BASE_URL, 'chromium');
+ const { context: deviceContext, page: devicePage } = await createDevicePage(device, baseUrl, 'chromium');
try {
await devicePage.waitForTimeout(WAIT.PAGE_SETTLE);
await devicePage.evaluate(() => {
@@ -224,7 +224,7 @@ describe('Tab Navigation', () => {
}
it('reveals a rightmost tab that selection would otherwise leave off-screen', async () => {
- const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
+ const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await seedTabs(page, 5);
@@ -250,7 +250,7 @@ describe('Tab Navigation', () => {
});
it('scrolls back to reveal a leftmost tab', async () => {
- const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
+ const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await seedTabs(page, 5);
@@ -271,7 +271,7 @@ describe('Tab Navigation', () => {
});
it('keeps the scroll position across an ambient full re-render', async () => {
- const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
+ const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await seedTabs(page, 5);
@@ -296,7 +296,7 @@ describe('Tab Navigation', () => {
});
it('renders tabs in sessionOrder on phones instead of hoisting the active one', async () => {
- const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
+ const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await seedTabs(page, 5, 3); // 4th tab active
@@ -317,7 +317,7 @@ describe('Tab Navigation', () => {
});
it('reaches the last tab with a horizontal touch drag', async () => {
- const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
+ const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await seedTabs(page, 5);
@@ -356,7 +356,7 @@ describe('Tab Navigation', () => {
let page: Page;
beforeAll(async () => {
- ({ context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium'));
+ ({ context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium'));
await page.waitForTimeout(WAIT.PAGE_SETTLE);
});
@@ -471,7 +471,7 @@ describe('Tab Navigation', () => {
it('synthetic swipe left triggers navigation on WebKit', async () => {
let context: BrowserContext | undefined;
try {
- const result = await createDevicePage(standardPhone, BASE_URL, 'webkit');
+ const result = await createDevicePage(standardPhone, baseUrl, 'webkit');
context = result.context;
await result.page.waitForTimeout(WAIT.PAGE_SETTLE);
@@ -510,7 +510,7 @@ describe('Tab Navigation', () => {
it('synthetic swipe right triggers prevSession on WebKit', async () => {
let context: BrowserContext | undefined;
try {
- const result = await createDevicePage(standardPhone, BASE_URL, 'webkit');
+ const result = await createDevicePage(standardPhone, baseUrl, 'webkit');
context = result.context;
await result.page.waitForTimeout(WAIT.PAGE_SETTLE);
@@ -578,7 +578,7 @@ describe('Tab Navigation', () => {
}
it('ArrowRight moves focus to next tab', async () => {
- const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
+ const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await injectMockTabs(page, 3);
@@ -602,7 +602,7 @@ describe('Tab Navigation', () => {
});
it('ArrowLeft moves focus to previous tab', async () => {
- const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
+ const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await injectMockTabs(page, 3);
@@ -627,7 +627,7 @@ describe('Tab Navigation', () => {
});
it('Home key focuses first tab', async () => {
- const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
+ const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await injectMockTabs(page, 4);
@@ -651,7 +651,7 @@ describe('Tab Navigation', () => {
});
it('End key focuses last tab', async () => {
- const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
+ const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await injectMockTabs(page, 4);
@@ -675,7 +675,7 @@ describe('Tab Navigation', () => {
});
it('Enter activates focused tab', async () => {
- const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
+ const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await injectMockTabs(page, 3);
@@ -711,7 +711,7 @@ describe('Tab Navigation', () => {
describe('Tab Touch Focus', () => {
it('switching tabs with the keyboard closed does not leave the terminal textarea focused', async () => {
- const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
+ const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
@@ -758,7 +758,7 @@ describe('Tab Navigation', () => {
describe('Tab Close Button Visibility', () => {
it('close button hidden on non-active tabs', async () => {
- const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
+ const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
@@ -794,7 +794,7 @@ describe('Tab Navigation', () => {
});
it('gear icon visible only on active tab', async () => {
- const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
+ const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
@@ -850,7 +850,7 @@ describe('Tab Navigation', () => {
describe('Subagent Badge on Tab', () => {
it('subagent badge has correct dimensions', async () => {
- const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
+ const { context, page } = await createDevicePage(standardPhone, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
diff --git a/test/mobile/visual-regression.test.ts b/test/mobile/visual-regression.test.ts
index 3f41d345..d5d10a49 100644
--- a/test/mobile/visual-regression.test.ts
+++ b/test/mobile/visual-regression.test.ts
@@ -1,16 +1,15 @@
-// Port 3206 - Screenshot comparison tests
+// Ephemeral port - Screenshot comparison tests
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { BrowserContext, Page } from 'playwright';
import type { WebServer } from '../src/web/server.js';
import { createTestServer, stopTestServer } from './helpers/server.js';
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
-import { PORTS, SELECTORS, VISUAL, VISUAL_BREAKPOINTS, WAIT } from './helpers/constants.js';
+import { SELECTORS, VISUAL, VISUAL_BREAKPOINTS, WAIT } from './helpers/constants.js';
import { showKeyboard, hideKeyboard } from './helpers/keyboard-sim.js';
import { compareScreenshot, assertScreenshotMatch } from './helpers/visual.js';
import { REPRESENTATIVE_DEVICES } from './devices.js';
-const PORT = PORTS.VISUAL_REGRESSION;
-const BASE_URL = `http://localhost:${PORT}`;
+let baseUrl: string;
let server: WebServer;
@@ -20,7 +19,8 @@ const KEY_BREAKPOINTS = [320, 375, 393, 430, 768, 1024] as const;
describe('Visual Regression', () => {
beforeAll(async () => {
- server = await createTestServer(PORT);
+ server = await createTestServer();
+ baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -49,7 +49,7 @@ describe('Visual Regression', () => {
defaultBrowserType: 'chromium' as const,
};
- const { context, page } = await createDevicePage(device, BASE_URL);
+ const { context, page } = await createDevicePage(device, baseUrl);
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
const result = await compareScreenshot(page, `landing-${width}w`);
@@ -76,7 +76,7 @@ describe('Visual Regression', () => {
defaultBrowserType: 'chromium' as const,
};
- const { context, page } = await createDevicePage(device, BASE_URL);
+ const { context, page } = await createDevicePage(device, baseUrl);
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
// Show keyboard via DOM manipulation (works on all engines)
@@ -109,7 +109,7 @@ describe('Visual Regression', () => {
defaultBrowserType: 'chromium' as const,
};
- const { context, page } = await createDevicePage(device, BASE_URL);
+ const { context, page } = await createDevicePage(device, baseUrl);
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
@@ -143,7 +143,7 @@ describe('Visual Regression', () => {
describe('Representative Devices', () => {
for (const [category, device] of Object.entries(REPRESENTATIVE_DEVICES)) {
it(`${device.name} (${category}) landing page`, async () => {
- const { context, page } = await createDevicePage(device, BASE_URL);
+ const { context, page } = await createDevicePage(device, baseUrl);
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
const name = `device-${device.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}`;
From eceaecf076f3c10d7a85a778d3ea497abda5729b Mon Sep 17 00:00:00 2001
From: Randalix
Date: Fri, 9 Oct 2026 16:29:01 +0200
Subject: [PATCH 12/76] test: raw test servers listen on port 0 (#440)
http/Fastify servers in daemon-control, deepseek-status-shim, session-input-wait and the three tui tests listen on 0 and read address().port. The two "nothing listens here" ports (a fixed 3243, and the server's port + 1) come from closedPort(): bind 0, read the port, close.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
test/daemon-control.test.ts | 25 +++++++++----
test/deepseek-status-shim.test.ts | 11 +++---
test/routes/session-input-wait.test.ts | 5 ++-
test/tui/tui-client-sse.test.ts | 27 +++++++-------
test/tui/tui-client.test.ts | 50 +++++++++++++++++---------
test/tui/tui-e2e.test.ts | 9 ++---
6 files changed, 81 insertions(+), 46 deletions(-)
diff --git a/test/daemon-control.test.ts b/test/daemon-control.test.ts
index a5bb642f..54c19ff9 100644
--- a/test/daemon-control.test.ts
+++ b/test/daemon-control.test.ts
@@ -6,6 +6,7 @@
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import http from 'node:http';
+import type { AddressInfo } from 'node:net';
import {
buildBaseUrl,
buildStatusUrl,
@@ -16,7 +17,17 @@ import {
probeServer,
} from '../src/daemon-control.js';
-const PORT = 3216;
+/**
+ * A port nothing listens on: bind 0, read what the OS handed out, close. Free at the
+ * moment of use, unlike "the server's port + 1", which anything may hold.
+ */
+async function closedPort(): Promise {
+ const probe = http.createServer();
+ await new Promise((resolve) => probe.listen(0, '127.0.0.1', resolve));
+ const { port: free } = probe.address() as AddressInfo;
+ await new Promise((resolve) => probe.close(() => resolve()));
+ return free;
+}
describe('buildWebArgs', () => {
it('always passes host and port through explicitly', () => {
@@ -143,6 +154,7 @@ describe('isProcessAlive', () => {
describe('probeServer', () => {
let server: http.Server;
+ let port: number;
beforeAll(async () => {
server = http.createServer((req, res) => {
@@ -157,7 +169,8 @@ describe('probeServer', () => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, data: { version: '9.9.9' } }));
});
- await new Promise((resolve) => server.listen(PORT, '127.0.0.1', resolve));
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ port = (server.address() as AddressInfo).port;
});
afterAll(async () => {
@@ -165,23 +178,23 @@ describe('probeServer', () => {
});
it('reports up and reads the version back', async () => {
- const result = await probeServer(`http://127.0.0.1:${PORT}/api/status`);
+ const result = await probeServer(`http://127.0.0.1:${port}/api/status`);
expect(result.up).toBe(true);
expect(result.version).toBe('9.9.9');
});
it('counts a 401 as up, because auth being active proves a server is there', async () => {
- const result = await probeServer(`http://127.0.0.1:${PORT}/unauthorized`);
+ const result = await probeServer(`http://127.0.0.1:${port}/unauthorized`);
expect(result.up).toBe(true);
});
it('does not mistake an unrelated service squatting on the port for Codeman', async () => {
- const result = await probeServer(`http://127.0.0.1:${PORT}/foreign`);
+ const result = await probeServer(`http://127.0.0.1:${port}/foreign`);
expect(result.up).toBe(false);
});
it('reports down when nothing is listening', async () => {
- const result = await probeServer(`http://127.0.0.1:${PORT + 1}/api/status`, 1000);
+ const result = await probeServer(`http://127.0.0.1:${await closedPort()}/api/status`, 1000);
expect(result.up).toBe(false);
});
diff --git a/test/deepseek-status-shim.test.ts b/test/deepseek-status-shim.test.ts
index 15b51c81..ec72cc5d 100644
--- a/test/deepseek-status-shim.test.ts
+++ b/test/deepseek-status-shim.test.ts
@@ -16,6 +16,7 @@
import { describe, expect, it, beforeEach, beforeAll, afterAll } from 'vitest';
import { execFileSync, spawn } from 'node:child_process';
import { createServer, type Server } from 'node:http';
+import type { AddressInfo } from 'node:net';
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, chmodSync } from 'node:fs';
import { dirname } from 'node:path';
import {
@@ -25,8 +26,6 @@ import {
DEEPSEEK_STATE_TO_HOOK_EVENT,
} from '../src/deepseek-status-shim.js';
-const PORT = 3251;
-
describe('DeepSeek status shim: provisioning', () => {
beforeEach(() => {
resetDeepSeekStatusShimForTest();
@@ -73,6 +72,7 @@ describe('DeepSeek status shim: provisioning', () => {
describe('DeepSeek status shim: the supervisor contract', () => {
let server: Server | undefined;
+ let port: number;
const received: Array<{ body: unknown; secret: string | undefined }> = [];
let status = 200;
@@ -96,7 +96,10 @@ describe('DeepSeek status shim: the supervisor contract', () => {
res.end('{}');
});
});
- server.listen(PORT, '127.0.0.1', resolve);
+ server.listen(0, '127.0.0.1', () => {
+ port = (server!.address() as AddressInfo).port;
+ resolve();
+ });
});
beforeAll(() => listen());
@@ -120,7 +123,7 @@ describe('DeepSeek status shim: the supervisor contract', () => {
const child = spawn(process.execPath, [path, ...args], {
env: {
...process.env,
- CODEMAN_API_URL: `http://127.0.0.1:${PORT}`,
+ CODEMAN_API_URL: `http://127.0.0.1:${port}`,
CODEMAN_SESSION_ID: 'sess-from-env',
...env,
},
diff --git a/test/routes/session-input-wait.test.ts b/test/routes/session-input-wait.test.ts
index 9d1bf965..95080459 100644
--- a/test/routes/session-input-wait.test.ts
+++ b/test/routes/session-input-wait.test.ts
@@ -451,13 +451,12 @@ describe('POST /api/sessions/:id/input: delivered reports the write, not just th
* exactly why this regression needs a POST and a real socket to catch.
*/
describe('POST /api/sessions/:id/input over real HTTP: hang-up handling', () => {
- const PORT = 3181;
- const base = `http://127.0.0.1:${PORT}`;
+ let base: string;
let app: FastifyInstance;
beforeAll(async () => {
app = (await harness()).app;
- await app.listen({ port: PORT, host: '127.0.0.1' });
+ base = await app.listen({ port: 0, host: '127.0.0.1' });
});
afterAll(async () => {
diff --git a/test/tui/tui-client-sse.test.ts b/test/tui/tui-client-sse.test.ts
index a811b91c..5b4f383d 100644
--- a/test/tui/tui-client-sse.test.ts
+++ b/test/tui/tui-client-sse.test.ts
@@ -9,10 +9,11 @@
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import http from 'node:http';
+import type { AddressInfo } from 'node:net';
import { TuiClient, type TuiApprovalEvent, type TuiSseStatusDetail } from '../../src/tui/tui-client.js';
-const PORT = 3242;
-const BASE_URL = `http://127.0.0.1:${PORT}`;
+let port: number;
+let baseUrl: string;
interface Connection {
url: string;
@@ -57,7 +58,9 @@ beforeAll(async () => {
res.flushHeaders();
connections.push({ url: req.url, headers: req.headers, res });
});
- await new Promise((resolve) => server.listen(PORT, '127.0.0.1', resolve));
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ port = (server.address() as AddressInfo).port;
+ baseUrl = `http://127.0.0.1:${port}`;
});
afterAll(async () => {
@@ -82,7 +85,7 @@ describe('subscribeEvents', () => {
let planUsage: unknown = null;
let init: unknown = null;
- client = new TuiClient({ baseUrl: BASE_URL, password: 's3cret' });
+ client = new TuiClient({ baseUrl, password: 's3cret' });
client.subscribeEvents({
onInit: (state) => {
init = state;
@@ -117,7 +120,7 @@ describe('subscribeEvents', () => {
});
it('suppresses the terminal firehose by default and carries the auth header', async () => {
- client = new TuiClient({ baseUrl: BASE_URL, password: 's3cret' });
+ client = new TuiClient({ baseUrl, password: 's3cret' });
client.subscribeEvents({});
await until(() => connections.length === 1);
expect(connections[0].url).toBe('/api/events?sessions=tui-no-terminal');
@@ -126,7 +129,7 @@ describe('subscribeEvents', () => {
});
it('subscribes to the terminal stream of named sessions when asked', async () => {
- client = new TuiClient({ baseUrl: BASE_URL });
+ client = new TuiClient({ baseUrl });
client.subscribeEvents({}, { sessionIds: ['a', 'b'] });
await until(() => connections.length === 1);
expect(connections[0].url).toBe('/api/events?sessions=a%2Cb');
@@ -134,7 +137,7 @@ describe('subscribeEvents', () => {
it('reconnects when the stream ends', async () => {
const statuses: Array<[string, TuiSseStatusDetail]> = [];
- client = new TuiClient({ baseUrl: BASE_URL });
+ client = new TuiClient({ baseUrl });
const stream = client.subscribeEvents(
{ onStatus: (status, detail) => statuses.push([status, detail]) },
{ baseBackoffMs: 10, maxBackoffMs: 20 }
@@ -148,7 +151,7 @@ describe('subscribeEvents', () => {
});
it('reconnects when a live stream goes silent, which no socket error reports', async () => {
- client = new TuiClient({ baseUrl: BASE_URL });
+ client = new TuiClient({ baseUrl });
client.subscribeEvents({}, { staleTimeoutMs: 150, checkIntervalMs: 25, baseBackoffMs: 10, maxBackoffMs: 20 });
await until(() => connections.length === 1);
@@ -161,7 +164,7 @@ describe('subscribeEvents', () => {
it('recommends polling once connecting keeps failing', async () => {
refuse = true;
const details: TuiSseStatusDetail[] = [];
- client = new TuiClient({ baseUrl: BASE_URL });
+ client = new TuiClient({ baseUrl });
const stream = client.subscribeEvents(
{ onStatus: (_status, detail) => details.push(detail) },
{ baseBackoffMs: 10, maxBackoffMs: 20, pollingAfterFailures: 2 }
@@ -175,7 +178,7 @@ describe('subscribeEvents', () => {
});
it('stops reconnecting after close, so the process can exit', async () => {
- client = new TuiClient({ baseUrl: BASE_URL });
+ client = new TuiClient({ baseUrl });
const stream = client.subscribeEvents({}, { baseBackoffMs: 10, maxBackoffMs: 20 });
await until(() => connections.length === 1);
@@ -187,7 +190,7 @@ describe('subscribeEvents', () => {
});
it('closes every stream the client opened', async () => {
- client = new TuiClient({ baseUrl: BASE_URL });
+ client = new TuiClient({ baseUrl });
client.subscribeEvents({});
client.subscribeEvents({});
await until(() => connections.length === 2);
@@ -200,7 +203,7 @@ describe('subscribeEvents', () => {
});
it('refuses to subscribe before the client knows where the server is', () => {
- const disconnected = new TuiClient({ port: 3999 });
+ const disconnected = new TuiClient({ port });
expect(() => disconnected.subscribeEvents({})).toThrow(/connect\(\)/);
});
});
diff --git a/test/tui/tui-client.test.ts b/test/tui/tui-client.test.ts
index 907858d5..4a2eccc7 100644
--- a/test/tui/tui-client.test.ts
+++ b/test/tui/tui-client.test.ts
@@ -11,6 +11,7 @@
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import http from 'node:http';
+import type { AddressInfo } from 'node:net';
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { dataPath } from '../../src/config/instance.js';
@@ -32,10 +33,22 @@ import {
type TuiExecFile,
} from '../../src/tui/tui-client.js';
-const PORT = 3241;
-/** Nothing ever listens here: the "no server" path. */
-const DEAD_PORT = 3243;
-const BASE_URL = `http://127.0.0.1:${PORT}`;
+/**
+ * A port nothing listens on: bind 0, read what the OS handed out, close. Free at the
+ * moment of use, unlike "the server's port + 1", which anything may hold.
+ */
+async function closedPort(): Promise {
+ const probe = http.createServer();
+ await new Promise((resolve) => probe.listen(0, '127.0.0.1', resolve));
+ const { port: free } = probe.address() as AddressInfo;
+ await new Promise((resolve) => probe.close(() => resolve()));
+ return free;
+}
+
+let port: number;
+/** Nothing listens one above the bound port: the "no server" path. */
+let deadPort: number;
+let baseUrl: string;
interface Recorded {
method: string;
@@ -115,7 +128,7 @@ const defaultResponder: Responder = (req, res) => {
};
function client(overrides: Record = {}): TuiClient {
- return new TuiClient({ baseUrl: BASE_URL, timeoutMs: 4000, ...overrides });
+ return new TuiClient({ baseUrl, timeoutMs: 4000, ...overrides });
}
let server: http.Server;
@@ -140,7 +153,10 @@ beforeAll(async () => {
(responder ?? defaultResponder)(req, res, body);
});
});
- await new Promise((resolve) => server.listen(PORT, '127.0.0.1', resolve));
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ port = (server.address() as AddressInfo).port;
+ deadPort = await closedPort();
+ baseUrl = `http://127.0.0.1:${port}`;
});
afterAll(async () => {
@@ -391,8 +407,8 @@ describe('TuiClient remaining API surface', () => {
describe('TuiClient.connect', () => {
it('discovers the loopback server and reports its identity', async () => {
- const info = await new TuiClient({ port: PORT, probeTimeoutMs: 1000 }).connect();
- expect(info?.baseUrl).toBe(BASE_URL);
+ const info = await new TuiClient({ port, probeTimeoutMs: 1000 }).connect();
+ expect(info?.baseUrl).toBe(baseUrl);
expect(info?.version).toBe('9.9.9');
expect(info?.hostname).toBeTruthy();
expect(info?.authRequired).toBeUndefined();
@@ -403,17 +419,17 @@ describe('TuiClient.connect', () => {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="codeman"' });
res.end('Unauthorized');
};
- const info = await new TuiClient({ port: PORT, probeTimeoutMs: 1000 }).connect();
- expect(info?.baseUrl).toBe(BASE_URL);
+ const info = await new TuiClient({ port, probeTimeoutMs: 1000 }).connect();
+ expect(info?.baseUrl).toBe(baseUrl);
expect(info?.authRequired).toBe(true);
});
it('returns null when nothing answers', async () => {
- await expect(new TuiClient({ port: DEAD_PORT, probeTimeoutMs: 500 }).connect()).resolves.toBeNull();
+ await expect(new TuiClient({ port: deadPort, probeTimeoutMs: 500 }).connect()).resolves.toBeNull();
});
it('refuses to talk to an unconnected client', async () => {
- await expect(new TuiClient({ port: DEAD_PORT }).fetchApprovals()).rejects.toThrow(/not connected/);
+ await expect(new TuiClient({ port: deadPort }).fetchApprovals()).rejects.toThrow(/not connected/);
});
});
@@ -501,7 +517,7 @@ describe('degraded-mode tmux enumeration', () => {
describe('attach window sizing', () => {
/** A client that only ever needs its injected exec: none of this talks to a server. */
function sizingClient(exec: TuiExecFile): TuiClient {
- return new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec });
+ return new TuiClient({ baseUrl, socket: 'codeman-beta', exec });
}
it('parses the sizing format, and rejects a window tmux could not measure', () => {
@@ -615,7 +631,7 @@ describe('TuiClient.bindSwitchKey', () => {
calls.push([...args]);
return { stdout: '', stderr: '' };
};
- const client = new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec });
+ const client = new TuiClient({ baseUrl, socket: 'codeman-beta', exec });
return client.bindSwitchKey('M-2', 'codeman-aaaa1111').then(() => {
const bind = calls.find((args) => args.includes('bind-key'));
expect(bind).toBeDefined();
@@ -672,7 +688,7 @@ describe('TuiClient.clearLeakedAttachBanners', () => {
}
return { stdout: '', stderr: '' };
};
- return { client: new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec }), calls };
+ return { client: new TuiClient({ baseUrl, socket: 'codeman-beta', exec }), calls };
}
it('takes down a bar a killed TUI left behind, and puts status back off', async () => {
@@ -713,7 +729,7 @@ describe('TuiClient.clearLeakedAttachBanners', () => {
const exec: TuiExecFile = async () => {
throw new Error('no server running on /tmp/tmux-1000/codeman-beta');
};
- const client = new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec });
+ const client = new TuiClient({ baseUrl, socket: 'codeman-beta', exec });
await expect(client.clearLeakedAttachBanners()).resolves.toBe(0);
});
});
@@ -761,7 +777,7 @@ describe('parseDetachKey', () => {
describe('attach status bar options', () => {
function optionsClient(exec: TuiExecFile): TuiClient {
- return new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec });
+ return new TuiClient({ baseUrl, socket: 'codeman-beta', exec });
}
const SHOW = [
diff --git a/test/tui/tui-e2e.test.ts b/test/tui/tui-e2e.test.ts
index 9163e366..6e93d011 100644
--- a/test/tui/tui-e2e.test.ts
+++ b/test/tui/tui-e2e.test.ts
@@ -25,6 +25,7 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { spawn } from 'node:child_process';
import http from 'node:http';
+import type { AddressInfo } from 'node:net';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
@@ -35,8 +36,7 @@ import type { SearchResponseData } from '../../src/types/search.js';
import type { ApprovalItem } from '../../src/web/approval-inbox.js';
import type { AwayDigestResponse } from '../../src/web/away-digest.js';
-const PORT = 3244;
-const BASE_URL = `http://127.0.0.1:${PORT}`;
+let baseUrl: string;
const ROOT = resolve(import.meta.dirname, '..', '..');
const COLS = 100;
const ROWS = 30;
@@ -234,7 +234,7 @@ function childEnv(): Record {
delete env.CODEMAN_PORT;
return {
...env,
- CODEMAN_API_URL: BASE_URL,
+ CODEMAN_API_URL: baseUrl,
CODEMAN_DATA_DIR: dataDir,
CODEMAN_TMUX_SOCKET: 'codeman-tui-e2e',
CODEMAN_TUI_GLYPHS: 'ascii',
@@ -347,7 +347,8 @@ beforeAll(async () => {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'no route', errorCode: 'NOT_FOUND' }));
});
- await new Promise((done) => server.listen(PORT, '127.0.0.1', done));
+ await new Promise((done) => server.listen(0, '127.0.0.1', done));
+ baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(async () => {
From fda189710951eea99c6e4fab26c500eb325f7de8 Mon Sep 17 00:00:00 2001
From: Randalix
Date: Fri, 9 Oct 2026 16:29:02 +0200
Subject: [PATCH 13/76] test(guard): no legacy list left; flag raw listeners on
a fixed port (#440)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
With the sweep done, LEGACY_FIXED_PORT_FILES and its staleness test go. A second rule flags a raw listen() and a port: with a number or …PORT constant inside listen({ … }) or new WebSocketServer({ … }); a socket path and a lower-case variable pass. CLAUDE.md, AGENTS.md, CONTRIBUTING.md and the wiki's Contributing page lose the mobile exception, and CLAUDE.md names closedPort() instead of port + 1.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.github/CONTRIBUTING.md | 2 +-
AGENTS.md | 2 +-
CLAUDE.md | 4 +-
docs/wiki/Contributing.md | 5 +-
test/test-ports-guard.test.ts | 122 ++++++++++++++--------------------
5 files changed, 57 insertions(+), 78 deletions(-)
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index fab29389..02874dd1 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -57,7 +57,7 @@ Expect `test:browser`/`test:mobile`/`test:perf` to fail where the machine cannot
The browser suite also runs nightly (and on demand) in `.github/workflows/browser-suite.yml`; it is informational, not a gate.
-If you add a test that binds a port, bind port 0 (`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`), or use `app.inject()` when no socket is needed; `test/test-ports-guard.test.ts` fails a `WebServer` built on any other port. Mobile tests (`test/mobile/**`, via `createTestServer(PORT)`) keep the fixed-port convention in `test/mobile/README.md` for now, because that helper caches servers by port. Never 3000.
+If you add a test that binds a port, bind port 0 (`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`), or use `app.inject()` when no socket is needed; mobile tests call `createTestServer()` and read `server.boundPort`. `test/test-ports-guard.test.ts` fails a `WebServer` built on any other port and a raw `listen` on a fixed one. Never 3000.
Tests are tmux-safe by design: under vitest, the tmux layer becomes an in-memory mock, so tests cannot touch real sessions.
diff --git a/AGENTS.md b/AGENTS.md
index e0cf802b..a3b084da 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -12,6 +12,6 @@ Quick pointers:
- Type check: `tsc --noEmit` · Lint: `npm run lint` · Format: `npm run format:check`
- Tests: `npm test` (the CI gate, safe to run bare) or `npm test -- test/.test.ts` for one file
-- Route tests use `app.inject()`; new tests needing a socket bind port 0 (`new WebServer(0, …)` + `boundPort`), except mobile tests, which keep `createTestServer(PORT)` for now
+- Route tests use `app.inject()`; new tests needing a socket bind port 0 (`new WebServer(0, …)` + `boundPort`); mobile tests use `createTestServer()` and read `server.boundPort`
- Branch off `master` for all work; Conventional Commit-style messages (`fix(mobile): ...`)
- Never commit secrets or local state from `~/.codeman/`
diff --git a/CLAUDE.md b/CLAUDE.md
index d122f877..185820fe 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -436,7 +436,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/`
- **App setting**: decide per-device vs synced first. Per-device keys go in the `displayKeys` set in settings-ui.js and must NOT be added to `SettingsUpdateSchema` (it is `.strict()`). ⚠️ Anything in `PUT /api/settings` that acts on a setting (the `toggleService` watcher calls) must resolve from **`merged`** (persisted + incoming), never from the raw request body: a partial PUT omits keys it doesn't intend to change, and `body.x ?? default` turns every omission into "apply the default" and silently resets live services. Pinned by `test/routes/system-routes-settings-partial-put.test.ts`.
- **Hook event**: Add to `HookEventType`, add hook in `hooks-config.ts:generateHooksConfig()`, update `HookEventSchema`
- **Mobile feature**: Add to relevant singleton, guard with `MobileDetection.isMobile()`. New header buttons must stay off phones (`test/mobile-header-buttons-policy.test.ts`).
-- **New test**: a test that constructs `WebServer` itself binds port 0 (`new WebServer(0, …)`, then `server.boundPort`; enforced by `test/test-ports-guard.test.ts`), and a raw Fastify/`ws` server listens on `port: 0` and reads `address().port`. Mobile tests (`test/mobile/**`, via `createTestServer(PORT)`) keep the fixed-port convention in `test/mobile/README.md` until the follow-up sweep, because that helper caches servers by port. Route tests use `app.inject()` (no port needed); see `test/routes/_route-test-utils.ts`.
+- **New test**: a test that constructs `WebServer` itself binds port 0 (`new WebServer(0, …)`, then `server.boundPort`), and a raw `http`/`net`/Fastify/`ws` server listens on `0` and reads `address().port`; mobile tests get theirs from `createTestServer()` and read `boundPort` too. Both enforced by `test/test-ports-guard.test.ts`. Route tests use `app.inject()` (no port needed); see `test/routes/_route-test-utils.ts`.
**Validation**: Zod v4 (different API from v3). Define schemas in `schemas.ts`, use `.parse()`/`.safeParse()`.
@@ -475,7 +475,7 @@ Raw `npx vitest` skips the config (and with it `setup.ts`); always use `npm test
**Tmux safety**: under vitest (`VITEST`), `TmuxManager` no-ops ALL shell commands (`IS_TEST_MODE` in `src/tmux-manager.ts`), docker IO is no-op'd likewise, and `Session` spawns an echo PTY (`TEST_PTY_SCRIPT`) instead of attaching tmux. `test/setup.ts` gives each file a temp `HOME`/`USERPROFILE` and strips `CODEMAN_PASSWORD`/`CODEMAN_USERNAME`, `CODEMAN_GESTURE` and `CODEMAN_INSTANCE`/`CODEMAN_DATA_DIR`/`CODEMAN_TMUX_SOCKET` (pinned by `test/test-env-isolation.test.ts`). ⚠️ `CODEMAN_DATA_DIR` overrides the temp HOME, so never drop its strip; strip `CODEMAN_INSTANCE` in the setup file, never in a hook (captured at first import). ⚠️ Delete case trees only via `safeRmHomeTree()`. ⚠️ Raw `npx vitest` without `--config` skips `setup.ts` and its isolation. → [architecture-invariants#test-isolation-tmux-docker-and-home](docs/architecture-invariants.md#test-isolation-tmux-docker-and-home)
-**Ports**: a test that constructs `WebServer` binds port 0 and reads `server.boundPort`; `test/test-ports-guard.test.ts` fails any other port outside its shrink-only legacy list. A raw Fastify/`ws` server listens on `port: 0` and reads `address().port`. The mobile suite keeps fixed ports (3150+, per `test/mobile/README.md`) until the follow-up sweep, since `createTestServer()` caches servers by port. Never 3000 (the live instance).
+**Ports**: every test binds port 0. A test that constructs `WebServer` reads `server.boundPort` (mobile tests: `createTestServer()`, then `server.boundPort`); a raw `http`/`net`/Fastify/`ws` server listens on `0` and reads `address().port`. `test/test-ports-guard.test.ts` fails a `WebServer` built on any other port and a raw `listen` on a number or `…PORT` constant. A test that needs a port nothing listens on binds 0, reads it and closes (`closedPort()` in `test/daemon-control.test.ts`), never "the server's port + 1".
⚠️ **Browser tests can pass vacuously on mobile input paths.** Two traps, both hit on 2026-07-27 while fixing the phone Enter button: **(1)** driving input with `app.sendInput('…')` writes PAST the `LocalEchoOverlay`, so `pendingText` stays empty and any overlay bug is invisible — type with `page.keyboard.type()` instead; **(2)** headless Chromium reports `MobileDetection.isTouchDevice()` **false even with `hasTouch: true`**, so `_localEchoEnabled` is off and the local-echo branch never executes. Force it (`app._localEchoEnabled = true`) or the test proves nothing. Assert on real state (`app._localEchoOverlay.pendingText`, plus `tmux -L codeman capture-pane -p -t ` for what actually reached the PTY), not on HTTP 200.
diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md
index 6872ca90..171203b7 100644
--- a/docs/wiki/Contributing.md
+++ b/docs/wiki/Contributing.md
@@ -63,9 +63,8 @@ provide what they need; that means "not runnable here", not a regression.
Tests are tmux-safe by design: under vitest the tmux layer becomes an in-memory mock, so
tests cannot touch real sessions. If you add a test that binds a port, bind port 0
(`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`),
-or use `app.inject()` when no socket is needed. Never 3000. Mobile tests (`test/mobile/**`,
-via `createTestServer(PORT)`) keep the fixed ports in `test/mobile/README.md` for now,
-because that helper caches servers by port.
+or use `app.inject()` when no socket is needed. Mobile tests call `createTestServer()` and
+read `server.boundPort`. Never 3000.
## Finding your way around
diff --git a/test/test-ports-guard.test.ts b/test/test-ports-guard.test.ts
index 20c7d8b8..79018d90 100644
--- a/test/test-ports-guard.test.ts
+++ b/test/test-ports-guard.test.ts
@@ -1,84 +1,33 @@
/**
- * @fileoverview Static guard: a test builds `WebServer` on an ephemeral port.
+ * @fileoverview Static guard: a test binds an ephemeral port.
*
* A fixed port is a red suite on any machine where something else holds it, and a
* collision between two runs on one host (two worktrees, or CI plus a local run): the
- * suite runs files serially (`fileParallelism: false`), so the four port pairs #440
- * found never met inside one run, only across runs. `new WebServer(0, …)` binds
- * whatever the OS hands out and `boundPort` reads it back, so there is nothing left
- * to collide on.
+ * suite runs files serially (`fileParallelism: false`), so the port pairs #440 found
+ * never met inside one run, only across runs. Binding port 0 takes whatever the OS
+ * hands out, so there is nothing left to collide on.
*
- * A WebServer built under test/ whose port argument is not the literal `0` fails —
- * `new WebServer(…)`, a class declared `extends WebServer`, or a destructured alias
- * (`{ WebServer: T }`) — unless the file is in LEGACY_FIXED_PORT_FILES, the files that
- * construct one with a non-zero port today (a few never call `start()`); the follow-up
- * sweep converts them. A converted file cannot stay listed: an entry whose file no
- * longer matches fails too.
+ * Two rules, both over every file under test/:
+ * - A `WebServer` is built with the literal `0` as its port — `new WebServer(…)`, a
+ * class declared `extends WebServer`, or a destructured alias (`{ WebServer: T }`) —
+ * and the test reads `server.boundPort`.
+ * - A raw server (`http`/`net`/Fastify `listen`, `new WebSocketServer`) never listens
+ * on a number or a `…PORT` constant: `listen(0, …)` / `{ port: 0 }`, then
+ * `address().port`.
*
* Not covered: a helper that takes the port as a parameter is checked at the helper,
- * not at its callers (test/mobile/helpers/server.ts is listed, so the mobile tests
- * calling `createTestServer(PORT)` are not checked), `import { WebServer as X }`, and
- * `new mod.WebServer(…)`. Raw `listen({ port: N })` and `new WebSocketServer({ port: N })`
- * belong to the sweep.
+ * not at its callers; `import { WebServer as X }`; `new mod.WebServer(…)`; a port held
+ * in a variable that is not named `…PORT`.
*
* Port: N/A (pure static analysis).
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
-import { join, relative, sep } from 'node:path';
+import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const TEST_ROOT = fileURLToPath(new URL('.', import.meta.url));
-/** Predates the guard; converted in the follow-up sweep. Shrink only. */
-const LEGACY_FIXED_PORT_FILES = new Set(
- [
- 'admin-routes.test.ts',
- 'base-path-server.test.ts',
- 'capture-geometry-retry.browser.test.ts',
- 'capture-load-window.browser.test.ts',
- 'case-custom-path.browser.test.ts',
- 'doctor-settings.browser.test.ts',
- 'edge-cases.test.ts',
- 'file-link-click.test.ts',
- 'git-status.browser.test.ts',
- 'hooks-config.test.ts',
- 'http-contract.test.ts',
- 'inline-rename.test.ts',
- 'integration-flows.test.ts',
- 'key-tester.browser.test.ts',
- 'mobile/helpers/server.ts',
- 'opencode-resize.test.ts',
- 'operation-lightspeed.test.ts',
- 'ownership-scoping.test.ts',
- 'pane-exit-sweep.test.ts',
- 'paste-image-dir-shared.test.ts',
- 'perf-browser.test.ts',
- 'quick-start.test.ts',
- 'ralph-integration.test.ts',
- 'scheduled-runs.test.ts',
- 'security-regression.test.ts',
- 'session-cleanup.test.ts',
- 'session-pane-exit.test.ts',
- 'session.test.ts',
- 'shift-enter-keypress.browser.test.ts',
- 'split-pane-auto-collapse.browser.test.ts',
- 'split-pane-orchestration.browser.test.ts',
- 'split-pane-terminal.browser.test.ts',
- 'sse-cors-headers.test.ts',
- 'sse-events.test.ts',
- 'sse-routing-remote.test.ts',
- 'sse-subscription-filter.test.ts',
- 'static-cache-headers.test.ts',
- 'terminal-copy-shortcut.test.ts',
- 'terminal-keycode229-recovery.browser.test.ts',
- 'webgl-fallback.test.ts',
- 'webhook-settings.browser.test.ts',
- 'webview-lost-root-frame.test.ts',
- 'webview-sse.test.ts',
- ].map((p) => p.split('/').join(sep))
-);
-
function testFiles(dir: string): string[] {
const out: string[] = [];
for (const name of readdirSync(dir)) {
@@ -108,10 +57,29 @@ function webServerPortArgs(source: string): string[] {
);
}
+/** A fixed port as a reader would see it: a non-zero number, or a constant named `…PORT`. */
+const FIXED_PORT = /^(?:[1-9]\d*|[A-Z_]*PORT)$/;
+
+/**
+ * Fixed ports handed to a raw server in `source`: the first argument of `.listen(…)`,
+ * and `port:` inside `.listen({ … })` or `new WebSocketServer({ … })`. A socket path or
+ * `0` is fine; so is anything held in a lower-case variable (see the header).
+ */
+function rawListenerFixedPorts(source: string): string[] {
+ const firstArgs = [...source.matchAll(/\.listen\(\s*([^,){\s]+)/g)].map((m) => m[1]);
+ const options = [...source.matchAll(/(?:\.listen|new WebSocketServer)\(\s*\{[^}]*?\bport\s*:\s*([^,}\s]+)/g)].map(
+ (m) => m[1]
+ );
+ return [...firstArgs, ...options].filter((arg) => FIXED_PORT.test(arg));
+}
+
const SELF = fileURLToPath(import.meta.url);
const scanned = testFiles(TEST_ROOT)
.filter((f) => f !== SELF)
- .map((file) => ({ rel: relative(TEST_ROOT, file), args: webServerPortArgs(readFileSync(file, 'utf8')) }));
+ .map((file) => {
+ const source = readFileSync(file, 'utf8');
+ return { rel: relative(TEST_ROOT, file), args: webServerPortArgs(source), raw: rawListenerFixedPorts(source) };
+ });
const fixed = (args: string[]) => args.some((a) => a !== '0');
describe('test servers bind an ephemeral port', () => {
@@ -124,9 +92,19 @@ describe('test servers bind an ephemeral port', () => {
expect(webServerPortArgs('const { WebServer: T } = mod;\nreturn new T(port, false);')).toEqual(['port']);
});
- it('no test outside the legacy list builds WebServer on a fixed port', () => {
+ it('reads raw listeners the way a reader would', () => {
+ expect(rawListenerFixedPorts("server.listen(0, '127.0.0.1', done)")).toEqual([]);
+ expect(rawListenerFixedPorts("server.listen(PORT, '127.0.0.1', done)")).toEqual(['PORT']);
+ expect(rawListenerFixedPorts('srv.listen(3216)')).toEqual(['3216']);
+ expect(rawListenerFixedPorts("await app.listen({ port: TEST_PORT, host: '127.0.0.1' })")).toEqual(['TEST_PORT']);
+ expect(rawListenerFixedPorts("await app.listen({ port: 0, host: '127.0.0.1' })")).toEqual([]);
+ expect(rawListenerFixedPorts('new WebSocketServer({ port: 8081 })')).toEqual(['8081']);
+ expect(rawListenerFixedPorts('net.createServer().listen(socketPath)')).toEqual([]);
+ });
+
+ it('no test builds WebServer on a fixed port', () => {
const offenders = scanned
- .filter((f) => fixed(f.args) && !LEGACY_FIXED_PORT_FILES.has(f.rel))
+ .filter((f) => fixed(f.args))
.map(
(f) =>
`${f.rel}: new WebServer(${f.args.find((a) => a !== '0')}, …) — use new WebServer(0, …) and server.boundPort`
@@ -134,8 +112,10 @@ describe('test servers bind an ephemeral port', () => {
expect(offenders).toEqual([]);
});
- it('the legacy list only names files that still need converting', () => {
- const stale = [...LEGACY_FIXED_PORT_FILES].filter((rel) => !scanned.some((f) => f.rel === rel && fixed(f.args)));
- expect(stale).toEqual([]);
+ it('no test listens on a fixed port', () => {
+ const offenders = scanned
+ .filter((f) => f.raw.length > 0)
+ .map((f) => `${f.rel}: listens on ${f.raw.join(', ')} — listen on 0 and read address().port`);
+ expect(offenders).toEqual([]);
});
});
From ad573946182dd2c88890b8f40807e4ebd791858b Mon Sep 17 00:00:00 2001
From: Codeman maintainer
Date: Fri, 9 Oct 2026 16:34:09 +0200
Subject: [PATCH 14/76] feat(tabs): a per-device switch to hide the CLI logos
on tabs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Since 1.40.0 every agent tab draws its CLI logo through the run-mode-dot
slot, and there was no way to turn that off. App Settings → Appearance →
Tabs now has "CLI Logos on Tabs" (showTabCliLogos), right after Tall Tabs.
- Modelled on tabTwoRows: a per-device display key in the server-settings
merge and an optional boolean in the .strict() SettingsUpdateSchema,
loaded and saved by the id appSettingsShowTabCliLogos. Default ON on
every device; only an explicit false turns it off (tabCliLogosEnabled).
- CSS only, no tab re-render: applyTabOrientation() and the pre-paint
script in index.html stamp html[data-tab-logos="on"|"off"] from the
same stored blob (so a reload never flashes the logos), and one rule in
styles.css hides .session-tab .tab-harness and .home-sessions-harness.
The rows space their children with flex gap, so nothing is left behind.
- A live flip resizes every agent tab with no render behind it, so
applyTabOrientation() then re-takes the strip's one-row wrap decision
(updateTabOverflowMode) and re-anchors the lines drawn from tab rects;
a header that changes height reaches the PTY through the terminal
container's ResizeObserver, as any header change does.
- Covered: the header strip, vertical rail, sidebar, grouped rail, ledger
and case clusters and phone chips (all render the same tab markup) and
the desktop home rail. Untouched: tile and split headers, the Run menus,
the welcome launchers. The phone overview rows, the command palette and
the tab action menu draw no logo. The shell's SH pill and the status dot
stay.
- zh-CN for the label and the description.
test/tab-cli-logos-setting.test.ts drives the real openAppSettings() and
saveAppSettings() in JSDOM (the saved PUT body must pass the schema), the
server-settings merge, the defaults on desktop and phone, the live stamp
and its re-measure without a re-render, the real pre-paint script (on,
off, the phone key, the catch fallback), the CSS rule's exact selectors,
and the translations.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
src/web/public/app.js | 4 +-
src/web/public/home-sessions.js | 3 +-
src/web/public/i18n.js | 3 +
src/web/public/index.html | 9 +-
src/web/public/settings-ui.js | 31 +-
src/web/public/styles.css | 13 +
src/web/schemas.ts | 7 +
test/tab-cli-logos-setting.test.ts | 450 +++++++++++++++++++++++++++++
8 files changed, 516 insertions(+), 4 deletions(-)
create mode 100644 test/tab-cli-logos-setting.test.ts
diff --git a/src/web/public/app.js b/src/web/public/app.js
index 447a959e..1c945bd2 100644
--- a/src/web/public/app.js
+++ b/src/web/public/app.js
@@ -6709,7 +6709,9 @@ class CodemanApp {
// every agent CLI, claude included, shows its logo through PR #532's
// `run-mode-dot ` slot, the id as DATA, so the tab, the tile and split
// headers and the Run menus draw the same mark. An id with no logo rule (a
- // CLI added through ~/.codeman/clis.json) gets that slot's plain dot.
+ // CLI added through ~/.codeman/clis.json) gets that slot's plain dot. The
+ // span is always emitted: CLI Logos on Tabs (`showTabCliLogos`) hides it
+ // in CSS under html[data-tab-logos='off'], so a toggle never re-renders.
const tabModeHtml = mode === 'shell'
? 'sh'
: ``;
diff --git a/src/web/public/home-sessions.js b/src/web/public/home-sessions.js
index a0fb9268..7865c08c 100644
--- a/src/web/public/home-sessions.js
+++ b/src/web/public/home-sessions.js
@@ -428,7 +428,8 @@ Object.assign(CodemanApp.prototype, {
line1.appendChild(badge);
} else {
// The agent's logo: PR #532's slot, the mode id as data (an id with no
- // logo rule gets the slot's plain dot).
+ // logo rule gets the slot's plain dot). CLI Logos on Tabs hides it in CSS
+ // (html[data-tab-logos='off']), exactly as it does on the tab strip.
const logo = document.createElement('span');
logo.className = `home-sessions-harness run-mode-dot ${row.mode}`;
logo.setAttribute('aria-hidden', 'true');
diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js
index ffdd8d86..dfa75f3e 100644
--- a/src/web/public/i18n.js
+++ b/src/web/public/i18n.js
@@ -369,6 +369,9 @@
'会话列表显示为顶栏横向标签条,或左侧可折叠侧边栏(Alt+B)。完整侧边栏为每个会话显示与主界面相同的详细信息。',
'Tall Tabs (Name + Folder)': '双行标签(名称 + 文件夹)',
'Pop-out Button on Tabs': '标签页弹出窗口按钮',
+ 'CLI Logos on Tabs': '标签页上的 CLI 图标',
+ "Show each agent's CLI logo before the session name on tabs and the home screen's tab list. Off leaves the status dot and the shell's SH badge. Tiles, split headers and the Run menus keep their logos.":
+ '在标签页和主界面的标签列表中,于会话名称前显示每个智能体的 CLI 图标。关闭后仍保留状态圆点和 Shell 的 SH 标记。平铺、分屏标题栏和运行菜单中的图标不受影响。',
// Tab Layout and Header Stats Style (Discussion #426). The header style's
// "Tiles" is 磁贴, never 平铺: that is the tile grid's word (the Tiles
// button), and "Tiles (label over value)" must not read as the grid.
diff --git a/src/web/public/index.html b/src/web/public/index.html
index 335d3785..f2fdbee0 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -65,7 +65,7 @@
app.js, NOT the handheld storage-key test `m`. Use a different predicate
here and boot will contradict this value, animating the drawer open by
itself on every load between 768 and 1023px. -->
-
+
+
+ ${RAIL_HTML}
+
+