mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
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:
commit
14a9e66547
159 files changed
+10409
-591
No files matched your search
+1
-1
@@ -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>',
|
||||
|
||||
@@ -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() })
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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 & 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">×</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-<name> 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 & 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">⑂</span>
|
||||
Worktrees
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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?.();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user