Overview Home Screen phone
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js
index 92631b0f..79bee3c0 100644
--- a/src/web/public/settings-ui.js
+++ b/src/web/public/settings-ui.js
@@ -408,6 +408,8 @@ Object.assign(CodemanApp.prototype, {
// header), so the row is hidden elsewhere rather than offering a toggle that
// changes nothing. Default ON — only an explicit false turns it off.
document.getElementById('appSettingsLineageLines').checked = settings.sessionLineageLines ?? defaults.sessionLineageLines ?? true;
+ // Auto-name sessions: synced, default OFF (opt-in; only an explicit true enables).
+ document.getElementById('appSettingsAutoNameSessions').checked = settings.autoNameSessions === true;
const lineageItem = document.getElementById('appSettingsLineageLinesItem');
if (lineageItem) lineageItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none';
document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false;
@@ -2111,6 +2113,7 @@ Object.assign(CodemanApp.prototype, {
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
sessionLineageLines: document.getElementById('appSettingsLineageLines').checked,
+ autoNameSessions: document.getElementById('appSettingsAutoNameSessions').checked,
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts
index 950c04ff..03f92e5d 100644
--- a/src/web/routes/session-routes.ts
+++ b/src/web/routes/session-routes.ts
@@ -1081,7 +1081,6 @@ export function registerSessionRoutes(
workingDir,
mode,
name: body.name || '',
- nameSource: body.name ? undefined : 'auto',
mux: ctx.mux,
useMux: true,
niceConfig: globalNice,
@@ -1596,6 +1595,9 @@ export function registerSessionRoutes(
// Write input to PTY. Direct write is synchronous; writeViaMux
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
+ // Every write here is `fromUser`: this route carries a person's prompt, or an
+ // agent's on their behalf, so it may name the tab (Ralph, respawn, cron and
+ // approvals write through the session directly and never say so).
//
// Because the response has already been sent by then, a failure there is the
// one case the caller can never learn about — so the dedup bookkeeping is
@@ -1618,32 +1620,32 @@ export function registerSessionRoutes(
} else if (useMux && waitPromise) {
// The response is already staying open for the wait, so the tmux write can be
// awaited here. This is the ONE path where a writeViaMux failure is observable.
- const ok = await session.writeViaMux(inputStr).catch(() => false);
+ const ok = await session.writeViaMux(inputStr, { fromUser: true }).catch(() => false);
if (ok) {
delivered = true;
} else {
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
- delivered = session.write(inputStr);
+ delivered = session.write(inputStr, { fromUser: true });
if (!delivered) undoOnFailure();
}
} else if (useMux) {
// Fire-and-forget: don't block the HTTP response on a tmux child process.
// Fallback to a direct write on failure. Unchanged from before send-and-wait.
session
- .writeViaMux(inputStr)
+ .writeViaMux(inputStr, { fromUser: true })
.then((ok) => {
if (ok) return;
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
- if (!session.write(inputStr)) undoOnFailure();
+ if (!session.write(inputStr, { fromUser: true })) undoOnFailure();
})
.catch(() => {
- if (!session.write(inputStr)) undoOnFailure();
+ if (!session.write(inputStr, { fromUser: true })) undoOnFailure();
});
} else {
// Same rollback. NOT an error response, deliberately: a session can
// legitimately have no PTY yet (created but not started), and callers have
// always been able to write to one without a 4xx.
- delivered = session.write(inputStr);
+ delivered = session.write(inputStr, { fromUser: true });
if (!delivered && tagged) {
session.forgetInputSeq(clientId as string, seq as number);
}
@@ -1892,6 +1894,9 @@ export function registerSessionRoutes(
console.error('[Server] send-key failed:', err);
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'tmux send-keys failed');
}
+ // The bytes bypassed the session's write path, so tell the auto-name
+ // tracker about them or the two lines of a prompt join with no separator.
+ session.trackUserInput(hex.map((byte) => String.fromCharCode(parseInt(byte, 16))).join(''));
return {};
});
@@ -3485,7 +3490,6 @@ export function registerSessionRoutes(
const session = new Session({
workingDir: resolvedCasePath,
name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '',
- nameSource: sessionName ? undefined : 'auto',
mux: ctx.mux,
useMux: true,
mode: mode,
diff --git a/src/web/routes/ws-routes.ts b/src/web/routes/ws-routes.ts
index ed60f5d3..ec7fe9e1 100644
--- a/src/web/routes/ws-routes.ts
+++ b/src/web/routes/ws-routes.ts
@@ -185,7 +185,8 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
// Typed input from a claim-holding desktop keeps the claim "hot"
// and re-asserts the desktop layout after a mobile override.
if (holdsDesktopClaim) session.noteDesktopActivity();
- delivered = session.write(msg.d);
+ // Browser keystrokes are the user's own, so they may name the tab.
+ delivered = session.write(msg.d, { fromUser: true });
// A session whose PTY is gone swallows the write. ACKing anyway told
// the client to drop the frame from its durable queue and left the seq
// burnt, so the retry that reliable delivery exists for was rejected as
diff --git a/src/web/schemas.ts b/src/web/schemas.ts
index e7e8c28c..b5dabdd0 100644
--- a/src/web/schemas.ts
+++ b/src/web/schemas.ts
@@ -1230,6 +1230,13 @@ export const SettingsUpdateSchema = z
* already pending immediately.
*/
approvalsInboxEnabled: z.boolean().optional(),
+ /**
+ * Auto-name sessions: a placeholder tab (`w3-case`) takes its first real
+ * prompt as a title (`w3-case: fix the login redirect`). Synced, default
+ * OFF: the prompt lands in mux-sessions.json, every session:updated
+ * broadcast and /api/search, which is the user's choice to make.
+ */
+ autoNameSessions: z.boolean().optional(),
/**
* Read My Mind (docs/readmymind-plan.md): capture the user's submitted
* prompts into per-case intent profiles. SYNCED, default OFF (opt-in:
diff --git a/src/web/server.ts b/src/web/server.ts
index 477ae5d6..cf5927c0 100644
--- a/src/web/server.ts
+++ b/src/web/server.ts
@@ -1729,6 +1729,9 @@ export class WebServer extends EventEmitter {
registerAttachment: (id: string, filePath: string, source: 'external' | 'codex-generated') =>
this.registerAttachment(id, filePath, source),
updateSessionName: (id: string, name: string) => this.mux.updateSessionName(id, name),
+ // Opt-in: the first prompt lands in the tab name, mux-sessions.json, every
+ // session:updated broadcast and /api/search, so it is a choice, not a default.
+ isAutoNameEnabled: async () => (await this.readSettings()).autoNameSessions === true,
};
}
diff --git a/src/web/session-listener-wiring.ts b/src/web/session-listener-wiring.ts
index 2af8d7a0..6522c01a 100644
--- a/src/web/session-listener-wiring.ts
+++ b/src/web/session-listener-wiring.ts
@@ -29,7 +29,8 @@ import { getLifecycleLog } from '../session-lifecycle-log.js';
import { fileStreamManager } from '../file-stream-manager.js';
import { sessionWaits } from './session-wait-registry.js';
import { approvalInbox } from './approval-inbox.js';
-import { deriveAutoSessionName } from '../session-auto-name.js';
+import { composeAutoSessionName, deriveAutoSessionName } from '../session-auto-name.js';
+import { MAX_SESSION_NAME_LENGTH } from '../config/terminal-limits.js';
/** Stored listener references for session cleanup (prevents memory leaks) */
export interface SessionListenerRefs {
@@ -86,6 +87,8 @@ interface SessionListenerDeps {
getStore(): import('../state-store.js').StateStore;
registerAttachment(sessionId: string, filePath: string, source: 'external' | 'codex-generated'): Promise;
updateSessionName(sessionId: string, name: string): boolean;
+ /** The synced `autoNameSessions` setting, read fresh so a flip applies to the next prompt. */
+ isAutoNameEnabled(): Promise;
}
/**
@@ -455,13 +458,28 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
});
},
- /** Assigns a bounded local title from the first real task prompt. */
+ /**
+ * Names a placeholder tab after its first real prompt (`w3-case: fix the
+ * login redirect`), behind the synced `autoNameSessions` setting. The
+ * eligibility check comes first so the settings read costs nothing on the
+ * prompts of an already-named session; a prompt that yields no title (a
+ * slash command) leaves the session eligible for the next one.
+ */
promptSubmitted: (prompt: string) => {
- const name = deriveAutoSessionName(prompt);
- if (!name || !session.applyAutoName(name)) return;
- deps.updateSessionName(session.id, session.name);
- deps.persistSessionState(session);
- deps.broadcast(SseEvent.SessionUpdated, deps.getSessionStateWithRespawn(session));
+ if (session.nameSource !== 'placeholder') return;
+ const title = deriveAutoSessionName(prompt);
+ if (!title) return;
+ void deps
+ .isAutoNameEnabled()
+ .then((enabled) => {
+ if (!enabled) return;
+ const name = composeAutoSessionName(session.name, title, MAX_SESSION_NAME_LENGTH);
+ if (!session.applyAutoName(name)) return;
+ deps.updateSessionName(session.id, session.name);
+ deps.persistSessionState(session);
+ deps.broadcast(SseEvent.SessionUpdated, deps.getSessionStateWithRespawn(session));
+ })
+ .catch((err) => console.error(`[Session] auto-name failed for ${session.id}:`, err));
},
};
}
diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts
index e32b8e68..4666afe0 100644
--- a/test/mocks/mock-session.ts
+++ b/test/mocks/mock-session.ts
@@ -68,6 +68,9 @@ export class MockSession extends EventEmitter {
this.lastSubmitAt = Date.now();
}
+ /** Mirrors Session.trackUserInput (the send-key route feeds it around the write path). */
+ trackUserInput(_data: string): void {}
+
private _muxName: string | null = null;
constructor(id: string = 'mock-session-id') {
diff --git a/test/routes/session-name-routes.test.ts b/test/routes/session-name-routes.test.ts
new file mode 100644
index 00000000..ba161759
--- /dev/null
+++ b/test/routes/session-name-routes.test.ts
@@ -0,0 +1,60 @@
+/**
+ * @fileoverview PUT /api/sessions/:id/name hands the name to the user (#376).
+ *
+ * A rename flips `nameSource` to `manual`, persists it and broadcasts it, so
+ * auto-naming can never overwrite a name a person chose, on this server or
+ * on the one that restores the session after a restart.
+ *
+ * Uses app.inject() — no real HTTP ports needed.
+ */
+
+import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
+import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
+import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
+import { Session } from '../../src/session.js';
+import { SseEvent } from '../../src/web/sse-events.js';
+
+describe('PUT /api/sessions/:id/name', () => {
+ let harness: RouteTestHarness;
+ let session: Session;
+ const updateSessionName = vi.fn(() => true);
+
+ beforeAll(async () => {
+ harness = await createRouteTestHarness(registerSessionRoutes);
+ // A REAL session, since the ownership flag lives on the class, not the mock.
+ session = new Session({ id: 'name-route-test', workingDir: '/tmp', name: 'w1-demo' });
+ harness.ctx.sessions.set(session.id, session as never);
+ (harness.ctx.mux as Record).updateSessionName = updateSessionName;
+ });
+
+ afterAll(async () => {
+ await harness.app.close();
+ });
+
+ it('flips a placeholder to manual, then persists and broadcasts the ownership', async () => {
+ expect(session.nameSource).toBe('placeholder');
+
+ const res = await harness.app.inject({
+ method: 'PUT',
+ url: `/api/sessions/${session.id}/name`,
+ payload: { name: 'my window' },
+ });
+
+ expect(res.statusCode).toBe(200);
+ // The harness registers the bare route; the {success,data} envelope is a server-level hook.
+ expect(res.json()).toMatchObject({ name: 'my window' });
+ expect(session.name).toBe('my window');
+ expect(session.nameSource).toBe('manual');
+ expect(session.applyAutoName('w1-demo: fix it')).toBe(false);
+ expect(session.name).toBe('my window');
+
+ expect(updateSessionName).toHaveBeenCalledWith(session.id, 'my window');
+ expect(harness.ctx.persistSessionState).toHaveBeenCalledWith(session);
+ expect(harness.ctx.broadcast).toHaveBeenCalledWith(
+ SseEvent.SessionUpdated,
+ expect.objectContaining({ id: session.id, name: 'my window', nameSource: 'manual' })
+ );
+ // What the restore path will read back: the persisted state carries the flag.
+ expect(session.toState().nameSource).toBe('manual');
+ });
+});
diff --git a/test/session-auto-name.test.ts b/test/session-auto-name.test.ts
new file mode 100644
index 00000000..7d997071
--- /dev/null
+++ b/test/session-auto-name.test.ts
@@ -0,0 +1,274 @@
+/**
+ * @fileoverview Auto-naming a session after its first prompt (#376).
+ *
+ * The tracker sits on the raw keystroke stream, so most of these pin the
+ * per-key rules that a review of the first cut found missing: a bare Esc ate
+ * the next prompt's first character, a wheel report mid-word dropped half the
+ * prompt, pasted newlines counted as Enter, and every prompt renamed the tab.
+ *
+ * Port: N/A (no server needed)
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { Session } from '../src/session.js';
+import {
+ SubmittedPromptTracker,
+ deriveAutoSessionName,
+ composeAutoSessionName,
+ isGeneratedSessionName,
+} from '../src/session-auto-name.js';
+
+describe('SubmittedPromptTracker', () => {
+ it('reports the draft on Enter across arbitrary chunks, honouring backspace', () => {
+ const tracker = new SubmittedPromptTracker();
+ expect(tracker.feed('fix the')).toEqual([]);
+ expect(tracker.feed(' login bugs\x7f')).toEqual([]);
+ expect(tracker.feed('\r')).toEqual(['fix the login bug']);
+ expect(tracker.feed('\r')).toEqual([]);
+ expect(tracker.feed('修复登录跳转\x08问题\r')).toEqual(['修复登录跳问题']);
+ });
+
+ it('treats a bare Esc as the Esc key, not the start of a sequence', () => {
+ const tracker = new SubmittedPromptTracker();
+ tracker.feed('\x1b');
+ expect(tracker.feed('fix the login bug\r')).toEqual(['fix the login bug']);
+ tracker.feed('\x1b');
+ expect(tracker.feed('修复登录\r')).toEqual(['修复登录']);
+ // Esc then digits and punctuation used to grow the escape buffer without bound.
+ tracker.feed('\x1b');
+ expect(tracker.feed('12345, ok?\r')).toEqual(['12345, ok?']);
+ // A double Esc is two Esc keys, each its own write (in ONE chunk, `ESC s`
+ // is Alt+s by the terminal's own encoding and stays swallowed).
+ tracker.feed('\x1b');
+ tracker.feed('\x1b');
+ expect(tracker.feed('still here\r')).toEqual(['still here']);
+ });
+
+ it('swallows Alt chords and turns Alt+Enter into a newline in the draft', () => {
+ const tracker = new SubmittedPromptTracker();
+ expect(tracker.feed('fix\x1bb the\x1b\rbug\r')).toEqual(['fix the bug']);
+ });
+
+ it('ignores cursor keys, mouse and focus reports, Shift+Tab and Tab', () => {
+ const tracker = new SubmittedPromptTracker();
+ expect(tracker.feed('fix the \x1b[<64;10;5M\x1b[<65;10;5mlogin bug\r')).toEqual(['fix the login bug']);
+ expect(tracker.feed('look at @src/ses\tsion.ts and fix it\r')).toEqual(['look at @src/session.ts and fix it']);
+ expect(tracker.feed('typo\x1b[D\x1b[C\x1b[H\x1b[F\x1b[3~\x1b[Z\x1b[I\x1b[O\x1bOC fixed\r')).toEqual(['typo fixed']);
+ expect(tracker.feed('mod\x1b[1;5D\x1b[1;2Cifiers\r')).toEqual(['modifiers']);
+ });
+
+ it('taints the draft on history recall so Enter submits nothing rather than a fragment', () => {
+ const tracker = new SubmittedPromptTracker();
+ expect(tracker.feed('old text\x1b[A and more\r')).toEqual([]);
+ expect(tracker.feed('\x1bOB\r')).toEqual([]);
+ expect(tracker.feed('\x1b[1;5A\r')).toEqual([]);
+ expect(tracker.feed('\x10x\r')).toEqual([]);
+ expect(tracker.feed('\x12search\r')).toEqual([]);
+ expect(tracker.feed('fresh prompt\r')).toEqual(['fresh prompt']);
+ // Ctrl+C empties the composer, which also clears the taint.
+ expect(tracker.feed('stale\x1b[A\x03typed after\r')).toEqual(['typed after']);
+ });
+
+ it('keeps bracketed-paste newlines inside the draft', () => {
+ const tracker = new SubmittedPromptTracker();
+ expect(tracker.feed('\x1b[200~line one\nline two\r\nline three\x1b[201~ plus typed\r')).toEqual([
+ 'line one line two line three plus typed',
+ ]);
+ // A paste split across chunks stays a paste.
+ tracker.feed('\x1b[200~first\r');
+ expect(tracker.feed('second\x1b[201~\r')).toEqual(['first second']);
+ });
+
+ it('joins a Shift+Enter / Ctrl+J newline with a space', () => {
+ const tracker = new SubmittedPromptTracker();
+ tracker.feed('Fix the login bug');
+ tracker.feed('\n');
+ expect(tracker.feed('Also add tests.\r')).toEqual(['Fix the login bug Also add tests.']);
+ });
+
+ it('mirrors Ctrl+W, Ctrl+U and Ctrl+C', () => {
+ const tracker = new SubmittedPromptTracker();
+ expect(tracker.feed('fix the bugs\x17bug\r')).toEqual(['fix the bug']);
+ expect(tracker.feed('discarded\x15kept\r')).toEqual(['kept']);
+ expect(tracker.feed('discarded\x03kept\r')).toEqual(['kept']);
+ });
+
+ it('keeps the HEAD of an over-long draft', () => {
+ const tracker = new SubmittedPromptTracker();
+ const [prompt] = tracker.feed(`${'a'.repeat(9000)}\r`);
+ expect(prompt).toHaveLength(8192);
+ // Backspaces past the cap consume the overflow before the kept text.
+ const [again] = tracker.feed(`${'b'.repeat(8200)}${'\x7f'.repeat(10)}\r`);
+ expect(again).toHaveLength(8190);
+ });
+
+ it('abandons a malformed escape without eating the text, and taints on an over-long one', () => {
+ const tracker = new SubmittedPromptTracker();
+ expect(tracker.feed('\x1b[修复\r')).toEqual(['修复']);
+ expect(tracker.feed('\x1b]0;window title\x07hello\r')).toEqual(['hello']);
+ // Nothing a terminal sends runs past 64 bytes; the tail is garbage, not a title.
+ expect(tracker.feed(`\x1b]${'x'.repeat(80)}after\r`)).toEqual([]);
+ expect(tracker.feed('next prompt\r')).toEqual(['next prompt']);
+ });
+
+ it('resumes a CSI split across chunks', () => {
+ const tracker = new SubmittedPromptTracker();
+ tracker.feed('abc\x1b[');
+ expect(tracker.feed('Ddef\r')).toEqual(['abcdef']);
+ });
+});
+
+describe('deriveAutoSessionName', () => {
+ it('takes the first sentence, drops the full stop, and bounds the length', () => {
+ expect(deriveAutoSessionName('Fix the login bug. Also add tests.')).toBe('Fix the login bug');
+ expect(deriveAutoSessionName(' 修复登录跳转问题。\n不要改数据库')).toBe('修复登录跳转问题');
+ expect(deriveAutoSessionName('Why does this crash? It worked before')).toBe('Why does this crash?');
+ expect(deriveAutoSessionName('Run v2.0 tests. Then deploy')).toBe('Run v2.0 tests');
+ expect(Array.from(deriveAutoSessionName('a'.repeat(200)) ?? '')).toHaveLength(72);
+ const cut = deriveAutoSessionName('word '.repeat(40).trim()) ?? '';
+ expect(cut.endsWith('…')).toBe(true);
+ expect(cut).toMatch(/^(word )+word…$/);
+ });
+
+ it('does not cut on an abbreviation early in the prompt', () => {
+ expect(deriveAutoSessionName('e.g. fix this now')).toBe('e.g. fix this now');
+ expect(deriveAutoSessionName('Ok. Fix the login bug')).toBe('Ok. Fix the login bug');
+ });
+
+ it('returns null for commands and empties, but not for paths', () => {
+ expect(deriveAutoSessionName('/clear')).toBeNull();
+ expect(deriveAutoSessionName('/model opus')).toBeNull();
+ expect(deriveAutoSessionName('/ralph-loop:ralph-loop')).toBeNull();
+ expect(deriveAutoSessionName('! npm test')).toBeNull();
+ expect(deriveAutoSessionName(' ')).toBeNull();
+ expect(deriveAutoSessionName('/home/me/notes.txt what is this')).toBe('/home/me/notes.txt what is this');
+ });
+
+ it('strips control bytes and ANSI before the title is persisted', () => {
+ expect(deriveAutoSessionName('\x1b[31m整理项目文档\x1b[0m')).toBe('整理项目文档');
+ expect(deriveAutoSessionName('a\x00b\tc')).toBe('a b c');
+ });
+});
+
+describe('composeAutoSessionName', () => {
+ it('keeps the placeholder as a prefix so the case and the counter survive', () => {
+ expect(composeAutoSessionName('w3-myapp', 'fix the login bug')).toBe('w3-myapp: fix the login bug');
+ expect(composeAutoSessionName('', 'fix the login bug')).toBe('fix the login bug');
+ });
+
+ it('honours the rename cap in UTF-16 units', () => {
+ const name = composeAutoSessionName('w3-myapp', '😀'.repeat(100), 40);
+ expect(name.length).toBeLessThanOrEqual(40);
+ expect(name.startsWith('w3-myapp: ')).toBe(true);
+ expect(name.endsWith('…')).toBe(true);
+ expect(composeAutoSessionName('x'.repeat(127), 'title', 128)).toBe('x'.repeat(127));
+ });
+
+ it('recognises only the generated w/s + number + case form', () => {
+ expect(isGeneratedSessionName('w12-my_case-2')).toBe(true);
+ expect(isGeneratedSessionName('s1-shell')).toBe(true);
+ expect(isGeneratedSessionName('w1-case: fix it')).toBe(false);
+ expect(isGeneratedSessionName('alpha')).toBe(false);
+ });
+});
+
+describe('Session name ownership', () => {
+ it('infers placeholder vs manual from the name and persists the source', () => {
+ const placeholder = new Session({ workingDir: '/tmp', name: 'w1-demo' });
+ expect(placeholder.nameSource).toBe('placeholder');
+ expect(placeholder.toState().nameSource).toBe('placeholder');
+ expect(new Session({ workingDir: '/tmp' }).nameSource).toBe('placeholder');
+ expect(new Session({ workingDir: '/tmp', name: 'my window' }).nameSource).toBe('manual');
+ expect(new Session({ workingDir: '/tmp', name: 'w1-demo: fix it' }).nameSource).toBe('manual');
+ // The boot restore passes the persisted source, which outranks the inference.
+ const recovered = new Session({ workingDir: '/tmp', name: 'w1-demo: fix it', nameSource: 'auto' });
+ expect(recovered.nameSource).toBe('auto');
+ expect(recovered.applyAutoName('w1-demo: other')).toBe(false);
+ });
+
+ it('names once: the first prompt takes it, later prompts and renames do not', () => {
+ const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
+ expect(session.applyAutoName('w1-demo: fix the login bug')).toBe(true);
+ expect(session.name).toBe('w1-demo: fix the login bug');
+ expect(session.nameSource).toBe('auto');
+ expect(session.applyAutoName('w1-demo: 1')).toBe(false);
+ expect(session.name).toBe('w1-demo: fix the login bug');
+
+ session.name = 'mine';
+ expect(session.nameSource).toBe('manual');
+ expect(session.applyAutoName('other')).toBe(false);
+ expect(session.name).toBe('mine');
+ });
+
+ it('consumes the first prompt even when the composed name is unchanged', () => {
+ const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
+ expect(session.applyAutoName('w1-demo')).toBe(false);
+ expect(session.nameSource).toBe('auto');
+ });
+});
+
+describe('Session promptSubmitted', () => {
+ function withFakePty(session: Session): ReturnType {
+ const write = vi.fn();
+ (session as unknown as { ptyProcess: { write: typeof write } }).ptyProcess = { write };
+ return write;
+ }
+
+ it('emits for user input only, after the bytes reached the PTY', () => {
+ const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
+ const prompts: string[] = [];
+ session.on('promptSubmitted', (p: string) => prompts.push(p));
+
+ // No PTY yet: the write fails and nothing is reported.
+ expect(session.write('lost\r', { fromUser: true })).toBe(false);
+ expect(prompts).toEqual([]);
+
+ const write = withFakePty(session);
+ expect(session.write('Read @ralph_prompt.md and follow the instructions.\r')).toBe(true);
+ expect(prompts).toEqual([]);
+ expect(session.write('fix the ', { fromUser: true })).toBe(true);
+ expect(session.write('login bug\r', { fromUser: true })).toBe(true);
+ expect(prompts).toEqual(['fix the login bug']);
+ expect(write).toHaveBeenCalledTimes(3);
+ // The pane's last-Enter stamp is kept for EVERY write, user or not.
+ expect(session.lastSubmitAt).toBeGreaterThan(0);
+ });
+
+ it('never feeds the tracker for a shell session', () => {
+ const session = new Session({ workingDir: '/tmp', name: 's1-demo', mode: 'shell' });
+ const prompts: string[] = [];
+ session.on('promptSubmitted', (p: string) => prompts.push(p));
+ withFakePty(session);
+ expect(session.write('ls -la\r', { fromUser: true })).toBe(true);
+ session.trackUserInput('cd src\r');
+ expect(prompts).toEqual([]);
+ });
+
+ it('feeds the send-key line feed so a two-line prompt keeps its separator', () => {
+ const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
+ const prompts: string[] = [];
+ session.on('promptSubmitted', (p: string) => prompts.push(p));
+ withFakePty(session);
+ session.write('Fix the login bug', { fromUser: true });
+ session.trackUserInput('\n');
+ session.write('Also add tests.\r', { fromUser: true });
+ expect(prompts).toEqual(['Fix the login bug Also add tests.']);
+ });
+
+ it('reports through writeViaMux only when the mux accepted the input', async () => {
+ const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
+ const prompts: string[] = [];
+ session.on('promptSubmitted', (p: string) => prompts.push(p));
+ const sendInput = vi.fn(async () => false);
+ (session as unknown as { _mux: unknown; _muxSession: unknown })._mux = { sendInput };
+ (session as unknown as { _mux: unknown; _muxSession: unknown })._muxSession = { sessionId: session.id };
+
+ expect(await session.writeViaMux('dropped\r', { fromUser: true })).toBe(false);
+ expect(prompts).toEqual([]);
+ sendInput.mockResolvedValue(true);
+ expect(await session.writeViaMux('delivered\r', { fromUser: true })).toBe(true);
+ expect(prompts).toEqual(['delivered']);
+ expect(await session.writeViaMux('/clear\r')).toBe(true);
+ expect(prompts).toEqual(['delivered']);
+ });
+});
diff --git a/test/session-listener-wiring.test.ts b/test/session-listener-wiring.test.ts
index 5ffd56e0..1173fecb 100644
--- a/test/session-listener-wiring.test.ts
+++ b/test/session-listener-wiring.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import { Session } from '../src/session.js';
import { createSessionListeners } from '../src/web/session-listener-wiring.js';
+import { SseEvent } from '../src/web/sse-events.js';
describe('session listener wiring', () => {
it('forwards the attachment request source through registerAttachment', async () => {
@@ -21,30 +22,69 @@ describe('session listener wiring', () => {
expect(registerAttachment).toHaveBeenNthCalledWith(2, 'wiring-attach-source-test', '/tmp/report.pdf', 'external');
});
- it('renames an eligible session when its first prompt is submitted', () => {
- const session = new Session({ id: 'wiring-auto-name-test', workingDir: '/tmp', name: 'w1-demo' });
- const updateSessionName = vi.fn(() => true);
- const persistSessionState = vi.fn();
- const broadcast = vi.fn();
- const getSessionStateWithRespawn = vi.fn(() => session.toState());
+ /** The listener reads the setting asynchronously; let its promise chain settle. */
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 5));
+
+ function autoNameDeps(session: Session, enabled: boolean) {
const deps = {
- updateSessionName,
- persistSessionState,
- broadcast,
- getSessionStateWithRespawn,
- } as unknown as Parameters[1];
+ updateSessionName: vi.fn(() => true),
+ persistSessionState: vi.fn(),
+ broadcast: vi.fn(),
+ getSessionStateWithRespawn: vi.fn(() => session.toState()),
+ isAutoNameEnabled: vi.fn(async () => enabled),
+ };
+ return {
+ deps,
+ refs: createSessionListeners(session, deps as unknown as Parameters[1]),
+ };
+ }
+
+ it('names a placeholder tab after its first real prompt, in the prefix form, once', async () => {
+ const session = new Session({ id: 'wiring-auto-name-test', workingDir: '/tmp', name: 'w1-demo' });
+ const { deps, refs } = autoNameDeps(session, true);
+
+ // A slash command yields no title and leaves the session eligible; the
+ // setting is not even read for it.
+ refs.promptSubmitted('/clear');
+ await flush();
+ expect(deps.isAutoNameEnabled).not.toHaveBeenCalled();
+ expect(session.name).toBe('w1-demo');
- const refs = createSessionListeners(session, deps);
refs.promptSubmitted('整理登录模块并补充测试');
+ await flush();
+ expect(session.name).toBe('w1-demo: 整理登录模块并补充测试');
+ expect(session.nameSource).toBe('auto');
+ expect(deps.updateSessionName).toHaveBeenCalledWith('wiring-auto-name-test', 'w1-demo: 整理登录模块并补充测试');
+ expect(deps.persistSessionState).toHaveBeenCalledWith(session);
+ expect(deps.broadcast).toHaveBeenCalledWith(
+ SseEvent.SessionUpdated,
+ expect.objectContaining({ name: 'w1-demo: 整理登录模块并补充测试', nameSource: 'auto' })
+ );
- expect(session.name).toBe('整理登录模块并补充测试');
- expect(updateSessionName).toHaveBeenCalledWith('wiring-auto-name-test', '整理登录模块并补充测试');
- expect(persistSessionState).toHaveBeenCalledWith(session);
- expect(broadcast).toHaveBeenCalled();
+ // The second prompt never reaches the setting: the tab is named.
+ refs.promptSubmitted('1');
+ await flush();
+ expect(deps.isAutoNameEnabled).toHaveBeenCalledTimes(1);
+ expect(session.name).toBe('w1-demo: 整理登录模块并补充测试');
+ });
+
+ it('leaves the tab alone while the setting is off, and never touches a manual name', async () => {
+ const session = new Session({ id: 'wiring-auto-name-off', workingDir: '/tmp', name: 'w1-demo' });
+ const { deps, refs } = autoNameDeps(session, false);
+
+ refs.promptSubmitted('fix the login bug');
+ await flush();
+ expect(deps.isAutoNameEnabled).toHaveBeenCalledTimes(1);
+ expect(session.name).toBe('w1-demo');
+ // Still a placeholder: flipping the setting on names the NEXT prompt.
+ expect(session.nameSource).toBe('placeholder');
+ expect(deps.updateSessionName).not.toHaveBeenCalled();
session.name = '人工命名';
refs.promptSubmitted('新的任务不能覆盖人工命名');
+ await flush();
+ expect(deps.isAutoNameEnabled).toHaveBeenCalledTimes(1);
expect(session.name).toBe('人工命名');
- expect(updateSessionName).toHaveBeenCalledTimes(1);
+ expect(deps.persistSessionState).not.toHaveBeenCalled();
});
});
diff --git a/test/session-submit-anchor.test.ts b/test/session-submit-anchor.test.ts
index 5c75564c..a1298fb9 100644
--- a/test/session-submit-anchor.test.ts
+++ b/test/session-submit-anchor.test.ts
@@ -12,7 +12,6 @@
import { describe, it, expect } from 'vitest';
import { Session } from '../src/session.js';
-import { deriveAutoSessionName, SubmittedPromptTracker } from '../src/session-auto-name.js';
describe('session submit anchor', () => {
it('records the pane Enter and carries it into persisted state', () => {
@@ -54,31 +53,3 @@ describe('session submit anchor', () => {
expect(recovered.lastSubmitAt).toBe(0);
});
});
-
-describe('automatic session names', () => {
- it('builds a bounded title from the first sentence without exposing controls', () => {
- expect(deriveAutoSessionName(' 修复登录跳转问题。\n不要改数据库')).toBe('修复登录跳转问题。');
- expect(deriveAutoSessionName('/clear')).toBeNull();
- expect(deriveAutoSessionName('\x1b[31m整理项目文档\x1b[0m')).toBe('整理项目文档');
- expect(Array.from(deriveAutoSessionName('a'.repeat(200)) ?? '')).toHaveLength(72);
- });
-
- it('tracks chunked typing, backspace, and Enter without treating arrows as prompt text', () => {
- const tracker = new SubmittedPromptTracker();
- expect(tracker.feed('修复登')).toEqual([]);
- expect(tracker.feed('录跳转\x7f问题\r')).toEqual(['修复登录跳问题']);
- expect(tracker.feed('旧内容\x1b[A新内容\r')).toEqual(['新内容']);
- });
-
- it('keeps manual names protected while generated names remain eligible', () => {
- const generated = new Session({ workingDir: '/tmp', name: 'w1-demo' });
- expect(generated.nameSource).toBe('auto');
- expect(generated.applyAutoName('修复登录')).toBe(true);
- expect(generated.applyAutoName('继续重命名')).toBe(true);
-
- const manual = new Session({ workingDir: '/tmp', name: '我的工作窗口' });
- expect(manual.nameSource).toBe('manual');
- expect(manual.applyAutoName('不应覆盖')).toBe(false);
- expect(manual.name).toBe('我的工作窗口');
- });
-});
From 3248f3508105268974e5b60ffee1bb2d9feae1de Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 15 Sep 2026 18:18:09 +0200
Subject: [PATCH 4/5] chore: version packages (#437)
* chore: version packages
* chore: sync the CLAUDE.md version line to 1.29.1
Co-Authored-By: Claude Fable 5.1
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Codeman maintainer
---
.changeset/auto-name-sessions.md | 11 -----------
.claude-plugin/marketplace.json | 2 +-
CHANGELOG.md | 11 +++++++++++
CLAUDE.md | 2 +-
package-lock.json | 4 ++--
package.json | 2 +-
plugins/codeman/.claude-plugin/plugin.json | 2 +-
7 files changed, 17 insertions(+), 17 deletions(-)
delete mode 100644 .changeset/auto-name-sessions.md
diff --git a/.changeset/auto-name-sessions.md b/.changeset/auto-name-sessions.md
deleted file mode 100644
index 8dbb6f95..00000000
--- a/.changeset/auto-name-sessions.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-"aicodeman": patch
----
-
-Auto-name sessions from the first prompt (#376, opt-in). With the new synced **Auto-name Sessions** setting on (App Settings → Appearance → Tabs, default off), a tab that still carries its generated name takes a title from the first real prompt you submit, keeping the case prefix: `w3-myapp` becomes `w3-myapp: fix the login redirect`. The strip shows the title with the prefix in the tooltip, and the next session in that case still counts up. It happens once per session, only for prompts you type or send through the input API (never a Ralph, respawn, cron or approval answer), never for shells, and a name you set yourself is never touched. Slash commands such as `/clear` do not become titles. The title is derived locally from the prompt's first sentence; no text leaves the machine. `nameSource` (`placeholder` / `auto` / `manual`) is a new additive field on session state.
-
-Landed with the fixes the review of #376 asked for: first prompt only (not every prompt), a user-input gate so Ralph, respawn, cron and approval writes cannot name a tab, the prefix form so the case identity and `w` counter survive, and a keystroke tracker that handles a bare Esc, bracketed pastes, wheel reports, Tab and history recall instead of mis-titling the tab.
-
-### Thanks
-
-- @shenlvkang-collab for #376, the auto-naming idea and the ownership plumbing (`nameSource`, the listener wiring, the restore path) it shipped with.
diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index 9d6c3cb8..1d5433b4 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -10,7 +10,7 @@
"name": "codeman",
"source": "./plugins/codeman",
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
- "version": "1.29.0",
+ "version": "1.29.1",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3c10b5a5..c2740d9e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,16 @@
# aicodeman
+## 1.29.1
+
+### Patch Changes
+
+- 5b920cb: Auto-name sessions from the first prompt (#376, opt-in). With the new synced **Auto-name Sessions** setting on (App Settings → Appearance → Tabs, default off), a tab that still carries its generated name takes a title from the first real prompt you submit, keeping the case prefix: `w3-myapp` becomes `w3-myapp: fix the login redirect`. The strip shows the title with the prefix in the tooltip, and the next session in that case still counts up. It happens once per session, only for prompts you type or send through the input API (never a Ralph, respawn, cron or approval answer), never for shells, and a name you set yourself is never touched. Slash commands such as `/clear` do not become titles. The title is derived locally from the prompt's first sentence; no text leaves the machine. `nameSource` (`placeholder` / `auto` / `manual`) is a new additive field on session state.
+
+ Landed with the fixes the review of #376 asked for: first prompt only (not every prompt), a user-input gate so Ralph, respawn, cron and approval writes cannot name a tab, the prefix form so the case identity and `w` counter survive, and a keystroke tracker that handles a bare Esc, bracketed pastes, wheel reports, Tab and history recall instead of mis-titling the tab.
+
+ ### Thanks
+ - @shenlvkang-collab for #376, the auto-naming idea and the ownership plumbing (`nameSource`, the listener wiring, the restore path) it shipped with.
+
## 1.29.0
### Minor Changes
diff --git a/CLAUDE.md b/CLAUDE.md
index 5401e6c8..4ef118e5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -77,7 +77,7 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
-**Version**: 1.29.0 (must match `package.json`)
+**Version**: 1.29.1 (must match `package.json`)
## Project Overview
diff --git a/package-lock.json b/package-lock.json
index e7293301..22edc788 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
- "version": "1.29.0",
+ "version": "1.29.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
- "version": "1.29.0",
+ "version": "1.29.1",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
diff --git a/package.json b/package.json
index 791bc3cc..287879ba 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
- "version": "1.29.0",
+ "version": "1.29.1",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
diff --git a/plugins/codeman/.claude-plugin/plugin.json b/plugins/codeman/.claude-plugin/plugin.json
index 65fd65d6..8d16d73b 100644
--- a/plugins/codeman/.claude-plugin/plugin.json
+++ b/plugins/codeman/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "codeman",
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
- "version": "1.29.0",
+ "version": "1.29.1",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
From bd286bf502c9c9dfa8e9be90eeed6ce55e895d1e Mon Sep 17 00:00:00 2001
From: Codeman maintainer
Date: Tue, 15 Sep 2026 19:05:59 +0200
Subject: [PATCH 5/5] docs(wiki): catch the manual up to 1.29.0 and add the
three run modes it never had
The wiki was written for seven run modes and never received Grok Build, DeepSeek
Harness or OMP. They now appear everywhere the others do: the modes table and
per-CLI notes, install commands, environment prefixes, the Quick Start table, the
requirements rows, the vocabulary, and every "seven modes" count.
The 1.27 to 1.29.0 changes land on the pages that own them: attaching a case to an
existing container, multi-case adoption and the copy-a-case picker (Docker Cases);
file reads over ssh in remote cases and what stays unavailable (Remote SSH Sessions,
Working With Files, Security); single-page app routing, frame recovery, localhost
links as tabs and the egress guard (Web Tabs); DeepSeek as the one non-Claude mode
with real stop/blocked signals and Approvals items, Codex's own work detection,
last-response, the model-endpoint routes and refreshed counts (HTTP API, Driving
From An Agent, Hooks, Notifications, Keeping Agents Running, Core Concepts);
Shift+drag, right-click copy, Auto Copy, the Ctrl+Z guard, font weight, the vertical
rail and its activity sort (Keyboard Shortcuts, Input And Voice, The Dashboard,
Settings Reference); the 600px phone cutoff, Codex shift arrows and iPhone Duo
(Mobile Guide); the Docker Compose route and its update rule (Installation, Running
As A Service); four new symptom entries and a "which CLIs" question (Troubleshooting,
FAQ).
Custom model endpoints are deliberately left to #430, which adds that page and edits
Agent CLIs, Settings Reference and the sidebar; these edits stay out of the regions
#430, #428 and #376 touch, and all three still merge cleanly on top.
Both READMEs: the web-tab menu entry is labelled "Add URL" in the UI, not
"Add dashboard".
Co-Authored-By: Claude Fable 5.1
---
README.md | 2 +-
README.zh-CN.md | 2 +-
docs/wiki/Agent-CLIs.md | 96 +++++++++++++++++++---
docs/wiki/Contributing.md | 2 +-
docs/wiki/Core-Concepts.md | 20 +++--
docs/wiki/Docker-Cases.md | 33 ++++++--
docs/wiki/Driving-Codeman-From-An-Agent.md | 23 ++++--
docs/wiki/FAQ.md | 6 ++
docs/wiki/HTTP-API.md | 27 ++++--
docs/wiki/Home.md | 8 +-
docs/wiki/Hooks-And-Integrations.md | 10 ++-
docs/wiki/Input-And-Voice.md | 11 +++
docs/wiki/Installation.md | 30 ++++++-
docs/wiki/Keeping-Agents-Running.md | 24 ++++--
docs/wiki/Keyboard-Shortcuts.md | 3 +
docs/wiki/Mobile-Guide.md | 12 ++-
docs/wiki/Notifications-And-Approvals.md | 10 ++-
docs/wiki/Quick-Start.md | 3 +
docs/wiki/Remote-SSH-Sessions.md | 15 +++-
docs/wiki/Running-As-A-Service.md | 13 +++
docs/wiki/Security.md | 1 +
docs/wiki/Settings-Reference.md | 8 +-
docs/wiki/The-Dashboard.md | 19 +++--
docs/wiki/Troubleshooting.md | 34 +++++++-
docs/wiki/Web-Tabs.md | 23 ++++++
docs/wiki/Working-With-Files.md | 16 ++++
26 files changed, 371 insertions(+), 80 deletions(-)
diff --git a/README.md b/README.md
index 31acc908..df93d622 100644
--- a/README.md
+++ b/README.md
@@ -443,7 +443,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
- **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) and [`docs/omp-integration.md`](docs/omp-integration.md)
- **Custom model endpoints** _(new in 1.29.0, HTTP API for now)_ — point a session's CLI at any OpenAI-compatible endpoint instead of its native backend: a local llama.cpp, llama-swap, Ollama or vLLM box, or a cloud gateway such as Azure AI Foundry or OpenRouter. Save an endpoint once (`POST /api/model-endpoints`; its models are discovered from `/v1/models`), apply it to a session (`POST /api/sessions/:id/custom-model`), and the CLI restarts in place on that endpoint. Verified live for Claude, OpenCode, Pi, Grok and OMP; Codex, Gemini and DeepSeek have documented gaps, Antigravity has no mechanism. A toolbar picker is the follow-up. See [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
-- **Web tabs** — open Grafana, Uptime Kuma, a Vite dev server or any dashboard URL as a tab beside your sessions (Run dropdown → **Web / URL** → **Add dashboard**). Dashboards are proxied through Codeman's own origin, so an `http://` target works from a phone over HTTPS and through the tunnel, single-page apps route on their own paths, and a frame that reloads recovers itself. A `localhost` link an agent prints opens as a web tab automatically. See [`docs/web-tabs.md`](docs/web-tabs.md)
+- **Web tabs** — open Grafana, Uptime Kuma, a Vite dev server or any dashboard URL as a tab beside your sessions (Run dropdown → **Web / URL** → **Add URL**). Dashboards are proxied through Codeman's own origin, so an `http://` target works from a phone over HTTPS and through the tunnel, single-page apps route on their own paths, and a frame that reloads recovers itself. A `localhost` link an agent prints opens as a web tab automatically. See [`docs/web-tabs.md`](docs/web-tabs.md)
- **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container, or attach a case to a container you already run; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md)
- **Remote SSH sessions** — point a case at another machine and run the agent there inside a durable remote tmux: survives SSH drops, auto-reconnects, and can discover + attach sessions already running on the host; file previews and downloads come over the same ssh connection. See [`docs/remote-sessions.md`](docs/remote-sessions.md)
- **Effort & Ultracode** — set a per-session default effort (`low`–`max`) or enable **ultracode** (dynamic multi-agent workflows). Soft defaults only — switchable anytime with `/effort` in-session. Extended-thinking budget is configurable too
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 8f19c042..ed56cd41 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -445,7 +445,7 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
- **把 GitHub 仓库克隆成 case** —— 在 **Add Case → Clone Repo** 里粘贴一个仓库 URL,Codeman 会把它克隆到 `~/codeman-cases/` 并注册为普通 case,随时可以跑智能体。输入时它会预检 URL(告诉你能否匿名克隆,并为可选的分支/标签字段提供仓库真实的分支与标签),从 URL 里填好 case 名,还让你选 Run 按钮该用哪个 CLI。支持 `https://` 的公开仓库;Codeman 绝不收集或保存凭据
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex**、**Antigravity**、**Gemini**、**Pi**、**Grok**、**DeepSeek Harness** 或 **OMP**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*`、`ANTIGRAVITY_*`、`GEMINI_*`/`GOOGLE_*`、`PI_*`、`GROK_*`/`XAI_*`、`DSH_*`/`DEEPSEEK_*` 与 `OMP_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)、[`docs/pi-integration.md`](docs/pi-integration.md)、[`docs/grok-integration.md`](docs/grok-integration.md)、[`docs/deepseek-integration.md`](docs/deepseek-integration.md) 与 [`docs/omp-integration.md`](docs/omp-integration.md)
- **自定义模型端点**(1.29.0 新增,目前仅 HTTP API)—— 让某个会话的 CLI 指向任意 OpenAI 兼容端点,而不是它自己的官方后端:本地的 llama.cpp、llama-swap、Ollama 或 vLLM 机器,也可以是 Azure AI Foundry、OpenRouter 这类云端网关。端点只需保存一次(`POST /api/model-endpoints`,模型列表从它的 `/v1/models` 自动发现),再应用到会话(`POST /api/sessions/:id/custom-model`),CLI 就会在原地重启并接上该端点。Claude、OpenCode、Pi、Grok 与 OMP 已实测通过;Codex、Gemini 与 DeepSeek 存在已记录的缺口,Antigravity 没有可用机制。工具栏选择器是下一步。详见 [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
-- **Web 标签页** —— 把 Grafana、Uptime Kuma、一个 Vite 开发服务器或任何仪表盘 URL 作为标签页打开在会话旁边(Run 下拉菜单 → **Web / URL** → **Add dashboard**)。仪表盘通过 Codeman 自己的源代理,因此 `http://` 目标在手机上走 HTTPS 也能用、走隧道也能用;单页应用能在自己的路径上正常路由,页面自己重载后也能自行恢复。智能体打印出的 `localhost` 链接会自动以 Web 标签页打开。详见 [`docs/web-tabs.md`](docs/web-tabs.md)
+- **Web 标签页** —— 把 Grafana、Uptime Kuma、一个 Vite 开发服务器或任何仪表盘 URL 作为标签页打开在会话旁边(Run 下拉菜单 → **Web / URL** → **Add URL**)。仪表盘通过 Codeman 自己的源代理,因此 `http://` 目标在手机上走 HTTPS 也能用、走隧道也能用;单页应用能在自己的路径上正常路由,页面自己重载后也能自行恢复。智能体打印出的 `localhost` 链接会自动以 Web 标签页打开。详见 [`docs/web-tabs.md`](docs/web-tabs.md)
- **Docker 会话** —— 在隔离且加固的容器中运行 case。**Create New** 上勾选一个复选框即可用合理的默认值启动容器并在其中启动智能体;同一 case 的多个会话共享一个容器,也可以把 case 挂到你已经在跑的容器上;可将容器连同工作区导出为可移植的 `.tar.gz`,迁移到另一台机器。详见 [`docs/docker-cases.md`](docs/docker-cases.md)
- **远程 SSH 会话** —— 把 case 指向另一台机器,让智能体在那里一个持久的远程 tmux 中运行:SSH 断连不中断任务、自动重连,还能发现并附着主机上已在运行的会话;文件预览与下载走同一条 ssh 连接。详见 [`docs/remote-sessions.md`](docs/remote-sessions.md)
- **Effort 与 Ultracode** —— 设置每会话的默认 effort(`low`–`max`),或启用 **ultracode**(动态多智能体工作流)。这些都只是软默认值 —— 会话中可随时用 `/effort` 切换。扩展思考预算也可配置
diff --git a/docs/wiki/Agent-CLIs.md b/docs/wiki/Agent-CLIs.md
index 6c34f274..eb013b29 100644
--- a/docs/wiki/Agent-CLIs.md
+++ b/docs/wiki/Agent-CLIs.md
@@ -1,9 +1,9 @@
# Agent CLIs
-Codeman drives seven run modes: six agent CLIs plus a plain shell. This page covers picking
+Codeman drives ten run modes: nine agent CLIs plus a plain shell. This page covers picking
one, setting it up, and the differences that actually change how you work.
-## The seven modes
+## The ten modes
| Mode | CLI | Get it |
| -------------------- | ---------------------------- | ---------------------------------------------------------------------- |
@@ -13,6 +13,9 @@ one, setting it up, and the differences that actually change how you work.
| **Gemini** | `gemini` | [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli) |
| **Antigravity** | `agy` | [antigravity.google](https://antigravity.google) |
| **Pi** | `pi` | [pi.dev](https://pi.dev) |
+| **Grok Build** | `grok` | [github.com/xai-org/grok-build](https://github.com/xai-org/grok-build) |
+| **DeepSeek Harness** | `dsh` | [github.com/deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) |
+| **OMP** | `omp` | [github.com/can1357/oh-my-pi](https://github.com/can1357/oh-my-pi) |
| **Terminal / Shell** | your `$SHELL` | Already installed. |
Any combination works, including all of them. The run mode is chosen per session from the
@@ -47,8 +50,12 @@ If a CLI is installed but a Run button for it never appears:
precisely to avoid this; a hand-written plist or unit will not.
3. Restart the server after installing a new CLI.
-`pi` is additionally version-probed rather than trusted by name, because `pi` is a generic
-enough command that something else on your PATH may answer to it.
+`pi`, `grok`, `omp` and `dsh` are additionally identity-probed rather than trusted by name:
+`pi` and `omp` are generic enough that something else on your PATH may answer to them,
+`grok` has npm squatters, and Debian ships an unrelated `dsh` (dancer's shell). Each has a
+status endpoint (`/api/grok/status`, `/api/deepseek/status`, `/api/omp/status`) that reports
+the path and version that actually resolved, so a misresolution is visible rather than
+presenting as "the mode just does not work".
## Claude is the reference mode
@@ -62,15 +69,15 @@ output. The other CLIs expose no equivalent.
| Respawn cycling and unattended runs | Yes | Yes |
| Cron jobs | Yes | Yes |
| Docker cases, remote SSH cases | Yes | Yes |
-| Precise idle detection (hook-driven) | Yes | Output-stabilization fallback, coarser |
+| Precise idle detection | Yes | Codex: same screen check, via its own prompt and working line. DeepSeek: reports its state itself. Others: output stabilization, coarser |
| Auto-resume when a usage limit resets | Yes | No |
| Plan usage chip | Yes | No |
-| Approvals Inbox | Yes | No |
+| Approvals Inbox | Yes | DeepSeek yes; others no |
| Read My Mind | Yes | No |
| Ralph loop and its task tracker | Yes | No |
| Subagent and team windows | Yes | No |
| Model, effort, and ultracode controls | Yes | No |
-| `stop` and `blocked` wait signals | Yes | 400 if you ask for them explicitly |
+| `stop` and `blocked` wait signals | Yes | DeepSeek yes; elsewhere 400 if you ask for them explicitly |
| The bundled agent skill | Yes | No |
Everything that makes a session a session works everywhere. What is Claude-only is mostly
@@ -124,6 +131,11 @@ Two behaviours that are deliberate and worth knowing:
- **The wheel is not forwarded** into its transcript. Codex ignores the mouse reports
Codeman would send, so forwarding produced a dead wheel. Scrolling in a Codex session is
local scrollback.
+- **Work detection is Codex's own.** Codex declares its `›` composer glyph and its
+ `esc to interrupt` working line, so it gets the same screen-checked idle detection Claude
+ does; before 1.26.1 every Codex session reported idle for its whole life. Codex
+ conversations also appear in Past Sessions and can be resumed, and on phones the keyboard
+ bar grows `⇧←` / `⇧→` for Codex's queued-message editing and prompt stack.
### Gemini
@@ -157,6 +169,60 @@ Pi needs the opposite instincts from every other CLI here.
Guide: [`docs/pi-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/pi-integration.md).
+### Grok Build
+
+xAI's `grok`, installed with `curl -fsSL https://x.ai/cli/install.sh | bash` into
+`~/.grok/bin`. Codex-shaped on permissions and OpenCode-shaped on rendering:
+
+- **Its bypass switch is `--always-approve`**, Grok's own `bypassPermissions` mode, and the
+ Run button sends it the way it sends Codex's. In multi-user mode a user without a grant
+ has it stripped.
+- **Authentication is Grok's own**: browser OAuth on first run (a device-code screen inside
+ a Codeman pane), `grok login --device-auth` for headless hosts, or `XAI_API_KEY` as a
+ per-session environment override.
+- It renders a full-screen TUI, so scrolling is local scrollback.
+
+Guide: [`docs/grok-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/grok-integration.md).
+
+### DeepSeek Harness
+
+The mode wired least like the others, for two reasons worth knowing before you use it.
+
+**`dsh` is a launcher, not an agent.** It boots a *profile*, and the three DeepSeek ships
+(`web`, `headless`, `base`) cannot drive a terminal pane. So "installed" and "runnable" are
+different questions: the Run menu offers **DeepSeek** only once a pane-capable profile
+exists, and until then shows **DeepSeek — add a terminal profile…**, which installs the
+community `dsh-tui` with one click (`pnpm` must be on PATH, because the launcher spawns it
+directly).
+
+**Permissions are an environment variable, not a flag.** The harness has no
+skip-permissions switch. `DSH_PERMISSION_MODE` (`read-only`, `workspace-write`,
+`danger-full-access`) is the whole control, and it is the one setting Codeman deliberately
+carries as an environment variable, because the harness reads it as a soft boot-time
+default. In multi-user mode a user without a grant is clamped to `workspace-write`.
+
+The reward for the odd wiring: **DeepSeek is the one non-Claude mode with real signals.**
+Its terminal front door reports idle, working and blocked to Codeman, so a DeepSeek
+session gets precise idle detection, the `stop` and `blocked` wait signals, and Approvals
+Inbox items. Answers are read from the harness's own transcript on disk rather than
+scraped off the pane. The model is not a session setting; it is part of the profile.
+
+Guide: [`docs/deepseek-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/deepseek-integration.md).
+
+### OMP
+
+Oh My Pi, installed with `curl -fsSL https://omp.sh/install | sh` into `~/.local/bin`.
+OMP owns its auth, provider routing and approval mode entirely in `~/.omp`: there is no
+Codeman-side login, key field, or bypass switch. Run `omp` once outside Codeman to finish
+its own onboarding, and every session started through Codeman inherits that config. Its
+documented default approval mode is `yolo`, so an OMP pane auto-approves tool use with no
+flag from Codeman; change that in OMP's own config, not here.
+
+OMP conversations appear in Past Sessions and can be resumed, and a respawn continues the
+same conversation with `--continue`.
+
+Guide: [`docs/omp-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/omp-integration.md).
+
### Terminal / Shell
A plain shell in a tmux session. No agent, no hooks, no idle detection.
@@ -180,9 +246,15 @@ respawns. Which variables are accepted depends on the mode:
| Gemini | `GEMINI_*`, `GOOGLE_*` |
| Antigravity | `ANTIGRAVITY_*` |
| Pi | `PI_*` |
+| Grok | `GROK_*`, `XAI_*` |
+| DeepSeek | `DSH_*`, `DEEPSEEK_*` |
+| OMP | `OMP_*` |
Anything outside the allowlist is rejected at the schema. This is intentional: the allowlist
-is one global list, so widening it for one CLI widens it for all of them.
+is one global list, so widening it for one CLI widens it for all of them. In multi-user mode
+the keys that could redirect a CLI's traffic or move its config home (`DSH_PERMISSION_MODE`,
+`DSH_HOME`, `DEEPSEEK_BASE_URL`, `OMP_AUTH_BROKER_URL`, and the base URLs and config
+directories of the others) are dropped for a user without the bypass grant.
Two things that deliberately do **not** travel as environment variables: **effort**, because
an environment variable hard-locks it and blocks `/effort`, and **model**, which is written
@@ -192,9 +264,11 @@ into the case's `.claude/settings.local.json` so that `/model` keeps working.
- **Claude Code** if you want every Codeman feature. Unattended overnight runs, usage-limit
auto-resume, the Approvals Inbox, and subagent visualization all assume it.
-- **Codex, OpenCode, Gemini, Antigravity** when you prefer that agent or that model. You get
- the session layer, respawn, cron, Docker, and remote SSH; you do not get the hook-driven
- features.
+- **Codex, OpenCode, Gemini, Antigravity, Grok, OMP** when you prefer that agent or that
+ model. You get the session layer, respawn, cron, Docker, and remote SSH; you do not get the
+ hook-driven features.
+- **DeepSeek Harness** if you want DeepSeek's models with real status signals. It is the one
+ non-Claude mode that reports idle, working and blocked to Codeman itself.
- **Pi** if you want a fast, unsandboxed agent and you understand what project trust does.
- **Shell** for the times you want a terminal on your phone with no agent at all. It is a
genuinely useful mode, not a fallback.
diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md
index f99e6796..892e594f 100644
--- a/docs/wiki/Contributing.md
+++ b/docs/wiki/Contributing.md
@@ -108,7 +108,7 @@ Conventions for wiki pages:
- Images are referenced from the main repository over raw URLs rather than being copied into
the wiki.
- Say what the default is, especially when it is off. Most of Codeman is opt-in.
-- Label Claude-only behaviour every time it appears. Six of the seven run modes are not
+- Label Claude-only behaviour every time it appears. Nine of the ten run modes are not
Claude.
## Conduct
diff --git a/docs/wiki/Core-Concepts.md b/docs/wiki/Core-Concepts.md
index 5110e5ef..0afd077d 100644
--- a/docs/wiki/Core-Concepts.md
+++ b/docs/wiki/Core-Concepts.md
@@ -50,10 +50,10 @@ A session carries state the case does not:
## Run mode
The **run mode** is which CLI the session runs: `claude`, `opencode`, `codex`, `gemini`,
-`antigravity`, `pi`, or `shell`. It is chosen at start and does not change afterwards; to
+`antigravity`, `pi`, `grok`, `deepseek`, `omp`, or `shell`. It is chosen at start and does not change afterwards; to
switch, start another session.
-Claude is the reference mode. Six of the seven are not Claude, and a number of Codeman
+Claude is the reference mode. Nine of the ten are not Claude, and a number of Codeman
features are Claude-only for structural reasons rather than missing effort: they depend on
Claude Code's hook system or on parsing its terminal output. Every such feature is labelled
Claude-only where it appears, and [Agent CLIs](Agent-CLIs) lists them in one place.
@@ -68,8 +68,8 @@ Where a case runs is **separate from** which CLI it runs. There are three locati
| **Docker** | One long-lived container per case; sessions `docker exec` into it. See [Docker Cases](Docker-Cases). |
| **Remote SSH** | A durable tmux server on the remote host, fronted by a local pane running `ssh`. See [Remote SSH Sessions](Remote-SSH-Sessions). |
-This matters because it is a common source of confusion: Docker is **not** an eighth run
-mode. All seven run modes work in all three locations. A case is docker-backed or
+This matters because it is a common source of confusion: Docker is **not** an eleventh run
+mode. All ten run modes work in all three locations. A case is docker-backed or
ssh-backed; a session is claude or codex or shell.
**Web tabs** are the other thing that is not a session. A saved dashboard URL renders as a
@@ -155,9 +155,11 @@ report events back: a permission prompt appeared, the turn finished, the agent w
task completed. Those events drive tab alerts, the Approvals Inbox, notifications, and the
wait primitives.
-This is why some features are Claude-only. The other CLIs have no equivalent hook system,
-so for them Codeman falls back to watching terminal output, which is coarser: it can see
-that something happened, not what it was.
+This is why some features are Claude-only. The one partial exception is DeepSeek Harness,
+whose terminal front door reports idle, working and blocked to Codeman over the harness's
+own supervisor contract, so it gets the hook-driven signals without a hook file. The other
+CLIs have no equivalent, so for them Codeman falls back to watching terminal output, which
+is coarser: it can see that something happened, not what it was.
See [Hooks And Integrations](Hooks-And-Integrations).
@@ -167,7 +169,7 @@ See [Hooks And Integrations](Hooks-And-Integrations).
| --------------- | ---------------------------------------------------------------------------- |
| **Case** | Named working directory. |
| **Session** | One CLI in one tmux session. |
-| **Run mode** | Which CLI: claude, opencode, codex, gemini, antigravity, pi, shell. |
+| **Run mode** | Which CLI: claude, opencode, codex, gemini, antigravity, pi, grok, deepseek, omp, shell. |
| **Respawn** | Restarting the CLI on idle to keep an unattended run going. |
| **Ralph loop** | An autonomous single-session task loop. |
| **Orchestrator**| A phased plan driven across multiple agents. |
@@ -178,6 +180,6 @@ See [Hooks And Integrations](Hooks-And-Integrations).
## Read next
- [The Dashboard](The-Dashboard) - what the UI is showing you.
-- [Agent CLIs](Agent-CLIs) - the seven run modes in detail.
+- [Agent CLIs](Agent-CLIs) - the ten run modes in detail.
- [Keeping Agents Running](Keeping-Agents-Running) - respawn, idle detection, usage limits.
- [`docs/architecture-invariants.md`](https://github.com/Ark0N/Codeman/blob/master/docs/architecture-invariants.md) - the mechanisms behind all of this, for contributors.
diff --git a/docs/wiki/Docker-Cases.md b/docs/wiki/Docker-Cases.md
index 76c226d1..ff5ccca1 100644
--- a/docs/wiki/Docker-Cases.md
+++ b/docs/wiki/Docker-Cases.md
@@ -4,7 +4,7 @@ Run a case inside its own container instead of directly on your host: for isolat
reproducible toolchain, and for the ability to pick the whole environment up and move it to
another machine.
-A docker case is a **location overlay**, not a run mode. All seven run modes work inside a
+A docker case is a **location overlay**, not a run mode. All ten run modes work inside a
container. See [Core Concepts](Core-Concepts).
## One-time setup: the base image
@@ -26,7 +26,7 @@ A zero exit code proves the layers ran, not that the toolchain works. Verify:
```bash
docker run --rm codeman/agent:base bash -lc \
- 'for c in claude codex gemini opencode agy pi; do printf "%-9s " $c; $c --version 2>&1 | head -1; done'
+ 'for c in claude codex gemini opencode agy pi grok dsh omp; do printf "%-9s " $c; $c --version 2>&1 | head -1; done'
```
The image is secret-free. Credentials are delivered at runtime, never baked in, so exports
@@ -79,6 +79,25 @@ Exactly one long-lived container per case, shared by every session in it.
conversation** from the bind-mounted transcript.
- Deleting the case removes the container. The workspace on the host survives.
+## Attaching to a container you already run
+
+Tick **Attach to an existing container** on **Add Case → Docker** to link a case to a
+container that already exists instead of creating one. Codeman only `exec`s into it and
+never creates, starts, stops, restarts or removes it, so a container that is missing or
+stopped fails with a message rather than being fixed for you. Drift detection does not
+apply (the container carries no Codeman configuration label). The full-image export is
+refused, since it would `docker commit` someone else's container, and the workspace export
+skips the pause that keeps an owned container consistent during the capture.
+
+One adopted container can back several cases at different in-container directories, and
+**copy an existing case** pre-fills the form from a sibling on the same container. An exact
+twin (the same container and the same directory) is refused, as is a container another
+user adopted.
+
+Adoption is **admin-only in multi-user mode**. Linking creates Codeman's own container
+with one bind mount that has already been checked; an adopted container's mounts belong to
+whoever started it, and one that mounts `/` hands the adopter the host.
+
## Credentials
Your existing host logins work inside the container without logging in again. Credentials
@@ -92,10 +111,12 @@ the container instead.
Bind mounts are excluded from image capture, so exports stay secret-free.
-One consequence worth knowing: Pi's credentials are seeded per file rather than as a whole
-directory, because that directory also holds sessions, extensions, and installed packages,
-which can be gigabytes. So in-container Pi sessions are invisible from the host, and `pi -c`
-inside a docker case sees only that container's history.
+One consequence worth knowing: Pi, Grok and OMP credentials are seeded per file rather than
+as whole directories, because those directories also hold sessions, extensions, downloads and
+installed packages, which can be gigabytes. So in-container Pi and Grok sessions are
+invisible from the host (`pi -c` and `grok -c` inside a docker case see only that
+container's history). OMP's `sessions/` is the exception and is shared read-write, because
+Codeman reads it host-side for history and resume.
## Isolation
diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md
index e324a3af..25faa321 100644
--- a/docs/wiki/Driving-Codeman-From-An-Agent.md
+++ b/docs/wiki/Driving-Codeman-From-An-Agent.md
@@ -54,7 +54,10 @@ create-time sweep would yank the skill out from under other live sessions sharin
directory. Remove them per case with `codeman skill uninstall --case `.
The skill ships with the verb index always loaded, plus on-demand references for the verbs,
-worked multi-worker recipes, endpoint tables, and cross-session messaging.
+worked multi-worker recipes, endpoint tables, and cross-session messaging. It drives
+DeepSeek Harness workers the same way it drives Claude ones (`spawn_workers alpha
+beta:deepseek` is a mixed fleet in one call), since those are the two modes with real
+completion signals.
## The manual path
@@ -92,8 +95,9 @@ Read these before writing any code. Each one has cost somebody an afternoon.
5. **Wait instead of polling, and a timeout is not an error.** The wait endpoints answer
`200` with `wait.timedOut: true`. Loop over short waits rather than one long call, because
tunnels cut idle connections.
-6. **Only `claude` sessions emit `stop` and `blocked`.** They come from Claude Code hooks.
- Shell and the external CLIs accept only `idle`, `working`, and `exit`; asking for `stop`
+6. **Only `claude` and `deepseek` sessions emit `stop` and `blocked`.** Claude's come from
+ Claude Code hooks, DeepSeek's from the harness reporting its state to Codeman. Shell and
+ the other external CLIs accept only `idle`, `working`, and `exit`; asking for `stop`
explicitly there is a `400`, while omitting `until` is always safe. On a shell session
`idle` fires **once at startup and never again**, so synchronize hook-less sessions with an
output marker instead.
@@ -130,7 +134,10 @@ curl -s -X POST "$API/api/sessions/$ID/input" \
# Or wait for a marker in the output, which works on shell sessions too
curl -s "$API/api/sessions/$ID/wait-output?contains=DONE_17909&from=buffer" | jq
-# Read the terminal back
+# Read the last answer as clean text (claude, codex, deepseek sessions)
+curl -s "$API/api/sessions/$ID/last-response" | jq -r '.data.text'
+
+# Or read the terminal back
curl -s "$API/api/sessions/$ID/terminal?tail=4000" | jq -r '.data.output'
# Clean up, by exact id
@@ -157,7 +164,13 @@ Make it unique per call, because tmux repaints replay old screen text.
### Reading output
-Use `terminal?tail=`, not `/output`. The latter's text field is empty for every tmux-backed
+For `claude`, `codex` and `deepseek` sessions, read the answer from the transcript rather
+than the screen: `GET /api/sessions/:id/last-response` returns the last reply as clean text
+with no TUI frames or repaint noise. Poll it briefly rather than reading once, because the
+transcript lands slightly after the `stop` signal, so a read immediately after send-and-wait
+returns often comes back empty.
+
+For everything else, use `terminal?tail=`, not `/output`. The latter's text field is empty for every tmux-backed
session, which is every interactive session. `tail` counts **bytes**, and what comes back is
terminal data with ANSI sequences included.
diff --git a/docs/wiki/FAQ.md b/docs/wiki/FAQ.md
index bdbdf26b..45c7514b 100644
--- a/docs/wiki/FAQ.md
+++ b/docs/wiki/FAQ.md
@@ -21,6 +21,12 @@ No. Codeman drives agent CLIs you have already installed and logged in yourself.
subscription or key that CLI uses is what pays for the tokens. Codeman never collects,
stores, or refreshes your credentials.
+### Which agent CLIs does it support?
+
+Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness and
+OMP, plus a plain shell, chosen per session. Claude is the reference mode and a few features
+are Claude-only; [Agent CLIs](Agent-CLIs) has the table.
+
### Does Codeman send my code or prompts anywhere?
No. There is no telemetry, no analytics, and no phone-home. The only network traffic
diff --git a/docs/wiki/HTTP-API.md b/docs/wiki/HTTP-API.md
index 46bf7dc6..e31c0dc8 100644
--- a/docs/wiki/HTTP-API.md
+++ b/docs/wiki/HTTP-API.md
@@ -66,14 +66,14 @@ self-signed certificate, add `-k`.
## Endpoint map
-Roughly 200 handlers across 24 route modules. By domain:
+Roughly 235 handlers across 26 route modules. By domain:
| Domain | Handlers | Covers |
| ------------------- | -------- | --------------------------------------------------- |
-| System | 45 | Status, settings, search, digest, updates. |
-| Sessions | 34 | Create, input, terminal, wait, kill. |
-| Cases | 29 | Create, link, clone, remote and docker cases. |
-| Files | 16 | Preview, edit, raw, attachments, path picker. |
+| System | 56 | Status, settings, digest, updates, tunnel. |
+| Sessions | 34 | Create, input, terminal, wait, last response, kill. |
+| Cases | 34 | Create, link, clone, remote and docker cases. |
+| Files | 17 | Preview, edit, raw, attachments, path picker. |
| Orchestrator | 10 | Plans and phases. |
| Ralph | 9 | Loop control and configuration. |
| Cron | 9 | Jobs and run history. |
@@ -82,10 +82,12 @@ Roughly 200 handlers across 24 route modules. By domain:
| Respawn | 7 | Respawn configuration and presets. |
| Webviews | 6 | Saved dashboards, plus the proxy. |
| Mux | 5 | tmux operations. |
+| Custom model endpoints | 5 | Saved OpenAI-compatible endpoints, and applying one to a session. |
| Push | 4 | Web push subscriptions. |
| Read My Mind | 4 | Intent profiles and prediction. |
| Scheduled | 4 | The legacy scheduled-run concept. |
-| Approvals | 3 | The inbox and answering. |
+| Approvals | 4 | The inbox, answering, acknowledging. |
+| Tab layout | 2 | Named tab groups per owner. |
| Teams, me, search, hooks, clipboard, telemetry, voice, ws | 1-2 each | |
Each route module documents its own endpoints in its file header.
@@ -114,12 +116,13 @@ Three semantics that break callers who assume otherwise:
`wait-output` matches a **literal substring, never a regex.** That is deliberate: no regex
means no catastrophic backtracking on attacker-influenced output.
-Only `claude` sessions emit `stop` and `blocked`, because those come from Claude Code hooks.
-Shell and external CLI sessions accept `idle`, `working`, and `exit`.
+Only `claude` and `deepseek` sessions emit `stop` and `blocked`: Claude's come from Claude
+Code hooks, DeepSeek's from the harness reporting its state to Codeman. Shell and the other
+external CLI sessions accept `idle`, `working`, and `exit`.
## SSE
-`GET /api/events` is the live event stream. 156 event names, kept in sync between server and
+`GET /api/events` is the live event stream. 158 event names, kept in sync between server and
client with a test that fails on drift.
The heartbeat is a **named** `sse:heartbeat` event rather than an SSE comment, because
@@ -141,6 +144,12 @@ curl -s "$API/api/sessions" | jq '.data[].name' # live sessions
curl -s "$API/api/sessions/unified" | jq # live + historical, deduped
curl -s "$API/api/subagents" | jq # background agents
curl -s "$API/api/search?q=deploy" | jq # cross-session search
+
+# with ID set to a session id:
+curl -s "$API/api/sessions/$ID/last-response" | jq -r '.data.text' # last answer, from the transcript (claude, codex, deepseek)
+curl -s "$API/api/model-endpoints" | jq # saved custom OpenAI-compatible endpoints
+curl -s -X POST "$API/api/sessions/$ID/custom-model" -H 'Content-Type: application/json' \
+ -d '{"endpointId":"local-llama","modelId":"qwen3-27b"}' | jq # restart the CLI on that endpoint; {"clear":true} undoes it
```
## Limits
diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md
index 1153d61b..6f469007 100644
--- a/docs/wiki/Home.md
+++ b/docs/wiki/Home.md
@@ -5,8 +5,8 @@
Mission control for AI coding agents
Codeman runs your coding agents on your own machine and puts them behind one dashboard you
-can open from any device. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, or
-Pi inside persistent tmux sessions, streams the real terminal to the browser, and keeps
+can open from any device. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi,
+Grok, DeepSeek Harness, or OMP inside persistent tmux sessions, streams the real terminal to the browser, and keeps
working while you are away from the keyboard: it re-prompts idle agents, resumes when a
subscription limit resets, runs jobs on a schedule, and shows every background subagent
live.
@@ -33,7 +33,7 @@ codeman web # then open http://localhost:3000
**Already running it**
-- [Agent CLIs](Agent-CLIs) - the seven run modes, their setup, and which features are Claude-only.
+- [Agent CLIs](Agent-CLIs) - the ten run modes, their setup, and which features are Claude-only.
- [Mobile Guide](Mobile-Guide) - phone and tablet use, QR login, the touch keyboard bar.
- [Remote Access](Remote-Access) - Tailscale, Cloudflare tunnel, LAN plus password, QR login.
- [Keeping Agents Running](Keeping-Agents-Running) - idle detection, respawn cycling, auto-resume on usage limits.
@@ -122,7 +122,7 @@ codeman web # then open http://localhost:3000
| OS | macOS or Linux. Windows works through WSL2. |
| Node.js | 22 or newer. |
| tmux | Required. Sessions live in tmux, which is what makes them survive restarts. |
-| An agent CLI | At least one of Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi. Plain shell sessions need none. |
+| An agent CLI | At least one of Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness, OMP. Plain shell sessions need none. |
| Network | Binds to `127.0.0.1` by default. Reaching it from another device is a deliberate step: see [Remote Access](Remote-Access). |
Codeman is MIT licensed, self-hosted, and sends no telemetry. Everything runs on your
diff --git a/docs/wiki/Hooks-And-Integrations.md b/docs/wiki/Hooks-And-Integrations.md
index 08c1efa8..832e0be6 100644
--- a/docs/wiki/Hooks-And-Integrations.md
+++ b/docs/wiki/Hooks-And-Integrations.md
@@ -19,9 +19,11 @@ terminal into something that can notify you.
| `teammate_idle` | An agent-team member goes idle. | Team surfaces. |
| `task_completed` | A task finishes. | Task tracking, run summary. |
-This is why several Codeman features are Claude-only. The other CLIs have no hook system, so
-for them Codeman watches terminal output, which reveals that something happened but not what
-it was.
+This is why several Codeman features are Claude-only. The one partial exception is DeepSeek
+Harness, whose terminal front door reports idle, working and blocked to Codeman over the
+harness's own supervisor contract, so it gets the hook-driven surfaces without any hook
+file. The other CLIs have no equivalent, so for them Codeman watches terminal output, which
+reveals that something happened but not what it was.
### How hooks get installed
@@ -67,7 +69,7 @@ sit beside the agents with no code at all. See [Web Tabs](Web-Tabs).
### 2. SSE events
`GET /api/events` streams everything Codeman knows: session lifecycle, output, agent
-activity, approvals, cron runs. 155 named events, stable under semantic versioning.
+activity, approvals, cron runs. 158 named events, stable under semantic versioning.
This is the seam for anything that reacts. A bot that pings your chat channel when an agent
needs a human is a short script over this stream.
diff --git a/docs/wiki/Input-And-Voice.md b/docs/wiki/Input-And-Voice.md
index 2942f26a..a873a504 100644
--- a/docs/wiki/Input-And-Voice.md
+++ b/docs/wiki/Input-And-Voice.md
@@ -27,6 +27,15 @@ The result is the property you want on a phone: a connection that drops mid-prom
loses the prompt and never delivers it twice. Two browser tabs on the same session coexist,
and only a reconnect from the *same* tab supersedes the old connection.
+## Selecting and copying
+
+Agent CLIs hold the mouse: clicks and drags are reported into the transcript rather than
+selecting text. `Shift+drag` starts a selection anyway, right-click copies it (with nothing
+selected the native context menu is left alone), and `Ctrl+Shift+C` copies without ever
+interrupting. **Auto Copy Selection** in **App Settings → Terminal & Input**, off by
+default, copies the moment you release the mouse. On phones, long-press selects; see
+[Mobile Guide](Mobile-Guide).
+
## Zero-lag local echo
On touch devices, keystrokes are painted in the terminal immediately and sent when you press
@@ -55,6 +64,8 @@ reconcile against the real buffer and only apply while the cursor is on the comp
Chinese, Japanese, and Korean input needs an IME, and an IME needs a real text field.
Turning on CJK input in **App Settings → Terminal & Input** puts an always-visible textarea
below the terminal that owns composition, then delivers the composed text to the session.
+Ctrl- and Alt-modified navigation keys typed through it reach the CLI as the modified
+sequences, so word jumps and history keys keep working.
## Voice dictation
diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md
index 476a528c..b8968a4c 100644
--- a/docs/wiki/Installation.md
+++ b/docs/wiki/Installation.md
@@ -9,7 +9,7 @@ Getting Codeman onto a machine, verifying it works, updating it, and removing it
| **macOS or Linux** | Windows works through WSL2. See [Windows](#windows-wsl) below. |
| **Node.js 22+** | The installer offers to install it if missing. |
| **tmux** | Not optional. Sessions live inside tmux, which is what makes them survive a server restart, a dropped connection, or a closed laptop. |
-| **An agent CLI** | At least one of [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev). Plain shell sessions need none. See [Agent CLIs](Agent-CLIs). |
+| **An agent CLI** | At least one of [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [OMP](https://github.com/can1357/oh-my-pi). Plain shell sessions need none. See [Agent CLIs](Agent-CLIs). |
Codeman itself sends no telemetry and phones no home. The only network traffic is your
browser to your server, and whatever the agent CLI you chose does on its own.
@@ -20,13 +20,16 @@ browser to your server, and whatever the agent CLI you chose does on its own.
curl -fsSL https://getcodeman.com/install | bash
```
-This installs Node.js and tmux if they are missing, clones Codeman into `~/.codeman/app`,
-and builds it.
+This installs Node.js, tmux and a build toolchain if they are missing (node-pty ships no
+Linux prebuild, so it compiles from source), clones Codeman into `~/.codeman/app`, and
+builds it.
What it asks you:
1. **Permission for every system change.** Package installs and agent CLI downloads are
- prompted individually. Nothing is installed silently.
+ prompted individually. Nothing is installed silently. If no agent CLI is found, a menu
+ offers to install any of them (DeepSeek excepted: its npm package installs only a
+ launcher with no runnable profile), or you skip and install one yourself later.
2. **How the dashboard should be reachable.** Three choices:
- **Tailscale** (recommended for phone access): keeps the loopback bind and walks you
through `tailscale serve`, including the tailnet HTTPS toggle, then verifies the result
@@ -101,6 +104,21 @@ at server start, so markup changes need a restart.
See [Contributing](Contributing) for the rest of the development loop.
+## Route D: Docker Compose
+
+Codeman itself can run in a container and spawn Docker cases as sibling containers through
+the host's Docker socket. Copy `docker/.env.example` to `docker/.env`, set
+`CODEMAN_PASSWORD`, then:
+
+```bash
+bash docker/Start-Codeman.sh
+```
+
+Run the script again after updating rather than a plain `docker compose up`, so the rebuilt
+image, the refreshed volumes and the entrypoint arrive together. The full guide, including
+storage and networking options, is
+[`docker/README.md`](https://github.com/Ark0N/Codeman/blob/master/docker/README.md).
+
## Installing an agent CLI
Codeman drives CLIs, it does not bundle them. Install at least one:
@@ -113,6 +131,9 @@ Codeman drives CLIs, it does not bundle them. Install at least one:
| **Antigravity** | See [antigravity.google](https://antigravity.google) | Google's successor to the consumer Gemini CLI. |
| **Gemini CLI** | See [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli) | Enterprise only since Google's June 2026 consumer cutover. |
| **Pi** | See [pi.dev](https://pi.dev) | No permission prompts and no sandbox by design. Read [Agent CLIs](Agent-CLIs) before using it on a repo you care about. |
+| **Grok Build** | `curl -fsSL https://x.ai/cli/install.sh \| bash` | xAI. Lands in `~/.grok/bin`; `grok login --device-auth` for headless hosts. |
+| **DeepSeek Harness** | `npm i -g @deepseek-ai/dsh pnpm`, then a terminal profile | The npm package is only a launcher. Codeman's Run menu installs the community terminal profile for you. See [Agent CLIs](Agent-CLIs). |
+| **OMP** | `curl -fsSL https://omp.sh/install \| sh` | Oh My Pi. Run it once by hand to finish its own onboarding. |
Log each CLI in once, by hand, before pointing Codeman at it. Codeman never collects or
stores your CLI credentials.
@@ -161,6 +182,7 @@ Full detail, including logs and the self-updater, is in
| Installer | Re-run the one-liner, or **App Settings → System → Updates** in the UI. |
| npm | `npm update -g aicodeman` |
| git clone | `git pull && npm install && npm run build`, then restart. |
+| Docker Compose | Re-run `Start-Codeman.sh`. The in-app updater works too, and refuses a release that changes the container definition until you re-run the script. |
The in-app updater covers git-clone installs supervised by systemd or launchd. It restarts
the process that is running it, so the actual work happens in a detached script and the
diff --git a/docs/wiki/Keeping-Agents-Running.md b/docs/wiki/Keeping-Agents-Running.md
index 8ba04e8a..68886a5a 100644
--- a/docs/wiki/Keeping-Agents-Running.md
+++ b/docs/wiki/Keeping-Agents-Running.md
@@ -27,9 +27,12 @@ keystroke echo. Idle now lands a few seconds after a turn genuinely ends.
There are several layers stacked on that: a completion message from the CLI, an AI check,
output silence, and token stability.
-**For every other CLI**, there are no hooks to lean on, so detection is output
-stabilization: the session is idle when output stops changing. Coarser, and it is why the
-features further down this page are Claude-only.
+**For the other CLIs** it depends on what the CLI tells Codeman. Codex declares its own
+prompt glyph and working line, so it gets the same screen check Claude does (before 1.26.1
+every Codex session reported idle for its whole life). DeepSeek Harness reports idle,
+working and blocked to Codeman itself, which is as precise as hooks. Everything else is
+output stabilization: the session is idle when output stops changing. Coarser, and it is
+why the features further down this page are Claude-only.
## The Respawn Controller
@@ -101,13 +104,18 @@ subscription plan.
**Claude only.** A header chip showing live subscription usage, on by default on desktop and
off on phones.
-It works by installing a status line exporter into Claude Code, which posts Claude's own
-rate limit data back to Codeman. The exporter is marker-identified, so it only ever touches
-a status line Codeman installed, never one you wrote yourself, and it prints your footer
-through so the in-terminal status line still works.
+It works through a status line exporter that Codeman hands to `claude` as an ephemeral
+setting when it spawns the session, never written to disk, which posts Claude's own rate
+limit data back to Codeman. Your own status line (project-local, project, then
+`~/.claude/settings.json`) is wrapped and printed through, and a `claude` you run by hand
+outside Codeman sees nothing of it. Workspaces an older Codeman wrote the exporter into are
+cleaned up the first time a session starts there. Codex limits come from a read-only poll of
+its own app-server. Known limit: sessions inside a Docker case do not feed the chip yet.
The chip and the exporter are the same setting. Turning the chip on without the exporter
-would leave it showing a dash forever, so resolve it in one place: **App Settings**.
+would leave it showing a dash forever, so resolve it in one place: **App Settings**. A
+device writes the switch only when it flips the chip, so a phone (chip off by default)
+saving its font size cannot switch collection off for your desktop.
## Circuit breakers
diff --git a/docs/wiki/Keyboard-Shortcuts.md b/docs/wiki/Keyboard-Shortcuts.md
index e0c15b3c..bfa32d5d 100644
--- a/docs/wiki/Keyboard-Shortcuts.md
+++ b/docs/wiki/Keyboard-Shortcuts.md
@@ -30,6 +30,9 @@ Press `Ctrl+?` in the app for the same list in a floating overlay.
| `Ctrl+Shift+R` | Restore terminal size. |
| `Ctrl` `+` / `Ctrl` `-` | Font size. |
| `Shift+Wheel` | Scroll the local buffer, even where the wheel is forwarded to the CLI. |
+| `Shift+drag` | Start a selection in a pane whose mouse events go to the CLI. |
+| Right-click | Copy the selection. With nothing selected the native menu is left alone. |
+| `Ctrl+Z` | Swallowed in agent sessions so a running CLI cannot be suspended. Normal job control in a shell. |
## Everything else
diff --git a/docs/wiki/Mobile-Guide.md b/docs/wiki/Mobile-Guide.md
index 9489c55e..1751db5c 100644
--- a/docs/wiki/Mobile-Guide.md
+++ b/docs/wiki/Mobile-Guide.md
@@ -30,8 +30,12 @@ require a secure context.
| Toolbar | Bottom: Run, Stop, **Enter**, case picker, voice, settings. |
| Keyboard bar | Above the on-screen keyboard when it is open. |
-Layout respects notch and home-indicator safe areas, touch targets are 44px, and the case
-picker is a bottom sheet rather than a dropdown.
+The phone layout applies up to 599px of viewport width, so the Plus and Pro Max iPhones,
+the Pixel Pro and a folded Z Fold get it too; wider devices get the tablet layout. Layout
+respects notch and home-indicator safe areas, touch targets are 44px, and the case picker is
+a bottom sheet rather than a dropdown. On a folding phone (iPhone Duo) dialogs stay clear of
+the hinge, and opening or closing the device is treated as the device changing shape, never
+as the keyboard appearing.
**Swipe left and right** on the terminal to switch sessions.
@@ -58,7 +62,9 @@ A row of keys above the virtual keyboard, and what it contains depends on the se
**Agent sessions** get quick actions: `/init`, `/clear`, `/compact`, a clipboard key, `Esc`,
a path picker, an image key, and 🧠 when Read My Mind is on. Destructive commands need a
-double press, so you cannot fire `/clear` with a stray thumb.
+double press, so you cannot fire `/clear` with a stray thumb. On Codex sessions the bar also
+shows `⇧←` and `⇧→`, the Shift-modified arrows Codex binds to editing the last queued
+message and walking the prompt stack.
**Shell sessions** automatically swap it for terminal controls: `Ctrl`, `Esc`, `Tab`, four
arrows, paste, and dismiss. Your normal preference is remembered and restored when you
diff --git a/docs/wiki/Notifications-And-Approvals.md b/docs/wiki/Notifications-And-Approvals.md
index ed7de7f5..c096cd50 100644
--- a/docs/wiki/Notifications-And-Approvals.md
+++ b/docs/wiki/Notifications-And-Approvals.md
@@ -33,8 +33,8 @@ reloading the dashboard while a permission dialog is blocking a session does not
with a normal-looking tab.
For Claude sessions, these come from Claude Code's hooks and are precise about *why* the
-session stopped. For other CLIs there are no hooks, so you get the coarser output-based
-signal.
+session stopped; DeepSeek Harness sessions report the same states themselves. For the other
+CLIs there are no hooks, so you get the coarser output-based signal.
## Window title and OS notifications
@@ -62,7 +62,8 @@ Once subscribed, a blocking prompt reaches your phone even from a locked screen.
## The Approvals Inbox
-**Opt-in, off by default. Claude sessions only.**
+**Opt-in, off by default. Claude sessions, plus DeepSeek Harness sessions, whose terminal
+front door reports its prompts to Codeman.**
One queue of every prompt currently waiting on a human, across all your sessions, answerable
in place. When you have eight workers running, this is the difference between checking eight
@@ -136,7 +137,8 @@ from the lock screen.
- **No push over plain HTTP.** It is a browser requirement, not a Codeman one.
- **iOS needs the home screen install.** A Safari tab will never receive push.
- **The bell is invisible at zero.** That is deliberate, not a broken setting.
-- **Approvals are Claude-only.** They are built on hook events the other CLIs do not emit.
+- **Approvals need real signals.** They are built on hook events, which Claude emits and
+ DeepSeek Harness reports itself; the other CLIs do neither.
- **A stale menu answer is refused, not sent.** If you answer a card for a dialog that has
since gone away, Codeman declines rather than typing a digit into the composer.
diff --git a/docs/wiki/Quick-Start.md b/docs/wiki/Quick-Start.md
index 91e1fc04..2256b545 100644
--- a/docs/wiki/Quick-Start.md
+++ b/docs/wiki/Quick-Start.md
@@ -67,6 +67,9 @@ one:
| **Gemini** | Enterprise only since Google's consumer cutover. |
| **Antigravity** | Google's successor to the consumer Gemini CLI. |
| **Pi** | No permission prompts and no sandbox by design. |
+| **Grok Build** | xAI's CLI. |
+| **DeepSeek Harness** | Needs a terminal profile; the menu offers to install one. |
+| **OMP** | Oh My Pi, configured entirely through its own `~/.omp`. |
| **Terminal / Shell** | A plain shell, no agent. Also the **Run Shell** button. |
The dropdown also lists any saved dashboard URLs ([Web Tabs](Web-Tabs)) and your recent
diff --git a/docs/wiki/Remote-SSH-Sessions.md b/docs/wiki/Remote-SSH-Sessions.md
index 77b8f67a..d6e63b61 100644
--- a/docs/wiki/Remote-SSH-Sessions.md
+++ b/docs/wiki/Remote-SSH-Sessions.md
@@ -4,7 +4,7 @@ Point a case at another machine and the agent runs **there**, with the same dash
mobile UI, and autonomy features. Your laptop becomes a window onto a session living on the
remote host.
-Like Docker, this is a **location overlay** on a case, not a run mode. All seven run modes
+Like Docker, this is a **location overlay** on a case, not a run mode. All ten run modes
work remotely. See [Core Concepts](Core-Concepts).
## Why bother
@@ -52,7 +52,10 @@ A watcher with bounded backoff notices a dead SSH pane and quietly reattaches to
running remote session. On by default; the kill switch is in
**App Settings → Agents & CLIs → Remote auto-reconnect**.
-Intentional kills are never revived. Closing a session means closing it.
+Intentional kills are never revived. Closing a session means closing it. Neither is a clean
+exit inside the pane (Ctrl-D, `exit`, Ctrl-C at the CLI's prompt): that tears the remote
+tmux session down, and the watcher revives a session only when that durable session is
+verifiably still alive. Only a transport drop is reconnected.
## Discover and attach
@@ -70,6 +73,14 @@ Attaching to someone else's session and closing your tab must not end their run,
not. Several clients can attach the same remote session at different window sizes without
clamping each other, and discovery shows a shared badge with the client count.
+## Files
+
+Previews, downloads and text reads in a remote case go over the same ssh connection the
+session uses, so a clicked path opens the file on the machine the agent is on, `Range`
+seeking included. Nothing is copied to the Codeman host. Editing, Office previews,
+thumbnails, the file tree and the tail viewer are not available remotely and answer a clear
+400 rather than a misleading 404. Details in [Working With Files](Working-With-Files).
+
## Security
Every SSH command line in Codeman flows through one builder that shell-escapes every
diff --git a/docs/wiki/Running-As-A-Service.md b/docs/wiki/Running-As-A-Service.md
index 17d7c49f..616ac0ea 100644
--- a/docs/wiki/Running-As-A-Service.md
+++ b/docs/wiki/Running-As-A-Service.md
@@ -139,6 +139,7 @@ log stream --predicate 'process == "node"' # macOS, noisy
| Installer | Re-run the one-liner, or **App Settings → System → Updates**. |
| npm | `npm update -g aicodeman` |
| git clone | `git pull && npm install && npm run build`, then restart. |
+| Docker Compose | Re-run `Start-Codeman.sh`, or the in-app updater, which restarts the container in place. |
### The in-app updater
@@ -170,6 +171,18 @@ service without colliding with the main one. `CODEMAN_DATA_DIR` and `CODEMAN_TMU
exist for the rare case where they need to differ, but setting only one of them recreates
exactly the problem you were avoiding.
+## Running Codeman itself in Docker
+
+The Compose deployment in `docker/` runs the server in a container and spawns Docker cases
+as sibling containers through the mounted host socket. Start it with
+`bash docker/Start-Codeman.sh` rather than a bare `docker compose up`: the script pre-creates
+the bind-mounted directories with the right owner, honours a `docker-compose.override.yml`,
+and refreshes the build volumes when the checkout moved under them. The in-app updater
+applies code only and restarts by letting the container exit, so it refuses a release that
+changes the Dockerfile, the compose file, or adds a new `.env` key, until you re-run the
+script. Guide:
+[`docker/README.md`](https://github.com/Ark0N/Codeman/blob/master/docker/README.md).
+
## The tunnel as a service
```bash
diff --git a/docs/wiki/Security.md b/docs/wiki/Security.md
index 2e16511e..8dbaaa38 100644
--- a/docs/wiki/Security.md
+++ b/docs/wiki/Security.md
@@ -67,6 +67,7 @@ be wrong for at least one of them:
| **File Viewer** | Real path resolution before boundary checks, so symlinks cannot escape. Sensitive trees blocked. Edit mode adds an extension allowlist, a size cap, `.git` denial, and optimistic concurrency. It never creates files. |
| **Attachments** | An id-based registry, so browser requests never carry absolute paths. The magic-link scanner is prompt-injectable by nature and is therefore force-confined to the session's workspace. Extension allowlist, not a blocklist. |
| **Path picker** | Its own root allowlist rather than the workspace confinement. In multi-user mode a non-admin gets only their own user space, because per-user spaces live inside the home directory. |
+| **Remote cases** | Reads go over the session's own ssh connection and are resolved and contained on the remote host, with a bounded number of ssh children. Nothing is copied to the Codeman host; writes, Office previews and thumbnails are refused. |
Downloads block sensitive paths outright (`.env`, credentials files, `~/.ssh`, AWS
credentials), and SVG and HTML are served as downloads with `nosniff` so they cannot execute
diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md
index a23a44c6..3255f56f 100644
--- a/docs/wiki/Settings-Reference.md
+++ b/docs/wiki/Settings-Reference.md
@@ -46,6 +46,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
| Extended Keyboard Bar | Per device | Which accessory bar phones get. Shell sessions override it while they are active. |
| Wheel Scrolls Local History | Off | Keeps the wheel on the local buffer instead of forwarding it to the CLI. |
| Auto Copy Selection | Off | Copies highlighted terminal text to the clipboard the moment you finish selecting it. Ctrl+C still copies on demand. |
+| Normal / Bold font weight | xterm defaults | Per device, each slot from 100 to 900. The bundled JetBrains Mono renders every step, so a lighter normal weight makes Claude's bold headings stand out. Applies live to the terminal, both echo overlays and open team panes. |
| WebGL Renderer | On | With a GPU-stall watchdog that falls back to DOM rendering. |
| Gesture Control | Off | Camera hand tracking. Also needs `CODEMAN_GESTURE=1` on the server. |
@@ -72,7 +73,9 @@ every session or only the active tab.
| Entrance Animations | Per-surface animation styles for tabs, terminals, windows, and lineage lines. All default to the legacy no-animation behaviour. |
| Display Name | Your name in the UI. Cosmetic only; it never renames the package, CLI, API, or storage. |
| Interface Language | English or Simplified Chinese. Per device. |
-| Session List Layout | Header tab strip (default) or a collapsible left sidebar. See [The Dashboard](The-Dashboard#session-list-layout). |
+| Session List Layout | Header tab strip (default), a collapsible left sidebar, or the sidebar with detailed rows. See [The Dashboard](The-Dashboard#session-list-layout). |
+| Tab Orientation | Keeps the header list but turns the strip vertical beside the terminal, resizable, with detailed rows by default. Desktop and tablet only. |
+| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. |
| Tall Tabs | Taller tab strip. |
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
| Spawn Lineage Lines | Arcs from a parent tab to sessions it spawned. Desktop only, on by default. |
@@ -156,6 +159,9 @@ Some things are configured before the server starts, not in the UI:
| `CODEMAN_DOCKER_BRIDGE_HOOKS` | Lets in-container hooks reach the host on a loopback bind. |
| `CODEMAN_FILE_PICKER_ROOTS` | Extra roots for the path picker. |
| `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledges exposing the server with no password. |
+| `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). |
+| `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. |
+| `CODEMAN_MAX_REMOTE_FILE_SSH` | Concurrent ssh reads for files in remote cases. 4 by default. |
## Gotchas
diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md
index 5672b70a..a0e86e5c 100644
--- a/docs/wiki/The-Dashboard.md
+++ b/docs/wiki/The-Dashboard.md
@@ -22,12 +22,14 @@ page says so and names the setting.
The session list lives in the header as a horizontal strip by default. With a lot of
sessions open that strip stops being scannable, so **App Settings → Appearance → Tabs →
-Session List Layout** can move it into a vertical sidebar on the left instead.
+Session List Layout** can move it into a vertical sidebar on the left instead, and
+**Tab Orientation** can turn the strip itself into a vertical rail.
| Layout | Behaviour |
| -------------------- | --------------------------------------------------------------------------------- |
| **Header tab strip** | The default. Wraps to a second row on desktop, scrolls sideways on a phone. |
-| **Left sidebar** | A vertical list with a filter box and a live session count. `Alt+B` collapses it to a narrow rail that keeps the status dots and task badges visible. On a phone it is an off-canvas drawer rather than a docked rail. |
+| **Left sidebar** | A vertical list with a filter box and a live session count. `Alt+B` collapses it to a narrow rail that keeps the status dots and task badges visible. On a phone it is an off-canvas drawer rather than a docked rail. A detailed variant adds the home screen's per-session line (`created 3d ago · working 12m`) and a status pill. |
+| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. Desktop and tablet only. |
It is the same list either way, just re-hosted: tab order, drag-to-reorder, the `Alt+1`
to `Alt+9` numbers and every status colour below behave identically in both. The setting is
@@ -154,6 +156,10 @@ Worth knowing:
always local scrollback. Other CLIs scroll locally.
- **Selection copy.** `Ctrl+C` copies when text is selected and interrupts when it is not.
`Ctrl+Shift+C` always copies.
+- **Selecting where the CLI owns the mouse.** `Shift+drag` starts a selection even in a pane
+ whose mouse events are forwarded to the CLI, and right-click copies the selection (with
+ nothing selected the native menu is left alone). **Auto Copy Selection** in App Settings
+ copies the moment you release.
- **Zero-lag input.** On touch devices, keystrokes paint locally before the round trip. See
[Input And Voice](Input-And-Voice).
- **Renderer.** WebGL by default, with a watchdog that falls back to DOM rendering if the
@@ -167,8 +173,9 @@ which lists past sessions including Claude conversations started outside Codeman
Two extras depending on the device:
-- **Desktop, wide windows**: your open tabs appear as a rail docked to the left edge, in tab
- order, with created and last-active stamps. It needs at least 1180px of width; below that
+- **Desktop, wide windows**: your open tabs appear as a rail docked to the left edge, in
+ overview order (blocked on you first, then longest running, then most recently quiet),
+ with created and state-duration stamps. It needs at least 1180px of width; below that
it is hidden so it cannot overlap the search panel.
- **Phones**: tapping the "C" logo gives a session overview instead: NEEDS YOU first, then
current sessions, then past ones. On by default.
@@ -204,7 +211,9 @@ so it is fast and cannot be turned into a traversal.
## Appearance
**App Settings → Appearance** carries the theme skins, including light ones. The choice is
-applied before the first paint, so there is no flash of the wrong theme on load.
+applied before the first paint, so there is no flash of the wrong theme on load. Terminal
+font family and weight are per device too: a normal and a bold weight, each from 100 to
+900, and the bundled JetBrains Mono renders every step.
The same section has the entrance animations for tabs, terminals, agent windows, and
lineage lines. All of them default to the legacy no-animation behaviour, so an untouched
diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md
index 75b3c768..7eb279b3 100644
--- a/docs/wiki/Troubleshooting.md
+++ b/docs/wiki/Troubleshooting.md
@@ -138,6 +138,12 @@ That is the PTY-exit circuit breaker. Repeated rapid PTY exits trip it, and it b
automatic restarts so a broken configuration does not spin forever. Reset it explicitly from
the session's controls. Reattaching does not clear it, deliberately.
+### Typed prompts are silently ignored after restoring a tab
+
+Update. A browser whose input sequence counter fell behind the server's (a restored tab,
+cleared site data) used to have every prompt deduplicated away. Since 1.29.0 the duplicate
+acknowledgement carries the watermark and the client re-sends.
+
### Sessions I did not create appeared, or my session resized itself
Two Codeman servers are running against the same data directory and tmux socket. The second
@@ -167,6 +173,16 @@ Things to try:
Codex ignores the mouse reports that forwarding would send, so Codeman does not forward
there. Scrolling is local, and `Shift+Wheel` behaves the same way.
+### Selected text is invisible on a light skin
+
+Update. Every skin named its selection colour under a key xterm renamed in v5, so the four
+light skins painted white at 30% over near-white. Fixed in 1.29.0.
+
+### `Ctrl+Z` suspended my agent
+
+Update. Since 1.28.0 `Ctrl+Z` is swallowed in agent sessions, so a running CLI cannot be
+stopped by job control. Shell sessions keep it.
+
### `Ctrl+C` copies when I wanted to interrupt
With a selection, `Ctrl+C` copies. With no selection, it interrupts. Clear the selection
@@ -252,11 +268,25 @@ node scripts/build-agent-image.mjs --no-cache
A plain rebuild reuses the cached `npm install -g` layer and keeps the CLIs frozen at their
original versions while reporting success.
+### Every file in a remote case says "File not found"
+
+Update. Before 1.29.0 the file routes resolved every path on the Codeman host, so in a
+remote case every click failed while the file plainly existed on the other machine. Reads
+now go over ssh; see [Working With Files](Working-With-Files). Editing and Office previews
+stay unavailable remotely and say so with a 400.
+
+### Compose: the server crash-loops with `EACCES` on first start
+
+Start the stack with `bash docker/Start-Codeman.sh` rather than a plain `docker compose up`,
+and update: since 1.29.0 the entrypoint corrects a root-owned bind mount before dropping
+privileges. See [Running As A Service](Running-As-A-Service).
+
### A remote SSH session dropped and did not come back
A bounded-backoff watcher reattaches dropped sessions, and it is on by default. Intentional
-kills are never revived. Check the host is reachable and that the remote tmux server is
-still running.
+kills are never revived, and neither is a clean exit inside the pane (Ctrl-D, `exit`): only
+a transport drop is reconnected. Check the host is reachable and that the remote tmux server
+is still running.
## Gathering diagnostics
diff --git a/docs/wiki/Web-Tabs.md b/docs/wiki/Web-Tabs.md
index 76744dad..b22e5010 100644
--- a/docs/wiki/Web-Tabs.md
+++ b/docs/wiki/Web-Tabs.md
@@ -24,6 +24,23 @@ Switching tabs does not reload a dashboard. Frames stay alive in the background,
took a while to authenticate is still there when you come back. Past six live frames, the
least recently viewed is dropped to bound memory.
+## Single-page apps, reloads and links
+
+A history-routed dashboard (React Router, Vue Router, a Vite dev server) sees the path it
+would see on its own origin, not the proxy prefix, so it renders its real route instead of
+its own "page not found". A navigation the page starts itself afterwards, a dev server's
+full reload or a root-absolute `location.href`, would land outside the proxy with no
+capability; Codeman recognises it, answers with a small recovery page, and remounts the
+frame at the path that was lost, bounded to five recoveries a minute per frame. A reload on
+the dashboard's landing page is recovered the same way.
+
+A `localhost` or `127.0.0.1` link in agent output opens as a web tab automatically, reusing
+a saved dashboard for the same server or saving one under its `host:port`. On a phone that
+address only exists on the Codeman box, so the link would otherwise be a guaranteed
+connection error. LAN and tailnet addresses still open directly. `*.localhost` names are
+deliberately not auto-routed: they are DNS names rather than address literals, and the link
+came from agent output. Add such a dashboard by hand instead.
+
## Why dashboards are proxied
A plain cross-origin iframe fails three ways at once in the setup Codeman actually ships in:
@@ -89,6 +106,12 @@ The proxy authenticates on an in-memory capability embedded in the path, which i
exempt from the cookie and Origin checks that every API route enforces. That exemption is
fenced to safe methods and non-API paths, and there is a test pinning it in place.
+Saved URLs are refused when they point at a link-local or cloud-metadata address, at save
+time and again against the address the name resolves to at connect time; loopback and
+private ranges stay allowed, because a `localhost` Grafana is the feature. Capabilities are
+revoked on logout, and proxied responses carry a same-origin referrer policy so a dashboard
+cannot hand the capability-bearing URL to a third party.
+
Two failure modes that only appear inside a sandboxed frame, and that curl can never
reproduce, are handled: runtime-built root-absolute URLs escaping the injected base, and
same-host requests being CORS-checked with a null origin. Both present as the dashboard's own
diff --git a/docs/wiki/Working-With-Files.md b/docs/wiki/Working-With-Files.md
index b579c4cd..d9ebaf48 100644
--- a/docs/wiki/Working-With-Files.md
+++ b/docs/wiki/Working-With-Files.md
@@ -110,6 +110,22 @@ it is written. Outside the workspace they open in the preview instead: the tail
Nothing is registered until you click. Opening a file this way does not add an attachment card.
+## Remote (SSH) cases
+
+In a remote case the workspace lives on the other machine, and so do the files. Previews,
+downloads, text reads and the clicked-path route all go over the same ssh connection the
+session uses: one `realpath` plus `stat` probe for the file and the workspace root, then a
+streamed `cat` (or a slice of it, so video seeking works). Symlinks are resolved on the host
+that can resolve them, the size cap applies to the remote size before a byte is requested,
+and an unreachable host answers 502 rather than pretending the file is missing. Nothing is
+ever copied onto the Codeman host, and a same-named local file is never served under a
+remote name.
+
+Not available over ssh, and said so with a 400 instead of a misleading 404: editing in
+place, Office previews and generated thumbnails (both need the bytes on the server's disk),
+the file tree and path picker, and the tail viewer. Docker cases are unaffected, because
+their workspace is bind-mounted at the same path.
+
## The path picker
For choosing a path rather than typing one. It appears in two places: