Merge origin/master into #644: keep diff|patch beside #621's tsv and zip

Master's #621 added tsv and zip to both file-link extension groups in
constants.js while this branch added diff|patch to the same lines. The
resolution keeps both, in master's order with diff|patch after sql where the
PR put it; the rooted and relative groups stay identical, and every upload
document extension plus .diff/.patch still links in both patterns.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 08:12:35 +02:00
commit 14a9e66547
159 files changed
+10409 -591

No files matched your search

+1 -1
View File
@@ -914,7 +914,7 @@ export function registerAgentCommands(program: Command): Command {
)
.option(
'-u, --until <signals>',
'Comma list: stop,idle,exit,working,blocked (stop/blocked need hook signals for the session; where there are none the server answers 400, passed through)'
'Comma list: stop,idle,exit,working,blocked (stop needs hook signals for the session, blocked hooks or a dialog Codeman reads off the screen, as for codex; where there are none the server answers 400, passed through)'
)
.option(
'-m, --match <marker>',
+20
View File
@@ -368,6 +368,22 @@ const capabilitiesSchema = z
'awaitingLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
)
.optional(),
// Same guard again: run over the foot of a pane capture every time a session
// settles and on a timer while a dialog is up, and ~/.codeman/clis.json can set it.
blockedLine: z
.string()
.min(1)
.refine(
(src) => compileVersionRegex(src) !== null,
'blockedLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
)
.optional(),
// Wider than `watchingLines` because a dialog's options wrap at narrow widths (codex
// needs 20 at 36 columns). That is safe only for a pattern that ends on the dialog's
// own footer: the rows above it, which the agent may write, can then supply a
// marker but never the footer. Still bounded, since it is also how much text the
// pattern runs over.
blockedLines: z.number().int().min(1).max(24).optional(),
})
.strict()
// A window with nothing to search is a typo, not a configuration. Refused at LOAD
@@ -378,6 +394,10 @@ const capabilitiesSchema = z
(v) => v.watchingLines === undefined || v.watchingLine !== undefined,
'watchingLines has nothing to bound without a watchingLine'
)
.refine(
(v) => v.blockedLines === undefined || v.blockedLine !== undefined,
'blockedLines has nothing to bound without a blockedLine'
)
.optional(),
model: z
.object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() })
+47 -6
View File
@@ -677,10 +677,11 @@ const CODEX: CliEntry = {
// which the agent writes (and while a prompt is typed, the last two). Matching the complete row raises the bar — an assistant
// message has to end with this exact line, to the character — but nothing here makes
// forging it impossible, so do not read the Claude comment above as applying here.
// What contains it is that codex declares `hooks: 'none'`: no hook event from a codex
// session ever reaches `notePrompt()`, so there is no idle item to pre-acknowledge
// and a forged label costs a wrong badge and nothing else. A CLI that gains hook
// signals must not keep a pattern this soft.
// What contains it is that codex declares `hooks: 'none'`: no IDLE prompt from a codex
// session ever reaches `notePrompt()` (its screen-read dialogs below open PERMISSION
// items, which a watching label never acknowledges), so there is no idle item to
// pre-acknowledge and a forged label costs a wrong badge and nothing else. A CLI that
// gains idle signals must not keep a pattern this soft.
// ⚠️ Background TERMINALS are the only background work codex advertises on screen.
// A sub-agent started without waiting outlives the turn just as a terminal does —
// measured 2026-09-22, the sandboxed process was still running — and the pane shows
@@ -689,11 +690,45 @@ const CODEX: CliEntry = {
// composer. So a codex session waiting on a sub-agent reads as plainly idle here.
// Nothing is misfiled by that (codex raises no idle prompts), and there is no row to
// match until codex pins one.
// Codex is the one CLI Codeman launches with its approvals on by default, and it has no
// hook to say it stopped for one, so its dialogs are read off the screen. Measured on a
// live codex-cli 0.162.0 pane on 2026-10-14 (`-a on-request -s read-only`, widths 200,
// 80, 60, 44 and 36): every dialog it blocks on REPLACES the composer and its footer
// with a numbered option list, the selected one marked `›`, over a key-hint row:
// command approval (a network `› 1. Yes, proceed (y)` ...
// escalation too), edit approval
// ` Press enter to confirm or esc to cancel`
// question (request_user_input) ` › 1. Blue Write the color blue.` ...
// ` tab to add notes | enter to submit answer | esc to interrupt`
// (its notes field open) ` tab or esc to clear notes | enter to submit answer`
// workspace trust, at startup `› 1. Trust and continue`
// ` enter continue · esc quit`
// (the same prompt reached after the ` 2. Back to Agent Command Center`
// update prompt, 0.162.0, 2026-10-16) ` enter continue · esc back`
// The pattern needs both: a `› N.` option row, then the key-hint row, which must END
// the window. Below about 60 columns that hint wraps onto rows of its own
// (` Press enter to confirm or esc to` / ` cancel`, or `tab to add notes` /
// `enter to submit answer` / `esc to interrupt` one per row), so the hint may run on
// over rows that start with two spaces and a lowercase word or arrow, and must end on
// one of its own closing words. The transcript can quote every one of those strings
// (measured: an answer that printed them verbatim) and still not match, because while
// no dialog is up codex keeps its composer row (`› ...`, column 0, which ends the
// run) and its footer below anything the agent prints. The longest option list
// measured, a question at 36 columns, put the `›` row 16 non-blank rows up; 20 leaves
// room for longer option text, and a dialog whose marker scrolls out of the window is
// simply not seen (the behaviour before this field). Not matched on purpose:
// `Implement this plan?` after a plan turn, whose `enter select · esc back` footer is
// the same as `/model`, `/permissions` and `/review`, menus the user opened at the
// keyboard; the plan turn has ended there, and the session reads idle as before.
// ⚠️ The question dialog's hint contains `esc to interrupt`, the working line, so
// such a pane read as WORKING before this field: a blocked screen now wins over it.
workDetect: {
promptGlyph: '›',
workingLine: '[Ee]sc to interrupt',
watchingLine: String.raw`^\s{0,4}(\d+ background terminals?) running · /ps to view · /stop to close$`,
watchingLines: 4,
blockedLine: String.raw`(?:^|\n) {0,4}› \d\. [\s\S]*\n {2}(?:Press enter to confirm|tab (?:to add|or esc to clear) notes|enter continue)(?:[^\n]|\n {2}(?=[a-z←])){0,120}(?:cancel|interrupt|quit|back|answer|all)$`,
blockedLines: 20,
},
// The footer under the composer, measured on a live 0.147.0 pane:
// ` gpt-5.6-terra default · ~/codeman-cases/th-scratch` (model, reasoning effort,
@@ -1300,8 +1335,14 @@ const DEEPSEEK: CliEntry = {
launcherTargetParam: 'profile',
install: {
command: {
linux: 'npm install -g @deepseek-ai/dsh',
darwin: 'npm install -g @deepseek-ai/dsh',
// pnpm rides along: `dsh plugin` spawns a literal `pnpm` with no npm fallback, so the
// terminal profile this CLI needs before it can run cannot be installed without it (#352).
// ⚠️ Only when none is on PATH. `corepack enable` puts a `pnpm` shim in node's own bin
// directory, which is npm's global bin for nvm and Homebrew, and `npm install -g pnpm`
// refuses to overwrite it (EEXIST). Asked for in the same command as dsh, that failure
// took dsh down with it, on exactly the machines that already had a working pnpm.
linux: 'npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)',
darwin: 'npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)',
},
npmPackage: '@deepseek-ai/dsh',
docsUrl: 'https://github.com/deepseek-ai/deepseek-harness',
+26
View File
@@ -406,6 +406,32 @@ export interface CliCapabilities {
* tested. See `isAwaitingWorkers()` in `session-activity.ts`.
*/
awaitingLine?: string;
/**
* Source of a regex matching the dialog this CLI draws when it stops and waits for the
* user to answer something (an approval, a question, a trust prompt): Codeman reads
* such a pane as BLOCKED, with the same red "needs you" alert, push, webhook and
* `blocked` wait signal a Claude `permission_prompt` hook raises. It is the source of
* that state for a CLI with no hook of its own (`hooks: 'none'`); a CLI whose hooks
* report it never consults the screen for it.
*
* Tested against the last `blockedLines` non-blank rows of a pane capture, joined
* with `\n`, so the pattern can require several markers at once and anchor on the
* end of the window (no `m` flag: `$` is the last row). It must anchor on chrome
* only that CLI draws, at the FOOT of the screen, where a dialog replaces the
* composer: the text above is the agent's own, and it can print anything. A forged
* match may cost a false alert and nothing else, because Codeman never answers such a
* dialog. See `isBlockedScreen()` in `session-activity.ts`.
*
* A CLI that omits it is never blocked from the screen, which is what every CLI was
* before the field existed.
*/
blockedLine?: string;
/**
* How many non-blank rows at the foot of the screen `blockedLine` sees. A dialog's
* options wrap at narrow widths, which is what pushes its markers up the screen, so
* this is sized for the narrowest pane the CLI was measured at. Default 1.
*/
blockedLines?: number;
};
/**
* How many columns this CLI indents its transcript body by, so a copy taken from its
+953
View File
@@ -0,0 +1,953 @@
/**
* @fileoverview Git worktrees for a LOCAL case: list, create and remove the worktrees a
* repository-backed case keeps under `<repo>/.claude/worktrees/<name>`, so several agents working
* on one repository each get their own working tree instead of sharing the case folder (sessions
* in one case share its folder, and two agents editing, building and committing in one checkout
* step on each other). Routes: `src/web/routes/worktree-routes.ts`; a session starts in one
* through the existing create paths (`POST /api/quick-start` with `worktree`, or
* `POST /api/sessions` with the worktree as `workingDir`).
*
* WHERE. `<repo>/.claude/worktrees/<name>` on a new branch `worktree-<name>` is Claude Code's own
* `--worktree` layout (measured in Claude Code 2.1.296: path `<repo>/.claude/worktrees/<name>`,
* branch `worktree-<name>`, names of letters, digits, `.`, `_`, `-`, at most 64 characters), and
* Codeman already recognizes it (the worktree pill, `CLAUDE_WORKTREE_PATH` in session-routes.ts).
* Names here are a strict subset of Claude's (one segment, starting with a letter or digit), so a
* worktree made here is one `claude --worktree <name>` would also find. The main checkout must not
* show the worktrees as untracked, so `/.claude/worktrees/` is appended to the repository's LOCAL
* `info/exclude` (in the real common git dir, never a tracked `.gitignore`) when git does not
* already ignore it. `/.claude/settings.local.json` gets the same treatment: Codeman installs its
* hooks there for every claude session, and an untracked copy in a worktree makes
* `git worktree remove` refuse forever (Claude Code itself ignores that file through the global
* excludes file, which is not guaranteed to be set up on a given machine). The copy of Codeman's
* agent skill (`<skills dir>/codeman/`, injected while `agentSkillEnabled` is on) is excluded for
* the same reason, for every skills folder the registry names (`WorktreeOptions.agentSkillDirs`).
*
* Split like the other git modules: a PURE half (name and branch validation, `worktree list`
* parsing, exclude text, the managed-path rule) unit-tested without git, and an IO half that
* spawns git through an injectable runner.
*
* Rules the code keeps and the tests pin:
* - git is spawned with an argv array, never a shell, with `--` before every path operand, the
* non-interactive environment of `gitNonInteractiveEnv()`, a timeout that kills the whole
* process group, and capped output. Read-only calls add `--no-optional-locks`.
* - Names and branches are validated BEFORE they reach git (charset, length, then
* `git check-ref-format --branch`, whose answer must equal the input: `@{-1}` style shorthands
* are refused up front, since `--branch` would expand them).
* - Only `<repo>/.claude/worktrees/<valid-name>` is ever created or removed. Every component of
* that path is `lstat`ed (a symlinked `.claude` or `worktrees` directory, or a symlink at the
* worktree itself, is refused) and its real path must be exactly the expected one, so a
* repository cannot redirect a create or a remove outside itself. What is left is a same-user
* race (an agent swapping a directory between the check and git), which is accepted: that
* agent already runs code as this user.
* - Removal is `git worktree remove` WITHOUT `--force` (git refuses a tree with modified or
* untracked files, and that refusal is the answer), refused while a live session runs inside
* the worktree, and the optional branch delete is `git branch -d` (never `-D`, so an unmerged
* branch survives).
* - git never runs for a case at or inside a Docker case workspace (or whose common git dir is
* there): a container could plant a checkout filter or a hook, and `git worktree add` runs the
* repository's post-checkout hook and filters on the HOST. On a local case that is acceptable,
* since the user's agent runs code there anyway. A managed worktree that is, sits inside, or
* holds a Docker case workspace is never removed either: `git worktree remove` runs a status
* inside the tree, then deletes it under the container.
* - A repository that merely encloses the case and is the home folder or above (a dotfiles repo
* in `$HOME`) is not the case's repository, and a case folder below a repository's top level
* is refused: the worktrees would not be where the case is.
*
* @module git-worktrees
*/
import { spawn } from 'node:child_process';
import { promises as fs } from 'node:fs';
import { homedir } from 'node:os';
import { join, resolve, sep } from 'node:path';
import { gitNonInteractiveEnv, isSafeGitRef, sanitizeGitOutput } from './git-clone.js';
import { isInsideAny, isUnrelatedAncestor } from './git-workspace-status.js';
// ─── Limits ──────────────────────────────────────────────────────────────────
/** Claude Code's own limit for a `--worktree` name (2.1.296). */
export const MAX_WORKTREE_NAME_LENGTH = 64;
/** Longest accepted branch name. git allows more; 200 covers every real one. */
export const MAX_WORKTREE_BRANCH_LENGTH = 200;
/** A read-only git call (rev-parse, worktree list, check-ignore, check-ref-format). */
export const WORKTREE_READ_TIMEOUT_MS = 15_000;
/**
* `git worktree add` checks out a whole tree and runs the repository's post-checkout hook and
* smudge filters (Git LFS downloads objects there), so it gets the longest budget.
*/
export const WORKTREE_ADD_TIMEOUT_MS = 180_000;
/** `git worktree remove` runs a status of the tree first, then deletes it. */
export const WORKTREE_REMOVE_TIMEOUT_MS = 60_000;
/** Captured stdout. `worktree list` is one short block per worktree. */
const MAX_STDOUT_BYTES = 1_000_000;
/** Captured stderr tail. */
const MAX_STDERR_BYTES = 8_192;
/** Worktrees listed. A repository with more is unusual; the count says when it was cut. */
export const MAX_WORKTREES_LISTED = 200;
/** Path segments of the managed worktrees folder, relative to the repository root. */
export const WORKTREES_DIR_SEGMENTS = ['.claude', 'worktrees'] as const;
/** The `info/exclude` line that hides the managed worktrees from the main checkout. */
export const WORKTREES_EXCLUDE_LINE = '/.claude/worktrees/';
/** The `info/exclude` line for the hooks file Codeman writes into every claude workspace. */
export const SETTINGS_LOCAL_EXCLUDE_LINE = '/.claude/settings.local.json';
/** A workspace-relative skills folder (`.claude/skills`, `.agents/skills`): plain segments only. */
const SKILLS_DIR_RE = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/;
/**
* The `info/exclude` line for the copy of Codeman's agent skill in `skillsDir` of a workspace
* (`<skillsDir>/codeman/`, written into the folder a session runs in while `agentSkillEnabled`
* is on), or null for a folder that is not a plain relative path.
*/
export function agentSkillExcludeLine(skillsDir: string): string | null {
if (!SKILLS_DIR_RE.test(skillsDir) || skillsDir.split('/').some((s) => s === '.' || s === '..')) return null;
return `/${skillsDir}/codeman/`;
}
// ─── Pure: names ─────────────────────────────────────────────────────────────
const WORKTREE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
const BRANCH_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
/**
* Why `name` cannot be a worktree name, or null when it can. One path segment of letters, digits,
* `.`, `_` and `-`, starting with a letter or digit, at most 64 characters, never `..` (the
* session path schema refuses any path containing it), and not ending in `.` or `.lock` (git's
* own ref rules, since the default branch is derived from the name).
*/
export function worktreeNameError(name: unknown): string | null {
if (typeof name !== 'string' || name.length === 0) return 'A worktree name is required.';
if (name.length > MAX_WORKTREE_NAME_LENGTH)
return `A worktree name is at most ${MAX_WORKTREE_NAME_LENGTH} characters.`;
if (!WORKTREE_NAME_RE.test(name))
return 'A worktree name may contain only letters, digits, ".", "_" and "-", and must start with a letter or digit.';
if (name.includes('..')) return 'A worktree name may not contain "..".';
if (name.endsWith('.') || name.toLowerCase().endsWith('.lock'))
return 'A worktree name may not end with "." or ".lock".';
return null;
}
/** The branch a new worktree gets when the caller names none: Claude Code's `worktree-<name>`. */
export function defaultWorktreeBranch(name: string): string {
return `worktree-${name}`;
}
/**
* Why `branch` cannot be a new branch name, or null when it passes the charset gate. The final
* word is `git check-ref-format --branch` (see `createCaseWorktree`); this gate exists so nothing
* option-shaped, `@{...}`-shaped or otherwise surprising ever reaches git at all.
*/
export function worktreeBranchError(branch: unknown): string | null {
if (typeof branch !== 'string' || branch.length === 0) return 'A branch name is required.';
if (branch.length > MAX_WORKTREE_BRANCH_LENGTH)
return `A branch name is at most ${MAX_WORKTREE_BRANCH_LENGTH} characters.`;
if (!BRANCH_NAME_RE.test(branch))
return 'A branch name may contain only letters, digits, ".", "_", "-" and "/", and must start with a letter or digit.';
if (branch.includes('..') || branch.includes('//')) return 'A branch name may not contain ".." or "//".';
if (branch.endsWith('/') || branch.endsWith('.') || branch.toLowerCase().endsWith('.lock'))
return 'A branch name may not end with "/", "." or ".lock".';
if (branch.split('/').some((part) => part.startsWith('.'))) return 'No part of a branch name may start with ".".';
return null;
}
/** Why `base` cannot be a start point, or null. The same gate Clone Repo uses for a ref. */
export function worktreeBaseError(base: unknown): string | null {
if (typeof base !== 'string' || !isSafeGitRef(base))
return 'The base must be a branch, tag or commit (letters, digits, ".", "_", "-", "/" and "+").';
return null;
}
// ─── Pure: paths ─────────────────────────────────────────────────────────────
/** `<repo>/.claude/worktrees`. */
export function managedWorktreesDir(repoRoot: string): string {
return join(repoRoot, ...WORKTREES_DIR_SEGMENTS);
}
/** `<repo>/.claude/worktrees/<name>`. The caller validates `name` first. */
export function managedWorktreePath(repoRoot: string, name: string): string {
return join(managedWorktreesDir(repoRoot), name);
}
/**
* The managed worktree name of `path`, or null when it is not exactly
* `<repoRoot>/.claude/worktrees/<valid-name>`. Lexical: both sides must already be real paths.
*/
export function managedWorktreeName(repoRoot: string, path: string): string | null {
const dir = managedWorktreesDir(repoRoot) + sep;
const clean = path.endsWith(sep) && path.length > 1 ? path.slice(0, -1) : path;
if (!clean.startsWith(dir)) return null;
const name = clean.slice(dir.length);
if (!name || name.includes(sep)) return null;
return worktreeNameError(name) === null ? name : null;
}
/** True when `child` is `root` or inside it (lexical, on resolved paths). */
export function isPathWithin(child: string, root: string): boolean {
const c = resolve(child);
const r = resolve(root);
return c === r || c.startsWith(r === sep ? r : r + sep);
}
/**
* `path` as the repository spells it: a path under `alias` (the case folder as its case resolves
* it, which can run through a symlink) moved under `root` (the repository's real top level), else
* `path` unchanged. Lexical, so it costs no filesystem call per session. Sessions keep the
* `workingDir` they were created with, while the worktree paths git lists are real, so without
* this a session spelled through the case's link counted for no worktree and did not block its
* removal.
*/
export function respellUnder(path: string, alias: string, root: string): string {
const p = resolve(path);
const a = resolve(alias);
if (a === resolve(root) || !isPathWithin(p, a)) return p;
return join(root, p.slice(a.length));
}
// ─── Pure: `git worktree list --porcelain` ───────────────────────────────────
export interface ParsedWorktree {
path: string;
/** Commit checked out (absent for a bare entry). */
head: string | null;
/** Short branch name (`refs/heads/` stripped), null when detached or bare. */
branch: string | null;
detached: boolean;
bare: boolean;
locked: boolean;
lockReason?: string;
prunable: boolean;
prunableReason?: string;
}
/**
* Undo git's C-style path quoting (`"a\\"b\\303\\251"` → `a"bé`). Paths are printed quoted when
* they hold a `"`, a `\` or a control character even with `core.quotePath=false`; octal escapes are
* raw bytes, decoded as UTF-8. Text that is not quoted comes back unchanged.
*/
export function unquoteGitPath(text: string): string {
if (text.length < 2 || !text.startsWith('"') || !text.endsWith('"')) return text;
const body = text.slice(1, -1);
const bytes: number[] = [];
const simple: Record<string, number> = { a: 7, b: 8, f: 12, n: 10, r: 13, t: 9, v: 11, '"': 34, '\\': 92 };
for (let i = 0; i < body.length; i++) {
const ch = body[i];
if (ch !== '\\') {
for (const b of Buffer.from(ch, 'utf-8')) bytes.push(b);
continue;
}
const next = body[i + 1];
if (next !== undefined && /[0-7]/.test(next)) {
const oct = /^[0-7]{1,3}/.exec(body.slice(i + 1))![0];
bytes.push(parseInt(oct, 8) & 0xff);
i += oct.length;
} else if (next !== undefined && next in simple) {
bytes.push(simple[next]);
i += 1;
} else {
bytes.push(92);
}
}
return Buffer.from(bytes).toString('utf-8');
}
/**
* Parse `git worktree list --porcelain` (newline-terminated form: `-z` needs git 2.36, and Ubuntu
* 22.04 ships 2.34). Records are separated by blank lines; the first is the main worktree. Paths
* are read through `unquoteGitPath`, which together with `-c core.quotePath=false` keeps non-ASCII
* repository paths readable.
*/
export function parseWorktreeList(text: string): ParsedWorktree[] {
const out: ParsedWorktree[] = [];
let cur: ParsedWorktree | null = null;
const flush = () => {
if (cur) out.push(cur);
cur = null;
};
for (const raw of text.split('\n')) {
const line = raw.replace(/\r$/, '');
if (!line) {
flush();
continue;
}
const space = line.indexOf(' ');
const key = space === -1 ? line : line.slice(0, space);
const value = space === -1 ? '' : line.slice(space + 1);
if (key === 'worktree') {
flush();
cur = {
path: unquoteGitPath(value),
head: null,
branch: null,
detached: false,
bare: false,
locked: false,
prunable: false,
};
continue;
}
if (!cur) continue;
const entry: ParsedWorktree = cur;
if (key === 'HEAD') entry.head = value || null;
else if (key === 'branch') entry.branch = value.startsWith('refs/heads/') ? value.slice(11) : value || null;
else if (key === 'detached') entry.detached = true;
else if (key === 'bare') entry.bare = true;
else if (key === 'locked') {
entry.locked = true;
if (value) entry.lockReason = value;
} else if (key === 'prunable') {
entry.prunable = true;
if (value) entry.prunableReason = value;
}
}
flush();
return out;
}
// ─── Pure: exclude file ──────────────────────────────────────────────────────
/**
* What to append to an `info/exclude` holding `existing` so that every line of `lines` is in it:
* the text ('' when nothing is missing) and the lines it adds. A file that does not end in a
* newline gets one first, so an appended pattern never fuses with the user's last line.
*/
export function excludeAppendText(existing: string, lines: readonly string[]): { text: string; added: string[] } {
const present = new Set(existing.split(/\r?\n/).map((l) => l.trim()));
const added = lines.filter((l) => !present.has(l));
if (added.length === 0) return { text: '', added };
const lead = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
return {
text: `${lead}# Added by Codeman: git worktrees and the files Codeman writes into each workspace stay local\n${added.join('\n')}\n`,
added,
};
}
// ─── Pure: git's answers ─────────────────────────────────────────────────────
/**
* The part of git's stderr a user should see: its `fatal:`/`error:` lines (with any `hint:` lines
* dropped, they repeat what the UI says), sanitized and capped. Shown through `textContent` only.
*/
export function gitRefusalText(stderr: string): string {
const clean = sanitizeGitOutput(stderr, 2000);
const lines = clean
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !/^hint:/i.test(l));
const diagnostic = lines.filter((l) => /^(fatal|error|warning):/i.test(l));
const text = (diagnostic.length ? diagnostic : lines).join('\n');
return text.length > 1000 ? `${text.slice(0, 1000)}…` : text;
}
// ─── IO: git runner ──────────────────────────────────────────────────────────
export interface WorktreeGitResult {
code: number | null;
stdout: string;
stderr: string;
timedOut: boolean;
spawnError?: string;
}
/** Runs `git <args>` in `cwd`; never throws. Injected so tests can refuse to run git at all. */
export type WorktreeGitRunner = (
cwd: string,
args: string[],
opts?: { timeoutMs?: number }
) => Promise<WorktreeGitResult>;
/**
* Global options for a call that only reads: no optional index refresh, no filesystem monitor,
* no path quoting of non-ASCII names. Must precede the subcommand.
*/
const READ_ONLY_GIT_OPTIONS = ['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'core.quotePath=false'];
/**
* The production runner: argv only (no shell), the non-interactive environment, captured output
* caps, and a timeout that signals the whole process group (`detached: true`), because
* `git worktree add` fans out into checkout children, hooks and filters that a signal to the
* parent alone would leave running.
*/
export const runWorktreeGit: WorktreeGitRunner = (cwd, args, opts) =>
new Promise<WorktreeGitResult>((resolveRun) => {
let child: ReturnType<typeof spawn>;
try {
child = spawn('git', args, {
cwd,
env: { ...gitNonInteractiveEnv(), GIT_OPTIONAL_LOCKS: '0' },
stdio: ['ignore', 'pipe', 'pipe'],
detached: true,
});
} catch (err) {
resolveRun({ code: null, stdout: '', stderr: '', timedOut: false, spawnError: String(err) });
return;
}
let stdout = '';
let stdoutBytes = 0;
let stderr = '';
let timedOut = false;
let settled = false;
let killTimer: NodeJS.Timeout | undefined;
const killTree = (signal: NodeJS.Signals) => {
try {
if (child.pid) process.kill(-child.pid, signal);
} catch {
try {
child.kill(signal);
} catch {
/* already gone */
}
}
};
const timer = setTimeout(() => {
timedOut = true;
killTree('SIGTERM');
killTimer = setTimeout(() => killTree('SIGKILL'), 3_000);
}, opts?.timeoutMs ?? WORKTREE_READ_TIMEOUT_MS);
child.stdout?.on('data', (chunk: Buffer) => {
stdoutBytes += chunk.length;
if (stdoutBytes <= MAX_STDOUT_BYTES) stdout += chunk.toString('utf-8');
});
child.stderr?.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf-8');
if (stderr.length > MAX_STDERR_BYTES * 2) stderr = stderr.slice(-MAX_STDERR_BYTES);
});
const finish = (result: WorktreeGitResult) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (killTimer) clearTimeout(killTimer);
resolveRun(result);
};
child.on('error', (err) => finish({ code: null, stdout, stderr, timedOut, spawnError: String(err) }));
child.on('close', (code) => finish({ code, stdout, stderr, timedOut }));
});
// ─── IO: results ─────────────────────────────────────────────────────────────
/** Failure codes, named after the API error codes the routes map them to. */
export type WorktreeFailureCode = 'INVALID_INPUT' | 'NOT_FOUND' | 'ALREADY_EXISTS' | 'CONFLICT' | 'OPERATION_FAILED';
export interface WorktreeFailure {
ok: false;
code: WorktreeFailureCode;
message: string;
/** git's own words, when git refused (sanitized; render as text). */
gitMessage?: string;
}
const fail = (code: WorktreeFailureCode, message: string, gitMessage?: string): WorktreeFailure => ({
ok: false,
code,
message,
...(gitMessage ? { gitMessage } : {}),
});
function gitFailure(run: WorktreeGitResult, what: string): WorktreeFailure {
if (run.spawnError && /enoent/i.test(run.spawnError))
return fail('OPERATION_FAILED', 'git is not installed on this machine (or not on the server’s PATH).');
if (run.timedOut) return fail('OPERATION_FAILED', `git timed out while ${what}.`);
const text = gitRefusalText(run.stderr);
return fail('OPERATION_FAILED', text ? `git failed while ${what}: ${text}` : `git failed while ${what}.`, text);
}
// ─── IO: the case's repository ───────────────────────────────────────────────
export interface CaseRepository {
/** The repository's top level, which is the case folder (real path). */
repoRoot: string;
/** The real common git dir (`.git` of the main checkout), where `info/exclude` lives. */
commonDir: string;
}
export interface WorktreeOptions {
git?: WorktreeGitRunner;
/** Host paths of every Docker case workspace: git never runs at or inside one. */
dockerWorkspaces?: string[];
/** Overrides `os.homedir()` for the unrelated-ancestor rule (tests). */
home?: string;
/**
* Every workspace skills folder a session can get Codeman's agent skill in (the registry's
* `capabilities.agentSkillDir` values, passed in as data by the caller). A create excludes
* `<dir>/codeman/` for each, like the hooks file: a session in a worktree gets an untracked
* copy there, and `git worktree remove` would refuse that worktree forever.
*/
agentSkillDirs?: readonly string[];
}
const realOr = async (p: string): Promise<string> => {
try {
return await fs.realpath(p);
} catch {
return resolve(p);
}
};
const DOCKER_REFUSAL =
'Worktrees are not available for a folder inside a Docker case workspace: a container can write there, and git would run its hooks and filters on this machine.';
const DOCKER_WORKTREE_REFUSAL =
'This worktree is, or holds, a Docker case workspace: git would run inside a folder a container can write, and removing it would delete that workspace. Remove the Docker case first.';
/**
* The repository a case folder IS, or why it is not one Codeman makes worktrees for. Never runs
* git when the folder (or the repository's common git dir) is at or inside a Docker case workspace.
*/
export async function resolveCaseRepository(
casePath: string,
opts: WorktreeOptions = {}
): Promise<{ ok: true; repo: CaseRepository } | WorktreeFailure> {
const git = opts.git ?? runWorktreeGit;
const dockerRoots = await Promise.all((opts.dockerWorkspaces ?? []).filter(Boolean).map(realOr));
if (await isInsideAny(casePath, dockerRoots)) return fail('INVALID_INPUT', DOCKER_REFUSAL);
const run = await git(casePath, [...READ_ONLY_GIT_OPTIONS, 'rev-parse', '--show-toplevel', '--git-common-dir'], {
timeoutMs: WORKTREE_READ_TIMEOUT_MS,
});
if (run.code !== 0 || run.spawnError || run.timedOut) {
if (/must be run in a work tree/i.test(run.stderr)) {
return fail('INVALID_INPUT', 'This case folder is a bare git repository, which has no work tree to branch from.');
}
if (/not a git repository/i.test(run.stderr)) {
return fail('INVALID_INPUT', 'This case folder is not a git repository.');
}
return gitFailure(run, 'reading the repository');
}
const [top, common] = run.stdout.split('\n').map((l) => l.trim());
if (!top || !common) return fail('OPERATION_FAILED', 'git did not report the repository root.');
const [repoRoot, caseReal] = await Promise.all([realOr(top), realOr(casePath)]);
const commonDir = await realOr(resolve(casePath, common));
if (await isInsideAny(commonDir, dockerRoots)) return fail('INVALID_INPUT', DOCKER_REFUSAL);
if (repoRoot !== caseReal) {
if (await isUnrelatedAncestor(repoRoot, caseReal, opts.home ?? homedir())) {
return fail('INVALID_INPUT', 'This case folder is not a git repository.');
}
return fail(
'INVALID_INPUT',
`Worktrees need the case folder to be the top of a git repository; this folder is inside ${repoRoot}.`
);
}
return { ok: true, repo: { repoRoot, commonDir } };
}
// ─── IO: listing ─────────────────────────────────────────────────────────────
export interface CaseWorktree extends ParsedWorktree {
/** The repository's main checkout (git lists it first). */
main: boolean;
/** The case folder itself (the main checkout, or a linked worktree linked as the case). */
isCase: boolean;
/** Under `<repo>/.claude/worktrees/<valid-name>`: Codeman may start sessions in it and remove it. */
managed: boolean;
/** The managed name, when `managed`. */
name?: string;
}
export async function listCaseWorktrees(
repo: CaseRepository,
opts: WorktreeOptions = {}
): Promise<{ ok: true; worktrees: CaseWorktree[]; truncated: boolean } | WorktreeFailure> {
const git = opts.git ?? runWorktreeGit;
const run = await git(repo.repoRoot, [...READ_ONLY_GIT_OPTIONS, 'worktree', 'list', '--porcelain'], {
timeoutMs: WORKTREE_READ_TIMEOUT_MS,
});
if (run.code !== 0 || run.spawnError || run.timedOut) return gitFailure(run, 'listing worktrees');
const parsed = parseWorktreeList(run.stdout);
const truncated = parsed.length > MAX_WORKTREES_LISTED;
const worktrees = parsed.slice(0, MAX_WORKTREES_LISTED).map((wt, index): CaseWorktree => {
const name = index === 0 ? null : managedWorktreeName(repo.repoRoot, wt.path);
return {
...wt,
main: index === 0,
isCase: wt.path === repo.repoRoot,
managed: name !== null,
...(name ? { name } : {}),
};
});
return { ok: true, worktrees, truncated };
}
// ─── IO: path guards ─────────────────────────────────────────────────────────
type DirState = 'dir' | 'absent' | 'symlink' | 'other';
async function lstatState(path: string): Promise<DirState> {
try {
const st = await fs.lstat(path);
if (st.isSymbolicLink()) return 'symlink';
return st.isDirectory() ? 'dir' : 'other';
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') return 'absent';
throw err;
}
}
/**
* Make sure `<repo>/.claude` and `<repo>/.claude/worktrees` are real directories (created when
* absent, refused when a symlink or a file) whose real path is exactly the lexical one.
*/
async function prepareWorktreesDir(repoRoot: string): Promise<{ ok: true; dir: string } | WorktreeFailure> {
let cur = repoRoot;
for (const segment of WORKTREES_DIR_SEGMENTS) {
cur = join(cur, segment);
const state = await lstatState(cur);
if (state === 'symlink') {
return fail(
'INVALID_INPUT',
`${cur} is a symlink. Codeman will not create worktrees through a link that could point outside the repository.`
);
}
if (state === 'other') return fail('INVALID_INPUT', `${cur} exists and is not a directory.`);
if (state === 'absent') {
try {
await fs.mkdir(cur);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
return fail('OPERATION_FAILED', `Could not create ${cur}: ${(err as Error).message}`);
}
if ((await lstatState(cur)) !== 'dir') return fail('INVALID_INPUT', `${cur} is not a plain directory.`);
}
}
}
if ((await realOr(cur)) !== cur) {
return fail('INVALID_INPUT', `${cur} does not resolve to itself; refusing to create a worktree there.`);
}
return { ok: true, dir: cur };
}
/**
* The managed worktree `name` of `repo` as it is on disk now: every component a real directory
* (no symlinks), the real path the expected one, and git listing it as a worktree of this
* repository. Used by removal and by starting a session in it.
*
* `allowMissing` (removal only, never a session start) also accepts a worktree whose folder is
* GONE (deleted by hand, or by an agent) while git still lists it, as prunable, at exactly the
* managed path: no component on the way may be a symlink or a file, and `missing` says so.
* Without it such a worktree was a dead end: it could not be removed, and its name could not be
* used again (the branch exists, then git refuses "a missing but already registered worktree").
*/
export async function resolveManagedWorktree(
repo: CaseRepository,
name: string,
opts: WorktreeOptions & { allowMissing?: boolean } = {}
): Promise<{ ok: true; path: string; worktree: CaseWorktree; missing: boolean } | WorktreeFailure> {
const nameError = worktreeNameError(name);
if (nameError) return fail('INVALID_INPUT', nameError);
const target = managedWorktreePath(repo.repoRoot, name);
let cur = repo.repoRoot;
let missing = false;
for (const segment of [...WORKTREES_DIR_SEGMENTS, name]) {
cur = join(cur, segment);
const state = await lstatState(cur);
if (state === 'absent') {
if (!opts.allowMissing) return fail('NOT_FOUND', `No worktree named "${name}" in this case.`);
// Nothing below an absent folder exists, so nothing below it can redirect anything.
missing = true;
break;
}
if (state === 'symlink') {
return fail('INVALID_INPUT', `${cur} is a symlink. Codeman only manages worktrees that are real directories.`);
}
if (state === 'other') return fail('INVALID_INPUT', `${cur} is not a directory.`);
}
if (!missing && (await realOr(target)) !== target) {
return fail('INVALID_INPUT', `${target} does not resolve to itself; refusing to use it.`);
}
const listed = await listCaseWorktrees(repo, opts);
if (!listed.ok) return listed;
for (const wt of listed.worktrees) {
if (wt.main) continue;
if (missing) {
// Lexical only: the folder is gone, so there is no real path to compare. git never calls a
// LOCKED worktree prunable (prune skips it), so a locked one is taken too, and the caller's
// lock check then refuses it with git's lock reason.
if (wt.path === target && (wt.prunable || wt.locked)) {
return { ok: true, path: target, worktree: { ...wt, managed: true, name }, missing: true };
}
continue;
}
if (wt.path === target || (await realOr(wt.path)) === target) {
return { ok: true, path: target, worktree: { ...wt, managed: true, name }, missing: false };
}
}
if (missing) return fail('NOT_FOUND', `No worktree named "${name}" in this case.`);
return fail(
'NOT_FOUND',
`${target} is not a registered git worktree of this repository (a leftover folder, or one git no longer knows about).`
);
}
// ─── IO: excludes ────────────────────────────────────────────────────────────
/**
* Append each of `lines` that git does not already ignore to `<commonDir>/info/exclude`. Returns
* the lines written. A symlinked `info` dir or `exclude` file is left alone (reported, never
* written through).
*/
async function ensureExcluded(
repo: CaseRepository,
probes: Array<{ line: string; probe: string }>,
git: WorktreeGitRunner
): Promise<{ added: string[]; warning?: string }> {
const needed: string[] = [];
for (const { line, probe } of probes) {
const run = await git(repo.repoRoot, [...READ_ONLY_GIT_OPTIONS, 'check-ignore', '-q', '--', probe], {
timeoutMs: WORKTREE_READ_TIMEOUT_MS,
});
// 0: ignored. 1: not ignored. Anything else (128) is a git error: assume not ignored, since an
// extra exclude line is harmless and a missing one leaves the worktrees untracked.
if (run.code !== 0) needed.push(line);
}
if (needed.length === 0) return { added: [] };
const infoDir = join(repo.commonDir, 'info');
const excludePath = join(infoDir, 'exclude');
try {
const infoState = await lstatState(infoDir);
if (infoState === 'absent') await fs.mkdir(infoDir);
else if (infoState !== 'dir')
return { added: [], warning: `${infoDir} is not a plain directory; nothing was excluded.` };
const fileState = await lstatState(excludePath);
if (fileState === 'symlink' || fileState === 'dir') {
return { added: [], warning: `${excludePath} is not a plain file; nothing was excluded.` };
}
const existing = fileState === 'absent' ? '' : await fs.readFile(excludePath, 'utf-8');
const { text, added } = excludeAppendText(existing, needed);
if (text) await fs.appendFile(excludePath, text, { mode: 0o644 });
return { added };
} catch (err) {
return { added: [], warning: `Could not update ${excludePath}: ${(err as Error).message}` };
}
}
// ─── IO: one operation per repository at a time ──────────────────────────────
const repoLocks = new Map<string, Promise<unknown>>();
/** Serialize create/remove per repository: two creates must not both pass the "absent" check. */
async function withRepoLock<T>(repoRoot: string, fn: () => Promise<T>): Promise<T> {
const prev = repoLocks.get(repoRoot) ?? Promise.resolve();
const next = prev.then(fn, fn);
const tail = next.catch(() => undefined);
repoLocks.set(repoRoot, tail);
try {
return await next;
} finally {
if (repoLocks.get(repoRoot) === tail) repoLocks.delete(repoRoot);
}
}
// ─── IO: create ──────────────────────────────────────────────────────────────
export interface CreateWorktreeRequest {
name: string;
/** New branch; default `worktree-<name>`. */
branch?: string;
/** Start point; default `HEAD` (the case's current commit). */
base?: string;
}
export interface CreatedWorktree {
name: string;
path: string;
branch: string;
base: string;
/** The commit the new branch starts at. */
head: string;
/** Lines appended to the repository's `info/exclude` (empty when git already ignored them). */
excludeAdded: string[];
warnings: string[];
}
export async function createCaseWorktree(
repo: CaseRepository,
req: CreateWorktreeRequest,
opts: WorktreeOptions = {}
): Promise<{ ok: true; worktree: CreatedWorktree } | WorktreeFailure> {
const git = opts.git ?? runWorktreeGit;
const nameError = worktreeNameError(req.name);
if (nameError) return fail('INVALID_INPUT', nameError);
const branch = req.branch || defaultWorktreeBranch(req.name);
const branchError = worktreeBranchError(branch);
if (branchError) return fail('INVALID_INPUT', branchError);
const base = req.base || 'HEAD';
const baseError = worktreeBaseError(base);
if (baseError) return fail('INVALID_INPUT', baseError);
return withRepoLock(repo.repoRoot, async () => {
// A name already taken says so in those words, before the branch check would report the
// derived branch instead. Rechecked after the folders are prepared, below.
try {
if ((await lstatState(managedWorktreePath(repo.repoRoot, req.name))) !== 'absent') {
return fail('ALREADY_EXISTS', `A worktree named "${req.name}" already exists in this case.`);
}
} catch {
/* unreadable: the check after the folders are prepared reports it */
}
// git's own word on the branch name. The output must be the input: `--branch` expands
// shorthands, and anything it rewrote is not the name the user typed.
const refFormat = await git(repo.repoRoot, [...READ_ONLY_GIT_OPTIONS, 'check-ref-format', '--branch', branch], {
timeoutMs: WORKTREE_READ_TIMEOUT_MS,
});
if (refFormat.code !== 0 || refFormat.stdout.trim() !== branch) {
return fail('INVALID_INPUT', `"${branch}" is not a valid branch name.`, gitRefusalText(refFormat.stderr));
}
const existingBranch = await git(
repo.repoRoot,
[...READ_ONLY_GIT_OPTIONS, 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`],
{ timeoutMs: WORKTREE_READ_TIMEOUT_MS }
);
if (existingBranch.code === 0) {
return fail('ALREADY_EXISTS', `A branch named "${branch}" already exists. Pick another worktree name or branch.`);
}
const resolved = await git(
repo.repoRoot,
// No `--end-of-options` (git 2.30+): the base already passed the ref gate, which refuses a
// leading `-`, so it cannot be read as an option.
[...READ_ONLY_GIT_OPTIONS, 'rev-parse', '--verify', '--quiet', `${base}^{commit}`],
{ timeoutMs: WORKTREE_READ_TIMEOUT_MS }
);
const head = resolved.stdout.trim();
if (resolved.code !== 0 || !/^[0-9a-f]{40,64}$/.test(head)) {
return fail('INVALID_INPUT', `"${base}" is not a commit in this repository (an empty repository has none yet).`);
}
const prepared = await prepareWorktreesDir(repo.repoRoot);
if (!prepared.ok) return prepared;
const target = join(prepared.dir, req.name);
try {
if ((await lstatState(target)) !== 'absent') {
return fail('ALREADY_EXISTS', `${target} already exists. Pick another worktree name.`);
}
} catch (err) {
return fail('OPERATION_FAILED', `Could not inspect ${target}: ${(err as Error).message}`);
}
const skillProbes: Array<{ line: string; probe: string }> = [];
for (const dir of new Set(opts.agentSkillDirs ?? [])) {
const line = agentSkillExcludeLine(dir);
if (line) skillProbes.push({ line, probe: `${dir}/codeman` });
}
const excluded = await ensureExcluded(
repo,
[
{ line: WORKTREES_EXCLUDE_LINE, probe: `${WORKTREES_DIR_SEGMENTS.join('/')}/${req.name}` },
{ line: SETTINGS_LOCAL_EXCLUDE_LINE, probe: '.claude/settings.local.json' },
...skillProbes,
],
git
);
// The resolved commit, not the ref text, after `--`: nothing here can be read as an option,
// and `--no-track` keeps a worktree started from `origin/main` from tracking (and pulling) main.
const add = await git(repo.repoRoot, ['worktree', 'add', '--no-track', '-b', branch, '--', target, head], {
timeoutMs: WORKTREE_ADD_TIMEOUT_MS,
});
if (add.code !== 0 || add.spawnError || add.timedOut) {
const text = gitRefusalText(add.stderr);
if (/already exists/i.test(text)) return fail('ALREADY_EXISTS', `git refused: ${text}`, text);
return gitFailure(add, 'creating the worktree');
}
return {
ok: true,
worktree: {
name: req.name,
path: target,
branch,
base,
head,
excludeAdded: excluded.added,
warnings: excluded.warning ? [excluded.warning] : [],
},
};
});
}
// ─── IO: remove ──────────────────────────────────────────────────────────────
export interface RemoveWorktreeRequest {
name: string;
/** Also `git branch -d` the worktree's branch (never `-D`). */
deleteBranch?: boolean;
/** True when a live session runs at or inside `path`: the removal is refused. */
isInUse: (path: string) => boolean | Promise<boolean>;
}
export interface RemovedWorktree {
name: string;
path: string;
branch: string | null;
branchDeleted: boolean;
/** git's refusal to delete the branch (typically "not fully merged"); the worktree is gone regardless. */
branchError?: string;
}
export async function removeCaseWorktree(
repo: CaseRepository,
req: RemoveWorktreeRequest,
opts: WorktreeOptions = {}
): Promise<{ ok: true; removed: RemovedWorktree } | WorktreeFailure> {
const git = opts.git ?? runWorktreeGit;
return withRepoLock(repo.repoRoot, async () => {
// A worktree whose folder is gone is removable too: `git worktree remove` then only drops
// git's record of it (measured on git 2.43: exit 0, nothing on disk touched).
const found = await resolveManagedWorktree(repo, req.name, { ...opts, allowMissing: true });
if (!found.ok) return found;
// The case passed the Docker rule, but the worktree folder itself can be a Docker case's
// workspace (a Docker case linked to it), or hold one. `git worktree remove` runs a status
// INSIDE the tree first (container-written files, checkout filters on this machine), then
// deletes the whole tree, ignored files included, under a container that still mounts it.
// A folder that is gone has neither problem: git only drops its record.
if (!found.missing) {
const dockerRoots = await Promise.all((opts.dockerWorkspaces ?? []).filter(Boolean).map(realOr));
if (dockerRoots.some((root) => isPathWithin(found.path, root) || isPathWithin(root, found.path))) {
return fail('INVALID_INPUT', DOCKER_WORKTREE_REFUSAL);
}
}
if (await req.isInUse(found.path)) {
return fail(
'CONFLICT',
`A live session is still running in worktree "${req.name}". Close it first, then remove the worktree.`
);
}
if (found.worktree.locked) {
return fail(
'CONFLICT',
`Worktree "${req.name}" is locked${found.worktree.lockReason ? ` (${found.worktree.lockReason})` : ''}. Unlock it with git first.`
);
}
const removed = await git(repo.repoRoot, ['worktree', 'remove', '--', found.path], {
timeoutMs: WORKTREE_REMOVE_TIMEOUT_MS,
});
if (removed.code !== 0 || removed.spawnError || removed.timedOut) {
if (removed.timedOut || removed.spawnError) return gitFailure(removed, 'removing the worktree');
const text = gitRefusalText(removed.stderr);
// git's own refusal (modified or untracked files, a lock): Codeman never forces, so this is
// the answer, in git's words.
return fail(
'CONFLICT',
text ? `git refused to remove the worktree: ${text}` : 'git refused to remove the worktree.',
text
);
}
const branch = found.worktree.branch;
let branchDeleted = false;
let branchError: string | undefined;
if (req.deleteBranch && branch) {
const del = await git(repo.repoRoot, ['branch', '-d', '--', branch], { timeoutMs: WORKTREE_READ_TIMEOUT_MS });
if (del.code === 0) branchDeleted = true;
else branchError = gitRefusalText(del.stderr) || 'git refused to delete the branch.';
}
return {
ok: true,
removed: {
name: req.name,
path: found.path,
branch,
branchDeleted,
...(branchError ? { branchError } : {}),
},
};
});
}
+2 -1
View File
@@ -270,7 +270,8 @@ export function buildToolDefinitions(modes: readonly string[]): ToolDefinition[]
'prompt, idle, working, exit) or prints a literal marker (match). Returns timed_out when timeout_ms passes: ' +
'call again to keep waiting. Your own prompt is echoed into the output, so a marker written whole in the ' +
'prompt matches at once: ask for it in two halves ("print DONE followed by _7f3a") and match the joined ' +
'form (DONE_7f3a), one word without spaces. stop and blocked exist only for claude and deepseek sessions.',
'form (DONE_7f3a), one word without spaces. stop exists only for claude and deepseek sessions; blocked ' +
'also for codex, whose dialogs Codeman reads off its screen.',
inputSchema: {
type: 'object',
properties: {
+23
View File
@@ -167,6 +167,18 @@ function overwrite<K extends keyof UnifiedSessionItem>(
if (value !== undefined) item[key] = value;
}
/** `<repo>/.claude/worktrees/<name>`: Claude Code's `--worktree` layout, which Codeman's own worktrees use too. */
const WORKTREE_PATH = /^(.+)\/\.claude\/worktrees\/([^/]+)\/?$/;
/**
* The worktree a working directory is, by its layout alone: `{ repo, name }` for
* `<repo>/.claude/worktrees/<name>` (the folder itself, not a subfolder of it), else null.
*/
export function worktreeFromPath(workingDir: string): { repo: string; name: string } | null {
const m = WORKTREE_PATH.exec(workingDir);
return m ? { repo: m[1], name: m[2] } : null;
}
/**
* Merge all source views into one list, applying precedence
* (history → lifecycle → persisted → live) and the meaningfulness floor.
@@ -326,6 +338,17 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
if (byDir) item.lastPrompt = byDir.prompt;
}
}
// A live or persisted session started in a worktree Codeman created (or Claude Code's own
// `--worktree`) has no transcript record naming it until claude writes one, and a codex or
// shell session never gets one, so its row read like any other session of the repo. The
// folder layout says which worktree it is; a transcript record, when there is one, still wins.
if (!item.worktreeName && item.workingDir) {
const wt = worktreeFromPath(item.workingDir);
if (wt) {
item.worktreeName = wt.name;
item.worktreeRepo ??= wt.repo;
}
}
}
// Meaningfulness floor: keep real rows, drop bare lifecycle/mux-only noise.
+71
View File
@@ -159,6 +159,77 @@ export function watchingLabel(
return null;
}
/**
* How many non-blank rows at the foot of a pane capture `isBlockedScreen()` reads for a
* CLI that declares no number of its own (`capabilities.workDetect.blockedLines`).
*/
export const BLOCKED_TAIL_LINES = 1;
/** Hard cap on that window, whatever a registry entry asks for (the schema's own max). */
export const BLOCKED_MAX_TAIL_LINES = 24;
/**
* How often a session that is showing a blocking dialog re-reads its screen to learn the
* dialog went away. The raise needs no timer, because a dialog is drawn at the END of a
* burst of output and the idle confirmation reads the screen once the pane goes quiet. The
* clear does: answering resumes the turn, and a running turn keeps the pane busy, so the
* settle path would not look again until the whole turn ended, and the red alert would
* outlive the dialog by minutes. One `capture-pane` per interval, and only while a dialog
* is up.
*/
export const BLOCKED_RECHECK_MS = 2000;
/**
* The longest a session showing a blocking dialog goes without re-reading a QUIET pane.
* A dialog leaves the screen only through a repaint, and every repaint reaches the
* session as PTY output, so while nothing has been drawn since the last reading that saw
* the dialog it is still up, and the BLOCKED_RECHECK_MS read is skipped. That read is a
* synchronous `tmux capture-pane`, about 8 ms of blocked event loop (measured 2026-10-14),
* and a dialog can sit unanswered overnight: one per 2 s per waiting session for hours.
* This bound is only the safety net under that reasoning, never the clear's latency.
*/
export const BLOCKED_QUIET_RECHECK_MS = 30_000;
/**
* Consecutive readable captures without the dialog before the blocked state clears. One
* read can land mid-repaint (a resize redraws the whole frame), and a clear followed by a
* re-raise would send the alert, the push and the webhook a second time for the same
* dialog. Two reads at BLOCKED_RECHECK_MS clear 2 to 4 s after the answer.
*/
export const BLOCKED_CLEAR_READS = 2;
/**
* Whether the foot of a pane shows a dialog this CLI blocks on (an approval, a question,
* a trust prompt), read with the CLI's `capabilities.workDetect.blockedLine`.
*
* The last `tailLines` non-blank rows are joined with `\n` and tested once, so the pattern
* can demand several markers and anchor its footer on the end of the window. Blank rows
* are dropped first, like `watchingLabel()`, because a TUI pads its dialog with them.
*
* ⚠️ The text is pane-derived and the agent writes most of the pane, so a pattern must
* end on chrome only the CLI draws (a dialog's key-hint footer replaces the composer and
* its footer, which nothing the agent prints can sit below). Even so, a match is only
* ever an ALERT: nothing keys a keystroke off it.
*
* @param tailLines how many non-blank rows from the bottom the pattern sees, defaulting to
* `BLOCKED_TAIL_LINES` and capped at `BLOCKED_MAX_TAIL_LINES`
*/
export function isBlockedScreen(
paneText: string | null | undefined,
pattern: RegExp,
tailLines: number = BLOCKED_TAIL_LINES
): boolean {
if (!paneText) return false;
const rows = stripAnsi(paneText)
.split('\n')
.map((row) => row.trimEnd())
.filter((row) => row !== '');
const window = rows.slice(-Math.max(1, Math.min(tailLines, BLOCKED_MAX_TAIL_LINES))).join('\n');
// Same reasoning as watchingLabel(): a caller's `g` flag must not make this flap.
pattern.lastIndex = 0;
return pattern.test(window);
}
/**
* How many rows above the composer the turn's closing row may sit. Between the two Claude
* draws only its composer border and, sometimes, a right-aligned hint
+1 -1
View File
@@ -4,7 +4,7 @@
*
* Claude Code 2.1.277 (auto-installed 2026-09-18) takes typed text the moment its
* composer paints but ignores Enter for the first 30 to 50 seconds after it, so the
* `send-keys -l <text>` + `send-keys Enter` pair `TmuxManager.sendInput()` sends 50 ms
* text + `send-keys Enter` pair `TmuxManager.sendInput()` sends 50 ms
* apart leaves the prompt sitting on the composer with `0 tokens`, and every caller
* that then waits for the turn (send-and-wait, the agent skill, the maintainer bot,
* cron, Ralph) burns its whole timeout on a turn that never started. Measured through
+204 -8
View File
@@ -89,7 +89,12 @@ import {
isPaneQuiet,
watchingLabel,
isAwaitingWorkers,
isBlockedScreen,
WATCHING_TAIL_LINES,
BLOCKED_TAIL_LINES,
BLOCKED_RECHECK_MS,
BLOCKED_QUIET_RECHECK_MS,
BLOCKED_CLEAR_READS,
IDLE_RECHECK_MS,
PANE_PROBE_MIN_INTERVAL_MS,
PANE_PROBE_RECHECK_MS,
@@ -563,6 +568,7 @@ export class Session extends EventEmitter {
private _activityStreak: ActivityStreak | null = null; // Unbroken run of PTY repaints (working detection)
private _lastPaneProbeAt = 0; // Throttle for the tmux screen probe
private _lastPaneProbeWorking: boolean | null = null; // Its last verdict (null = could not read)
private _lastPaneProbeTrustDialog = false; // Same capture showed the workspace-trust dialog (see _confirmIdle)
/**
* Background work the pane's own footer reports, e.g. `1 monitor`; null for none.
*
@@ -596,6 +602,23 @@ export class Session extends EventEmitter {
private _watchingWindow = WATCHING_TAIL_LINES;
/** Lazily compiled `capabilities.workDetect.awaitingLine`. See _awaitingLinePattern(). */
private _awaitingLineRe: RegExp | null | undefined = undefined;
/** Lazily compiled `capabilities.workDetect.blockedLine`. See _blockedLinePattern(). */
private _blockedLineRe: RegExp | null | undefined = undefined;
/** Resolved with the pattern above: how many rows at the foot of the screen it sees. */
private _blockedWindow = BLOCKED_TAIL_LINES;
/**
* The pane is showing a dialog the CLI blocks on, read off the screen with
* `capabilities.workDetect.blockedLine` (codex: an approval, a question, the trust
* prompt). Live state, not persisted: a restarted server re-reads it from the attach
* repaint the same way the watching label comes back. See `_noteBlockedReading()`.
*/
private _screenBlocked = false;
/** Consecutive readable captures without the dialog (cleared at BLOCKED_CLEAR_READS). */
private _blockedGoneReads = 0;
/** When the last reading that SAW the dialog was taken. See `_armBlockedRecheck()`. */
private _blockedSeenAt = 0;
/** Re-reads the screen while a dialog is up, so the alert clears with it. */
private _blockedRecheckTimer: NodeJS.Timeout | null = null;
/**
* The newest model the running CLI reported for itself (its statusline, or its own
* footer read off the probe's capture), or null when none has. Feeds `displayModel`
@@ -1429,6 +1452,16 @@ export class Session extends EventEmitter {
return this._watching;
}
/**
* True while the pane shows a dialog its CLI is blocked on, as read off the screen
* (`capabilities.workDetect.blockedLine`). Always false for a CLI that declares none,
* Claude included: its dialogs arrive as hook events instead. The `blocked` wait reads
* it so a caller that asks after the dialog appeared is answered at once.
*/
get screenBlocked(): boolean {
return this._screenBlocked;
}
/**
* Prompt-cache state of the main conversation as Claude Code last reported it on the
* statusline (v2.1.251+), fed by `POST /api/status-telemetry`. Null until the first
@@ -2711,6 +2744,7 @@ export class Session extends EventEmitter {
this._interactiveStartedAt = Date.now();
this._trustDialogAttempts = 0;
this._lastTrustDialogScanAt = 0;
this._lastPaneProbeTrustDialog = false; // the last pane's verdict, not this one's
if (this._trustDialogTimer) {
clearTimeout(this._trustDialogTimer);
this._trustDialogTimer = null;
@@ -3081,6 +3115,10 @@ export class Session extends EventEmitter {
this._expensiveProcessTimer = null;
}
this._pendingCleanData = '';
// Nothing reads this pane any more, so a dialog on it can no longer be seen to
// close: drop the state (and its timer) while the listeners are still attached.
// A re-attach re-reads the screen and raises it again if the dialog is still up.
this._clearScreenBlocked();
// If using mux, mark the session as detached but don't kill it
if (this._muxSession && this._mux) {
this._mux.setAttached(this.id, false);
@@ -3143,13 +3181,24 @@ export class Session extends EventEmitter {
* a two-marker match (isTrustDialogScreen), and an attempt cap.
*/
private _maybeAcceptTrustDialog(): void {
if (this._trustDialogAccepted) return;
// Stopped: the kill itself produces PTY output, and the buffer tail still holds the
// dialog, so without this a deleted session kept "answering" it for several seconds.
if (this._trustDialogAccepted || this._isStopped) return;
const now = Date.now();
if (now - this._interactiveStartedAt > TRUST_DIALOG_WINDOW_MS) {
this._trustDialogAccepted = true; // window closed; anything matching now is not the dialog
return;
}
if (now - this._lastTrustDialogScanAt < TRUST_DIALOG_RETRY_MS) return;
const sinceScan = now - this._lastTrustDialogScanAt;
if (sinceScan < TRUST_DIALOG_RETRY_MS) {
// ⚠️ Defer a throttled read, never drop it. The dialog's own paint is usually the
// last output a launching pane produces, and it tends to land inside the throttle
// of the read the blank screen before it triggered: dropped, nothing re-read the
// screen and the session sat on the dialog for good (2 of 12 fresh launches on
// Claude Code 2.1.294). A read already scheduled covers this one.
if (!this._trustDialogTimer) this._rescanTrustDialogIn(TRUST_DIALOG_RETRY_MS - sinceScan + 100);
return;
}
this._lastTrustDialogScanAt = now;
// Prefer the pane; fall back to the buffer tail on a direct-PTY session,
@@ -3184,14 +3233,17 @@ export class Session extends EventEmitter {
// on a live 2.1.252 spawn: cursor moved at 6 s, then nothing). The timer is
// one-shot and self-rearming through this same path, and every exit route
// goes through _clearAllTimers().
// The +100ms puts the re-entry OUTSIDE the scan throttle above; firing at
// exactly the throttle boundary would let the scan return early and break
// the chain with the dialog still on screen.
// The +100ms puts the re-entry OUTSIDE the scan throttle above.
this._rescanTrustDialogIn(TRUST_DIALOG_RETRY_MS + 100);
}
/** Re-run the trust-dialog scan after `delayMs`, replacing any read already scheduled. */
private _rescanTrustDialogIn(delayMs: number): void {
if (this._trustDialogTimer) clearTimeout(this._trustDialogTimer);
this._trustDialogTimer = setTimeout(() => {
this._trustDialogTimer = null;
this._maybeAcceptTrustDialog();
}, TRUST_DIALOG_RETRY_MS + 100);
}, delayMs);
}
/**
@@ -3265,10 +3317,31 @@ export class Session extends EventEmitter {
if (now - this._lastPaneProbeAt < PANE_PROBE_MIN_INTERVAL_MS) return this._lastPaneProbeWorking;
this._lastPaneProbeAt = now;
const text = this._mux.capturePaneText?.(this._muxSession.muxName) ?? null;
// A dialog the CLI is blocked on is not work, whatever else is on screen: codex's
// question dialog carries `esc to interrupt` in its own key hints, which is codex's
// working line, so without this a session waiting on the user's answer read as busy.
// Decided from THIS capture, never from `_screenBlocked`, which lags a vanished dialog
// by design (see `_noteBlockedReading`) and would otherwise call a resumed turn idle.
const blockedOnScreen = text === null ? null : this._paneShowsBlocked(text);
this._noteBlockedReading(blockedOnScreen, text);
// A turn that ended by handing off to workers the CLI waits for is work too: the
// composer is up and the pane is quiet, but the next turn starts without the user.
this._lastPaneProbeWorking =
text === null ? null : this._workingLinePattern().test(text) || this._paneAwaitsWorkers(text);
text === null
? null
: !blockedOnScreen && (this._workingLinePattern().test(text) || this._paneAwaitsWorkers(text));
// A capture that failed proves nothing either way, so it keeps the last answer.
if (text !== null) {
const trustDialog = isTrustDialogScreen(text);
if (
trustDialog &&
!this._lastPaneProbeTrustDialog &&
now - this._interactiveStartedAt <= TRUST_DIALOG_WINDOW_MS
) {
console.log(`[Session] Holding idle while the workspace trust dialog is on screen: ${this.id}`);
}
this._lastPaneProbeTrustDialog = trustDialog;
}
this._readWatching(text);
this._readScreenModel(text);
return this._lastPaneProbeWorking;
@@ -3462,6 +3535,111 @@ export class Session extends EventEmitter {
return isAwaitingWorkers(paneText, this._awaitingLineRe, glyph);
}
/**
* The regex matching the dialog this CLI blocks on, or null for a CLI whose registry
* entry declares none (every CLI but codex today, Claude included: its dialogs arrive
* as hook events). Compiled once per session through `compileVersionRegex()`, the same
* guard the schema applies, so a pattern it refuses yields null rather than a throw.
*/
private _blockedLinePattern(): RegExp | null {
if (this._blockedLineRe === undefined) {
const detect = getCli(this.mode)?.capabilities.workDetect;
this._blockedLineRe = detect?.blockedLine ? compileVersionRegex(detect.blockedLine) : null;
this._blockedWindow = detect?.blockedLines ?? BLOCKED_TAIL_LINES;
}
return this._blockedLineRe;
}
/** Whether this capture shows a dialog the CLI is blocked on. See `isBlockedScreen()`. */
private _paneShowsBlocked(paneText: string): boolean {
const pattern = this._blockedLinePattern();
return pattern ? isBlockedScreen(paneText, pattern, this._blockedWindow) : false;
}
/**
* Fold one screen reading into the blocked state, announcing each transition with
* `blockedChanged` (the web server turns it into the alert and the `blocked` signal).
*
* The two directions are deliberately unequal:
* - RAISE on one reading. A match needs the dialog's own footer at the foot of the
* screen, which is only there when the dialog is fully drawn.
* - CLEAR on BLOCKED_CLEAR_READS readings in a row. A single capture can land inside a
* full-frame repaint, and clearing on it would re-raise a moment later and send the
* alert, push and webhook twice for one dialog.
* A capture that could not be read (`null`) is no evidence either way and changes
* nothing, so a tmux hiccup can neither raise nor drop an alert. That is the opposite
* of `_readWatching`, and for the same reason: each degrades toward the alert.
*
* While blocked, `_blockedRecheckTimer` keeps reading, since answering resumes the turn
* and a busy pane never reaches the settle path that raised it.
*
* @param frame the capture the reading came from; rides the raise as the alert's context
*/
private _noteBlockedReading(onScreen: boolean | null, frame: string | null): void {
if (onScreen === null || !this._blockedLinePattern()) return;
if (onScreen) {
this._blockedGoneReads = 0;
// Stamped after the capture returned: output processed later is newer than it.
this._blockedSeenAt = Date.now();
if (!this._screenBlocked) {
this._screenBlocked = true;
this.emit('blockedChanged', true, frame);
}
this._armBlockedRecheck();
return;
}
if (!this._screenBlocked) return;
if (++this._blockedGoneReads < BLOCKED_CLEAR_READS) {
this._armBlockedRecheck();
return;
}
this._clearScreenBlocked();
}
/** (Re)arm the while-blocked screen read. One-shot and self-rearming, like the trust scan. */
private _armBlockedRecheck(): void {
if (this._blockedRecheckTimer) clearTimeout(this._blockedRecheckTimer);
this._blockedRecheckTimer = setTimeout(() => {
this._blockedRecheckTimer = null;
// No pane left to read (detached): nothing can change until a re-attach, whose
// `_resetBuffers()` clears the state and whose repaint reads the screen afresh.
if (this._isStopped || !this._screenBlocked || !this._mux?.capturePaneText || !this._muxSession) return;
// A dialog leaves the screen only through a repaint, and tmux sends every repaint
// to this client as PTY output (`_markActivity`). So while the last reading saw
// the dialog and nothing has been drawn since, it is still up: skip the capture
// (synchronous, see BLOCKED_QUIET_RECHECK_MS) and look again next interval. Never
// once a reading has missed it: the confirming read must happen on a quiet pane
// too, or the clear would wait for output that may never come.
if (
this._blockedGoneReads === 0 &&
this._lastActivityAt < this._blockedSeenAt &&
Date.now() - this._blockedSeenAt < BLOCKED_QUIET_RECHECK_MS
) {
this._armBlockedRecheck();
return;
}
const text = this._mux.capturePaneText(this._muxSession.muxName) ?? null;
// Unreadable this time: keep the state, keep looking (_noteBlockedReading ignores a null).
if (text === null) {
this._armBlockedRecheck();
return;
}
this._noteBlockedReading(this._paneShowsBlocked(text), text);
}, BLOCKED_RECHECK_MS);
}
/** Drop the blocked state (and its timer), announcing it when it was set. */
private _clearScreenBlocked(): void {
this._blockedGoneReads = 0;
if (this._blockedRecheckTimer) {
clearTimeout(this._blockedRecheckTimer);
this._blockedRecheckTimer = null;
}
if (!this._screenBlocked) return;
this._screenBlocked = false;
this.emit('blockedChanged', false, null);
}
/**
* The regex matching this CLI's "a turn is running" status line.
*
@@ -3523,6 +3701,16 @@ export class Session extends EventEmitter {
this.activityTimeout = setTimeout(() => this._confirmIdle(), PANE_PROBE_RECHECK_MS);
return;
}
// ⚠️ The workspace-trust dialog is not a composer, although its `❯` (on "No, exit")
// arms this check and the dialog sits quiet. Calling it idle let an agent that waited
// for idle send its prompt into the dialog, and the prompt's Enter answered *exit*.
// Not working either, so the status stays where it is until the dialog is gone.
// Bounded by the auto-accept's own startup window: later on, a screen that merely
// quotes the dialog (an agent reading session-trust-dialog.ts) must not hold idle off.
if (this._lastPaneProbeTrustDialog && Date.now() - this._interactiveStartedAt <= TRUST_DIALOG_WINDOW_MS) {
this.activityTimeout = setTimeout(() => this._confirmIdle(), PANE_PROBE_RECHECK_MS);
return;
}
this._awaitingIdleConfirmation = false;
this.activityTimeout = null;
// Emit idle if either:
@@ -4004,6 +4192,9 @@ export class Session extends EventEmitter {
// into a program that never enabled tracking.
this._cliMouseModes.clear();
this._syncCliMouseTracking();
// Nor does it start inside a dialog. Announced, so a pending alert for the old
// pane's dialog is resolved rather than left behind it.
this._clearScreenBlocked();
this._markActivity(true);
}
@@ -4016,6 +4207,11 @@ export class Session extends EventEmitter {
clearTimeout(this._trustDialogTimer);
this._trustDialogTimer = null;
}
// Clear the while-blocked screen read (the state itself is left to its owner)
if (this._blockedRecheckTimer) {
clearTimeout(this._blockedRecheckTimer);
this._blockedRecheckTimer = null;
}
// Clear activity timeout to prevent memory leak
if (this.activityTimeout) {
@@ -4527,7 +4723,7 @@ export class Session extends EventEmitter {
*
* More reliable than direct PTY write for programmatic input, especially
* with Claude CLI which uses Ink (React for terminals).
* Uses tmux `send-keys -l` to inject text + Enter.
* Uses tmux to inject the text (one bracketed paste) + Enter.
*
* @param data - Input data with optional `\r` for Enter
* @returns true if input was sent, false if no mux session or PTY
+128 -23
View File
@@ -7,13 +7,13 @@
* - **Persistence**: Sessions survive server restarts and disconnects
* - **Ghost recovery**: Orphaned sessions are discovered and reattached on startup
* - **Resource tracking**: Memory, CPU, and child process stats per session
* - **Reliable input**: `send-keys -l` sends literal text in a single command
* - **Reliable input**: a submitted prompt goes in as one bracketed paste, then a separate Enter
* - **Teammate support**: Immutable pane IDs enable targeting individual teammates
*
* tmux sessions are named `codeman-{sessionId}` and stored in ~/.codeman/mux-sessions.json.
*
* Key features:
* - `send-keys 'text' Enter` sends literal text in a single command
* - `paste-buffer -p` + a separate `send-keys Enter` submits a prompt (see sendInput)
* - `list-sessions -F` provides structured queries
* - `display-message -p '#{pane_pid}'` for reliable PID discovery
* - Single server architecture
@@ -436,6 +436,84 @@ export function resolveTmuxPaneTarget(muxName: string, paneTarget?: string): str
return `${muxName}.${paneTarget}`;
}
let submitBufferSeq = 0;
/** The tmux commands of one bracketed paste, see buildPasteTextCommands(). */
export interface PasteTextCommands {
/** Fills a buffer of its own with the text. */
set: string;
/** Pastes that buffer into the target, bracketed, and deletes it (`-d`). */
paste: string;
/** Deletes the buffer when `paste` failed; `-d` only runs on a successful paste. */
discard: string;
}
/**
* Escape a final `;` for tmux. tmux splits commands on an argument that ENDS in
* `;` (`set-buffer -- 'a;'` stores `a`, `send-keys -l 'a;'` types `a`, a lone `;`
* is no argument at all); a final `\;` is its escape for a literal `;`. A `;`
* anywhere else in the argument is literal already. Exported for unit testing.
*/
export function escapeTmuxTrailingSemicolon(text: string): string {
return text.endsWith(';') ? `${text.slice(0, -1)}\\;` : text;
}
/**
* Whether a text+Enter submit types `text` as keys instead of pasting it: true for
* a single character. Claude Code's and Codex's selection dialogs ignore a bracketed
* paste, so a pasted `3` + Enter confirms the highlighted option instead of option 3;
* a typed `3` selects it. One character cannot form the burst Codex reads as a paste,
* so typing it is safe there. Exported for unit testing.
*/
export function submitsAsKeys(text: string): boolean {
return [...text].length === 1;
}
/**
* tmux commands that type `text` into `target` as ONE bracketed paste, ahead of a
* separately sent `Enter` that submits it. Exported for unit testing.
*
* Why a paste and not `send-keys -l`: Codex detects a paste that arrives WITHOUT
* bracketed-paste markers heuristically, from characters arriving in a fast burst,
* and folds an Enter that lands inside that burst window into the paste as a
* newline. `send-keys -l` types exactly such an unmarked burst, so a text+Enter
* pair sat in Codex's composer unsubmitted: always for long text (Codex shows it as
* `[Pasted Content N chars]`, which SubmitVerifier cannot match against the prompt,
* so nothing re-presses Enter), intermittently for short text at the 50 ms gap.
* `paste-buffer -p` wraps the text in the markers (only when the pane asked for
* them, so an app without bracketed paste sees the same bytes as before) and the
* Enter after it is unambiguous.
*
* Each call gets its own named buffer so concurrent sends cannot paste each other's
* text. tmux keeps a named buffer until something deletes it (`buffer-limit` trims
* only automatic ones), hence `discard` for a paste that fails.
*
* A single character is typed instead (submitsAsKeys), so a dialog answer selects.
* A final `;` is escaped (escapeTmuxTrailingSemicolon).
*/
export function buildPasteTextCommands(tmux: string, target: string, text: string): PasteTextCommands {
const buffer = `codeman-submit-${process.pid}-${++submitBufferSeq}`;
return {
set: `${tmux} set-buffer -b ${buffer} -- ${shellescape(escapeTmuxTrailingSemicolon(text))}`,
paste: `${tmux} paste-buffer -p -d -b ${buffer} -t ${shellescape(target)}`,
discard: `${tmux} delete-buffer -b ${buffer}`,
};
}
/**
* Run a bracketed paste. A failed paste (the pane or the server is gone) deletes
* the buffer before the paste's own error propagates; a failing delete is ignored.
*/
export async function pasteText(run: (cmd: string) => Promise<unknown>, cmds: PasteTextCommands): Promise<void> {
await run(cmds.set);
try {
await run(cmds.paste);
} catch (err) {
await run(cmds.discard).catch(() => undefined);
throw err;
}
}
/**
* Pick the active pane id from `tmux list-panes -F '#{pane_id}:#{pane_active}'`
* output (lines like `%0:1`). Returns the pane id whose active flag is 1.
@@ -3738,10 +3816,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
/**
* Send input directly to a tmux session using `send-keys`.
* Send input directly to a tmux session.
*
* Uses tmux send-keys for reliable input delivery:
* - `-l` flag sends literal text (no key interpretation)
* - Text followed by `\r` goes in as ONE bracketed paste (buildPasteTextCommands),
* so Codex cannot fold the Enter into a paste burst; a single character is typed
* with `send-keys -l` instead, so a dialog answer selects (submitsAsKeys)
* - Text without `\r` is typed literally with `send-keys -l` (no key interpretation)
* - `Enter` key is sent as a SEPARATE tmux invocation after a small delay
* - Ink (Claude CLI) needs text and Enter split to avoid treating Enter as a newline
*/
@@ -3776,19 +3856,32 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Send text first, then Enter as a SEPARATE tmux command after a short delay.
// Ink (Claude CLI's terminal framework) needs them split — sending both in a
// single tmux invocation (via \;) causes Ink to interpret Enter as a newline
// character in the input buffer rather than as form submission.
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
timeout: EXEC_TIMEOUT_MS,
});
// character in the input buffer rather than as form submission. The text goes
// in as a bracketed paste so Codex cannot fold the Enter into it either
// (see buildPasteTextCommands). A single character is typed, because a
// selection dialog ignores a pasted `3` and Enter then confirms the
// highlighted option (see submitsAsKeys).
if (submitsAsKeys(textPart)) {
await execAsync(
`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
{ timeout: EXEC_TIMEOUT_MS }
);
} else {
await pasteText(
(cmd) => execAsync(cmd, { timeout: EXEC_TIMEOUT_MS }),
buildPasteTextCommands(this.tmux(), session.muxName, textPart)
);
}
await new Promise((resolve) => setTimeout(resolve, 50));
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
timeout: EXEC_TIMEOUT_MS,
});
} else if (textPart) {
// Text only, no Enter
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
timeout: EXEC_TIMEOUT_MS,
});
await execAsync(
`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
{ timeout: EXEC_TIMEOUT_MS }
);
} else if (hasCarriageReturn) {
// Enter only
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
@@ -3930,19 +4023,25 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
if (textPart && hasCarriageReturn) {
execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
execSync(
`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
{
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}
);
execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
} else if (textPart) {
execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
execSync(
`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
{
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}
);
} else if (hasCarriageReturn) {
execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
encoding: 'utf-8',
@@ -4076,7 +4175,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Without geometry there is no cursor move, so fall back to the old trim.
// Keeping the blank rows here would park the caret at the bottom of the
// pane with nothing to correct it — worse than not trying at all.
if (!geometry) return normalizeScrollbackEol(rawCapture.replace(/\n+$/g, ''));
if (!geometry) {
const trimmed = rawCapture.replace(/\n+$/g, '');
return trimmed ? `${normalizeScrollbackEol(trimmed)}\x1b[0m` : '';
}
// Take the line terminator off and nothing else. The trailing blank rows
// that remain are the real bottom of the screen, and the cursor move
// below counts up from it. tmux joins rows with a bare `\n`; normalize to
@@ -4090,7 +4192,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// `_replayWouldShrinkBuffer` exists to refuse, arriving from the server
// side where that guard cannot see it.
if (!hasVisibleContent(trimmed)) return '';
return `${normalizeScrollbackEol(trimmed)}${formatCursorRestore(geometry)}`;
// capture-pane -e can leave the last row's SGR active (e.g. a nested
// tmux status bar). Hand live output the same default style as a visible
// snapshot, resetting before the cursor move so its position is kept.
return `${normalizeScrollbackEol(trimmed)}\x1b[0m${formatCursorRestore(geometry)}`;
}
const buffer = rawCapture.replace(/\n+$/g, '');
@@ -4099,7 +4204,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// positioned snapshot repaint and fall back to the raw capture. Normalize
// its bare `\n` line endings to `\r\n` so the replay doesn't staircase
// diagonally in a fresh xterm (COD-138, same reason as the fullHistory path).
return normalizeScrollbackEol(buffer);
return buffer ? `${normalizeScrollbackEol(buffer)}\x1b[0m` : '';
} catch (err) {
// ENOBUFS carries the truncated multi-MB stdout on the error object —
// log a concise line instead of dumping it into the journal.
+2 -1
View File
@@ -84,7 +84,8 @@ export const DEEPSEEK_VERSION_REGEX = /(?:^|\s)v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z][0
*/
export const DEEPSEEK_IDENTITY_REGEX = /DeepSeek\s+Harness/i;
const DEEPSEEK_NOT_FOUND = 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh';
const DEEPSEEK_NOT_FOUND =
'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)';
/** Where profiles live: `$DSH_HOME/profiles`, defaulting to `~/.dsh/profiles`. */
export function resolveDshHome(): string {
+13 -2
View File
@@ -15,7 +15,9 @@
* `Session` import, no IO; the server injects emit callbacks (`onPending`/
* `onUpdated`/`onResolved`), which keeps this unit-testable and cycle-free.
* - Items are in-memory only. A server restart drops them; the next prompt
* re-fires the hook. Claude-mode sessions only (hooks fire for nothing else).
* re-fires the hook. Answerable items are Claude's alone; the DeepSeek status
* bridge and the screen-read dialogs of a hook-less CLI (codex, see
* `web/screen-blocked.ts`) open alert-only items (`answerInTerminal`).
* - Answer flow is take-then-write: `take()` removes the item BEFORE keystrokes
* are sent so a double-tap cannot double-send; `restore()` re-inserts on a
* failed write unless a newer prompt arrived meanwhile.
@@ -24,7 +26,8 @@
*
* @dependencies utils (stripAnsi, CLAUDE_WORKING_LINE_PATTERN)
* @consumedby web/routes/hook-event-routes (notePrompt/resolve), web/routes/approval-routes,
* web/session-listener-wiring (working/exit resolution), web/server (emit callbacks + stop)
* web/session-listener-wiring (working/exit resolution), web/screen-blocked (screen-read
* dialogs), web/server (emit callbacks + stop)
*
* @module web/approval-inbox
*/
@@ -122,6 +125,13 @@ interface NotePromptArgs {
answerInTerminal?: boolean;
/** Returns the raw (ANSI-bearing) pane frame, or null when unavailable. */
capture?: () => string | null;
/**
* A frame the caller already read, kept as the card's context when there is no
* `capture`. Never parsed for options and never re-read: an item raised from the
* screen (`web/screen-blocked.ts`) belongs to the session that keeps reading it, and
* the option parser and staleness sweep here are shaped for Claude's dialogs.
*/
context?: string;
}
// ─── Tunables ────────────────────────────────────────────────────────────────
@@ -279,6 +289,7 @@ export class ApprovalInbox {
item.acknowledgedAt = item.createdAt;
item.acknowledgedReason = `watching ${args.watching}`;
}
if (!args.capture && args.context) item.context = normalizeCapturedFrame(args.context);
this.applyCapture(item, args.capture);
this.items.set(args.sessionId, item);
if (args.capture) this.captures.set(args.sessionId, args.capture);
+99 -33
View File
@@ -533,8 +533,8 @@ const DEFAULT_SHORTCUTS = [
group: 'Session',
label: 'Toggle Session Sidebar',
// Alt+B, not Ctrl+B: Ctrl+B must reach the terminal (tmux prefix,
// readline backward-char). The Alt block below claims only Digit1-9 and
// the brackets, and the registry claims Alt for KeyK and Slash only.
// readline backward-char). The Alt block below claims only Digit1-9; the
// brackets are registry entries, and the registry claims Alt for KeyK and Slash only.
bindings: [{ modifiers: ['alt'], key: 'b', code: 'KeyB' }],
action: 'toggleSessionSidebar',
},
@@ -638,11 +638,22 @@ const DEFAULT_SHORTCUTS = [
bindings: [],
action: 'removeTile',
},
// Physical key codes, so macOS Option layouts that print "“" for Option+[ still match.
{
id: 'previous-next-session',
id: 'previous-session',
group: 'Session',
label: 'Previous / Next Session',
displayBindings: ['Alt/Option+[', 'Alt/Option+]'],
label: 'Previous Session',
displayBindings: ['Alt/Option+['],
bindings: [{ modifiers: ['alt'], code: 'BracketLeft' }],
action: 'previousSession',
},
{
id: 'next-session-alt',
group: 'Session',
label: 'Next Session (alternate)',
displayBindings: ['Alt/Option+]'],
bindings: [{ modifiers: ['alt'], code: 'BracketRight' }],
action: 'nextSession',
},
{
id: 'switch-tab-n',
@@ -1388,19 +1399,7 @@ class CodemanApp {
// getShortcutRegistry()). The command palette chord is deliberately NOT in
// this map — shouldOpenCommandPaletteFromShortcut() dispatches it above with
// focus-target awareness (it must fire from the terminal but not from inputs).
const SHORTCUT_ACTIONS = {
showShortcutOverlay: () => this.showShortcutOverlay(),
killActiveSession: () => this.killActiveSession(),
nextSession: () => this.nextSession(),
clearTerminal: () => this.clearTerminal(),
restoreTerminalSize: () => this.restoreTerminalSize(),
increaseFontSize: () => this.increaseFontSize(),
decreaseFontSize: () => this.decreaseFontSize(),
toggleVoiceInput: () => VoiceInput.toggle(),
moveActiveTabLeft: () => this.moveActiveTabLeft(),
moveActiveTabRight: () => this.moveActiveTabRight(),
toggleSessionSidebar: () => this.toggleSessionSidebar(),
};
const SHORTCUT_ACTIONS = this._shortcutActions();
// Use capture to handle before terminal
document.addEventListener('keydown', (e) => {
@@ -1447,6 +1446,7 @@ class CodemanApp {
this.closeAllPanels();
this.closeHelp();
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
this.closeWorktreeModal?.();
this.closeSessionManager();
this.closeCommandPalette?.();
this.closeShortcutOverlay?.();
@@ -1463,7 +1463,8 @@ class CodemanApp {
// Option/Alt session navigation uses physical key CODES, not e.key, so macOS
// keyboard layouts that emit special characters under Option (Option+1 -> ¡,
// Option+[ -> "“") still switch sessions. e.code is the physical key regardless
// of layout. Option+1-9 = switch by index; Option+[ / Option+] = prev / next.
// of layout. Option+1-9 = switch by index. Option+[ / ] (prev / next) live in the
// shortcut registry, so they can be rebound.
if (e.altKey && !e.ctrlKey && !e.shiftKey) {
const code = e.code || '';
const digitMatch = code.match(/^Digit([1-9])$/);
@@ -1489,16 +1490,6 @@ class CodemanApp {
}
return;
}
if (e.code === 'BracketLeft') {
e.preventDefault();
this.prevSession();
return;
}
if (e.code === 'BracketRight') {
e.preventDefault();
this.nextSession();
return;
}
}
// Tile grid chords: only where they apply (tile-grid.js tileShortcutFor),
@@ -4843,6 +4834,8 @@ class CodemanApp {
}
}
if (data.version) this.checkWhatsNew?.(data.version);
// Stop any active voice recording on reconnect
VoiceInput.cleanup();
@@ -10336,9 +10329,10 @@ class CodemanApp {
delete this.respawnActionLogs[sessionId];
}
/** Resolves true once the session is gone from the server, false when it is still there. */
async closeSession(sessionId, killMux = true) {
// Already on its way out (a repeated click, the mux panel racing the tab).
if (this._closingSessions.has(sessionId)) return;
if (this._closingSessions.has(sessionId)) return false;
// The tab goes FIRST and the server is asked after. The kill takes the server
// a few hundred ms (SIGTERM grace, the process tree, tmux), and a tab that sat
// there that long after "Kill" read as a click that did nothing. A refused
@@ -10416,7 +10410,7 @@ class CodemanApp {
} else {
this.showToast('Tab hidden, tmux still running', 'info');
}
return;
return true;
}
// Still on the server (or the server is unreachable, in which case the resync
@@ -10431,6 +10425,7 @@ class CodemanApp {
this.renderSessionTabs();
}
this.showToast('Failed to close session', 'error');
return false;
}
// Request confirmation before closing a session
@@ -10445,6 +10440,20 @@ class CodemanApp {
const sessionNameEl = document.getElementById('closeConfirmSessionName');
sessionNameEl.textContent = name;
// A session in a Codeman-managed git worktree also offers to remove the worktree
// (git worktree remove, never forced) once the session is gone. Read now, before the
// session leaves this.sessions.
const worktree = this._managedWorktreeOfSession?.(session) || null;
this.pendingCloseWorktree = worktree;
const wtOption = document.getElementById('closeConfirmWorktree');
if (wtOption) {
wtOption.hidden = !worktree;
const wtName = document.getElementById('closeConfirmWorktreeName');
if (wtName) wtName.textContent = worktree ? worktree.name : '';
const wtBranch = document.getElementById('closeConfirmWorktreeBranch');
if (wtBranch) wtBranch.checked = false;
}
// Update kill button text based on session mode
const killTitle = document.getElementById('closeConfirmKillTitle');
if (killTitle) {
@@ -10472,15 +10481,24 @@ class CodemanApp {
cancelCloseSession() {
this.pendingCloseSessionId = null;
this.pendingCloseWorktree = null;
document.getElementById('closeConfirmModal').classList.remove('active');
}
async confirmCloseSession(killMux = true) {
/**
* `removeWorktree`: Close Session's "Kill & remove worktree". The worktree is removed only
* after the server confirms the session is gone (a live session inside is refused anyway),
* and only ever through `git worktree remove` without --force.
*/
async confirmCloseSession(killMux = true, { removeWorktree = false } = {}) {
const sessionId = this.pendingCloseSessionId;
const worktree = removeWorktree ? this.pendingCloseWorktree : null;
const deleteBranch = !!document.getElementById('closeConfirmWorktreeBranch')?.checked;
this.cancelCloseSession();
if (sessionId) {
await this.closeSession(sessionId, killMux);
const gone = await this.closeSession(sessionId, killMux);
if (gone && worktree) await this._removeWorktreeAfterClose?.(worktree, deleteBranch);
}
}
@@ -10698,6 +10716,54 @@ class CodemanApp {
}
// ─── Shortcut Registry ───────────────────────────────────────────────────────
/**
* Action name -> handler for the shortcut registry entries the document keydown
* handler dispatches generically (DEFAULT_SHORTCUTS + the user's rebinds). Entries
* absent here (copy selection, the palette, the tile chords) have their own dispatch.
*/
_shortcutActions() {
return {
showShortcutOverlay: () => this.showShortcutOverlay(),
killActiveSession: () => this.killActiveSession(),
nextSession: () => this.nextSession(),
previousSession: () => this.prevSession(),
clearTerminal: () => this.clearTerminal(),
restoreTerminalSize: () => this.restoreTerminalSize(),
increaseFontSize: () => this.increaseFontSize(),
decreaseFontSize: () => this.decreaseFontSize(),
toggleVoiceInput: () => VoiceInput.toggle(),
moveActiveTabLeft: () => this.moveActiveTabLeft(),
moveActiveTabRight: () => this.moveActiveTabRight(),
toggleSessionSidebar: () => this.toggleSessionSidebar(),
};
}
/**
* True when `e` is a chord the USER rebound to a generically dispatched action.
* The document handler has already run the action, but its preventDefault() does not
* stop xterm, so without this gate a rebind to e.g. Alt+J would also type ESC j into the
* session. Defaults are not covered on purpose: several (Ctrl+L) rely on reaching the PTY,
* and neither is a chord that matches the entry's own default (a re-capture of it).
*/
isUserBoundShortcutEvent(e) {
if (!e.ctrlKey && !e.metaKey && !e.altKey) return false;
const overrides = this.loadAppSettingsFromStorage().shortcutOverrides || {};
const actions = this._shortcutActions();
return this.getShortcutRegistry().some((shortcut) => {
if (shortcut.disabled || !Array.isArray(overrides[shortcut.id]?.bindings)) return false;
// toggleSessionSidebar has its own gate (shouldToggleSessionSidebarFromShortcut) that
// lets the chord through when the sidebar layout is off; swallowing it here would make a
// rebound sidebar key dead in the header layout.
if (shortcut.action === 'toggleSessionSidebar') return false;
if (typeof actions[shortcut.action] !== 'function') return false;
if (!this.matchesShortcutEvent(e, shortcut)) return false;
// Re-capturing the entry's own default (Ctrl+L for Clear Terminal) must keep reaching the
// CLI: clearTerminal() relies on ^L for the redraw.
const fallback = DEFAULT_SHORTCUTS.find((d) => d.id === shortcut.id);
return !this.matchesShortcutEvent(e, fallback);
});
}
// Returns the merged shortcut list: DEFAULT_SHORTCUTS with any per-shortcut
// overrides from settings.shortcutOverrides applied on top.
+2 -2
View File
@@ -2047,7 +2047,7 @@ function computeRewriteScrollLine(input) {
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
*/
const FILE_PATH_LINK_PATTERN =
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
function absoluteFilePathPattern() {
@@ -2100,7 +2100,7 @@ function previewsInFileViewer(filePath) {
* {@link findFilePathLinks}.
*/
const RELATIVE_FILE_PATH_LINK_PATTERN =
/(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
/(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
/** The `(?:log|txt|…)` extension group, read off the rooted pattern so it cannot drift. */
const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LINK_PATTERN.source)[1];
+73 -3
View File
@@ -168,8 +168,8 @@
'Restore the grid': '恢复平铺网格',
'Remove tile (the session keeps running)': '移除窗格(会话继续运行)',
'Drop a tab or a tile here': '将标签页或窗格拖放到此处',
// A file of an unsupported type dropped on a tile (tile-grid.js); the single
// view's drop (image-input.js) uses 'Only image or video files are supported'.
// A file of an unsupported type dropped on a tile (tile-grid.js) or the single
// view (image-input.js).
'Unsupported file type': '不支持的文件类型',
// Redraw (Ctrl+Shift+R, terminal-ui.js restoreTerminalSize) on the main pane, a tile or Pane B.
'No active session': '没有活动会话',
@@ -265,6 +265,50 @@
'New or link a case…': '新建或关联案例…',
'Case settings…': '案例设置…',
'No cases match': '没有匹配的案例',
// Git worktrees of a case (session-ui.js openWorktreeModal, the case picker row, the
// phone case sheet and Close Session's worktree option). Worktree and branch names,
// paths and git's own messages are user/git text and pass through untranslated.
'Git worktrees…': 'Git 工作树…',
'Git worktrees': 'Git 工作树',
'Close git worktrees': '关闭 Git 工作树',
Worktrees: '工作树',
'Worktrees…': '工作树…',
"Each worktree is its own checkout of the case's repository, on its own branch, in .claude/worktrees/ of the case. Agents working in parallel then never share one folder.":
'每个工作树都是案例仓库的独立检出,有自己的分支,位于案例的 .claude/worktrees/ 中。并行工作的智能体因此不会共用同一个文件夹。',
'Start sessions with': '启动会话所用',
'New worktree': '新建工作树',
Branch: '分支',
'Start from': '起点',
"New branch worktree-<name> from the case's current commit, unless you pick another below.":
'从案例当前提交新建分支 worktree-<name>,除非在下方另行选择。',
'Branch and start point': '分支和起点',
"A branch, tag or commit. Default: the case's current commit (HEAD).":
'分支、标签或提交。默认:案例当前提交(HEAD)。',
'Create & start session': '创建并启动会话',
'Loading worktrees…': '正在加载工作树…',
'No worktrees yet. Create one below.': '还没有工作树。请在下方创建。',
'detached HEAD': '游离 HEAD',
'main checkout': '主检出',
'not created by Codeman': '非 {name} 创建',
locked: '已锁定',
missing: '已丢失',
Open: '打开',
'Start a session in this worktree': '在此工作树中启动会话',
'Remove…': '移除…',
'git worktree remove refuses while the worktree has uncommitted or untracked files. Codeman never forces it.':
'工作树中有未提交或未跟踪的文件时,git worktree remove 会拒绝执行。{name} 从不强制移除。',
'Its folder is gone. Removing it clears the record git keeps of it, so the name can be used again.':
'它的文件夹已不存在。移除它会清除 git 保存的记录,这个名称便可再次使用。',
'Remove worktree': '移除工作树',
'Kill & remove worktree': '终止并移除工作树',
'git refuses while the worktree has uncommitted or untracked files; nothing is forced':
'工作树中有未提交或未跟踪的文件时 git 会拒绝;不会强制执行',
'Also delete its branch (git branch -d keeps a branch that is not merged)':
'同时删除其分支(git branch -d 会保留未合并的分支)',
'Name: letters, digits, ".", "_" and "-", starting with a letter or digit, at most 64 characters.':
'名称:字母、数字、"."、"_" 和 "-",以字母或数字开头,最多 64 个字符。',
'A launch is already in progress': '已有启动正在进行',
'Codeman did not answer.': '{name} 没有响应。',
'Add Case': '添加案例',
'Open sessions': '打开会话',
'Recent Sessions': '最近会话',
@@ -691,7 +735,7 @@
// the DOM translator skips <textarea> subtrees.
'Compose prompt': '撰写提示词',
'Open a session to attach a file': '请先打开一个会话再添加附件',
'Only image or video files are supported': '仅支持图片或视频文件',
'📎 Attach': '📎 添加附件',
'Compose prompt, draft saved': '撰写提示词,草稿已保存',
'Resume saved prompt draft': '继续编辑已保存的提示词草稿',
'Enter adds a new line': '按 Enter 换行',
@@ -798,9 +842,16 @@
// Dynamic common status / toasts
'Settings saved': '设置已保存',
// The once-per-release toast (settings-ui.js checkWhatsNew); its highlights stay English.
"See what's new": '查看更新内容',
'Settings applied': '设置已应用',
'Save and keep Settings open': '保存并保持设置打开',
'Settings saved locally': '设置已保存到本机',
// Multi-user mode, a non-admin (settings-ui.js / session-ui.js): server-wide settings
// writes are admin-only there.
'Saved on this device. Server-wide settings can only be changed by an admin.':
'已保存到本设备。服务器全局设置只能由管理员更改。',
'Server-wide settings can only be changed by an admin': '服务器全局设置只能由管理员更改',
'Tunnel active': '隧道已启用',
'Tunnel starting — QR code will appear when ready...': '隧道正在启动,准备好后将显示二维码…',
'Push notifications enabled': '推送通知已启用',
@@ -1179,6 +1230,8 @@
'Close Panels': '关闭面板',
'Previous / Next Session': '上一个 / 下一个会话',
'Next Session': '下一个会话',
'Previous Session': '上一个会话',
'Next Session (alternate)': '下一个会话(备用)',
'Switch to Tab N': '切换到第 N 个标签页',
'Move Active Tab Left': '向左移动当前标签页',
'Move Active Tab Right': '向右移动当前标签页',
@@ -1244,6 +1297,10 @@
[/^Update to v(.+)$/, (_m, version) => `更新到 v${version}`],
[/^You're up to date \(v(.+)\)\.$/, (_m, version) => `已是最新版本(v${version})。`],
[/^Update available: v(.+)$/, (_m, version) => `有可用更新:v${version}`],
[
/^Codeman updated to v([^:\s]+): (.+?)(?: \(\+(\d+) more\))?$/,
(_m, version, shown, more) => `Codeman 已更新到 v${version}:${shown}${more ? `(另有 ${more} 项)` : ''}`,
],
[/^Selected: (.+)$/, (_m, value) => `已选择:${value}`],
[/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`],
[/^Will create: (.+)$/, (_m, path) => `将创建:${path}`],
@@ -1252,6 +1309,16 @@
[/^Terminal restored to (\d+)x(\d+)$/, (_m, cols, rows) => `终端已恢复为 ${cols}x${rows}`],
[/^View truncated to the first (\d+) cells$/, (_m, n) => `视图仅显示前 ${n} 个单元格`],
[/^(\d+) unsupported number formats$/, (_m, n) => `${n} 种不支持的数字格式`],
// Git worktree toasts (session-ui.js): worktree and branch names and git's own
// words pass through untranslated.
[/^Worktree (\S+) created on branch (\S+)$/, (_m, wt, branch) => `已创建工作树 ${wt},分支 ${branch}`],
[
/^Worktree (\S+) removed\. Branch (\S+) kept: ([\s\S]+)$/,
(_m, wt, branch, why) => `已移除工作树 ${wt}。保留了分支 ${branch}:${why}`,
],
[/^Worktree (\S+) and branch (\S+) removed$/, (_m, wt, branch) => `已移除工作树 ${wt} 和分支 ${branch}`],
[/^Worktree (\S+) removed$/, (_m, wt) => `已移除工作树 ${wt}`],
[/^Worktree (\S+) was kept: ([\s\S]+)$/, (_m, wt, why) => `工作树 ${wt} 已保留:${why}`],
// Group names are user text: they pass through untranslated.
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
// Session names are user text too.
@@ -1287,6 +1354,9 @@
(_m, n) => `窗口太小,容纳不下 ${n} 个窗格:只显示聚焦的窗格`,
],
[/^The agent exited \((-?\d+)\)$/, (_m, code) => `智能体已退出(${code})`],
// A dialog read off a hook-less CLI's screen (src/web/screen-blocked.ts), as the
// notification drawer shows it. The CLI's label passes through.
[/^(.+) is waiting for your answer in the terminal$/, (_m, cli) => `${cli} 正在终端中等待你的回答`],
[/^The agent exited \(signal (\d+)\)$/, (_m, signal) => `智能体已退出(信号 ${signal})`],
// A session header's harness logo (tile grid, split pane): "<harness> · <model>",
// and where the model came from when the CLI did not report it. The harness
+19 -10
View File
@@ -103,7 +103,7 @@ Object.assign(CodemanApp.prototype, {
const mediaFiles = Array.from(e.dataTransfer.files).filter((f) => this._promptAttachKind(f));
if (mediaFiles.length === 0) {
this.showToast('Only image or video files are supported', 'error');
this.showToast('Unsupported file type', 'error');
return;
}
this._uploadAndInsertImages(mediaFiles);
@@ -124,7 +124,7 @@ Object.assign(CodemanApp.prototype, {
async _attachFromToolbar(files, sessionId) {
const media = files.filter((f) => this._promptAttachKind(f));
if (media.length === 0) {
this.showToast('Only image or video files are supported', 'error');
this.showToast('Unsupported file type', 'error');
return;
}
if (!sessionId) return; // no captured session: refuse rather than guess at one
@@ -230,7 +230,7 @@ Object.assign(CodemanApp.prototype, {
if (pasteConsumed) return;
pasteConsumed = true;
// Check for images or videos in clipboard items
// Check for images, videos or documents in clipboard items
var imageFiles = [];
var items = e.clipboardData && e.clipboardData.items;
if (items) {
@@ -266,18 +266,27 @@ Object.assign(CodemanApp.prototype, {
document.execCommand('paste');
},
/** What a pick or a paste may upload: 'image', or 'video' for the camera
* roll's clips (the server streams those to disk under their own cap), else
* null. The one filter behind Attach, drop, the Compose and paste-dialog
* pickers and the clipboard handlers. The MIME type decides; some Android
* file managers hand over a File with none, so the name's extension is the
* fallback, mirroring the server's own allowlists. */
/** What a pick or a paste may upload: 'image', 'video' for the camera roll's
* clips (the server streams those to disk under their own cap), 'document'
* for the office and text files the server admits, else null. The one filter
* behind Attach, drop, the Compose and paste-dialog pickers and the clipboard
* handlers. For media the MIME type decides, but a video MIME only for the
* containers the server admits; some Android file managers hand over a File
* with none, so the name's extension is the fallback. A document is known by
* its extension alone, as on the server (the MIME a file manager gives a .md
* or .csv is anyone's guess). Mirrors the server's allowlists. */
_promptAttachKind(file) {
if (file.type.startsWith('image/')) return 'image';
if (file.type.startsWith('video/')) return 'video';
// A video MIME counts only for the five containers the server accepts on
// ?kind=video (its own set in session-routes.ts); a 3gp or mkv falls to the
// extension check below and is refused up front, not after a 1GB upload and a 400.
if (/^video\/(mp4|quicktime|x-m4v|webm|ogg)$/i.test(file.type)) return 'video';
const ext = (file.name || '').toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] || '';
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'heic', 'heif'].includes(ext)) return 'image';
if (['mp4', 'webm', 'mov', 'm4v', 'ogv'].includes(ext)) return 'video';
if (['pdf', 'docx', 'pptx', 'xlsx', 'zip', 'txt', 'md', 'csv', 'tsv', 'json', 'log', 'yaml', 'yml', 'xml'].includes(ext)) {
return 'document';
}
return null;
},
+64 -1
View File
@@ -790,7 +790,7 @@
<path d="m21 15-5-5L5 21"/>
</svg>
</button>
<input type="file" class="btn-attach-mobile-input" id="attachFileInput" accept="image/*,video/*" multiple tabindex="-1" aria-hidden="true">
<input type="file" class="btn-attach-mobile-input" id="attachFileInput" accept="image/*,video/*,.pdf,.docx,.pptx,.xlsx,.zip,.txt,.md,.csv,.tsv,.json,.log,.yaml,.yml,.xml" multiple tabindex="-1" aria-hidden="true">
</span>
<button class="btn-voice-mobile" id="voiceInputBtnMobile" onclick="VoiceInput.toggle()"
title="Voice input" aria-label="Start voice input" aria-pressed="false">
@@ -1693,6 +1693,18 @@
<span class="close-option-title" id="closeConfirmKillTitle">Kill Tmux & Claude Code</span>
<span class="close-option-desc">Terminate the session completely</span>
</button>
<!-- Shown only for a session in a Codeman-managed git worktree (requestCloseSession,
app.js): kill it, then `git worktree remove` without --force. -->
<div class="close-option-worktree" id="closeConfirmWorktree" hidden>
<button class="close-option close-option-danger" onclick="app.confirmCloseSession(true, { removeWorktree: true })">
<span class="close-option-title">Kill &amp; remove worktree <span id="closeConfirmWorktreeName" data-i18n-skip></span></span>
<span class="close-option-desc">git refuses while the worktree has uncommitted or untracked files; nothing is forced</span>
</button>
<label class="close-option-worktree-branch">
<input type="checkbox" id="closeConfirmWorktreeBranch">
<span>Also delete its branch (git branch -d keeps a branch that is not merged)</span>
</label>
</div>
</div>
<div class="modal-footer-cancel">
<button class="btn-toolbar btn-sm" onclick="app.cancelCloseSession()">Cancel</button>
@@ -1700,6 +1712,52 @@
</div>
</div>
<!-- Git worktrees of a case (session-ui.js openWorktreeModal; routes/worktree-routes.ts).
Opened from the case picker's "Git worktrees…" row and the phone case sheet. -->
<div class="modal" id="worktreeModal" role="dialog" aria-modal="true" aria-labelledby="worktreeModalTitle">
<div class="modal-backdrop" onclick="app.closeWorktreeModal()"></div>
<div class="modal-content worktree-modal">
<div class="modal-header">
<h3 id="worktreeModalTitle"><span>Git worktrees</span> <span class="worktree-modal-case" id="worktreeModalCase" data-i18n-skip></span></h3>
<button class="modal-close" onclick="app.closeWorktreeModal()" aria-label="Close git worktrees">&times;</button>
</div>
<div class="modal-body">
<p class="form-hint worktree-intro">Each worktree is its own checkout of the case's repository, on its own branch, in .claude/worktrees/ of the case. Agents working in parallel then never share one folder.</p>
<div class="worktree-status" id="worktreeStatus" role="status" aria-live="polite" hidden></div>
<div class="worktree-list" id="worktreeList"></div>
<div class="form-row worktree-mode-row">
<label for="worktreeMode">Start sessions with</label>
<select id="worktreeMode" class="form-select"></select>
</div>
<fieldset class="worktree-new">
<legend>New worktree</legend>
<div class="form-row">
<label for="worktreeName">Name</label>
<input type="text" id="worktreeName" maxlength="64" placeholder="fix-login" autocomplete="off" autocapitalize="off" spellcheck="false" onkeydown="if (event.key === 'Enter') app.createWorktreeFromModal()">
<span class="form-hint">New branch worktree-&lt;name&gt; from the case's current commit, unless you pick another below.</span>
</div>
<details class="worktree-advanced">
<summary>Branch and start point</summary>
<div class="form-row">
<label for="worktreeBranch">Branch</label>
<input type="text" id="worktreeBranch" maxlength="200" placeholder="worktree-<name>" autocomplete="off" autocapitalize="off" spellcheck="false">
</div>
<div class="form-row">
<label for="worktreeBase">Start from</label>
<input type="text" id="worktreeBase" maxlength="200" placeholder="HEAD" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">A branch, tag or commit. Default: the case's current commit (HEAD).</span>
</div>
</details>
<div class="worktree-form-error" id="worktreeFormError" role="alert" hidden></div>
</fieldset>
</div>
<div class="form-actions">
<button type="button" class="wt-btn" onclick="app.closeWorktreeModal()">Close</button>
<button type="button" class="wt-btn wt-btn-primary" id="worktreeCreateBtn" onclick="app.createWorktreeFromModal()">Create &amp; start session</button>
</div>
</div>
</div>
<!-- App Settings Modal -->
<div class="modal" id="appSettingsModal">
<div class="modal-backdrop" onclick="app.closeAppSettings()"></div>
@@ -3663,6 +3721,11 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Create New Case
</button>
<!-- The selected case's git worktrees (session-ui.js openWorktreeModal). -->
<button class="btn-toolbar btn-case-create-mobile btn-case-worktrees-mobile" onclick="app.showWorktreesFromMobile()">
<span aria-hidden="true">&#x2442;</span>
Worktrees
</button>
</div>
</div>
</div>
+14 -14
View File
@@ -1282,11 +1282,11 @@ const KeyboardAccessoryBar = {
<textarea class="paste-textarea prompt-composer-textarea" aria-label="Prompt" placeholder="Write your prompt…" autocorrect="on" autocapitalize="sentences" spellcheck="true"></textarea>
<div class="paste-actions prompt-composer-actions">
<button type="button" class="prompt-composer-terminal">Use terminal keyboard</button>
<button type="button" class="paste-image">🖼 Image</button>
<button type="button" class="paste-image">📎 Attach</button>
<button type="button" class="paste-cancel">Cancel</button>
<button type="button" class="paste-send">Send</button>
</div>
<input type="file" class="paste-file-input" accept="image/*,video/*" multiple hidden>
<input type="file" class="paste-file-input" accept="image/*,video/*,.pdf,.docx,.pptx,.xlsx,.zip,.txt,.md,.csv,.tsv,.json,.log,.yaml,.yml,.xml" multiple hidden>
</div>
`;
@@ -1354,7 +1354,7 @@ const KeyboardAccessoryBar = {
const currentSendButton = currentOverlay?.querySelector('.paste-send');
if (currentImageButton) {
currentImageButton.disabled = count > 0;
currentImageButton.textContent = count > 0 ? 'Uploading…' : '🖼 Image';
currentImageButton.textContent = count > 0 ? 'Uploading…' : '📎 Attach';
}
if (currentSendButton) currentSendButton.disabled = count > 0;
const currentTerminalButton = currentOverlay?.querySelector('.prompt-composer-terminal');
@@ -1469,13 +1469,13 @@ const KeyboardAccessoryBar = {
/** Show a paste overlay for iOS compatibility.
* Handles three input paths from one dialog:
* - Text: long-press the textarea → Paste → Send (unchanged).
* - Image (picker): the "Image" button opens a native file picker
* (accept=image/*,video/* → camera / photo library / files), the most reliable
* way to attach a photo on mobile.
* - Image (paste): if the browser exposes image blobs on the textarea's
* - File (picker): the "Attach" button opens a native file picker
* (accept=image/*,video/* plus the document extensions → camera / photo
* library / files), the most reliable way to attach anything on mobile.
* - File (paste): if the browser exposes file blobs on the textarea's
* paste event, we intercept them and upload directly. Support is spotty
* on mobile, so it is a best-effort enhancement layered on the picker.
* All image paths reuse app._uploadAndInsertImages() (image-input.js), which
* All upload paths reuse app._uploadAndInsertImages() (image-input.js), which
* uploads to /api/sessions/:id/paste-image and inserts the saved path. */
pasteFromClipboard() {
if (typeof app === 'undefined' || !app.activeSessionId) return;
@@ -1488,13 +1488,13 @@ const KeyboardAccessoryBar = {
overlay.className = 'paste-overlay';
overlay.innerHTML = `
<div class="paste-dialog">
<textarea class="paste-textarea" placeholder="Long-press to paste text — or tap 🖼 to attach a photo or video"></textarea>
<textarea class="paste-textarea" placeholder="Long-press to paste text — or tap 📎 to attach a file"></textarea>
<div class="paste-actions">
<button class="paste-image">🖼 Image</button>
<button class="paste-image">📎 Attach</button>
<button class="paste-cancel">Cancel</button>
<button class="paste-send">Send</button>
</div>
<input type="file" class="paste-file-input" accept="image/*,video/*" multiple hidden>
<input type="file" class="paste-file-input" accept="image/*,video/*,.pdf,.docx,.pptx,.xlsx,.zip,.txt,.md,.csv,.tsv,.json,.log,.yaml,.yml,.xml" multiple hidden>
</div>
`;
@@ -1519,8 +1519,8 @@ const KeyboardAccessoryBar = {
if (this._sendBracketedPaste(sessionId, text, 'Paste')) close();
};
// Filter to images and videos, close the dialog, and hand off to the shared
// upload+insert pipeline. Returns true if any image was handled.
// Filter to what the server admits, close the dialog, and hand off to the
// shared upload+insert pipeline. Returns true if any file was handled.
const handleImages = (files) => {
const images = Array.from(files || []).filter((f) => app._promptAttachKind(f));
if (images.length === 0) return false;
@@ -1530,7 +1530,7 @@ const KeyboardAccessoryBar = {
return true;
};
// Image picker (camera / photo library) — the reliable mobile path.
// File picker (camera / photo library / files) — the reliable mobile path.
overlay.querySelector('.paste-image').addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => handleImages(fileInput.files));
+12 -2
View File
@@ -597,8 +597,18 @@ Object.assign(CodemanApp.prototype, {
// Update local cache + legacy localStorage
this._serverRespawnPresets = custom;
localStorage.setItem('codeman-respawn-presets', JSON.stringify(custom));
// Persist to server (cross-device sync)
this._apiPut('/api/settings', { respawnPresets: custom }).catch(() => {});
// Persist to server (cross-device sync). Not for a non-admin in multi-user mode: the
// presets are a key of the server-wide settings.json there, and its PUT is admin-only
// (403), so they are kept as their own on this device and win over the server's on the
// next load (_rememberOwnSetting, settings-ui.js). A PUT refused that way (sent before the
// role was known) marks the page read-only for the writes after it.
if (this._settingsReadOnly?.()) {
this._rememberOwnSetting?.('respawnPresets', custom);
return;
}
this._apiPut('/api/settings', { respawnPresets: custom })
.then((res) => this._noteSettingsWriteRefusal?.(res))
.catch(() => {});
},
renderPresetDropdown() {
+525 -56
View File
@@ -92,7 +92,7 @@ const RUN_MODE_LAUNCH = {
},
deepseek: {
label: 'DeepSeek',
installHint: 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh',
installHint: 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)',
// The two-part availability check is deliberate. `dsh` being installed is
// not enough — DeepSeek ships no terminal front door, so a box can have a
// perfect binary and nothing a pane can run.
@@ -128,14 +128,37 @@ const CASE_PICKER_REFRESH_MS = 5000;
/**
* Action rows at the bottom of the toolbar case picker. They replaced the "+"
* and gear buttons that sat beside the picker (owner, 1.36.0 beta): the same two
* actions, one click away, without two extra controls in the toolbar. The arrow
* keys reach them after the last case; Enter or a click runs `run`.
* actions, one click away, without two extra controls in the toolbar, and the git
* worktrees of the selected case joined them. The arrow keys reach them after the
* last case; Enter or a click runs `run`.
*/
const CASE_PICKER_ACTIONS = [
{ id: 'add', icon: '+', label: 'New or link a case…', run: (app) => app.showCreateCaseModal() },
{ id: 'settings', icon: '\u2699', label: 'Case settings…', run: (app) => app.toggleCaseSettings() },
// The selected case's git worktrees (openWorktreeModal below): a row here rather than a
// toolbar button, for the same reason the two above replaced theirs.
{ id: 'worktrees', icon: '⑂', label: 'Git worktrees…', run: (app) => app.openWorktreeModal() },
];
/** A worktree name the server accepts (src/git-worktrees.ts `worktreeNameError`), checked before the POST. */
const WORKTREE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
/**
* The Codeman-managed worktree a working directory IS: `{ caseName, name, path }` when it is
* exactly `<case path>/.claude/worktrees/<name>` of a local case in `cases`, else null. Lexical
* on purpose (the browser cannot resolve links): the server re-checks every removal against the
* case's own repository, so a stale or wrong match can only be refused, never act elsewhere.
*/
function managedWorktreeOfDir(workingDir, cases) {
if (typeof workingDir !== 'string') return null;
const m = /^(.+)\/\.claude\/worktrees\/([^/]+)\/?$/.exec(workingDir);
if (!m || !WORKTREE_NAME_RE.test(m[2]) || m[2].includes('..')) return null;
const owner = (cases || []).find(
(c) => c && c.path === m[1] && c.location !== 'remote' && c.location !== 'docker'
);
return owner ? { caseName: owner.name, name: m[2], path: workingDir.replace(/\/$/, '') } : null;
}
const EXTERNAL_CLI_MODES = new Set(Object.keys(RUN_MODE_LAUNCH));
const BUILT_IN_RUN_MODES = new Set(['claude', 'shell', ...Object.keys(RUN_MODE_LAUNCH)]);
@@ -604,6 +627,9 @@ Object.assign(CodemanApp.prototype, {
},
async saveLastUsedCase(caseName) {
// Multi-user non-admin: lastUsedCase is a key of the server-wide settings.json, whose
// PUT is admin-only (403), so it is not sent (_settingsReadOnly, settings-ui.js).
if (this._settingsReadOnly?.()) return;
try {
await fetch('/api/settings', {
method: 'PUT',
@@ -692,8 +718,18 @@ Object.assign(CodemanApp.prototype, {
this._runMode = mode;
try { localStorage.setItem('codeman_runMode', mode); } catch {}
this._applyRunMode();
// Sync to server for cross-device persistence
this._apiPut('/api/settings', { runMode: mode }).catch(() => {});
// Sync to server for cross-device persistence. Not for a non-admin in multi-user mode,
// where settings.json is server-wide and its PUT admin-only: the mode is kept as their own
// on this device and wins over the server's on the next load (_rememberOwnSetting,
// settings-ui.js). A PUT refused that way (sent before the role was known) marks the page
// read-only for the writes after it (_noteSettingsWriteRefusal).
if (this._settingsReadOnly?.()) {
this._rememberOwnSetting?.('runMode', mode);
} else {
this._apiPut('/api/settings', { runMode: mode })
.then((res) => this._noteSettingsWriteRefusal?.(res))
.catch(() => {});
}
// Close menu
document.getElementById('runModeMenu')?.classList.remove('active');
},
@@ -1919,12 +1955,16 @@ Object.assign(CodemanApp.prototype, {
},
async runClaude(options = {}) {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// `options.worktree` ({ caseName, name, path }, from the worktree modal): the session runs in
// that git worktree of the case and its tab is named after the worktree.
const wt = options.worktree || null;
const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase';
const nameBase = wt ? wt.name : caseName;
const tabCount = this._readTabCount(options.count);
const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {};
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} Claude session(s) in ${caseName}...`
`Starting ${tabCount} Claude session(s) in ${wt ? `worktree ${wt.name} of ` : ''}${caseName}...`
);
// Focus terminal NOW, in the synchronous user-gesture context (button click).
// iOS Safari ignores programmatic focus() after any await, so this must happen
@@ -1933,25 +1973,32 @@ Object.assign(CodemanApp.prototype, {
this.terminal.focus();
try {
// Get case path first
const caseRes = await fetch(`/api/cases/${caseName}`);
const caseLookup = await caseRes.json();
let caseData = caseLookup?.data ?? {};
let caseData;
if (wt) {
// A worktree is local by construction (the server refuses one for a remote or Docker
// case) and its folder came from the worktree list: no case lookup, never a scaffold.
caseData = { path: wt.path, location: 'local' };
} else {
// Get case path first
const caseRes = await fetch(`/api/cases/${caseName}`);
const caseLookup = await caseRes.json();
caseData = caseLookup?.data ?? {};
// Create the case only when the server says it does not exist. Any other
// failure (a linked folder on a mount that is not answering) must not
// scaffold a same-name local case that would then shadow the real one.
if (!caseData.path) {
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
const createCaseRes = await fetch('/api/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: caseName, description: '' })
});
const createCaseData = await createCaseRes.json();
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
// API returns { success, data: { case: { name, path } } }
caseData = createCaseData.data.case;
// Create the case only when the server says it does not exist. Any other
// failure (a linked folder on a mount that is not answering) must not
// scaffold a same-name local case that would then shadow the real one.
if (!caseData.path) {
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
const createCaseRes = await fetch('/api/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: caseName, description: '' })
});
const createCaseData = await createCaseRes.json();
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
// API returns { success, data: { case: { name, path } } }
caseData = createCaseData.data.case;
}
}
const workingDir = caseData.path;
@@ -2024,8 +2071,8 @@ Object.assign(CodemanApp.prototype, {
let firstSessionId = null;
// Find the highest existing w-number for THIS case to avoid duplicates
const startNumber = this._nextCaseSessionStartNumber(caseName);
// Find the highest existing w-number for THIS case (or worktree) to avoid duplicates
const startNumber = this._nextCaseSessionStartNumber(nameBase);
// Get global Ralph tracker setting
const ralphEnabled = this.isRalphTrackerEnabledByDefault();
@@ -2033,7 +2080,7 @@ Object.assign(CodemanApp.prototype, {
// Create all sessions in parallel for speed
const sessionNames = [];
for (let i = 0; i < tabCount; i++) {
sessionNames.push(`w${startNumber + i}-${caseName}`);
sessionNames.push(`w${startNumber + i}-${nameBase}`);
}
// Build env overrides from global + case settings (case overrides global)
@@ -2140,40 +2187,49 @@ Object.assign(CodemanApp.prototype, {
},
async runShell(options = {}) {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// `options.worktree`: see runClaude.
const wt = options.worktree || null;
const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase';
const nameBase = wt ? wt.name : caseName;
// Run Shell reads the toolbar's one instance stepper, like every other run*();
// its own second `− 1 +` group (#shellCount) was removed (#428).
const shellCount = this._readTabCount(options.count);
const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {};
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${shellCount} Shell session(s) in ${caseName}...`,
`Starting ${shellCount} Shell session(s) in ${wt ? `worktree ${wt.name} of ` : ''}${caseName}...`,
'1;33'
);
try {
// Get the case path
const caseRes = await fetch(`/api/cases/${caseName}`);
const caseLookup = await caseRes.json();
let caseData = caseLookup?.data ?? {};
let caseData;
if (wt) {
// Local by construction, folder from the worktree list (see runClaude).
caseData = { path: wt.path, location: 'local' };
} else {
// Get the case path
const caseRes = await fetch(`/api/cases/${caseName}`);
const caseLookup = await caseRes.json();
caseData = caseLookup?.data ?? {};
// Create the case only when the server says it does not exist. Any other
// failure (a linked folder on a mount that is not answering) must not
// scaffold a same-name local case that would then shadow the real one.
if (!caseData.path) {
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
const createCaseRes = await fetch('/api/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: caseName, description: '' })
});
const createCaseData = await createCaseRes.json();
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
// API returns { success, data: { case: { name, path } } }
caseData = createCaseData.data.case;
// Create the case only when the server says it does not exist. Any other
// failure (a linked folder on a mount that is not answering) must not
// scaffold a same-name local case that would then shadow the real one.
if (!caseData.path) {
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
const createCaseRes = await fetch('/api/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: caseName, description: '' })
});
const createCaseData = await createCaseRes.json();
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
// API returns { success, data: { case: { name, path } } }
caseData = createCaseData.data.case;
}
}
const selectedCase = (this.cases || []).find(c => c.name === caseName);
const selectedCase = wt ? null : (this.cases || []).find(c => c.name === caseName);
const isRemoteCase =
caseData.location === 'remote' ||
caseData.location === 'docker' ||
@@ -2206,13 +2262,13 @@ Object.assign(CodemanApp.prototype, {
return;
}
// Find the highest existing s-number for THIS case to avoid duplicates
const startNumber = this._nextCaseSessionStartNumber(caseName, 's');
// Find the highest existing s-number for THIS case (or worktree) to avoid duplicates
const startNumber = this._nextCaseSessionStartNumber(nameBase, 's');
// Create all shell sessions in parallel
const sessionNames = [];
for (let i = 0; i < shellCount; i++) {
sessionNames.push(`s${startNumber + i}-${caseName}`);
sessionNames.push(`s${startNumber + i}-${nameBase}`);
}
// Step 1: Create all sessions in parallel
@@ -2331,16 +2387,19 @@ Object.assign(CodemanApp.prototype, {
buildConfig: () => null,
});
if (!entry) throw new Error(`Unknown run mode: ${mode}`);
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// `options.worktree`: see runClaude. Quick-start resolves it server-side (`worktree`), so the
// session starts through the same path as any other, only in the worktree's folder.
const wt = options.worktree || null;
const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase';
// Remote/docker cases run the CLI on the OTHER side — the local status
// probe and the local-only config/env below don't apply (quick-start
// rejects them for remote cases).
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const _runLoc = wt ? 'local' : (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const tabCount = this._readTabCount(options.count);
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} ${entry.label} session(s) in ${caseName}...`
`Starting ${tabCount} ${entry.label} session(s) in ${wt ? `worktree ${wt.name} of ` : ''}${caseName}...`
);
// Focus in sync gesture context (see runClaude comment)
this.terminal.focus();
@@ -2369,13 +2428,15 @@ Object.assign(CodemanApp.prototype, {
// into `claude --settings`); none of these CLIs has an /effort. Each of
// the eight bodies this launcher replaced carried that rule as a comment.
const firstSessionId = await this._launchQuickStartInstances(
caseName,
// Only names the tabs (`w<n>-<name>`): a worktree session is named after the worktree.
wt ? wt.name : caseName,
tabCount,
entry.label,
(sessionName) => ({
caseName,
mode,
sessionName,
...(wt ? { worktree: wt.name } : {}),
...(options.tabGroupId ? { tabGroupId: options.tabGroupId } : {}),
...(isRemote ? {} : {
...(entry.buildConfig(globalSettings) || {}),
@@ -4833,6 +4894,16 @@ Object.assign(CodemanApp.prototype, {
},
async saveCaseOrder(order) {
// Multi-user non-admin: the case order is ONE server-wide list (settings.json caseOrder)
// and PUT /api/cases/order is admin-only (403), so the move is not sent: say why, and put
// the list back the way the server orders it.
if (this._settingsReadOnly?.()) {
this.showToast('Server-wide settings can only be changed by an admin', 'warning');
const select = document.getElementById('quickStartCase');
await this.loadQuickStartCases(select?.value);
this.renderCaseManageList();
return;
}
try {
await fetch('/api/cases/order', {
method: 'PUT',
@@ -5007,6 +5078,404 @@ Object.assign(CodemanApp.prototype, {
// Remove animation class after it plays
setTimeout(() => modal.classList.remove('from-mobile'), 300);
},
// ═══════════════════════════════════════════════════════════════
// Git worktrees of a case (src/git-worktrees.ts, routes/worktree-routes.ts)
// ═══════════════════════════════════════════════════════════════
//
// One dialog per case: its worktrees (Open, Remove), and a form for a new one that starts a
// session in it at once. Sessions start through run() with `options.worktree`, so every run
// mode keeps its own launch path (POST /api/sessions for claude and shell, quick-start with
// `worktree` for the rest). Everything git or the user named (worktree and branch names, paths,
// git's own refusals) renders through textContent and carries data-i18n-skip.
/** Open the worktree dialog for `caseName` (default: the case selected in the toolbar). */
openWorktreeModal(caseName) {
const modal = document.getElementById('worktreeModal');
const name = caseName || document.getElementById('quickStartCase')?.value || '';
if (!modal || !name) return;
this._worktreeCase = name;
this._worktreeData = null;
this._worktreeRemoveConfirm = null;
this._worktreeRemoveError = null;
const caseLabel = document.getElementById('worktreeModalCase');
if (caseLabel) caseLabel.textContent = name;
for (const id of ['worktreeName', 'worktreeBranch', 'worktreeBase']) {
const input = document.getElementById(id);
if (input) input.value = '';
}
this._setWorktreeFormError('');
this._fillWorktreeModeSelect();
this._renderWorktreeList();
modal.classList.add('active');
void this._loadWorktrees();
// Not on a touch device: focusing the field raises the on-screen keyboard over the list.
const touch = typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice?.();
if (!touch) setTimeout(() => document.getElementById('worktreeName')?.focus?.(), 50);
},
/** The phone case sheet's Worktrees button: the selected case's worktrees. */
showWorktreesFromMobile() {
this.closeMobileCasePicker();
this.openWorktreeModal();
},
closeWorktreeModal() {
const modal = document.getElementById('worktreeModal');
// Called on every Escape (app.js): a no-op unless the dialog is open.
if (!modal || !modal.classList.contains('active')) return;
modal.classList.remove('active');
this._worktreeCase = null;
this._worktreeLoadSeq = (this._worktreeLoadSeq || 0) + 1;
},
_worktreesUrl(caseName, suffix = '') {
return `/api/cases/${encodeURIComponent(caseName)}/worktrees${suffix}`;
},
/** A JSON call that keeps the server's error text, which `_apiJson()` drops. */
async _worktreeRequest(path, opts = {}) {
const res = await this._api(path, opts);
if (!res) return { ok: false, error: 'Codeman did not answer.' };
let body = null;
try {
body = await res.json();
} catch {
/* an empty or non-JSON body: the status says enough */
}
if (res.ok && body?.success !== false) return { ok: true, data: body?.data ?? body };
return { ok: false, status: res.status, error: body?.error || `Request failed (${res.status})` };
},
async _loadWorktrees() {
const caseName = this._worktreeCase;
if (!caseName) return;
const seq = (this._worktreeLoadSeq = (this._worktreeLoadSeq || 0) + 1);
this._setWorktreeStatus('Loading worktrees…');
const r = await this._worktreeRequest(this._worktreesUrl(caseName));
if (seq !== this._worktreeLoadSeq || caseName !== this._worktreeCase) return;
this._worktreeData = r.ok ? r.data : null;
this._setWorktreeStatus(r.ok ? '' : r.error, !r.ok);
// A case that cannot list worktrees (not a repository, a Docker or remote case) cannot get one.
const createBtn = document.getElementById('worktreeCreateBtn');
if (createBtn) createBtn.disabled = !r.ok;
this._renderWorktreeList();
},
_setWorktreeStatus(text, isError = false) {
const el = document.getElementById('worktreeStatus');
if (!el) return;
el.textContent = text || '';
el.hidden = !text;
el.classList.toggle('worktree-status-error', !!isError);
},
_setWorktreeFormError(text) {
const el = document.getElementById('worktreeFormError');
if (!el) return;
el.textContent = text || '';
el.hidden = !text;
},
/** Every enabled, installed run mode, the shell last; the Run button's mode preselected. */
_fillWorktreeModeSelect() {
const select = document.getElementById('worktreeMode');
if (!select) return;
// Every launchable registry entry (agents and the shell), as data: no id is branched on here.
const modes = [];
for (const cli of registryCliCatalog()) {
if (cli.enabled && cli.available !== false) modes.push({ id: cli.id, label: cli.label });
}
if (modes.length === 0) {
// A cached page from before the registry catalogue: the built-in modes.
modes.push({ id: 'claude', label: 'Claude Code' });
for (const [id, entry] of Object.entries(RUN_MODE_LAUNCH)) modes.push({ id, label: entry.label });
modes.push({ id: 'shell', label: 'Terminal / Shell' });
}
select.replaceChildren(
...modes.map((m) => {
const option = document.createElement('option');
option.value = m.id;
option.textContent = m.label;
return option;
})
);
const current = this._runMode || 'claude';
select.value = modes.some((m) => m.id === current) ? current : modes[0].id;
},
_selectedWorktreeMode() {
return document.getElementById('worktreeMode')?.value || this._runMode || 'claude';
},
_renderWorktreeList() {
const list = document.getElementById('worktreeList');
if (!list) return;
list.replaceChildren();
const data = this._worktreeData;
if (!data) return;
for (const wt of data.worktrees) list.appendChild(this._buildWorktreeRow(wt));
if (!data.worktrees.some((wt) => wt.managed)) {
const empty = document.createElement('div');
empty.className = 'worktree-empty';
empty.textContent = 'No worktrees yet. Create one below.';
list.appendChild(empty);
}
},
_buildWorktreeRow(wt) {
const row = document.createElement('div');
row.className = 'worktree-row' + (wt.managed ? ' worktree-row--managed' : '');
const head = document.createElement('div');
head.className = 'worktree-row-head';
const label = document.createElement('span');
label.className = 'worktree-row-name';
label.setAttribute('data-i18n-skip', '');
label.textContent = wt.managed ? `⑂ ${wt.name}` : wt.path.split('/').pop() || wt.path;
head.appendChild(label);
const badge = (text, extra = '', skip = false) => {
const b = document.createElement('span');
b.className = `worktree-badge${extra ? ` ${extra}` : ''}`;
if (skip) b.setAttribute('data-i18n-skip', '');
b.textContent = text;
head.appendChild(b);
};
if (wt.branch) badge(wt.branch, 'worktree-badge-branch', true);
else if (wt.detached) badge('detached HEAD', 'worktree-badge-branch');
if (wt.main) badge('main checkout');
else if (!wt.managed) badge('not created by Codeman', 'worktree-badge-dim');
if (wt.locked) badge('locked', 'worktree-badge-warn');
if (wt.prunable) badge('missing', 'worktree-badge-warn');
if (wt.sessionCount > 0) {
badge(`${wt.sessionCount} session${wt.sessionCount === 1 ? '' : 's'}`, 'worktree-badge-live');
}
if (wt.managed) {
const actions = document.createElement('span');
actions.className = 'worktree-row-actions';
// A worktree whose folder is gone (`prunable`) cannot host a session, but Remove still
// clears git's record of it, which is what frees its name for a new worktree.
if (!wt.prunable) {
const open = document.createElement('button');
open.type = 'button';
open.className = 'wt-btn wt-btn-sm';
open.textContent = 'Open';
open.title = 'Start a session in this worktree';
open.addEventListener('click', () => void this.openWorktreeSession(wt.name));
actions.appendChild(open);
}
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'wt-btn wt-btn-sm wt-btn-danger';
remove.textContent = 'Remove…';
remove.addEventListener('click', () => this.confirmWorktreeRemoval(wt.name));
actions.appendChild(remove);
head.appendChild(actions);
}
row.appendChild(head);
// A managed worktree's folder is always `.claude/worktrees/<name>` of the case: say just that,
// so the long repository prefix (already on the main row) is not repeated on every row.
const path = document.createElement('div');
path.className = 'worktree-row-path';
path.setAttribute('data-i18n-skip', '');
path.textContent = wt.managed ? `.claude/worktrees/${wt.name}` : this._shortenHomePath(wt.path);
path.title = wt.path;
row.appendChild(path);
if (wt.managed && this._worktreeRemoveConfirm === wt.name) row.appendChild(this._buildWorktreeRemoveConfirm(wt));
return row;
},
_buildWorktreeRemoveConfirm(wt) {
const box = document.createElement('div');
box.className = 'worktree-remove-confirm';
const note = document.createElement('p');
note.className = 'worktree-remove-note';
note.textContent = wt.prunable
? 'Its folder is gone. Removing it clears the record git keeps of it, so the name can be used again.'
: 'git worktree remove refuses while the worktree has uncommitted or untracked files. Codeman never forces it.';
box.appendChild(note);
let checkbox = null;
if (wt.branch) {
const lbl = document.createElement('label');
lbl.className = 'worktree-remove-branch';
checkbox = document.createElement('input');
checkbox.type = 'checkbox';
const text = document.createElement('span');
text.textContent = 'Also delete its branch (git branch -d keeps a branch that is not merged)';
lbl.append(checkbox, text);
box.appendChild(lbl);
}
if (this._worktreeRemoveError?.name === wt.name) {
const err = document.createElement('pre');
err.className = 'worktree-remove-error';
err.setAttribute('data-i18n-skip', '');
err.textContent = this._worktreeRemoveError.text;
box.appendChild(err);
}
const buttons = document.createElement('div');
buttons.className = 'worktree-remove-buttons';
const cancel = document.createElement('button');
cancel.type = 'button';
cancel.className = 'wt-btn wt-btn-sm';
cancel.textContent = 'Cancel';
cancel.addEventListener('click', () => this.cancelWorktreeRemoval());
const confirm = document.createElement('button');
confirm.type = 'button';
confirm.className = 'wt-btn wt-btn-sm wt-btn-danger';
confirm.textContent = 'Remove worktree';
confirm.addEventListener('click', async () => {
confirm.disabled = true;
try {
await this.removeWorktreeFromModal(wt.name, !!checkbox?.checked);
} finally {
confirm.disabled = false;
}
});
buttons.append(cancel, confirm);
box.appendChild(buttons);
return box;
},
confirmWorktreeRemoval(name) {
this._worktreeRemoveConfirm = name;
this._worktreeRemoveError = null;
this._renderWorktreeList();
},
cancelWorktreeRemoval() {
this._worktreeRemoveConfirm = null;
this._worktreeRemoveError = null;
this._renderWorktreeList();
},
async removeWorktreeFromModal(name, deleteBranch) {
const caseName = this._worktreeCase;
if (!caseName) return;
const r = await this.removeCaseWorktree(caseName, name, deleteBranch);
if (caseName !== this._worktreeCase) return;
if (!r.ok) {
this._worktreeRemoveError = { name, text: r.error };
this._renderWorktreeList();
return;
}
this._worktreeRemoveConfirm = null;
this._worktreeRemoveError = null;
await this._loadWorktrees();
},
/** DELETE one worktree (never forced); shared by the dialog and Close Session. Toasts the outcome. */
async removeCaseWorktree(caseName, name, deleteBranch) {
const query = deleteBranch ? '?deleteBranch=true' : '';
const r = await this._worktreeRequest(this._worktreesUrl(caseName, `/${encodeURIComponent(name)}${query}`), {
method: 'DELETE',
});
if (r.ok) {
const removed = r.data?.removed || { name };
if (removed.branchError) {
this.showToast?.(
`Worktree ${removed.name} removed. Branch ${removed.branch} kept: ${removed.branchError}`,
'warning'
);
} else if (removed.branchDeleted) {
this.showToast?.(`Worktree ${removed.name} and branch ${removed.branch} removed`, 'success');
} else {
this.showToast?.(`Worktree ${removed.name} removed`, 'success');
}
}
return r;
},
async createWorktreeFromModal() {
const caseName = this._worktreeCase;
if (!caseName) return;
const name = (document.getElementById('worktreeName')?.value || '').trim();
const branch = (document.getElementById('worktreeBranch')?.value || '').trim();
const base = (document.getElementById('worktreeBase')?.value || '').trim();
if (!WORKTREE_NAME_RE.test(name) || name.includes('..') || /\.(lock)?$/i.test(name)) {
this._setWorktreeFormError(
'Name: letters, digits, ".", "_" and "-", starting with a letter or digit, at most 64 characters.'
);
return;
}
this._setWorktreeFormError('');
const mode = this._selectedWorktreeMode();
const btn = document.getElementById('worktreeCreateBtn');
if (btn) btn.disabled = true;
try {
const r = await this._worktreeRequest(this._worktreesUrl(caseName), {
method: 'POST',
body: { name, ...(branch ? { branch } : {}), ...(base ? { base } : {}) },
});
if (caseName !== this._worktreeCase) return;
if (!r.ok) {
this._setWorktreeFormError(r.error);
return;
}
const created = r.data.worktree;
this.showToast?.(`Worktree ${created.name} created on branch ${created.branch}`, 'success');
this.closeWorktreeModal();
await this._launchInWorktree({ caseName, name: created.name, path: created.path }, mode);
} finally {
if (btn) btn.disabled = false;
}
},
async openWorktreeSession(name) {
const caseName = this._worktreeCase;
const wt = this._worktreeData?.worktrees?.find((w) => w.managed && w.name === name);
if (!caseName || !wt) return;
const mode = this._selectedWorktreeMode();
this.closeWorktreeModal();
await this._launchInWorktree({ caseName, name, path: wt.path }, mode);
},
/**
* Start ONE session of `mode` in a worktree through run(), the Run button's own entry (its
* launch lock, its per-mode paths), with the same temporary `_runMode` swap the custom-model
* entries use, so the Run button's chosen mode is untouched afterwards.
*/
async _launchInWorktree(worktree, mode) {
if (this._runInFlight) {
this.showToast?.('A launch is already in progress', 'info');
return;
}
const previous = this._runMode;
this._runMode = mode || previous;
try {
await this.run({ count: 1, worktree });
} finally {
this._runMode = previous;
}
},
/** The Codeman-managed worktree a session runs in, or null (see managedWorktreeOfDir). */
_managedWorktreeOfSession(session) {
if (!session || session.remote || session.docker) return null;
return managedWorktreeOfDir(session.workingDir, this.cases);
},
/**
* Close Session's "Kill & remove worktree", once the session is gone. git's refusal (a dirty
* tree) is the answer: the worktree stays, and the toast says why and opens the dialog.
*/
async _removeWorktreeAfterClose(wt, deleteBranch) {
const r = await this.removeCaseWorktree(wt.caseName, wt.name, deleteBranch);
if (r.ok) {
if (this._worktreeCase === wt.caseName) void this._loadWorktrees();
return;
}
this.showToast?.(`Worktree ${wt.name} was kept: ${r.error}`, 'error', {
duration: 15000,
action: { label: 'Worktrees…', onClick: () => this.openWorktreeModal(wt.caseName) },
});
},
});
Object.defineProperty(CodemanApp.prototype, 'runMode', {
+402 -37
View File
@@ -35,7 +35,11 @@ Object.assign(CodemanApp.prototype, {
this.setPendingHook(data.sessionId, 'permission_prompt');
}
const toolInfo = data.tool ? `${data.tool}${data.command ? ': ' + data.command : data.file ? ': ' + data.file : ''}` : '';
this._notifySession(data.sessionId, 'critical', 'hook-permission', 'Permission Required', toolInfo || 'Claude needs tool approval to continue');
// A dialog read off a hook-less CLI's screen (codex; `source: 'screen'`, see
// src/web/screen-blocked.ts) carries a server-built message naming that CLI, so the
// notification does not claim it is Claude asking.
const screenMessage = data.source === 'screen' && data.message ? data.message : '';
this._notifySession(data.sessionId, 'critical', 'hook-permission', 'Permission Required', toolInfo || screenMessage || 'Claude needs tool approval to continue');
},
_onHookElicitationDialog(data) {
@@ -369,6 +373,39 @@ Object.assign(CodemanApp.prototype, {
return select?.value.trim() || '';
},
/**
* Announce a new release once per browser: one toast naming its headline changes.
* The notes come from /whats-new.json, which `npm run version-packages` regenerates from
* CHANGELOG.md for each release. A browser that has never seen any version just records the
* current one, so a fresh install is not greeted with "what's new".
*/
async checkWhatsNew(runningVersion) {
if (this._whatsNewChecked) return;
this._whatsNewChecked = true;
const KEY = 'codeman-whats-new-seen';
let seen = null;
try { seen = localStorage.getItem(KEY); } catch { /* storage blocked: skip the announcement */ return; }
if (seen === runningVersion) return;
const remember = () => { try { localStorage.setItem(KEY, runningVersion); } catch { /* ignore */ } };
if (!seen) { remember(); return; }
try {
const res = await fetch('/whats-new.json', { cache: 'no-cache' });
if (!res.ok) return;
const notes = await res.json();
if (notes?.version !== runningVersion || !Array.isArray(notes.highlights) || !notes.highlights.length) return;
const shown = notes.highlights.slice(0, 3).join(' · ');
const more = notes.highlights.length > 3 ? ` (+${notes.highlights.length - 3} more)` : '';
remember();
this.showToast(`Codeman updated to v${runningVersion}: ${shown}${more}`, 'info', {
duration: 20000,
action: {
label: "See what's new",
onClick: () => window.open(`https://github.com/Ark0N/Codeman/releases/tag/codeman%40${runningVersion}`, '_blank', 'noopener'),
},
});
} catch { /* offline or malformed: try again next load */ }
},
openAppSettings() {
// Load current settings
const settings = this.loadAppSettingsFromStorage();
@@ -674,6 +711,10 @@ Object.assign(CodemanApp.prototype, {
// Activate focus trap
this.activeFocusTrap = new FocusTrap(modal);
this.activeFocusTrap.activate();
// Multi-user non-admin only: remember what the form shows now, so Save can tell a real
// change to a server-wide setting from an untouched one (see the method).
this._captureSettingsFormBaseline();
},
/**
@@ -1843,8 +1884,12 @@ Object.assign(CodemanApp.prototype, {
regenBtn.textContent = 'Regenerate QR';
regenBtn.style.cssText = 'margin-top:8px;padding:4px 12px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:4px;color:var(--text-secondary);cursor:pointer;font-size:11px';
regenBtn.onclick = () => {
// A refusal is not a success: in multi-user mode this is admin-only (403).
fetch('/api/tunnel/qr/regenerate', { method: 'POST' })
.then(() => this.showToast('QR code regenerated', 'success'))
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
this.showToast('QR code regenerated', 'success');
})
.catch(() => this.showToast('Failed to regenerate QR', 'error'));
};
const card = container.parentElement;
@@ -1939,6 +1984,11 @@ Object.assign(CodemanApp.prototype, {
async toggleTunnelFromWelcome() {
const btn = document.getElementById('welcomeTunnelBtn');
if (!btn) return;
// The tunnel is a server-wide setting (PUT /api/settings), admin-only in multi-user mode.
if (this._settingsReadOnly()) {
this.showToast('Server-wide settings can only be changed by an admin', 'warning');
return;
}
const isActive = btn.classList.contains('active');
btn.disabled = true;
try {
@@ -2265,12 +2315,22 @@ Object.assign(CodemanApp.prototype, {
async _handleTunnelEnableRefusal(res) {
if (!res || res.ok) return false;
let message = 'Tunnel refused: set CODEMAN_PASSWORD before exposing Codeman publicly.';
let errorCode = '';
try {
const body = await res.json();
if (body && body.error) message = body.error;
if (body && typeof body.errorCode === 'string') errorCode = body.errorCode;
} catch {
/* non-JSON body — use the default message */
}
// A non-admin in multi-user mode: PUT /api/settings is admin-only there (403 FORBIDDEN).
// That is not the no-password refusal below, so no security prompt and no retry. The
// callers skip the PUT for a known non-admin; this covers a role that had not resolved yet.
if (res.status === 403 && errorCode === 'FORBIDDEN') {
this._dismissTunnelConnecting?.();
this.showToast('Server-wide settings can only be changed by an admin', 'warning');
return true;
}
// 403 = the no-password safety refusal (COD-55). Warn loudly and let the
// operator acknowledge the risk; on confirm, retry with explicit acknowledgment.
if (res.status === 403) {
@@ -2319,6 +2379,12 @@ Object.assign(CodemanApp.prototype, {
},
async _tunnelPanelToggle(enable) {
// The tunnel is a server-wide setting (PUT /api/settings), admin-only in multi-user mode.
if (this._settingsReadOnly()) {
this.showToast('Server-wide settings can only be changed by an admin', 'warning');
this.closeTunnelPanel();
return;
}
try {
const res = await fetch('/api/settings', {
method: 'PUT',
@@ -2655,6 +2721,13 @@ Object.assign(CodemanApp.prototype, {
},
};
// One-shot form read for _captureSettingsFormBaseline(): hand back what the form
// shows, before anything below is checked, stored, applied or sent.
if (this._readSettingsFormOnce === true) {
this._readSettingsFormOnce = false;
return settings;
}
// SettingsUpdateSchema is .strict() and checks codexModel with this same
// pattern, so one bad character 400s the WHOLE settings PUT while the toast
// still says "Settings saved". Refuse it here, before anything is persisted.
@@ -2677,6 +2750,14 @@ Object.assign(CodemanApp.prototype, {
// general-settings DOM), so the fresh rebuild would drop them on every save.
if (_prev.shortcutOverrides !== undefined) settings.shortcutOverrides = _prev.shortcutOverrides;
// Multi-user mode, signed in as a non-admin: settings.json is one file for the whole
// server and PUT /api/settings answers 403 (system-routes.ts), so nothing is sent
// below. The per-device keys still save here exactly as before; every SYNCED key
// keeps the server's value, which the next page load would put back anyway, so this
// device never shows a server-wide change that did not happen.
const settingsReadOnly = this._settingsReadOnly();
const syncedChangeRefused = settingsReadOnly && this._keepServerSyncedSettings(settings, _prev);
// Save to localStorage
this.saveAppSettingsToStorage(settings);
this._updateLocalEchoState();
@@ -2897,18 +2978,31 @@ Object.assign(CodemanApp.prototype, {
let webhookError = '';
let serverSaved = false;
try {
const res = await this._apiPut('/api/settings', {
// A non-admin's save is not sent at all (see settingsReadOnly above): the server
// would refuse it with a 403, the plan-usage collection flip included.
const res = settingsReadOnly ? null : await this._apiPut('/api/settings', {
...serverSettings,
...(_chipFlip !== undefined ? { showPlanUsageLimits: _chipFlip } : {}),
notificationPreferences: notifPrefsToSave,
voiceSettings,
});
// Multi-user mode, a non-admin whose role was not known when this save started
// (/api/me had not answered, or failed and admin-ui.js fell back to a single-user
// admin): the server refused the PUT as admin-only. End it the way a known non-admin's save
// ends: every synced key back to the server's value on this device, and the one admin
// toast below instead of "Settings saved". The page is read-only from here on.
const refusedAsNonAdmin = !settingsReadOnly && (await this._noteSettingsWriteRefusal(res));
if (refusedAsNonAdmin) {
this._keepServerSyncedSettings(settings, _prev);
this.saveAppSettingsToStorage(settings);
}
// COD-55: the server refuses an unauthenticated public tunnel with a 403 — which
// rejects the WHOLE settings PUT. Surface the message and revert the tunnel toggle
// (in the UI + localStorage) so it doesn't look enabled. Other settings persisted
// to localStorage above still apply locally.
if (settings.tunnelEnabled && (await this._handleTunnelEnableRefusal(res))) {
if (!refusedAsNonAdmin && settings.tunnelEnabled && (await this._handleTunnelEnableRefusal(res))) {
settings.tunnelEnabled = false;
this.saveAppSettingsToStorage(settings);
const cb = document.getElementById('appSettingsTunnelEnabled');
@@ -2921,8 +3015,9 @@ Object.assign(CodemanApp.prototype, {
// evidence the server kept the flags the Apply refresh below reads.
serverSaved = !!res?.ok;
// Save model configuration separately
await this.saveModelConfigFromSettings();
// Save model configuration separately. For a non-admin it is not sent either, and the
// answer says whether this save would have changed it.
const modelConfigRefused = (await this.saveModelConfigFromSettings()) === true;
// The webhook is server state in its own 0600 file (its URL is a secret, kept out of
// settings.json), so like the model config above it is saved beside the settings PUT, not in
@@ -2931,12 +3026,18 @@ Object.assign(CodemanApp.prototype, {
webhookError = this._webhookPending() ? await this.saveWebhook() : '';
if (webhookError) {
this.showToast(`Settings saved, but not the webhook: ${webhookError}`, 'warning');
} else if (syncedChangeRefused || modelConfigRefused || refusedAsNonAdmin) {
// ONE toast for the whole save, and only when the user really changed a server-wide
// setting: a non-admin saving a font size hears "Settings saved" as everyone does.
// A refused PUT always gets it: the server said this user is not an admin.
this.showToast('Saved on this device. Server-wide settings can only be changed by an admin.', 'warning');
} else {
this.showToast(keepOpen ? 'Settings applied' : 'Settings saved', 'success');
}
// Show tunnel-specific feedback if toggled on
if (settings.tunnelEnabled) {
// Show tunnel-specific feedback if toggled on (never for a non-admin, whose save
// starts nothing: the box only shows what the server already has)
if (settings.tunnelEnabled && !settingsReadOnly && !refusedAsNonAdmin) {
this.showToast('Tunnel starting — QR code will appear when ready...', 'info');
}
} catch (err) {
@@ -2962,7 +3063,9 @@ Object.assign(CodemanApp.prototype, {
// gestureControlEnabled from settings.json), so a change only takes effect on
// reload. Reload when it actually changed — the server PUT above already
// persisted the new value.
if (settings.gestureControlEnabled !== _prevGestureEnabled) {
// `?? false`: a non-admin's save puts a synced key the server never stored back to
// absent (_keepServerSyncedSettings), and absent means off, as in _prevGestureEnabled.
if ((settings.gestureControlEnabled ?? false) !== _prevGestureEnabled) {
this.showToast(
settings.gestureControlEnabled ? 'Enabling gesture control — reloading…' : 'Disabling gesture control — reloading…',
'info'
@@ -2971,6 +3074,147 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* True only for a signed-in NON-ADMIN in multi-user mode, where every server-wide settings
* write is refused with 403 FORBIDDEN (PUT /api/settings, PUT /api/execution/model-config and
* PUT /api/cases/order: settings.json is one file for the whole server). Single-user mode and
* admins answer false. Read at call time, because `window.__codemanUser` resolves after page
* load (admin-ui.js fetches /api/me). Also true once the server has refused one of these
* writes as admin-only on this page (_noteSettingsWriteRefusal): before /api/me answers, and
* after it failed (admin-ui.js then falls back to a single-user admin), the role alone reads
* as writable. Other modules call it as
* `this._settingsReadOnly?.()`, so a harness that loads one of them without this file reads
* it as writable, which is what an admin gets.
*/
_settingsReadOnly() {
if (this._settingsWriteForbidden === true) return true;
// typeof guard: the load path calls this, and some vm harnesses define no `window`.
const me = (typeof window !== 'undefined' && window.__codemanUser) || {};
return !!(me.multiUser && me.role !== 'admin');
},
/**
* Whether `res` (a settings write's response) is the multi-user admin-only refusal: 403 with
* errorCode FORBIDDEN. The tunnel's no-password refusal is a 403 too, with another code, and
* is not this. When it is, the page reads as read-only from now on (_settingsReadOnly), so a
* write sent before the role was known is the last one. Reads a clone, so the caller can
* still read the body.
* @returns {Promise<boolean>}
*/
async _noteSettingsWriteRefusal(res) {
if (!res || res.status !== 403) return false;
let body = null;
try {
body = await (typeof res.clone === 'function' ? res.clone() : res).json();
} catch {
return false;
}
if (body?.errorCode !== 'FORBIDDEN') return false;
this._settingsWriteForbidden = true;
return true;
},
/**
* Where a non-admin's OWN run mode and respawn presets live (multi-user mode). Both are keys of
* the server-wide settings.json, whose PUT is admin-only, so this user's picks are kept on this
* device under their name instead (_rememberOwnSetting) and put back over the server's on every
* page load (_applyOwnSettings): loadAppSettingsFromServer() writes the server's values into
* `codeman_runMode` and `codeman-respawn-presets` each time, which used to drop them on reload.
*/
_ownSettingsKey() {
const me = (typeof window !== 'undefined' && window.__codemanUser) || {};
return `codeman:own-settings:${me.username || ''}`;
},
_readOwnSettings() {
try {
const own = JSON.parse(localStorage.getItem(this._ownSettingsKey()) || '{}');
return own && typeof own === 'object' && !Array.isArray(own) ? own : {};
} catch {
return {};
}
},
/** Keep a non-admin's own pick of a server-wide `key` on this device (see _ownSettingsKey). */
_rememberOwnSetting(key, value) {
try {
const own = this._readOwnSettings();
own[key] = value;
localStorage.setItem(this._ownSettingsKey(), JSON.stringify(own));
} catch {
/* storage unavailable: the pick lasts until the next page load */
}
},
/**
* A non-admin's own run mode and respawn presets win over the server's (see _ownSettingsKey).
* Called after the server's values are applied at load, and again on `codeman:me`, because the
* role can resolve either side of that load. A no-op for admins and single-user mode.
*/
_applyOwnSettings() {
if (!this._settingsReadOnly()) return;
const own = this._readOwnSettings();
if (Array.isArray(own.respawnPresets)) {
this._serverRespawnPresets = own.respawnPresets;
try { localStorage.setItem('codeman-respawn-presets', JSON.stringify(own.respawnPresets)); } catch {}
}
if (typeof own.runMode === 'string' && own.runMode) {
this.runMode = own.runMode;
try { localStorage.setItem('codeman_runMode', own.runMode); } catch {}
this._applyRunMode?.();
}
},
/**
* What the App Settings form showed when it opened, read the way saveAppSettings() reads it
* (its one-shot `_readSettingsFormOnce` form read), so a non-admin's save can tell whether the
* user changed a SYNCED setting. The stored blob cannot answer that: a key no admin has ever
* saved is absent from it while the form shows the default, which would read as a change on
* every save. Taken only for a non-admin; nobody else's save asks for it.
*/
_captureSettingsFormBaseline() {
this._settingsFormBaseline = null;
if (!this._settingsReadOnly()) return;
this._readSettingsFormOnce = true;
let pending;
try {
pending = this.saveAppSettings();
} finally {
// Consumed by the read itself; cleared here too in case it threw before reaching it.
this._readSettingsFormOnce = false;
}
Promise.resolve(pending)
.then((form) => {
this._settingsFormBaseline = form && typeof form === 'object' ? form : null;
})
.catch(() => {});
},
/**
* A non-admin's save in multi-user mode (saveAppSettings): put every SYNCED key of `settings`
* back to the value this device last had from the server (`prev`; deleted when it had none),
* and answer whether the form changed one since it opened. Synced means outside the per-device
* `displayKeys` merge policy of loadAppSettingsFromServer() (_displaySettingKeySet(), the one
* list both read): those are the keys the server's settings.json overwrites on every page load.
* A per-device key is left exactly as the user set it.
* @returns {boolean} true when the user changed at least one synced setting
*/
_keepServerSyncedSettings(settings, prev) {
const displayKeys = this._displaySettingKeySet();
// Without a baseline (the form was read before the role was known) the stored blob stands
// in, and a key it lacks is not counted: no toast is better than one on every save.
const baseline = this._settingsFormBaseline || prev || {};
let changed = false;
for (const key of Object.keys(settings)) {
// shortcutOverrides is carried over from `prev` already and never comes from the form.
if (displayKeys.has(key) || key === 'shortcutOverrides') continue;
if (key in baseline && JSON.stringify(settings[key]) !== JSON.stringify(baseline[key])) changed = true;
if (prev && key in prev) settings[key] = prev[key];
else delete settings[key];
}
return changed;
},
/**
* After Apply: bring the groups whose contents depend on a SAVED value up to date without
* reopening the modal. openAppSettings does the same on open; this is the part of it that
@@ -3021,10 +3265,12 @@ Object.assign(CodemanApp.prototype, {
} catch (err) {
console.warn('Failed to load model config:', err);
}
// What the form shows now, so a non-admin's save can tell whether it was changed.
this._modelConfigBaseline = JSON.stringify(this._modelConfigFromSettingsForm());
},
// Save model configuration from settings modal to server
async saveModelConfigFromSettings() {
/** The model configuration as the settings modal's form shows it (the PUT body). */
_modelConfigFromSettingsForm() {
const defaultModelEl = document.getElementById('appSettingsDefaultModel');
const showRecsEl = document.getElementById('appSettingsShowModelRecommendations');
const exploreEl = document.getElementById('appSettingsModelExplore');
@@ -3038,11 +3284,26 @@ Object.assign(CodemanApp.prototype, {
if (testEl?.value) agentTypeOverrides.test = testEl.value;
if (reviewEl?.value) agentTypeOverrides.review = reviewEl.value;
const config = {
return {
defaultModel: defaultModelEl?.value || '',
showRecommendations: showRecsEl?.checked ?? true,
agentTypeOverrides,
};
},
/**
* Save model configuration from settings modal to server.
* @returns {Promise<boolean|undefined>} true only when a non-admin in multi-user mode changed it:
* the config is a key of the server-wide settings.json and its PUT is admin-only (403), so it is
* not sent, and saveAppSettings() folds this into its one admin-only toast
*/
async saveModelConfigFromSettings() {
const config = this._modelConfigFromSettingsForm();
if (this._settingsReadOnly()) {
const baseline = this._modelConfigBaseline;
return typeof baseline === 'string' && JSON.stringify(config) !== baseline;
}
try {
await fetch('/api/execution/model-config', {
@@ -4327,6 +4588,37 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* The per-device DISPLAY keys. They are device-specific (mobile vs desktop) and must not be
* synced from the server, to avoid overriding mobile defaults: loadAppSettingsFromServer()
* seeds one from the server only when this device has no value for it. A non-admin's save in
* multi-user mode reads the same split (_keepServerSyncedSettings()), so the list lives here,
* once, and is there even when GET /api/settings failed at load.
* NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled)
* are NOT display keys: they control server-side behavior and must sync from server.
*/
_displaySettingKeySet() {
const displayKeys = new Set([
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
'language',
'terminalWheelLocalScrollback',
'autoCopySelection', 'copyStripMargin',
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds',
'showTabDetachButton',
'mobileOverviewEnabled',
'sessionLineageLines',
'showSplitButton',
'showTileGridButton',
'tileDetachEnabled',
]);
return displayKeys;
},
async loadAppSettingsFromServer(settingsPromise = null) {
// One-time migration: showPlanUsageLimits became a per-device display setting.
// Before this, it synced from the server, so the (separate) mobile settings blob
@@ -4352,28 +4644,9 @@ Object.assign(CodemanApp.prototype, {
if (settings) {
// Extract notification prefs before merging app settings
const { notificationPreferences, voiceSettings, respawnPresets, runMode, ...appSettings } = settings;
// Filter out display settings — these are device-specific (mobile vs desktop)
// and should not be synced from the server to avoid overriding mobile defaults.
// NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled)
// are NOT display keys — they control server-side behavior and must sync from server.
const displayKeys = new Set([
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
'language',
'terminalWheelLocalScrollback',
'autoCopySelection', 'copyStripMargin',
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds',
'showTabDetachButton',
'mobileOverviewEnabled',
'sessionLineageLines',
'showSplitButton',
'showTileGridButton',
'tileDetachEnabled',
]);
// Display settings are device-specific (mobile vs desktop) and must not be synced
// from the server: see _displaySettingKeySet().
const displayKeys = this._displaySettingKeySet();
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
// handheld default OFF): desktop can show it while mobile stays hidden. Drop
// the server's stored value here so it is NEVER seeded into a device that
@@ -4424,14 +4697,17 @@ Object.assign(CodemanApp.prototype, {
this._serverRespawnPresets = respawnPresets;
// Also update localStorage for offline access
localStorage.setItem('codeman-respawn-presets', JSON.stringify(respawnPresets));
} else {
// Migration: push existing localStorage presets to server
} else if (!this._settingsReadOnly()) {
// Migration: push existing localStorage presets to server. Not for a non-admin in
// multi-user mode: the presets are a server-wide setting there (admin-only PUT).
const localPresets = localStorage.getItem('codeman-respawn-presets');
if (localPresets) {
const parsed = JSON.parse(localPresets);
if (parsed.length > 0) {
this._serverRespawnPresets = parsed;
this._apiPut('/api/settings', { respawnPresets: parsed }).catch(() => {});
this._apiPut('/api/settings', { respawnPresets: parsed })
.then((res) => this._noteSettingsWriteRefusal(res))
.catch(() => {});
}
}
}
@@ -4443,6 +4719,10 @@ Object.assign(CodemanApp.prototype, {
this._applyRunMode();
}
// Multi-user non-admin: their own run mode and presets, kept on this device, win over
// the server's just applied (no-op otherwise; `codeman:me` below covers a late role).
this._applyOwnSettings();
return merged;
}
} catch (err) {
@@ -4719,6 +4999,14 @@ Object.assign(CodemanApp.prototype, {
this.showToast?.('Shortcut must include Ctrl, Cmd, or Alt', 'error');
return;
}
// Refuse a chord something else already owns: the registry dispatches the first match,
// so a duplicate would silently take over (or lose to) the other action.
const taken = this.findShortcutConflict(e, shortcutId);
if (taken) {
this.renderShortcutSettingsList();
this.showToast?.(`${this.describeShortcutEvent(e)} is already used by ${taken}`, 'error');
return;
}
const modifiers = [];
if (e.ctrlKey) modifiers.push('ctrl');
if (e.metaKey) modifiers.push('meta');
@@ -4735,7 +5023,70 @@ Object.assign(CodemanApp.prototype, {
this.renderShortcutSettingsList();
},
/** Label of the action that already owns the chord in `e`, or '' when it is free. */
findShortcutConflict(e, shortcutId) {
// Chords the keydown handlers claim before (or outside) the registry: they are fixed, so a
// rebind onto one of them would be dead or would hijack it. Alt+1-9 switch tabs by index
// (the document handler does not exclude Cmd); Ctrl/Shift+Enter insert a newline and
// Ctrl/Cmd+V pastes, and the user-bound gate sits above those branches in the xterm handler.
if (e.altKey && !e.ctrlKey && !e.shiftKey && /^Digit[1-9]$/.test(e.code || '')) {
return 'Switch to Tab N';
}
// (Alt+Shift+Enter is the tile grid's Zoom chord, a registry entry, so Alt stays free.)
if (e.key === 'Enter' && !e.altKey && (e.ctrlKey || e.shiftKey)) return 'Insert Newline';
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && String(e.key).toLowerCase() === 'v') return 'Paste';
if (typeof this.getShortcutRegistry !== 'function' || typeof this.matchesShortcutEvent !== 'function') return '';
const other = this.getShortcutRegistry().find(
(shortcut) =>
shortcut.id !== shortcutId && !shortcut.disabled && Array.isArray(shortcut.bindings) && this.matchesShortcutEvent(e, shortcut)
);
return other ? other.label : '';
},
/**
* Label of the action that already owns a chord in `shortcut`'s bindings, or ''. Used when a
* binding comes back without a capture (re-enable, Reset), where the chord may have been
* given to another action in the meantime.
*/
findShortcutBindingConflict(shortcut) {
for (const binding of shortcut?.bindings || []) {
const mods = binding.modifiers || [];
const taken = this.findShortcutConflict(
{
ctrlKey: mods.includes('ctrl'),
metaKey: mods.includes('meta'),
shiftKey: mods.includes('shift'),
altKey: mods.includes('alt'),
key: binding.key || '',
code: binding.code || '',
},
shortcut.id
);
if (taken) return taken;
}
return '';
},
describeShortcutEvent(e) {
const parts = [];
if (e.ctrlKey) parts.push('Ctrl');
if (e.metaKey) parts.push('Meta');
if (e.shiftKey) parts.push('Shift');
if (e.altKey) parts.push('Alt');
parts.push(e.key && e.key.length === 1 ? e.key.toUpperCase() : e.key || e.code);
return parts.join('+');
},
resetShortcutOverride(shortcutId) {
// Reset puts the default chord back (and re-enables the row): refuse when another action
// has taken that chord since, instead of leaving two actions on one key.
const fallback = typeof DEFAULT_SHORTCUTS !== 'undefined' ? DEFAULT_SHORTCUTS.find((d) => d.id === shortcutId) : null;
const taken = fallback ? this.findShortcutBindingConflict(fallback) : '';
if (taken) {
this.renderShortcutSettingsList();
this.showToast?.(`The default chord is already used by ${taken}`, 'error');
return;
}
const settings = this.loadAppSettingsFromStorage();
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
delete shortcutOverrides[shortcutId];
@@ -4745,6 +5096,17 @@ Object.assign(CodemanApp.prototype, {
},
toggleShortcutEnabled(shortcutId, enabled) {
if (enabled) {
// Re-enabling brings the row's chord back into play: leave it disabled when another action
// has been bound to that chord while it was off.
const shortcut = this.getShortcutRegistry?.().find((s) => s.id === shortcutId);
const taken = this.findShortcutBindingConflict(shortcut);
if (taken) {
this.renderShortcutSettingsList();
this.showToast?.(`Its chord is already used by ${taken}`, 'error');
return;
}
}
const settings = this.loadAppSettingsFromStorage();
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
shortcutOverrides[shortcutId] = { ...(shortcutOverrides[shortcutId] || {}), disabled: !enabled };
@@ -4777,6 +5139,9 @@ Object.assign(CodemanApp.prototype, {
// all, and a module-level statement that throws there fails the whole file's
// evaluation, not just this feature.
document.addEventListener?.('codeman:me', () => {
// A non-admin's own run mode and respawn presets (multi-user mode) win over the server's,
// which the settings load may already have applied before the role was known.
window.app?._applyOwnSettings?.();
window.app?._applyCustomModelAdminGate?.();
window.app?._applyCliManagementAdminGate?.();
window.app?._applyMcpSyncAdminGate?.();
+246
View File
@@ -7793,6 +7793,9 @@ html[data-skin="og"] .welcome-primary:hover {
.mobile-case-picker-footer {
padding: 12px 20px 16px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
/* Create New Case and Worktrees share the row, half each. */
display: flex;
gap: 8px;
}
.btn-case-create-mobile {
@@ -8148,6 +8151,249 @@ html[data-skin="og"] .welcome-primary:hover {
border-top: 1px solid var(--border);
}
/* Close Session: "Kill & remove worktree" (a session in a Codeman-managed git
worktree only). The wrapper re-asserts [hidden]: it lays out as a column. */
.close-option-worktree {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.close-option-worktree[hidden] {
display: none;
}
.close-option-worktree-branch {
display: flex;
align-items: flex-start;
gap: 0.4rem;
font-size: 0.75rem;
color: var(--text-dim);
padding: 0 0.25rem;
cursor: pointer;
}
.close-option-worktree-branch input {
margin-top: 0.1rem;
}
/* Git worktrees dialog (session-ui.js openWorktreeModal). Its own small button
set: the skin block greys `.btn-toolbar.btn-primary` on non-OG skins, and this
dialog needs a primary action that reads as one on every skin. */
.modal-content.worktree-modal {
max-width: 640px;
max-height: 88vh;
display: flex;
flex-direction: column;
}
.worktree-modal > .modal-body {
overflow-y: auto;
min-height: 0;
}
.worktree-modal-case {
font-family: 'SF Mono', Monaco, monospace;
font-weight: 500;
color: var(--text-dim);
}
.worktree-intro {
margin: 0 0 0.6rem;
font-size: 0.72rem;
line-height: 1.45;
}
.worktree-status {
font-size: 0.78rem;
color: var(--text-dim);
margin-bottom: 0.6rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.worktree-status[hidden],
.worktree-form-error[hidden] {
display: none;
}
.worktree-status-error,
.worktree-form-error {
color: var(--red);
}
.worktree-form-error {
font-size: 0.75rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
margin-top: 0.25rem;
}
.worktree-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 0.9rem;
}
.worktree-row {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.5rem 0.65rem;
background: var(--control-bg);
}
.worktree-row--managed {
border-color: var(--border-light);
}
.worktree-row-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem 0.45rem;
}
.worktree-row-name {
font-weight: 600;
font-size: 0.85rem;
color: var(--text);
overflow-wrap: anywhere;
}
.worktree-badge {
font-size: 0.66rem;
line-height: 1.5;
padding: 0 0.4rem;
border-radius: 999px;
border: 1px solid var(--border-light);
color: var(--text-dim);
white-space: nowrap;
}
.worktree-badge-branch {
font-family: 'SF Mono', Monaco, monospace;
color: var(--accent);
border-color: rgba(var(--accent-rgb), 0.35);
white-space: normal;
overflow-wrap: anywhere;
}
.worktree-badge-dim {
color: var(--text-muted);
}
.worktree-badge-warn {
color: var(--yellow);
border-color: var(--yellow);
}
.worktree-badge-live {
color: var(--green);
border-color: var(--green);
}
.worktree-row-actions {
display: inline-flex;
gap: 0.35rem;
margin-left: auto;
}
.worktree-row-path {
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.68rem;
color: var(--text-muted);
margin-top: 0.25rem;
overflow-wrap: anywhere;
}
.worktree-empty {
font-size: 0.75rem;
color: var(--text-muted);
padding: 0.25rem 0.1rem;
}
.worktree-remove-confirm {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px dashed var(--border-light);
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.worktree-remove-note {
margin: 0;
font-size: 0.72rem;
color: var(--text-dim);
}
.worktree-remove-branch {
display: flex;
align-items: flex-start;
gap: 0.4rem;
font-size: 0.72rem;
color: var(--text-dim);
cursor: pointer;
}
.worktree-remove-error {
margin: 0;
font-size: 0.72rem;
color: var(--red);
white-space: pre-wrap;
overflow-wrap: anywhere;
background: rgba(224, 99, 99, 0.08);
border-radius: 6px;
padding: 0.4rem 0.5rem;
}
.worktree-remove-buttons {
display: flex;
justify-content: flex-end;
gap: 0.4rem;
}
.worktree-mode-row {
max-width: 320px;
}
.worktree-new {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.6rem 0.75rem 0.4rem;
margin: 0;
min-width: 0;
}
.worktree-new > legend {
font-size: 0.72rem;
font-weight: 600;
color: var(--text-dim);
padding: 0 0.3rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.worktree-advanced {
margin-bottom: 0.5rem;
}
.worktree-advanced > summary {
cursor: pointer;
font-size: 0.75rem;
color: var(--text-dim);
margin-bottom: 0.5rem;
}
.wt-btn {
font: inherit;
font-size: 0.8rem;
padding: 0.4rem 0.85rem;
border-radius: var(--btn-radius);
border: 1px solid var(--control-border);
background: var(--control-bg);
color: var(--text);
cursor: pointer;
transition: background var(--transition-smooth), border-color var(--transition-smooth);
}
.wt-btn:hover:not(:disabled) {
background: var(--control-bg-hover);
border-color: var(--control-border-hover);
}
.wt-btn:disabled {
opacity: 0.5;
cursor: default;
}
.wt-btn-sm {
font-size: 0.72rem;
padding: 0.2rem 0.6rem;
}
.wt-btn-primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-ink);
font-weight: 600;
}
.wt-btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.wt-btn-danger {
color: var(--red);
border-color: rgba(224, 99, 99, 0.45);
}
.wt-btn-danger:hover:not(:disabled) {
background: rgba(224, 99, 99, 0.12);
border-color: var(--red);
}
.modal-session-name {
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.8rem;
+1
View File
@@ -327,6 +327,7 @@
) {
return false;
}
if (ev.type === 'keydown' && global.app?.isUserBoundShortcutEvent?.(ev)) return false;
if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) {
return false;
}
+3
View File
@@ -749,6 +749,9 @@ Object.assign(CodemanApp.prototype, {
return false;
}
// A chord the user rebound to an app action (Settings -> Shortcuts) must not also type into the PTY.
if (ev.type === 'keydown' && this.isUserBoundShortcutEvent?.(ev)) return false;
// Command palette chord (COD-153): keep it out of the PTY. The document
// CAPTURE handler has already opened the palette by the time xterm sees
// this keydown, but its preventDefault() does NOT stop xterm — without
+13
View File
@@ -0,0 +1,13 @@
{
"version": "1.42.0",
"highlights": [
"Git worktrees for a case (#625)",
"Codex sessions turn red when Codex asks you something (#624)",
"Prompt uploads take documents (#621)",
"Previous and Next Session are rebindable (#617)",
"A note after each update (#618)",
"Multi-user mode: only admins change server-wide settings",
"Codex submits long prompts (#628)",
"New Claude sessions in an untrusted folder (#634, #635)"
]
}
+13 -9
View File
@@ -24,6 +24,7 @@ import { ApprovalAnswerSchema } from '../schemas.js';
import { parseBody, getAuthUser, canAccessOwned, findSessionOrFail } from '../route-helpers.js';
import { approvalInbox, type ApprovalItem } from '../approval-inbox.js';
import { hooksAvailableForMode, sessionHookOptions } from '../session-wait-registry.js';
import { getCli } from '../../config/cli-registry/registry.js';
import type { SessionPort } from '../ports/index.js';
/**
@@ -99,20 +100,23 @@ export function registerApprovalRoutes(app: FastifyInstance, ctx: SessionPort):
// Throws 404 (not 403) for sessions the caller does not own, same
// no-existence-leak rule as every other session route.
const session = findSessionOrFail(ctx, item.sessionId, req);
if (!hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
return createErrorResponse(ApiErrorCode.CONFLICT, 'Session mode cannot have pending approvals');
}
// Alert-only items (supervised-hook CLIs such as DeepSeek Harness): the
// dialog belongs to a third-party TUI whose keystroke contract Codeman has
// not measured, so '1'/Esc would be a blind keystroke into a foreign
// composer. The item still raises the red alert and clears on the harness's
// own working/stop reports; answering happens in the terminal.
// Alert-only items: the dialog belongs to a TUI whose keystroke contract Codeman
// has not measured, so '1'/Esc would be a blind keystroke into a foreign composer.
// Two sources today: a supervised-hook CLI (DeepSeek Harness), whose item clears on
// the harness's own working/stop reports, and a dialog read off the screen of a
// hook-less CLI (codex, web/screen-blocked.ts), which clears when it leaves the
// screen and was read from text the agent writes, so it must never steer a key.
// Checked BEFORE the hooks gate below: a codex session has no hooks, and "this mode
// cannot have pending approvals" would be false about the item the caller holds.
if (item.answerInTerminal) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'DeepSeek Harness approvals must be answered in the terminal: the dialog belongs to a third-party TUI whose keystrokes Codeman cannot verify.'
`${getCli(session.mode)?.label ?? session.mode} approvals must be answered in the terminal: the dialog belongs to a TUI whose keystrokes Codeman cannot verify.`
);
}
if (!hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
return createErrorResponse(ApiErrorCode.CONFLICT, 'Session mode cannot have pending approvals');
}
// Re-capture the pane before aiming keystrokes at it: if the dialog was
// answered in the terminal moments ago, the digit would land in whatever
+8 -1
View File
@@ -60,6 +60,7 @@ import {
isAdmin,
isWorkingDirAllowed,
ownerFor,
requireAdmin,
resolveCasesDir,
SETTINGS_PATH,
validatePathWithinBase,
@@ -1665,7 +1666,13 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
// ========== Reorder Cases ==========
app.put('/api/cases/order', async (req): Promise<ApiResponse<{ order: string[] }>> => {
app.put('/api/cases/order', async (req, reply): Promise<ApiResponse<{ order: string[] }> | undefined> => {
// `caseOrder` is ONE list in the server-wide settings.json, read by every user's
// GET /api/cases, so a non-admin's reorder would replace everyone's order (and
// publish their case names to every user through GET /api/settings). Admin-only in
// multi-user mode, like PUT /api/settings; a per-user order would need per-user
// storage. No-op in single-user mode.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const { order } = parseBody(CaseOrderSchema, req.body, 'Invalid order data');
try {
+1
View File
@@ -17,6 +17,7 @@ export { registerReadMyMindRoutes } from './readmymind-routes.js';
export { registerGitStatusRoutes } from './git-status-routes.js';
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
export { registerCaseRoutes } from './case-routes.js';
export { registerWorktreeRoutes } from './worktree-routes.js';
export { registerSessionRoutes } from './session-routes.js';
export { registerRespawnRoutes } from './respawn-routes.js';
export { registerRalphRoutes } from './ralph-routes.js';
+17 -11
View File
@@ -16,6 +16,7 @@ import { SseEvent } from '../sse-events.js';
import {
autoConfigureRalph,
getAuthUser,
isAdmin,
ownerFor,
resolveCasesDir,
sessionCapacityMessage,
@@ -463,20 +464,25 @@ export function registerRalphRoutes(
});
}
// Save lastUsedCase
try {
let settings: Record<string, unknown> = {};
// Save lastUsedCase. It is a key of the server-wide settings.json, readable by every
// user through GET /api/settings, so a non-admin's case name is not written there in
// multi-user mode (writes are admin-only: PUT /api/settings). isAdmin is always true
// in single-user mode.
if (isAdmin(req)) {
try {
settings = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
let settings: Record<string, unknown> = {};
try {
settings = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
} catch {
/* ignore */
}
settings.lastUsedCase = caseName;
const dir = dirname(SETTINGS_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)).catch(() => {}); // Ignore - persisting lastUsedCase is non-critical
} catch {
/* ignore */
/* non-critical */
}
settings.lastUsedCase = caseName;
const dir = dirname(SETTINGS_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)).catch(() => {}); // Ignore - persisting lastUsedCase is non-critical
} catch {
/* non-critical */
}
const sessionId = session.id;
+203 -57
View File
@@ -13,6 +13,7 @@ import { execFile } from 'node:child_process';
import fs from 'node:fs/promises';
import { finished } from 'node:stream/promises';
import { randomBytes, randomUUID } from 'node:crypto';
import { isUtf8 } from 'node:buffer';
import { performance } from 'node:perf_hooks';
import {
ApiErrorCode,
@@ -181,6 +182,7 @@ import {
readDockerHosts,
toSessionDocker,
} from '../../docker-hosts.js';
import { resolveCaseRepository, resolveManagedWorktree } from '../../git-worktrees.js';
import { LRUMap } from '../../utils/lru-map.js';
import { describeUnknownPath, probePathKind } from '../../utils/index.js';
import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
@@ -323,18 +325,36 @@ export function stripInkRedrawBloat(buffer: string): string {
}
/**
* Validate image bytes against a declared extension. Sniffs the first ~12 bytes
* for a known magic-number signature. Defends against polyglots (e.g. HTML or
* SVG disguised under a `Content-Type: image/png` header) and against simple
* extension-only spoofing — both the multipart filename and the Content-Type
* are attacker-controlled, the raw bytes are not.
* The text half of the document allowlist: these are judged on their encoding
* (valid UTF-8, no NUL byte) rather than on a signature, since plain text has none.
*/
const TEXT_UPLOAD_EXTS = new Set(['.txt', '.md', '.csv', '.tsv', '.json', '.log', '.yaml', '.yml', '.xml']);
/**
* Validate upload bytes against a declared extension. Sniffs the first ~12 bytes
* for a known magic-number signature (a text file is instead required to be
* UTF-8 without a NUL byte, the one check plain text allows). Defends against
* polyglots (e.g. HTML or SVG disguised under a `Content-Type: image/png`
* header) and against simple extension-only spoofing — both the multipart
* filename and the Content-Type are attacker-controlled, the raw bytes are not.
* Fail-closed: an extension this function does not know is never a match.
*
* Signatures: https://en.wikipedia.org/wiki/List_of_file_signatures
*/
export function imageMagicMatchesExt(data: Buffer, ext: string): boolean {
if (TEXT_UPLOAD_EXTS.has(ext)) return data.length > 0 && !data.includes(0) && isUtf8(data);
if (data.length < 12) return false;
const u32be = (off: number): number => data.readUInt32BE(off);
switch (ext) {
case '.pdf':
// "%PDF-"
return u32be(0) === 0x25504446 && data[4] === 0x2d;
case '.docx':
case '.pptx':
case '.xlsx':
case '.zip':
// PK\x03\x04, the local-file header every non-empty zip (OOXML included) opens with
return u32be(0) === 0x504b0304;
case '.png':
return u32be(0) === 0x89504e47 && u32be(4) === 0x0d0a1a0a;
case '.jpg':
@@ -428,11 +448,27 @@ async function ensureUploadDir(workingDir: string): Promise<string | null> {
return uploadDir;
}
/**
* The part of an uploaded document's name that survives into the saved name,
* so `Q3 report (final).pdf` is findable as `paste-<ts>-<hex>-Q3-report-final.pdf`.
* Only `[A-Za-z0-9._-]` survive, runs of anything else become one `-`, and the
* result is capped at 40 chars; nothing in it can be a path separator or a
* second extension that matters (the allowlisted extension is appended after).
*/
function sanitizeUploadStem(filename: string): string {
const stem = basename(filename).replace(/\.[^.]*$/, '');
return stem
.replace(/[^A-Za-z0-9._-]+/g, '-')
.replace(/^[-.]+|[-.]+$/g, '')
.slice(0, 40);
}
/** Open a fresh, uniquely named paste file for writing. */
async function openPasteFile(imageDir: string, ext: string) {
async function openPasteFile(imageDir: string, ext: string, stem = '') {
// Date.now() collides on same-ms uploads from two tabs (last-write wins
// silently). Append 8 hex chars so concurrent pastes get distinct names.
const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${ext}`;
// The `paste-` prefix is what the GC (paste-image-gc.ts) matches on.
const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${stem ? `-${stem}` : ''}${ext}`;
const filepath = join(imageDir, filename);
// O_EXCL: refuse to overwrite (collision is impossible with random suffix,
// but defends against TOCTOU). O_NOFOLLOW: refuse if filepath is a symlink.
@@ -678,6 +714,27 @@ function currentSignalFor(session: { pid: number | null; status: SessionStatus }
return signalForStatus(session.status);
}
/**
* `currentSignalFor`, answering `blocked` for a session whose pane is showing a dialog
* its CLI is blocked on (`Session.screenBlocked`, read off the screen for a CLI with no
* hook: codex) when the caller asked for it. A hook-raised `blocked` is an edge with no
* history, but this one is a level the server is still looking at, so a caller that
* asks after the dialog appeared is answered at once instead of waiting out its timeout
* for an edge that already passed. Only when `blocked` was asked for: such a pane is also
* `idle`, and a caller that asked for `idle` alone must keep getting `idle`. Used by
* `GET .../wait` and by a DUPLICATE send-and-wait, every place a wait answers from the
* session's current state.
*/
function currentSignalForWait(
session: { pid: number | null; status: SessionStatus; screenBlocked?: boolean },
dead: boolean,
until: readonly WaitSignal[]
): WaitSignal | null {
const signal = currentSignalFor(session, dead);
if (signal !== 'exit' && session.screenBlocked && until.includes('blocked')) return 'blocked';
return signal;
}
// ── Worker liveness for tmux-backed sessions ────────────────────────────────
//
// `session.pid` is the LOCAL `tmux attach` client, not the worker. Codeman sets
@@ -1998,7 +2055,9 @@ export function registerSessionRoutes(
// A DUPLICATE has no new turn coming, so it answers from the current state
// instead of blocking for a transition that already happened.
requireTransition: !duplicate,
currentSignal: duplicate ? currentSignalFor(session, workerDead) : undefined,
// The `...ForWait` variant, as on GET .../wait: a screen-read dialog the first
// delivery raised is a level, and a retry asking for `blocked` must find it.
currentSignal: duplicate ? currentSignalForWait(session, workerDead, until) : undefined,
});
} catch (err) {
if (err instanceof WaitCapacityError) {
@@ -2014,7 +2073,7 @@ export function registerSessionRoutes(
const stopDeathWatch = wantsWait ? watchForDeadWorker(ctx.mux, session, id) : () => {};
// Write input to PTY. Direct write is synchronous; writeViaMux
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
// (tmux paste + Enter) 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).
@@ -2037,7 +2096,7 @@ export function registerSessionRoutes(
// A plain prompt (`<text>\r`) goes through the mux even when the caller did not
// ask for it: written straight into the pane it arrives as one burst, and Claude
// Code takes a long burst as a paste whose `\r` becomes a newline, so the prompt
// sat unsent (see isPlainPromptInput). The mux path types the text, presses Enter
// sat unsent (see isPlainPromptInput). The mux path pastes the text, presses Enter
// separately and arms the SubmitVerifier. An explicit `useMux: false` keeps the
// raw write for a caller that really wants it.
const autoMux = useMux === undefined && isPlainPromptInput(inputStr);
@@ -2182,7 +2241,7 @@ export function registerSessionRoutes(
abortSignal: abort.signal,
requireTransition: query.fresh === '1' || query.fresh === 'true',
// Read BEFORE awaiting: this is the state the caller is asking about.
currentSignal: currentSignalFor(session, workerIsDead(ctx.mux, session)),
currentSignal: currentSignalForWait(session, workerIsDead(ctx.mux, session), until),
});
return {
@@ -3675,6 +3734,7 @@ export function registerSessionRoutes(
agentOrigin,
customModel,
tabGroupId,
worktree,
} = parseBody(QuickStartSchema, req.body);
// Resolved ONCE here: the same value labels a case directory this request creates
@@ -3708,6 +3768,16 @@ export function registerSessionRoutes(
: (await readDockerCases(CODEMAN_CONFIG_DIR)).find(
(item) => item.name === caseName && canAccessOwned(getAuthUser(req), item.owner)
);
// Worktrees are local only (src/git-worktrees.ts): refused BEFORE a remote host is woken or a
// container is touched, since neither branch below could honour it.
if (worktree && (remoteCase || dockerCase)) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
remoteCase
? 'worktree is not available for remote (SSH) cases: the repository is on another machine'
: 'worktree is not available for Docker cases: git would run on this machine over a workspace the container can write'
);
}
if (remoteCase) {
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
@@ -3949,6 +4019,27 @@ export function registerSessionRoutes(
);
}
// Where the session runs: the case folder, or one of its git worktrees. A worktree is only
// ever USED here (created through POST /api/cases/:name/worktrees), so a case that does not
// exist yet is a 404 rather than a scaffold, and the worktree must be a registered, managed
// one, every path component a real directory (src/git-worktrees.ts).
let workspacePath = resolvedCasePath;
if (worktree) {
if (localCaseState === 'absent') {
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${caseName}" not found`);
}
if (localCaseState !== 'directory') {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Case path is not a folder');
}
const caseRepo = await resolveCaseRepository(resolvedCasePath, {
dockerWorkspaces: (await readDockerCases(CODEMAN_CONFIG_DIR)).map((c) => c.hostWorkspacePath).filter(Boolean),
});
if (!caseRepo.ok) return createErrorResponse(ApiErrorCode[caseRepo.code], caseRepo.message);
const managed = await resolveManagedWorktree(caseRepo.repo, worktree);
if (!managed.ok) return createErrorResponse(ApiErrorCode[managed.code], managed.message);
workspacePath = managed.path;
}
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote,
// non-docker cases — docker workspaces are scaffolded in their own block below)
if (localCaseState === 'absent') {
@@ -4007,9 +4098,9 @@ export function registerSessionRoutes(
// of its own. Skipped for remote cases — resolvedCasePath is a REMOTE path that
// doesn't exist on the local filesystem.
if (getCli(mode)?.capabilities.hooks === 'always') {
await applyWorkspaceHooks(resolvedCasePath, await ctx.getWorkspaceHooksEnabled());
await applyWorkspaceHooks(workspacePath, await ctx.getWorkspaceHooksEnabled());
} else {
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
await refreshStaleCodemanHooks(workspacePath).catch(() => {});
}
}
@@ -4021,7 +4112,7 @@ export function registerSessionRoutes(
// hostWorkspacePath is a real host dir and the skill crosses the bind mount.
const quickStartSkillDir = agentSkillDir(getCli(mode)?.capabilities);
if (!remote && quickStartSkillDir && (await ctx.getAgentSkillEnabled())) {
await injectAgentSkill(resolvedCasePath, quickStartSkillDir);
await injectAgentSkill(workspacePath, quickStartSkillDir);
}
// Docker cases: the workspace is a REAL host dir bind-mounted into the container.
@@ -4054,14 +4145,14 @@ export function registerSessionRoutes(
// docker — the docker workspace is a real host dir, so the settings file crosses
// the bind mount and the in-container claude reads it). Remote was rejected above.
if (getCli(mode)?.capabilities.model.source === 'claude-settings-file' && modelOverride !== undefined) {
await updateCaseModel(resolvedCasePath, modelOverride || null);
await updateCaseModel(workspacePath, modelOverride || null);
}
// Strip stale disk entries for keys this request is actively setting (Claude only —
// see POST /api/sessions for full rationale).
// Same chain, same replacement as the create path above: byte-identical, drift-proof.
if (!isExternalCliMode(mode) && !remote && envOverrides && Object.keys(envOverrides).length > 0) {
await stripCaseEnvKeys(resolvedCasePath, Object.keys(envOverrides));
await stripCaseEnvKeys(workspacePath, Object.keys(envOverrides));
}
// Create a new session with the case as working directory
@@ -4117,7 +4208,7 @@ export function registerSessionRoutes(
);
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const qsGatedEnvOverrides = await clampEnvOverridesForOwner(owner, envOverrides);
const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, resolvedCasePath, qsLaunchOmpConfig);
const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, workspacePath, qsLaunchOmpConfig);
// Custom Model Endpoint Profiles, applied AT CREATE TIME (docs/custom-model-endpoints-plan.md)
// rather than via the dedicated restart-in-place route (POST /api/sessions/:id/custom-
@@ -4223,7 +4314,7 @@ export function registerSessionRoutes(
customModel.modelId,
qsCustomModelSessionId,
cmContextLength,
resolvedCasePath
workspacePath
);
if (!cmApplied) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`);
@@ -4264,7 +4355,7 @@ export function registerSessionRoutes(
const qsSessionOptions: ConstructorParameters<typeof Session>[0] = {
id: qsCustomModelSessionId,
workingDir: resolvedCasePath,
workingDir: workspacePath,
name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '',
mux: ctx.mux,
useMux: true,
@@ -4322,7 +4413,7 @@ export function registerSessionRoutes(
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
// so the initial state already has the phrase configured (only if globally enabled)
if (getCli(mode)?.capabilities.ralph && !remote && !docker && ctx.store.getConfig().ralphEnabled) {
autoConfigureRalph(session, resolvedCasePath, ctx);
autoConfigureRalph(session, workspacePath, ctx);
if (!session.ralphTracker.enabled) {
session.ralphTracker.enable();
session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart
@@ -4386,34 +4477,40 @@ export function registerSessionRoutes(
).catch(() => {});
}
// Save lastUsedCase to settings for TUI/web sync
try {
const settingsFilePath = SETTINGS_PATH;
let settings: Record<string, unknown> = {};
// Save lastUsedCase to settings for TUI/web sync. It is a key of the server-wide
// settings.json, readable by every user through GET /api/settings, so a non-admin's
// case name is not written there in multi-user mode (writes are admin-only, like
// PUT /api/settings). isAdmin is always true in single-user mode.
if (isAdmin(req)) {
try {
settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8'));
const settingsFilePath = SETTINGS_PATH;
let settings: Record<string, unknown> = {};
try {
settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8'));
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
settings.lastUsedCase = caseName;
const dir = dirname(settingsFilePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
// Use async write to avoid blocking event loop
fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => {
// Non-critical but log for debugging
console.warn('[Server] Failed to save settings (lastUsedCase):', err);
});
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
settings.lastUsedCase = caseName;
const dir = dirname(settingsFilePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
// Use async write to avoid blocking event loop
fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => {
// Non-critical but log for debugging
console.warn('[Server] Failed to save settings (lastUsedCase):', err);
});
} catch (err) {
// Non-critical but log for debugging
console.warn('[Server] Failed to prepare settings update:', err);
console.warn('[Server] Failed to prepare settings update:', err);
}
}
return {
sessionId: session.id,
casePath: resolvedCasePath,
caseName,
...(worktree ? { worktree, workingDir: workspacePath } : {}),
...(customModel ? { modelSwapInProgress: qsCustomModelSwapInProgress } : {}),
...(tabGroupId ? { tabLayout } : {}),
};
@@ -5350,6 +5447,12 @@ export function registerSessionRoutes(
// so an uploaded video is a type that previews. 3gp/mkv are deliberately out: they
// would upload but not preview, and a phone's camera roll produces neither.
const ALLOWED_VIDEO_EXTS = new Set([...VIDEO_ATTACHMENT_EXTENSIONS].map((e) => `.${e}`));
// Documents a user hands an agent to read: the office formats plus the text
// family. Curated on purpose, never SUPPORTED_ATTACHMENT_EXTENSIONS or
// EDITABLE_EXTENSIONS, which carry sh/py/js/html; a prompt upload is a thing
// to read, not a thing to run or serve. Each is judged on its bytes by
// imageMagicMatchesExt (signature, or UTF-8 without NUL for text).
const ALLOWED_DOCUMENT_EXTS = new Set(['.pdf', '.docx', '.pptx', '.xlsx', '.zip', ...TEXT_UPLOAD_EXTS]);
const VIDEO_MIME_EXT: Record<string, string> = {
mp4: '.mp4',
quicktime: '.mov',
@@ -5359,6 +5462,19 @@ export function registerSessionRoutes(
};
// The per-file size cap (MAX_PASTE_IMAGE_BYTES) is enforced by @fastify/multipart (registered in server.ts).
/** Write buffered upload bytes into the session's upload dir. Null: the dir is not a real directory. */
async function writeBufferedUpload(workingDir: string, ext: string, bytes: Buffer, stem = '') {
const uploadDir = await ensureUploadDir(workingDir);
if (!uploadDir) return null;
const { filename, filepath, fh } = await openPasteFile(uploadDir, ext, stem);
try {
await fh.writeFile(bytes);
} finally {
await fh.close();
}
return { path: filepath, filename };
}
app.post('/api/sessions/:id/paste-image', async (req, reply) => {
// CSRF defense: state-changing routes must come from same origin.
// Cookies are SameSite=lax, multipart/form-data is a "simple" CORS request
@@ -5492,13 +5608,13 @@ export function registerSessionRoutes(
`Unsupported video type. Allowed: ${[...ALLOWED_VIDEO_EXTS].join(', ')}`
);
}
const imageDir = await ensureUploadDir(session.workingDir);
if (!imageDir) {
const uploadDir = await ensureUploadDir(session.workingDir);
if (!uploadDir) {
await finished(part.file.resume()).catch(() => {});
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
}
const { filename, filepath, fh } = await openPasteFile(imageDir, ext);
const { filename, filepath, fh } = await openPasteFile(uploadDir, ext);
// Written as it arrives: a phone video is far too large to buffer the way an
// image is. The container is judged on the first 12 bytes as soon as they are
// in, so a mislabeled upload stops touching disk at its first chunk; the rest
@@ -5557,12 +5673,41 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Empty file');
}
// Dispatch mirrors the client's _promptAttachKind: an image MIME type takes
// the image path whatever the name says, a document extension (the MIME a
// phone gives a .md is anyone's guess) takes the document branch, anything
// else the image path exactly as before, HEIC sniff included. See CLAUDE.md.
const origExt = part.filename ? extname(part.filename).toLowerCase() : '';
const imageMime = /^image\//i.test(part.mimetype || '');
if (!imageMime && ALLOWED_DOCUMENT_EXTS.has(origExt)) {
if (!imageMagicMatchesExt(imageBytes, origExt)) {
// The first bytes of a text file are user content, so for text the log
// names the failure and never the bytes; a signature is safe to print.
const detail = TEXT_UPLOAD_EXTS.has(origExt)
? `reason=${imageBytes.includes(0) ? 'nul-byte' : 'not-utf8'}`
: `magic=${imageBytes.subarray(0, 12).toString('hex')}`;
console.warn(
`[paste-image] document mismatch: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} declaredExt=${origExt} size=${imageBytes.length} ${detail}`
);
reply.code(415);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `File bytes do not match declared type ${origExt}`);
}
const saved = await writeBufferedUpload(
session.workingDir,
origExt,
imageBytes,
sanitizeUploadStem(part.filename)
);
if (!saved) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
}
return saved;
}
// Determine extension from filename or Content-Type.
let ext = '.png';
if (part.filename) {
const origExt = extname(part.filename).toLowerCase();
if (ALLOWED_IMAGE_EXTS.has(origExt)) ext = origExt;
}
if (ALLOWED_IMAGE_EXTS.has(origExt)) ext = origExt;
const mimeMatch = (part.mimetype || '').toLowerCase().match(/^image\/(png|jpeg|jpg|webp|gif|bmp|heic|heif)$/);
if (mimeMatch) {
const map: Record<string, string> = {
@@ -5603,6 +5748,15 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Could not convert HEIC image to JPEG');
}
} else if (!imageMagicMatchesExt(imageBytes, ext)) {
// A foreign extension with no image MIME (a script, an HTML page, a video
// without its flag) was never going to be an image: say so, rather than
// the ".png" the default extension would name. Decided only HERE, after
// the sniff, so anything the image path ever accepted by its bytes (a
// HEIC under a generic name) still is.
if (origExt && !ALLOWED_IMAGE_EXTS.has(origExt) && !imageMime) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unsupported file type: ${origExt}`);
}
// Sniff actual bytes — a polyglot HTML/PNG would otherwise pass and
// serve back with image/png MIME. Log the real header so format
// mismatches can be pinned down without a reproduce-and-guess loop. The
@@ -5614,20 +5768,12 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
}
// {workingDir}/.codeman-uploads/, see ensureUploadDir.
const imageDir = await ensureUploadDir(session.workingDir);
if (!imageDir) {
const saved = await writeBufferedUpload(session.workingDir, ext, imageBytes);
if (!saved) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
}
const { filename, filepath, fh } = await openPasteFile(imageDir, ext);
try {
await fh.writeFile(imageBytes);
} finally {
await fh.close();
}
return { path: filepath, filename };
return saved;
});
// Returned so the server can own the registry's LIFETIME (drop state when a session is
+10 -5
View File
@@ -51,12 +51,17 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
const payload = data as RawStatuslinePayload | undefined;
// Only a CLI that has a statusline exporter at all speaks for its own model and
// prompt-cache state: both ride the SAME statusline payload the exporter POSTs, so
// both are gated on capabilities.statusLineTelemetry (the gate that also decides
// whether the exporter is injected at all, in tmux-manager._resolveStatusLine).
const session = ctx.sessions.get(sessionId);
const reportsStatusLine = !!session && !!getCli(session.mode)?.capabilities.statusLineTelemetry;
// The model the CLI says it is running, for the session headers (displayModel). It
// rides every render, so it follows an in-session `/model`; the session dedupes and
// broadcasts only a change. Only a CLI that has a statusline exporter at all may
// report one here.
const session = ctx.sessions.get(sessionId);
if (session && getCli(session.mode)?.capabilities.statusLineTelemetry) {
// broadcasts only a change.
if (session && reportsStatusLine) {
session.noteReportedModel('statusline', payload?.model?.display_name);
}
@@ -68,7 +73,7 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
// it backgrounds this POST (hooks-config.ts), so two sub-second loopback reports can
// arrive out of order; a stale one then briefly overwrites newer state until the next
// render corrects it. Accepted for a display-only readout rather than ordered here.
const promptCache = parsePromptCache(payload);
const promptCache = reportsStatusLine ? parsePromptCache(payload) : null;
if (promptCache && session?.setPromptCache(promptCache)) {
ctx.broadcast(SessionPromptCache, { sessionId, ...session.promptCache });
// Persisted on change, like a model change, so a crash restores the latest report.
+81 -12
View File
@@ -17,7 +17,7 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } f
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { findUser, canUsernameRunPrivilegedCommands } from '../../user-store.js';
import { getAuthUser, requireAdmin, canAccessOwned } from '../route-helpers.js';
import { getAuthUser, isAdmin, requireAdmin, canAccessOwned, revokeUserSessions } from '../route-helpers.js';
import {
ConfigUpdateSchema,
SettingsUpdateSchema,
@@ -144,6 +144,28 @@ export function resolveSpanUrl(hostHeader: string | undefined, fallbackPort = '3
return `http://localhost:${port}`;
}
/**
* What GET /api/settings answers a NON-ADMIN in multi-user mode: settings.json minus what
* belongs to the server's admin rather than to the reader. `voiceSettings.apiKey` is the
* Deepgram key an admin saved, a credential (the reader's browser seeded it into its own
* voice config and dictated on the admin's account); `caseOrder` and `lastUsedCase` name the
* admin's cases. Everything else stays readable, because the page reads the synced feature
* switches from it. Writes are admin-only there (PUT /api/settings). Exported for unit testing.
*/
export function settingsForNonAdmin(settings: unknown): unknown {
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return settings;
const out: Record<string, unknown> = { ...(settings as Record<string, unknown>) };
delete out.caseOrder;
delete out.lastUsedCase;
const voice = out.voiceSettings;
if (voice && typeof voice === 'object' && !Array.isArray(voice) && 'apiKey' in voice) {
const voiceOut: Record<string, unknown> = { ...(voice as Record<string, unknown>) };
delete voiceOut.apiKey;
out.voiceSettings = voiceOut;
}
return out;
}
export function registerSystemRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort
@@ -163,12 +185,17 @@ export function registerSystemRoutes(
app.get('/api/tunnel/status', async () => ctx.tunnelManager.getStatus());
app.get('/api/tunnel/info', async () => {
app.get('/api/tunnel/info', async (req) => {
const status = ctx.tunnelManager.getStatus();
const sseClients = ctx.getSseClientCount();
const sessions: Array<{ ip: string; ua: string; createdAt: number; method: string }> = [];
// Multi-user mode: a non-admin is shown only their own logins (address and browser of
// every other user's sessions are not theirs to read), matching what their Revoke All
// can revoke (POST /api/auth/revoke). null = everyone's (admin, single-user mode).
const onlyUser = isMultiUserMode() && !isAdmin(req) ? getAuthUser(req).username : null;
if (ctx.authSessions) {
for (const [, record] of ctx.authSessions) {
if (onlyUser !== null && record.username !== onlyUser) continue;
sessions.push({ ip: record.ip, ua: record.ua, createdAt: record.createdAt, method: record.method });
}
}
@@ -300,7 +327,10 @@ export function registerSystemRoutes(
// ========== QR Regeneration ==========
app.post('/api/tunnel/qr/regenerate', async () => {
app.post('/api/tunnel/qr/regenerate', async (req, reply) => {
// Clears EVERY outstanding QR login code (each user's minted one too) and rotates the
// server's token: admin-only in multi-user mode, like the other tunnel controls.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
ctx.tunnelManager.regenerateQrToken();
return {};
});
@@ -309,6 +339,19 @@ export function registerSystemRoutes(
app.post('/api/auth/revoke', async (req) => {
const result = RevokeSessionSchema.safeParse(req.body);
// Multi-user mode: a NON-ADMIN revokes only their own login sessions. The bare form
// below clears every session on the server, so for them it used to sign out every
// user, admins included; a named token is revoked only when it is one of theirs.
if (isMultiUserMode() && !isAdmin(req)) {
const username = getAuthUser(req).username;
const token = result.success ? result.data.sessionToken : undefined;
if (token) {
if (ctx.authSessions?.peek(token)?.username === username) ctx.authSessions?.delete(token);
} else {
revokeUserSessions(ctx.authSessions, username);
}
return {};
}
if (result.success && result.data.sessionToken) {
ctx.authSessions?.delete(result.data.sessionToken);
} else {
@@ -327,6 +370,8 @@ export function registerSystemRoutes(
// panels can be dragged across the physical monitor seam. macOS only; needs
// the one-time "Displays have separate Spaces" OFF prerequisite (see script).
app.post('/api/system/span-displays', async (req, reply) => {
// Launches a browser window on the SERVER's desktop: admin-only in multi-user mode.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
// macOS only: the launcher uses osascript + Finder desktop bounds and Chrome
// --app geometry flags. Fail clearly elsewhere instead of spawning a bash
// that errors out invisibly (the toast would otherwise lie "Opening…").
@@ -379,7 +424,9 @@ export function registerSystemRoutes(
// Kick off a detached update to the latest release. Returns immediately; the
// browser then polls /api/system/update/status across the service restart.
app.post('/api/system/update', async (_req, reply) => {
app.post('/api/system/update', async (req, reply) => {
// Updates and restarts the server under every user: admin-only in multi-user mode.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const result = await startUpdate();
if (result.ok) {
return { updateId: result.updateId, toTag: result.toTag, toVersion: result.toVersion };
@@ -617,13 +664,16 @@ export function registerSystemRoutes(
// synchronous-spawn precedent this endpoint is modelled on.
detached: true,
// Inherit the environment: this needs a HOME to resolve $DSH_HOME
// against, and a PATH carrying `pnpm`. ⚠️ `dsh plugin` does NOT bundle a
// against, and a PATH carrying `pnpm` (see the PATH below). ⚠️ `dsh plugin` does NOT bundle a
// package manager — it `spawnSync`s a literal `pnpm` with no npm
// fallback, so on a host without one this exits 127 and dsh's own
// stderr ("pnpm not found on PATH") is what reaches the caller through
// the OPERATION_FAILED detail below. That is the same missing
// dependency that broke the docker agent image in issue #352.
env: process.env,
// PATH leads with the directory `dsh` itself resolved to: CLI management installs
// `dsh` and `pnpm` into the user prefix (`~/.local/bin`), which a service's own PATH
// usually lacks, so `dsh plugin` could not find the `pnpm` sitting next to it.
env: { ...process.env, PATH: [dir, process.env.PATH].filter(Boolean).join(':') },
});
} catch (err) {
resolve({ code: null, output: `spawn failed: ${getErrorMessage(err)}`, timedOut: false });
@@ -718,7 +768,10 @@ export function registerSystemRoutes(
// ========== State & Lifecycle ==========
app.post('/api/cleanup-state', async () => {
app.post('/api/cleanup-state', async (req, reply) => {
// Drops every user's stale session records (and their tab-layout entries): admin-only
// in multi-user mode, like the other server-wide maintenance operations.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const activeSessionIds = new Set(ctx.sessions.keys());
const result = await ctx.tabLayouts.runStaleSessionCleanup(activeSessionIds, (ids) =>
ctx.store.cleanupSessionsByIds(ids)
@@ -866,7 +919,10 @@ export function registerSystemRoutes(
return { config: ctx.store.getConfig() };
});
app.put('/api/config', async (req) => {
app.put('/api/config', async (req, reply) => {
// Process-wide app config in state.json (poll interval, timeouts, the session cap,
// respawn defaults): admin-only in multi-user mode, like PUT /api/settings.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const configData = parseBody(ConfigUpdateSchema, req.body, 'Invalid config');
ctx.store.setConfig(configData as Partial<ReturnType<typeof ctx.store.getConfig>>);
return { config: ctx.store.getConfig() };
@@ -935,7 +991,7 @@ export function registerSystemRoutes(
// ========== Settings ==========
app.get('/api/settings', async () => {
app.get('/api/settings', async (req) => {
// A plain read. This route must NEVER write settings.json: readJsonConfig()
// answers `{}` for ANY read failure (a parse error, EACCES, EMFILE, a read
// that lands inside PUT's non-atomic write), not only for a missing file,
@@ -947,10 +1003,20 @@ export function registerSystemRoutes(
// `workspaceHooksEnabled` means ON, and the client resolves its own display
// default through planUsageChipEnabled(). Pinned by
// test/routes/system-routes-settings-get-plan-usage-default.test.ts.
return readJsonConfig(SETTINGS_PATH, 'settings', {});
const settings = await readJsonConfig<unknown>(SETTINGS_PATH, 'settings', {});
// Readable by every signed-in user, but a non-admin in multi-user mode gets it without
// the admin's credential and case names (settingsForNonAdmin). isAdmin is always true in
// single-user mode, so that answer is the file as it is.
return isAdmin(req) ? settings : settingsForNonAdmin(settings);
});
app.put('/api/settings', async (req) => {
app.put('/api/settings', async (req, reply) => {
// Multi-user mode: settings.json is ONE file for the whole server (feature switches,
// the plan-usage collection switch, run mode, respawn presets, the tunnel), so a write
// is admin-only, like every other server-wide setting (docs/multi-user-plan.md,
// Settings row). Refused FIRST: nothing is parsed, written, toggled or started for a
// non-admin. GET above stays readable by everyone. No-op in single-user mode.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const settings = parseBody(SettingsUpdateSchema, req.body, 'Invalid settings') as Record<string, unknown>;
// COD-55: enabling the Cloudflare tunnel publishes the whole app (full terminal
@@ -1089,7 +1155,10 @@ export function registerSystemRoutes(
return { success: true, data: settings.modelConfig || {} };
});
app.put('/api/execution/model-config', async (req) => {
app.put('/api/execution/model-config', async (req, reply) => {
// The `modelConfig` key of the server-wide settings.json (cron's and the plan
// orchestrator's default model): admin-only in multi-user mode, like PUT /api/settings.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const modelConfig = parseBody(ModelConfigUpdateSchema, req.body, 'Invalid model config') as Record<string, unknown>;
try {
+290
View File
@@ -0,0 +1,290 @@
/**
* @fileoverview Git worktrees of a local case (src/git-worktrees.ts holds the rules):
*
* GET /api/cases/:name/worktrees the case repository's worktrees, which of them
* Codeman manages, and the live sessions in each
* POST /api/cases/:name/worktrees create `<case>/.claude/worktrees/<name>` on a
* new branch (`{ name, branch?, base? }`)
* DELETE /api/cases/:name/worktrees/:worktree `git worktree remove` WITHOUT --force, refused
* while a live session runs inside; optional
* `?deleteBranch=true` is `git branch -d`
*
* A session starts in a worktree through the EXISTING create paths, never a new spawn path here:
* `POST /api/quick-start` with `worktree: <name>` (validated against this module's rules), or
* `POST /api/sessions` with the worktree path as `workingDir`.
*
* The case is resolved the way quick-start resolves it: an owned remote or Docker case is refused
* with a 400 (git must never run on the host over a Docker workspace, and a remote case's folder
* is on another machine); a linked case resolves for admins only in multi-user mode; anything else
* is the caller's own case space, confined with `isWorkingDirAllowed`. The case folder is probed
* BOUNDED (#516): a case on a network mount that stopped answering is a 422, never a 404.
*
* Statuses are set explicitly from the error code, so the route answers the same with or without
* the server's envelope hook.
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { ApiErrorCode, createErrorResponse, httpStatusForErrorCode, type ApiResponse } from '../../types.js';
import { readDockerCases } from '../../docker-hosts.js';
import { readRemoteCases } from '../../remote-hosts.js';
import { dataPath, getDataDir } from '../../config/instance.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { listClis } from '../../config/cli-registry/registry.js';
import { agentSkillDir } from '../../hooks-config.js';
import { describeUnknownPath, probePathKind } from '../../utils/index.js';
import {
createCaseWorktree,
isPathWithin,
listCaseWorktrees,
managedWorktreesDir,
removeCaseWorktree,
resolveCaseRepository,
respellUnder,
worktreeNameError,
type CaseRepository,
type CaseWorktree,
type CreatedWorktree,
type RemovedWorktree,
type WorktreeFailure,
type WorktreeGitRunner,
} from '../../git-worktrees.js';
import { CreateWorktreeSchema, RemoveWorktreeQuerySchema } from '../schemas.js';
import {
canAccessOwned,
getAuthUser,
isAdmin,
isWorkingDirAllowed,
parseBody,
readJsonConfig,
resolveCasesDir,
validatePathWithinBase,
} from '../route-helpers.js';
import type { SessionPort } from '../ports/index.js';
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
/** Host paths of every Docker case workspace: git never runs for a case at or inside one. */
const defaultDockerWorkspaces = async (): Promise<string[]> =>
(await readDockerCases(getDataDir()).catch(() => [])).map((c) => c.hostWorkspacePath).filter(Boolean);
/**
* Every workspace skills folder a session of some registry CLI gets Codeman's agent skill in
* (`capabilities.agentSkillDir`), read from the registry at call time as data, never by CLI id.
*/
const registryAgentSkillDirs = (): string[] => {
const dirs = new Set<string>();
for (const cli of listClis()) {
const dir = agentSkillDir(cli.capabilities);
if (dir) dirs.add(dir);
}
return [...dirs];
};
/** A live session in a worktree, as the list reports it to a caller who may see it. */
export interface WorktreeSessionRef {
id: string;
name: string;
}
/** One row of `GET /api/cases/:name/worktrees`. */
export interface CaseWorktreeInfo extends CaseWorktree {
/** Live sessions running in this worktree (deepest worktree wins), all owners. */
sessionCount: number;
/** The ones the caller may see (all of them outside multi-user mode). */
sessions: WorktreeSessionRef[];
}
export interface CaseWorktreeList {
caseName: string;
repoRoot: string;
/** `<repo>/.claude/worktrees`, where Codeman creates and removes worktrees. */
worktreesDir: string;
worktrees: CaseWorktreeInfo[];
truncated: boolean;
}
type Fail = ApiResponse<never>;
function failWith(reply: FastifyReply, code: ApiErrorCode, message: string): Fail {
reply.code(httpStatusForErrorCode(code));
return createErrorResponse(code, message);
}
function failFrom(reply: FastifyReply, failure: WorktreeFailure): Fail {
return failWith(reply, ApiErrorCode[failure.code], failure.message);
}
/**
* The LOCAL folder of case `name` for this caller, or the refusal. Mirrors quick-start's
* resolution (remote, then Docker, then linked, then the caller's case space) so a name means
* the same case here as it does to the Run button.
*/
async function resolveLocalCaseFolder(
req: FastifyRequest,
name: string
): Promise<{ ok: true; casePath: string } | { ok: false; code: ApiErrorCode; message: string }> {
if (!SAFE_CASE_NAME.test(name)) {
return { ok: false, code: ApiErrorCode.INVALID_INPUT, message: 'Invalid case name' };
}
const user = getAuthUser(req);
const configDir = getDataDir();
// Ownership folded into the match, as quick-start does: a foreign same-named remote/Docker
// case falls through to the caller's own local case instead of shadowing it.
if ((await readRemoteCases(configDir)).some((c) => c.name === name && canAccessOwned(user, c.owner))) {
return {
ok: false,
code: ApiErrorCode.INVALID_INPUT,
message: 'Worktrees are not available for remote (SSH) cases: the repository is on another machine.',
};
}
if ((await readDockerCases(configDir)).some((c) => c.name === name && canAccessOwned(user, c.owner))) {
return {
ok: false,
code: ApiErrorCode.INVALID_INPUT,
message:
'Worktrees are not available for Docker cases: a container can write to the workspace, and git would run its hooks and filters on this machine.',
};
}
const linkedCases = await readJsonConfig<Record<string, string>>(dataPath('linked-cases.json'), 'linked cases', {});
// Linked cases carry no owner: only admins resolve them in multi-user mode.
const linked = !isMultiUserMode() || isAdmin(req) ? linkedCases[name] : undefined;
const casePath = linked || validatePathWithinBase(name, resolveCasesDir(user));
if (!casePath) return { ok: false, code: ApiErrorCode.INVALID_INPUT, message: 'Invalid case path' };
if (!isWorkingDirAllowed(user, casePath)) {
return { ok: false, code: ApiErrorCode.FORBIDDEN, message: 'case path is outside your workspace' };
}
const kind = await probePathKind(casePath, { pastCap: true });
if (kind === 'unknown') {
return {
ok: false,
code: ApiErrorCode.OPERATION_FAILED,
message: describeUnknownPath('Case folder', casePath, { pastCap: true }),
};
}
if (kind === 'absent') return { ok: false, code: ApiErrorCode.NOT_FOUND, message: `Case "${name}" not found` };
if (kind !== 'directory')
return { ok: false, code: ApiErrorCode.INVALID_INPUT, message: 'Case path is not a folder' };
return { ok: true, casePath };
}
/**
* Live local sessions, with the folder each runs in as the case's repository spells it
* (`respellUnder`: a folder named through the case's own path, which can run through a symlink,
* is moved onto the repository's real top level, where git lists the worktrees). Remote sessions
* have no local folder.
*/
function liveLocalSessions(
ctx: SessionPort,
casePath: string,
repoRoot: string
): Array<{ id: string; name: string; owner?: string; dir: string }> {
const out: Array<{ id: string; name: string; owner?: string; dir: string }> = [];
for (const session of ctx.sessions.values()) {
if (session.remote || !session.workingDir || !session.workingDir.startsWith('/')) continue;
const dir = respellUnder(session.workingDir, casePath, repoRoot);
out.push({ id: session.id, name: session.name, owner: session.owner, dir });
}
return out;
}
export function registerWorktreeRoutes(
app: FastifyInstance,
ctx: SessionPort,
deps: { git?: WorktreeGitRunner; dockerWorkspaces?: () => Promise<string[]> } = {}
): void {
const dockerWorkspaces = deps.dockerWorkspaces ?? defaultDockerWorkspaces;
/**
* Case folder → its repository, or the refusal already rendered. `casePath` is the folder as
* the case resolves it (possibly through a symlink); `repo.repoRoot` is its real path.
*/
async function caseRepository(
req: FastifyRequest,
reply: FastifyReply,
name: string
): Promise<{ ok: true; repo: CaseRepository; casePath: string; dockerRoots: string[] } | { ok: false; fail: Fail }> {
const folder = await resolveLocalCaseFolder(req, name);
if (!folder.ok) return { ok: false, fail: failWith(reply, folder.code, folder.message) };
const dockerRoots = await dockerWorkspaces();
const repo = await resolveCaseRepository(folder.casePath, { git: deps.git, dockerWorkspaces: dockerRoots });
if (!repo.ok) return { ok: false, fail: failFrom(reply, repo) };
return { ok: true, repo: repo.repo, casePath: folder.casePath, dockerRoots };
}
app.get('/api/cases/:name/worktrees', async (req, reply): Promise<ApiResponse<CaseWorktreeList>> => {
const { name } = req.params as { name: string };
const resolved = await caseRepository(req, reply, name);
if (!resolved.ok) return resolved.fail;
const { repo, casePath } = resolved;
const listed = await listCaseWorktrees(repo, { git: deps.git });
if (!listed.ok) return failFrom(reply, listed);
// Each live session belongs to the DEEPEST worktree holding its folder, so a session in a
// managed worktree is not also counted against the main checkout that contains it.
const user = getAuthUser(req);
const rows: CaseWorktreeInfo[] = listed.worktrees.map((wt) => ({ ...wt, sessionCount: 0, sessions: [] }));
for (const session of liveLocalSessions(ctx, casePath, repo.repoRoot)) {
let best: CaseWorktreeInfo | undefined;
for (const row of rows) {
if (!isPathWithin(session.dir, row.path)) continue;
if (!best || row.path.length > best.path.length) best = row;
}
if (!best) continue;
best.sessionCount += 1;
if (canAccessOwned(user, session.owner)) best.sessions.push({ id: session.id, name: session.name });
}
return {
success: true,
data: {
caseName: name,
repoRoot: repo.repoRoot,
worktreesDir: managedWorktreesDir(repo.repoRoot),
worktrees: rows,
truncated: listed.truncated,
},
};
});
app.post('/api/cases/:name/worktrees', async (req, reply): Promise<ApiResponse<{ worktree: CreatedWorktree }>> => {
const { name } = req.params as { name: string };
const body = parseBody(CreateWorktreeSchema, req.body);
const nameError = worktreeNameError(body.name);
if (nameError) return failWith(reply, ApiErrorCode.INVALID_INPUT, nameError);
const resolved = await caseRepository(req, reply, name);
if (!resolved.ok) return resolved.fail;
const created = await createCaseWorktree(resolved.repo, body, {
git: deps.git,
agentSkillDirs: registryAgentSkillDirs(),
});
if (!created.ok) return failFrom(reply, created);
reply.code(201);
return { success: true, data: { worktree: created.worktree } };
});
app.delete(
'/api/cases/:name/worktrees/:worktree',
async (req, reply): Promise<ApiResponse<{ removed: RemovedWorktree }>> => {
const { name, worktree } = req.params as { name: string; worktree: string };
const query = parseBody(RemoveWorktreeQuerySchema, req.query ?? {});
const nameError = worktreeNameError(worktree);
if (nameError) return failWith(reply, ApiErrorCode.INVALID_INPUT, nameError);
const resolved = await caseRepository(req, reply, name);
if (!resolved.ok) return resolved.fail;
const removed = await removeCaseWorktree(
resolved.repo,
{
name: worktree,
deleteBranch: query.deleteBranch === 'true' || query.deleteBranch === '1',
// Any owner's session blocks: removing the folder under a running agent is the damage
// this refusal exists to prevent, whoever started it.
isInUse: (path) =>
liveLocalSessions(ctx, resolved.casePath, resolved.repo.repoRoot).some((s) => isPathWithin(s.dir, path)),
},
// The worktree folder itself must not be (or hold) a Docker case workspace either.
{ git: deps.git, dockerWorkspaces: resolved.dockerRoots }
);
if (!removed.ok) return failFrom(reply, removed);
return { success: true, data: { removed: removed.removed } };
}
);
}
+45 -1
View File
@@ -707,7 +707,16 @@ export const StatusTelemetrySchema = z.object({
miss_recache_tokens: z.number().nullish(),
last_miss_at: z.number().nullish(),
recache_tokens_if_cold: z.number().nullish(),
last_miss_cause: z.object({ causes: z.array(z.unknown()).max(20).nullish() }).nullish(),
// Keep the first 20 causes instead of 400-ing the whole post (which also drops the
// footer, the plan chip and the model report it carries) when Claude Code reports more. The
// preprocess slices BEFORE the array validates, so an over-long array costs one bounded
// slice, never per-element validation across every entry (parsePromptCache then keeps
// the string ones among those 20).
last_miss_cause: z
.object({
causes: z.preprocess((v) => (Array.isArray(v) ? v.slice(0, 20) : v), z.array(z.unknown()).nullish()),
})
.nullish(),
})
.nullish(),
})
@@ -759,6 +768,34 @@ export const ClonePreflightSchema = z.object({
repository: z.string().min(1).max(2048),
});
// ========== Git worktrees of a case (src/git-worktrees.ts) ==========
/**
* A worktree name: one segment of letters, digits, `.`, `_`, `-`, starting with a letter or
* digit, at most 64 characters (Claude Code's own `--worktree` limit). The finer rules (no `..`,
* no trailing `.lock`) are `worktreeNameError()`, which every route applies after this.
*/
const worktreeNameSchema = z
.string()
.min(1)
.max(64)
.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, 'Invalid worktree name. Use letters, digits, ".", "_" and "-".');
/** Schema for POST /api/cases/:name/worktrees: a new worktree on a new branch. */
export const CreateWorktreeSchema = z.object({
name: worktreeNameSchema,
/** New branch name; default `worktree-<name>`, Claude Code's convention. */
branch: z.string().min(1).max(200).optional(),
/** Start point (branch, tag or commit); default the case's current HEAD. */
base: z.string().min(1).max(200).optional(),
});
/** Query of DELETE /api/cases/:name/worktrees/:worktree. */
export const RemoveWorktreeQuerySchema = z.object({
/** Also `git branch -d` the worktree's branch (never `-D`). */
deleteBranch: z.enum(['true', 'false', '1', '0']).optional(),
});
const RemoteCommandOverridesSchema = z
.object({
shell: z.string().min(1).max(300).optional(),
@@ -1139,6 +1176,13 @@ export const QuickStartSchema = z.object({
parentSessionId: parentSessionIdSchema,
/** Tab group to place the new session in: see tabGroupIdSchema. */
tabGroupId: tabGroupIdSchema,
/**
* Start the session in this git worktree of the case (`<case>/.claude/worktrees/<worktree>`,
* created through POST /api/cases/:name/worktrees) instead of the case folder. Local cases
* only: refused (400) for remote and Docker cases. The worktree must be a registered worktree
* of the case's repository; it is never created here.
*/
worktree: worktreeNameSchema.optional(),
/** Model override written to <case>/.claude/settings.local.json (e.g. "opus[1m]").
* Empty string clears. Applied for local AND docker cases (the docker workspace is
* a real host dir, so the settings file crosses the bind mount); rejected for
+135
View File
@@ -0,0 +1,135 @@
/**
* @fileoverview Screen-detected blocked dialogs: what the server does with them.
*
* A CLI that has no hook to say "I stopped to ask you something" (codex: `hooks: 'none'`,
* and the one CLI Codeman launches with its approvals on by default) declares
* `capabilities.workDetect.blockedLine`, and `Session` reads its dialogs off the pane and
* emits `blockedChanged` (see `_noteBlockedReading()` in session.ts). This module turns
* that edge into exactly what a Claude `permission_prompt` hook produces in
* `hook-event-routes.ts`, so every surface that already knows that event (the red tab
* alert and desktop notification, web push, webhooks, the phone overview's NEEDS YOU, the
* Approvals Inbox, `codeman tui`, the run summary and the `blocked` wait signal) needs no
* second code path:
*
* - `raiseScreenBlocked()`: the `blocked` wait signal, an Approvals Inbox item, the
* `hook:permission_prompt` broadcast and the push/webhook.
* - `clearScreenBlocked()`: resolve that item, which broadcasts `approval:resolved`, and
* every client clears the alert from it (`_onApprovalResolved`, approvals-ui.js).
*
* Rules it keeps:
* - ⚠️ ALERT ONLY. The item is `answerInTerminal` (the #610 shape the DeepSeek Harness
* items use): the answer route refuses it, push carries no Approve/Deny, and the inbox
* card and the overview strip say "answer in the terminal". The dialog is read
* off a screen the agent writes most of, so a forged match must cost a false alert and
* never a keystroke. Codeman does not answer these dialogs.
* - The item gets no `capture` callback, only the frame it was raised from as context:
* the inbox's option parser and staleness sweep are shaped for Claude's dialogs, and the
* SESSION owns this item's lifetime (it re-reads the screen while the dialog is up).
* - Hooks win. A session whose CLI reports blocked itself (`hooksAvailableForMode()`)
* gets nothing here, so a CLI that declared both could never alert twice for one dialog.
* - Only the item this module opened is resolved on clear, never a hook-raised one.
*
* @dependencies approval-inbox, session-wait-registry, sse-events, cli-registry
* @consumedby web/session-listener-wiring (blockedChanged)
* @module web/screen-blocked
*/
import { getCli } from '../config/cli-registry/registry.js';
import type { SessionMode } from '../types.js';
import { approvalInbox } from './approval-inbox.js';
import { sessionWaits, hooksAvailableForMode, sessionHookOptions } from './session-wait-registry.js';
import { SseEvent } from './sse-events.js';
/** Marks a screen-read event, so a consumer can tell it from a hook-raised one. */
export const SCREEN_BLOCKED_SOURCE = 'screen';
/** The facts about a session this module reads (structural, so tests need no `Session`). */
export interface ScreenBlockedSession {
id: string;
name: string;
mode: SessionMode;
deepSeekStatusReporting?: boolean;
docker?: unknown;
remote?: unknown;
}
/** What the server lends it: the same channels the hook route uses. */
export interface ScreenBlockedDeps {
broadcast(event: string, data: unknown): void;
broadcastSessionStateDebounced(sessionId: string): void;
sendPushNotifications(event: string, data: Record<string, unknown>): void;
getRunSummaryTracker?(
sessionId: string
): { recordHookEvent(event: string, data?: Record<string, unknown>): void } | undefined;
}
/**
* The line every surface shows for such a dialog. Built from the CLI's own label (data,
* never an id branch), and from nothing on the screen: the dialog's wording is the
* agent's to choose (a question's text, a command preview), so none of it reaches a push
* or a webhook. The approval card shows the frame itself, escaped.
*/
export function screenBlockedMessage(mode: SessionMode): string {
return `${getCli(mode)?.label ?? mode} is waiting for your answer in the terminal`;
}
/**
* The pane now shows a dialog its CLI is blocked on: raise everything a Claude
* `permission_prompt` hook would.
*
* @param frame the capture the dialog was read from, kept as the card's context
* @returns the id of the Approvals Inbox item opened, or null when nothing was raised
*/
export function raiseScreenBlocked(
session: ScreenBlockedSession,
deps: ScreenBlockedDeps,
frame?: string | null
): string | null {
if (hooksAvailableForMode(session.mode, sessionHookOptions(session))) return null;
const message = screenBlockedMessage(session.mode);
// Before anything that can throw, like the hook route: a caller parked on `blocked`
// is the reason this exists for agents.
sessionWaits.notifySignal(session.id, 'blocked');
const item = approvalInbox.notePrompt({
sessionId: session.id,
sessionName: session.name,
kind: 'permission',
message,
answerInTerminal: true,
context: frame ?? undefined,
});
const data = { source: SCREEN_BLOCKED_SOURCE, message };
deps.broadcast(SseEvent.HookPermissionPrompt, {
sessionId: session.id,
timestamp: Date.now(),
...data,
approvalId: item.id,
});
// Same ride-along as the hook route: the home screens rank the blocked group on the
// session's stamps, so the row moves without waiting for some unrelated event.
deps.broadcastSessionStateDebounced(session.id);
// No approvalId: push would offer Approve/Deny for a dialog nobody here may answer.
deps.sendPushNotifications(SseEvent.HookPermissionPrompt, {
sessionId: session.id,
sessionName: session.name,
...data,
});
deps.getRunSummaryTracker?.(session.id)?.recordHookEvent('permission_prompt', data);
return item.id;
}
/**
* The dialog left the screen (answered, cancelled, or the pane went away): resolve the
* item `raiseScreenBlocked()` opened, which clears the alert on every client. An item
* that is no longer that one (dismissed, expired, or superseded by a hook) is left alone.
*
* @param itemId what `raiseScreenBlocked()` returned
*/
export function clearScreenBlocked(sessionId: string, itemId: string | null): void {
if (!itemId) return;
if (approvalInbox.getForSession(sessionId)?.id !== itemId) return;
approvalInbox.resolveForSession(sessionId, 'resolved_in_terminal');
}
+11
View File
@@ -112,6 +112,7 @@ import { sessionWaits } from './session-wait-registry.js';
import { intentStore } from '../intent-store.js';
import { AI_CHECK_MODEL } from '../config/ai-defaults.js';
import { approvalInbox } from './approval-inbox.js';
import { SCREEN_BLOCKED_SOURCE } from './screen-blocked.js';
import { stopDeepSeekWeb } from '../deepseek-web-server.js';
import {
wireRespawnListeners,
@@ -191,6 +192,7 @@ import {
registerStatusTelemetryRoutes,
registerSystemRoutes,
registerCaseRoutes,
registerWorktreeRoutes,
registerSessionRoutes,
registerRespawnRoutes,
registerRalphRoutes,
@@ -1153,6 +1155,7 @@ export class WebServer extends EventEmitter {
registerStatusTelemetryRoutes(this.app, ctx);
registerSystemRoutes(this.app, ctx);
registerCaseRoutes(this.app, ctx);
registerWorktreeRoutes(this.app, ctx);
// The registry's lifetime is the server's: it drops per-session wake state on every
// cleanup path and resolves in-flight wakes on shutdown.
this.remoteWake = registerSessionRoutes(this.app, ctx);
@@ -2835,6 +2838,14 @@ export class WebServer extends EventEmitter {
} else if (event === SseEvent.HookPermissionPrompt && data.tool_name) {
body += body ? ' ' : '';
body += `Tool: ${String(data.tool_name)}`;
} else if (event === SseEvent.HookPermissionPrompt && data.source === SCREEN_BLOCKED_SOURCE && data.message) {
// A dialog read off a hook-less CLI's screen (web/screen-blocked.ts), whose message
// is built from the CLI's label, never from the screen. Keyed on the source so a
// Claude hook's own `message` keeps the body it always had; capped like the error
// text, since a hook poster holding the secret can claim the source too (as it can
// already claim any `tool_name` above).
body += body ? ' ' : '';
body += String(data.message).slice(0, 200);
}
return body;
}
+21
View File
@@ -29,6 +29,7 @@ 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 { raiseScreenBlocked, clearScreenBlocked } from './screen-blocked.js';
import { composeAutoSessionName, deriveAutoSessionName } from '../session-auto-name.js';
import { MAX_SESSION_NAME_LENGTH } from '../config/terminal-limits.js';
@@ -44,6 +45,7 @@ export interface SessionListenerRefs {
working: () => void;
idle: () => void;
watchingChanged: () => void;
blockedChanged: (blocked: boolean, frame: string | null) => void;
displayModelChanged: () => void;
taskCreated: (task: BackgroundTask) => void;
taskUpdated: (task: BackgroundTask) => void;
@@ -98,6 +100,8 @@ interface SessionListenerDeps {
* Call `attachSessionListeners()` after to wire them to the session.
*/
export function createSessionListeners(session: Session, deps: SessionListenerDeps): SessionListenerRefs {
/** The Approvals Inbox item the session's last screen-read dialog opened (see blockedChanged). */
let screenBlockedItemId: string | null = null;
return {
// ─── Terminal Output ─────────────────────────────────────
@@ -276,6 +280,21 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
deps.broadcastSessionStateDebounced(session.id);
},
/**
* The pane started or stopped showing a dialog its CLI is blocked on, read off the
* screen for a CLI with no hook to report it (`capabilities.workDetect.blockedLine`:
* codex). Raised as a Claude `permission_prompt` hook would be, alert-only, and
* cleared by resolving the item it opened; web/screen-blocked.ts has the rules.
*/
blockedChanged: (blocked: boolean, frame: string | null) => {
if (blocked) {
screenBlockedItemId = raiseScreenBlocked(session, deps, frame);
} else {
clearScreenBlocked(session.id, screenBlockedItemId);
screenBlockedItemId = null;
}
},
/**
* Pushes the session state when the model the CLI reports changes (its statusline,
* or its own footer), which the status does not reflect. Persisted too: a restart
@@ -519,6 +538,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
session.on('working', refs.working);
session.on('idle', refs.idle);
session.on('watchingChanged', refs.watchingChanged);
session.on('blockedChanged', refs.blockedChanged);
session.on('displayModelChanged', refs.displayModelChanged);
session.on('taskCreated', refs.taskCreated);
session.on('taskUpdated', refs.taskUpdated);
@@ -557,6 +577,7 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
session.off('working', refs.working);
session.off('idle', refs.idle);
session.off('watchingChanged', refs.watchingChanged);
session.off('blockedChanged', refs.blockedChanged);
session.off('displayModelChanged', refs.displayModelChanged);
session.off('taskCreated', refs.taskCreated);
session.off('taskUpdated', refs.taskUpdated);
+35 -14
View File
@@ -28,11 +28,17 @@
* `idle` / `working` / `exit` come from `Session`'s own events (heuristic: output
* stabilization plus prompt detection), so `idle` can flap mid-turn when a spinner
* pauses. `stop` and `blocked` come from Claude Code hooks (`POST /api/hook-event`)
* and are definitive. Only `claude` mode installs those hooks, so `stop` and
* `blocked` never fire for anything else: callers must be rejected up front rather
* than left to hit the timeout. `hooksAvailableForMode()` keeps that rule in one
* place, and it is keyed on the MODE rather than on `isExternalCliMode()` because
* `shell` is not an external CLI yet installs no hooks either.
* and are definitive. Only `claude` mode installs those hooks (and `deepseek`'s status
* bridge reports the same two), so `stop` never fires for anything else: callers must
* be rejected up front rather than left to hit the timeout. `hooksAvailableForMode()`
* keeps that rule in one place, and it is keyed on the MODE rather than on
* `isExternalCliMode()` because `shell` is not an external CLI yet installs no hooks
* either.
*
* `blocked` has a second source: a CLI with no hook for it can declare the dialogs it
* blocks on (`capabilities.workDetect.blockedLine`, codex), and `Session` reads them off
* the screen (`web/screen-blocked.ts`). `blockedSignalAvailable()` is the rule for that
* signal. `stop` has no such source, because nothing on a screen says a turn is over.
*
* ## Ordering contract for the wiring
*
@@ -152,7 +158,9 @@ export function parseWaitSignals(raw: unknown): ParsedWaitSignals {
* `stopped` and `error` both map to `exit` because both mean "not running", so a
* caller that does catch one of them does not block. `blocked` is deliberately
* underivable here, it exists only as a hook event until it becomes a real
* `SessionStatus` (see the deferred Part 3 in the plan).
* `SessionStatus` (see the deferred Part 3 in the plan). A screen-read dialog IS a
* level a later caller can find (`Session.screenBlocked`), and the wait route answers
* that one itself.
*/
export function signalForStatus(status: SessionStatus): WaitSignal | null {
switch (status) {
@@ -168,9 +176,6 @@ export function signalForStatus(status: SessionStatus): WaitSignal | null {
}
}
/** Signals that arrive only via Claude Code hooks, so only `claude` mode can emit them. */
const HOOK_ONLY_SIGNALS: readonly WaitSignal[] = ['stop', 'blocked'];
/** Per-session facts that can turn a mode's hook capability OFF for one session. */
export interface HookCapabilityOptions {
/**
@@ -266,6 +271,19 @@ export function sessionHookOptions(session: {
};
}
/**
* Whether `blocked` can ever fire for this session: its CLI reports it through hooks
* (`hooksAvailableForMode()`), or Codeman reads the dialogs it blocks on off its screen
* (`capabilities.workDetect.blockedLine`, codex). The screen half needs no per-session
* facts: a remote or Docker pane is still a local tmux pane showing the CLI's screen
* (their nested tmux runs with `status off`, so nothing is drawn below the CLI).
*
* ⚠️ Says nothing about `stop`. A screen shows a dialog; it never says a turn ended.
*/
export function blockedSignalAvailable(mode: SessionMode, options: HookCapabilityOptions = {}): boolean {
return hooksAvailableForMode(mode, options) || !!getCli(mode)?.capabilities.workDetect?.blockedLine;
}
/** Outcome of resolving a caller-supplied wait target against a session's mode. */
export interface ResolvedWaitSignals {
/** Signals to actually wait on. Empty when `error` is set. */
@@ -279,9 +297,9 @@ export interface ResolvedWaitSignals {
* every wait endpoint shares:
*
* 1. An unknown token is an ERROR, never a silent fallback to the default.
* 2. Hook-only signals are rejected when asked for EXPLICITLY on a mode that emits
* no hooks, but merely dropped from the DEFAULT set: omitting the parameter must
* never 400.
* 2. A signal the session can never emit (`stop` without hooks, `blocked` without
* hooks or a screen-read `blockedLine`) is rejected when asked for EXPLICITLY, but
* merely dropped from the DEFAULT set: omitting the parameter must never 400.
*
* Shared by `GET .../wait` and the `wait` field on `POST .../input` so the two can
* not drift; the second-guessing that produces is worse than the duplication.
@@ -304,7 +322,9 @@ export function resolveWaitSignals(
};
}
const unsupported = new Set<WaitSignal>(hooksAvailableForMode(options.mode, options) ? [] : HOOK_ONLY_SIGNALS);
const unsupported = new Set<WaitSignal>();
if (!hooksAvailableForMode(options.mode, options)) unsupported.add('stop');
if (!blockedSignalAvailable(options.mode, options)) unsupported.add('blocked');
if (parsed.signals.length === 0) {
return { until: DEFAULT_WAIT_SIGNALS.filter((signal) => !unsupported.has(signal)), error: null };
@@ -324,7 +344,8 @@ export function resolveWaitSignals(
`a remote host, where the local status bridge cannot reach the harness. Use idle or exit.`
: `Signal(s) ${rejected.join(', ')} never fire for this deepseek session: its status bridge is off ` +
`(deepSeekConfig.statusReporting: false), so nothing posts hook events. Use idle or exit.`
: `Signal(s) ${rejected.join(', ')} never fire for ${options.mode} sessions (no Claude Code hooks). Use idle or exit.`,
: `Signal(s) ${rejected.join(', ')} never fire for ${options.mode} sessions (no Claude Code hooks). ` +
`Use ${unsupported.has('blocked') ? 'idle or exit' : 'idle, blocked or exit'}.`,
};
}
return { until: parsed.signals, error: null };
+7 -2
View File
@@ -26,7 +26,8 @@
* - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed
* - **Image / attachments** (2): image:detected, attachment:detected
* - **Hooks** (10): idle_prompt, permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, stop, agent_working, teammate_idle, task_completed, prompt_submitted
* (agent_working is the odd one out: reported by the DeepSeek Harness status bridge, not by a Claude Code hook)
* (agent_working is the odd one out: reported by the DeepSeek Harness status bridge, not by a Claude Code hook;
* permission_prompt also arrives with `source: 'screen'` for a codex dialog read off its screen)
* - **Approvals** (3): pending, updated, resolved (cross-session Approvals Inbox)
* - **Custom Model Endpoint Profiles** (1): swapped-out (a session's model got evicted by another session on the same llama-swap endpoint)
* - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error
@@ -365,7 +366,11 @@ export const AttachmentDetected = 'attachment:detected' as const;
/** Claude Code hook: session idle, waiting for input. */
export const HookIdlePrompt = 'hook:idle_prompt' as const;
/** Claude Code hook: tool requesting permission. */
/**
* Claude Code hook: tool requesting permission. Also raised by the server itself, with
* `source: 'screen'`, for a dialog read off a hook-less CLI's screen (codex; see
* `web/screen-blocked.ts`), which is cleared by `approval:resolved`.
*/
export const HookPermissionPrompt = 'hook:permission_prompt' as const;
/** Claude Code hook: elicitation dialog (Claude asking a question). */
export const HookElicitationDialog = 'hook:elicitation_dialog' as const;