mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
chore: merge upstream master and retain tracked scrolling fixes
Resolve Copilot forwarding and registry/documentation overlaps against upstream e63bb65b. Reuse upstream always policy for measured version-independent forwarders and test slow clicks for Claude, Codex and Copilot.
This commit is contained in:
commit
dbb41c70cf
228 files changed
+15240
-862
No files matched your search
@@ -7,10 +7,12 @@
|
||||
* used to change only the tab, so `/resume` kept listing the old name.
|
||||
*
|
||||
* Appending the row is enough for a pane that was spawned WITHOUT `--name`
|
||||
* (every placeholder- or auto-named tab, see `Session.cliPinnedName`): that
|
||||
* process holds no title of its own and never writes one back. A process that
|
||||
* WAS spawned with `--name` re-appends its in-memory title after each turn, so
|
||||
* there the new title holds from the next spawn, which pins the new name.
|
||||
* (every placeholder- or auto-named tab, unless the opt-in `pinCliSessionName`
|
||||
* setting is on, see `Session.cliPinnedName`): that process holds no title of
|
||||
* its own and never writes one back. A process that WAS spawned with `--name`
|
||||
* (every tab with `pinCliSessionName` on) re-appends its in-memory title after
|
||||
* each turn, so there the new title holds only until the next turn, then from
|
||||
* the next spawn, which pins the new name.
|
||||
*
|
||||
* @module claude-session-title
|
||||
*/
|
||||
|
||||
+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>',
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
export type TokenPattern =
|
||||
| 'model'
|
||||
| 'model-claude'
|
||||
| 'model-plain'
|
||||
| 'model-pi'
|
||||
| 'id'
|
||||
| 'id-dotted'
|
||||
@@ -43,6 +44,9 @@ const PATTERNS: Record<TokenPattern, RegExp> = {
|
||||
model: /^[a-zA-Z0-9._\-/]+$/,
|
||||
// buildSpawnCommand's claude branch — `[` and `]` for bracketed model aliases
|
||||
'model-claude': /^[a-zA-Z0-9._\-[\]]+$/,
|
||||
// copilot --model. Same characters as `model`, but a leading alphanumeric: the value follows a
|
||||
// flag, so `--yolo` must not pass as a model id and become its own flag (the allowAll clamp bypass).
|
||||
'model-plain': /^[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/,
|
||||
// buildPiCommand — `:` for a thinking suffix (`sonnet:high`), `/` for `provider/id`
|
||||
'model-pi': /^[a-zA-Z0-9._\-/:]+$/,
|
||||
// opencode --session, codex resume
|
||||
|
||||
@@ -295,7 +295,7 @@ const capabilitiesSchema = z
|
||||
altScreen: z.enum(['strip-full', 'strip-mux-only', 'strip-mux-and-mouse', 'preserve']),
|
||||
echo: echoSchema,
|
||||
wheelForward: z
|
||||
.object({ mode: z.enum(['never', 'version-gated', 'mouse-tracking']), minVersion: z.string().max(20).optional() })
|
||||
.object({ mode: z.enum(['never', 'version-gated', 'always']), minVersion: z.string().max(20).optional() })
|
||||
.strict(),
|
||||
// Whether a hollow pane's wheel/touch scroll may be turned into PageUp/PageDown for
|
||||
// the CLI to page its own transcript. Distinct from wheelForward (SGR reports).
|
||||
@@ -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() })
|
||||
@@ -444,8 +464,10 @@ const capabilitiesSchema = z
|
||||
)
|
||||
.max(8),
|
||||
// Exact env var NAMES, not prefixes: this list is a targeted deny, and a prefix here
|
||||
// would let one entry silently strip a whole namespace off every owner's overrides.
|
||||
privilegedEnvKeys: z.array(envName).max(8),
|
||||
// would let one entry silently strip a whole namespace off every owner's overrides. The
|
||||
// bound is generous because a longer list only takes more away from non-granted owners:
|
||||
// copilot's COPILOT_ prefix covers dozens of variables that run code or redirect its token.
|
||||
privilegedEnvKeys: z.array(envName).max(48),
|
||||
gates: z.record(z.string(), z.object({ minVersion: z.string().max(20), failClosed: z.boolean() }).strict()),
|
||||
maxFrameBytes: z.number().int().positive().optional(),
|
||||
newline: z.enum(['line-feed', 'esc-enter']).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,
|
||||
@@ -733,7 +768,7 @@ const CODEX: CliEntry = {
|
||||
transcript: 'codex-rollout',
|
||||
altScreen: 'strip-full',
|
||||
echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' },
|
||||
wheelForward: { mode: 'mouse-tracking' }, // 0.163.0 fullscreen: measured SGR scrolling; require observed tracking
|
||||
wheelForward: { mode: 'always' }, // 0.163.0 fullscreen: measured SGR scrolling; require observed tracking
|
||||
// Without observed mouse tracking, retain PageUp/PageDown fallback. Measured on 0.157.1 (local,
|
||||
// alternate screen inside tmux, so xterm holds no history) and 0.160 (remote): PageUp
|
||||
// moved the visible transcript while SGR wheel reports and local scrolling did nothing.
|
||||
@@ -961,6 +996,30 @@ const ANTIGRAVITY: CliEntry = {
|
||||
...AGENTS_DIR_SKILL,
|
||||
altScreen: 'strip-mux-only',
|
||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
||||
// Measured on a live `agy` 1.3.3 pane (capture-pane every second through a prose turn
|
||||
// and a shell-call turn, plus the raw PTY stream, 2026-10-11). A running turn animates
|
||||
// `⣯ Generating...` (the label also reads `Working...` and `Loading...`) one row above
|
||||
// the composer, and the footer's left end reads `esc to cancel`; at rest the footer
|
||||
// reads `? for shortcuts` and nothing else on screen matches. A shell call replaces the
|
||||
// composer with a `Run this command?` list, the spinner stops, and the pane goes
|
||||
// silent. Nothing arms the idle check while the list is up (the footer hint is drawn only
|
||||
// at rest), so a pending approval reads as working until it is answered. That differs from
|
||||
// gemini, whose `▀` bar comes back on every spinner repaint.
|
||||
// ⚠️ The frames are the 8-dot braille set (`⣯ ⡿ ⣻ ⣾ ⣷ ⣟ ⢿ ⣽`), none of which is in
|
||||
// SPINNER_PATTERN's Claude set, and this entry used to declare nothing, so no frame
|
||||
// ever marked an antigravity tab working and it never showed the activity spinner.
|
||||
// The composer's `>` is no arming mark: the submitted prompt is echoed as `> <text>`
|
||||
// too. The footer hint is drawn only at rest, and the turn's last repaint carries it.
|
||||
// The schema caps a glyph at 8 characters, so this is the head of `? for shortcuts`. It also
|
||||
// keeps the submit verifier inert (no row starts with it and holds prompt text).
|
||||
// The working line is anchored to the start of a row, like gemini's braille branch: the
|
||||
// idle probe tests it against the whole pane, and an answer that QUOTES the spinner row
|
||||
// (an agent explaining this entry) would otherwise hold the tab busy while it is on screen.
|
||||
// Every `agy` capture draws the spinner at column 0.
|
||||
workDetect: {
|
||||
promptGlyph: '? for sh',
|
||||
workingLine: String.raw`(?:^|\n)[⠀-⣿] [A-Z][A-Za-z ]{2,30}(?:\.\.\.|…)`,
|
||||
},
|
||||
// Like codex: an ABSENT config already defaults safe (no bypass flag), so only a
|
||||
// SENT config needs the flag forced off — nothing is materialized.
|
||||
privilegedParams: [{ param: 'dangerouslySkipPermissions', clampTo: false }],
|
||||
@@ -1300,8 +1359,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',
|
||||
@@ -1611,5 +1676,236 @@ const OMP: CliEntry = {
|
||||
},
|
||||
};
|
||||
|
||||
// GitHub Copilot CLI (`copilot`, github/copilot-cli, npm @github/copilot). Measured on a live
|
||||
// 1.0.94 pane inside tmux (capture-pane through real turns, a shell tool call and /model,
|
||||
// 2026-10-09). It is an ALTERNATE-SCREEN TUI with mouse tracking on (tmux `alternate_on=1`,
|
||||
// `mouse_any_flag=1`, `history_size=0`), so like opencode its mouse DECSETs are stripped
|
||||
// (`altScreen: 'strip-mux-and-mouse'` below): a drag selects locally and the wheel is forwarded.
|
||||
// Keystrokes sent with `tmux send-keys` reach its composer and a shell tool call completes
|
||||
// inside tmux, which github/copilot-cli#4180 (a driven PTY ignoring input) and #4223 (a shell
|
||||
// command never reported done under tmux) say they do not on 1.0.70 to 1.0.74: neither
|
||||
// reproduces on 1.0.94, and the register of `version` below is what would show a regression.
|
||||
const COPILOT: CliEntry = {
|
||||
id: 'copilot' as CliEntry['id'],
|
||||
label: 'GitHub Copilot',
|
||||
shortBadge: 'CP',
|
||||
accent: '#a371f7',
|
||||
enabled: true,
|
||||
stock: true,
|
||||
order: 100,
|
||||
kind: 'agent',
|
||||
discovery: {
|
||||
binaries: ['copilot'],
|
||||
searchDirs: [HOME_DIRS.local, HOME_DIRS.usrLocal, HOME_DIRS.bunBin, HOME_DIRS.npmGlobal, HOME_DIRS.homeBin],
|
||||
// `copilot --version` prints `GitHub Copilot CLI 1.0.94.`; anchoring on the product name
|
||||
// keeps an unrelated `copilot` binary on PATH from passing the probe.
|
||||
version: { arg: '--version', regex: 'GitHub Copilot CLI (\\d+\\.\\d+\\.\\d+)', requireVersionMatch: true },
|
||||
install: {
|
||||
command: { linux: 'npm install -g @github/copilot', darwin: 'npm install -g @github/copilot' },
|
||||
npmPackage: '@github/copilot',
|
||||
docsUrl: 'https://github.com/github/copilot-cli',
|
||||
},
|
||||
},
|
||||
// `--yolo` (allow every tool, path and URL) is opt-in through `allowAll`, which the Run button
|
||||
// sends like the other agent CLIs' bypass switches; an absent config spawns a bare `copilot`
|
||||
// in its own Manual Approval mode. `--model` and `--resume=<id>` are Copilot's own flags
|
||||
// (`copilot --help`, 1.0.94). `--resume` takes an id, id prefix or session NAME and its value
|
||||
// is optional, so the token must be a single plain word that cannot start with `-`: a value
|
||||
// like `--yolo` would otherwise be parsed as its own flag and get around the `allowAll` clamp.
|
||||
launch: {
|
||||
params: {
|
||||
allowAll: { type: 'bool' },
|
||||
// `model-plain`, not `model`: the leading alphanumeric keeps a flag-shaped value out.
|
||||
model: { type: 'token', pattern: 'model-plain' },
|
||||
// `path-segment`, not `id-dotted`: the leading alphanumeric keeps a flag-shaped value out.
|
||||
resumeId: { type: 'token', pattern: 'path-segment' },
|
||||
continueSession: { type: 'bool' },
|
||||
// The tab's name, so the CLI's own session list reads the same as the tab.
|
||||
sessionName: { type: 'engine', source: 'sessionName' },
|
||||
},
|
||||
variants: [
|
||||
{
|
||||
id: 'default',
|
||||
args: [
|
||||
{ lit: 'copilot' },
|
||||
{ flag: '--yolo', when: { param: 'allowAll', is: true } },
|
||||
// `--name` is refused next to `--resume` and `--continue` (copilot 1.0.95: "cannot be used
|
||||
// with"), and a resumed session keeps the name it was created with.
|
||||
{
|
||||
flag: '--name',
|
||||
valueFrom: 'sessionName',
|
||||
quote: 'double',
|
||||
when: {
|
||||
allOf: [
|
||||
{ param: 'sessionName', state: 'set' },
|
||||
{ param: 'resumeId', state: 'unset' },
|
||||
{ not: { param: 'continueSession', is: true } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } },
|
||||
{ flag: '--resume', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } },
|
||||
{
|
||||
lit: '--continue',
|
||||
when: {
|
||||
allOf: [
|
||||
{ param: 'continueSession', is: true },
|
||||
{ param: 'resumeId', state: 'unset' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
legacyConfigAliases: { resumeId: 'resumeSessionId' },
|
||||
legacyConfigField: 'copilotConfig',
|
||||
resumeAppend: { style: 'flag', flag: '--resume' },
|
||||
},
|
||||
env: {
|
||||
exports: [{ name: 'COLORTERM', value: 'truecolor' }],
|
||||
unset: ['NO_COLOR'],
|
||||
// Sign-in is the CLI's own (`copilot login`, kept under ~/.copilot). A headless host sets
|
||||
// COPILOT_GITHUB_TOKEN, which the COPILOT_ prefix already admits. GH_TOKEN / GITHUB_TOKEN are
|
||||
// deliberately NOT allowlisted: the env allowlist is one global list with no mode context, so
|
||||
// admitting them would make them settable on every session (see allowedEnvPrefixes()).
|
||||
tmuxSetenvKeys: [],
|
||||
dockerExecEnvNames: [],
|
||||
allowedPrefixes: ['COPILOT_'],
|
||||
allowedKeys: [],
|
||||
},
|
||||
capabilities: {
|
||||
...agentDefaults(),
|
||||
// A full-screen TUI that turns mouse tracking on itself (measured on 1.0.94: alt screen,
|
||||
// mouse reporting on). Kept, xterm reports a plain drag to the TUI instead of selecting, so
|
||||
// Auto Copy and "mark text, copy on select" silently do nothing; stripped, a drag is a local
|
||||
// selection and clicks still reach the CLI through the browser's hand-encoded tap
|
||||
// (`cliMouseTracking`). Same shape and reason as opencode. The wheel keeps working because
|
||||
// `_shouldForwardWheelToApp` forwards it as SGR reports while the CLI is tracking.
|
||||
altScreen: 'strip-mux-and-mouse',
|
||||
// Forwards the wheel as SGR reports while the CLI is tracking the mouse, at any version
|
||||
// (`_shouldForwardWheelToApp` in terminal-ui.js has no version check for copilot).
|
||||
wheelForward: { mode: 'always' },
|
||||
// Pages its own transcript on PageUp/PageDown (1.0.95, measured in tmux: three PageUp moved
|
||||
// lines 109-140 to 010-042 and PageDown returned). This is the fallback when the
|
||||
// wheel-forward flag is stale after a server restart, so the wheel and a swipe never go dead.
|
||||
transcriptPageKeys: true,
|
||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
||||
// What `codeman agent spawn` waits for. The composer's footer hint, NOT the `❯` glyph: the
|
||||
// folder-trust dialog draws `❯ 1. Yes` for its selected row, so the glyph would call a worker
|
||||
// ready while it is still asking for trust (measured on 1.0.95: the dialog's footer reads
|
||||
// `↑/↓ to navigate`, the composer's `/ commands · ? help`).
|
||||
composerReadyMark: '/ commands · ? help',
|
||||
// The composer row is a `❯` between two rules, and the submitted prompt is echoed as
|
||||
// ` ❯ <text> <time>` above it. While a turn runs the footer's left end reads
|
||||
// `◉ Working esc edit prompt` (the dot alternates `◉` and `◎`); at rest it reads
|
||||
// `← open sidebar · Interactive · Manual Approval · / commands · ? help · tab next tab`.
|
||||
// A tool call that is waiting for approval replaces the composer and the footer's
|
||||
// `Working` goes with it, so it reads as idle (waiting on the user), like gemini.
|
||||
workDetect: {
|
||||
promptGlyph: '❯',
|
||||
workingLine: String.raw`[◉◎] Working\b`,
|
||||
},
|
||||
// The model is the right-hand field of the last row, both at rest and mid-turn
|
||||
// (`... tab next tab Claude Sonnet 5.5`, `◎ Working esc edit prompt GPT-6 Luna`).
|
||||
// Anchored on the footer's own words so a model-looking string in the transcript cannot match.
|
||||
modelDetect: {
|
||||
screenLine: String.raw`(?:tab next tab|\? help|esc edit prompt|github-mcp-server) {3,}([A-Za-z0-9][\w.:/@ -]{1,60}?) *(?:\n|$)`,
|
||||
screenLines: 2,
|
||||
},
|
||||
// `copilot mcp add` writes `~/.copilot/mcp-config.json`; COPILOT_HOME replaces ~/.copilot
|
||||
// (checked: `COPILOT_HOME=<dir> copilot mcp list` reads <dir>).
|
||||
mcpConfig: {
|
||||
path: '.copilot/mcp-config.json',
|
||||
format: 'copilot-json',
|
||||
relocation: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' },
|
||||
},
|
||||
// Every one of these matches the COPILOT_ allowedPrefix, so a non-granted multi-user owner
|
||||
// must not be able to set it through envOverrides. Read from the 1.0.98 bundle (copilot reads
|
||||
// far more COPILOT_* variables than `copilot help environment` lists), by what each hands back:
|
||||
// - approval: COPILOT_ALLOW_ALL turns every tool on; COPILOT_PLAN_THEN_AUTOPILOT switches a
|
||||
// plan into autopilot; COPILOT_ASSISTED_APPROVAL; COPILOT_HOME restates the permission config.
|
||||
// - code at boot or without an approval row: COPILOT_CLI_DIST_DIR runs `<dir>/index.js` in place
|
||||
// of the CLI, the package caches pick which CLI build runs, COPILOT_EDITOR,
|
||||
// COPILOT_PROVIDER_API_KEY_COMMAND and COPILOT_AHP_HOST_BIN are run as commands, skills and
|
||||
// provider configs are loaded from the named paths, and the hook switches widen what hooks run.
|
||||
// - credential redirects: the Copilot and GitHub API bases receive the CLI's sign-in token, which
|
||||
// in multi-user mode is the operator's (every user shares the OS account and ~/.copilot), and
|
||||
// the BYOK provider family aims a Codeman custom endpoint elsewhere.
|
||||
// The prefix stays because COPILOT_GITHUB_TOKEN and the harmless knobs live in it; a variable a
|
||||
// later release adds is NOT covered until it is listed here.
|
||||
privilegedEnvKeys: [
|
||||
'COPILOT_HOME',
|
||||
'COPILOT_ALLOW_ALL',
|
||||
'COPILOT_PLAN_THEN_AUTOPILOT',
|
||||
'COPILOT_ASSISTED_APPROVAL',
|
||||
'COPILOT_CLI_DIST_DIR',
|
||||
'COPILOT_CLI_RESOLVED_DIST_DIR',
|
||||
'COPILOT_PKG_CACHE_HOME',
|
||||
'COPILOT_CACHE_HOME',
|
||||
'COPILOT_EDITOR',
|
||||
'COPILOT_AHP_HOST_BIN',
|
||||
'COPILOT_DEBUG_VOICE_ENGINE_SCRIPT',
|
||||
'COPILOT_SKILLS_DIRS',
|
||||
'COPILOT_PROVIDERS_CONFIG',
|
||||
'COPILOT_HOOK_ALLOW_HTTP_AUTH_HOOKS',
|
||||
'COPILOT_HOOK_ALLOW_LOCALHOST',
|
||||
'COPILOT_E2E_TEST_HOOKS',
|
||||
'COPILOT_API_URL',
|
||||
'COPILOT_DEBUG_GITHUB_API_URL',
|
||||
'COPILOT_GH_HOST',
|
||||
'COPILOT_MC_BASE_URL',
|
||||
'COPILOT_AHP_URL',
|
||||
'COPILOT_DEBUG_ENTRA_BROKER_URL',
|
||||
'COPILOT_CAFE_METRICS_URL',
|
||||
'COPILOT_PROVIDER_BASE_URL',
|
||||
'COPILOT_PROVIDER_API_KEY',
|
||||
'COPILOT_PROVIDER_API_KEY_COMMAND',
|
||||
'COPILOT_PROVIDER_BEARER_TOKEN',
|
||||
'COPILOT_PROVIDER_HEADERS',
|
||||
'COPILOT_PROVIDER_TYPE',
|
||||
'COPILOT_PROVIDER_WIRE_MODEL',
|
||||
'COPILOT_PROVIDER_MODEL_ID',
|
||||
'COPILOT_PROVIDER_GHES_HOST',
|
||||
'COPILOT_PROVIDER_GHES_TOKEN',
|
||||
'COPILOT_MODEL',
|
||||
],
|
||||
// Grok/codex-shaped clamp: a bare spawn is already Manual Approval, so the multi-user clamp
|
||||
// only forces an EXPLICITLY-SENT `--yolo` back off; nothing is materialized when absent.
|
||||
privilegedParams: [{ param: 'allowAll', clampTo: false }],
|
||||
// BYOK (`copilot help environment`, 1.0.95): COPILOT_PROVIDER_BASE_URL switches the CLI to a
|
||||
// custom provider and drops the GitHub sign-in requirement; COPILOT_PROVIDER_TYPE defaults to
|
||||
// "openai", which is what a Codeman custom endpoint is (llama.cpp, vLLM, Ollama).
|
||||
// COPILOT_MODEL names the model and COPILOT_PROVIDER_MODEL_ID / _WIRE_MODEL default to it.
|
||||
// The OpenAI-style route is `<base>/v1/chat/completions`, so the base gets the `/v1` suffix
|
||||
// the same way deepseek's does. All four already match the COPILOT_ prefix allowlist, so
|
||||
// they are privileged env keys below: a non-granted multi-user owner must not be able to
|
||||
// redirect the session's endpoint or credentials through plain envOverrides.
|
||||
customModelInjection: {
|
||||
kind: 'env',
|
||||
baseUrlVar: 'COPILOT_PROVIDER_BASE_URL',
|
||||
apiKeyVar: 'COPILOT_PROVIDER_API_KEY',
|
||||
modelVars: ['COPILOT_MODEL'],
|
||||
appendV1Suffix: true,
|
||||
},
|
||||
},
|
||||
overlays: {
|
||||
// No credStore: Docker seeding is CRED_STORES in docker-hosts.ts, which has no `.copilot` row,
|
||||
// and the sign-in token sits in plain text in config.json on a host without a keyring, so it
|
||||
// is not copied into a container by default. Sign in inside the container instead.
|
||||
},
|
||||
};
|
||||
|
||||
/** The full stock catalog, in the order the run menu shows by default. */
|
||||
export const STOCK_CLIS: CliEntry[] = [CLAUDE, SHELL, OPENCODE, CODEX, GEMINI, ANTIGRAVITY, PI, GROK, DEEPSEEK, OMP];
|
||||
export const STOCK_CLIS: CliEntry[] = [
|
||||
CLAUDE,
|
||||
SHELL,
|
||||
OPENCODE,
|
||||
CODEX,
|
||||
GEMINI,
|
||||
ANTIGRAVITY,
|
||||
PI,
|
||||
GROK,
|
||||
DEEPSEEK,
|
||||
OMP,
|
||||
COPILOT,
|
||||
];
|
||||
@@ -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
|
||||
@@ -499,7 +525,7 @@ export interface CliCapabilities {
|
||||
* own mouse support must work in the pane (htop/vim in a shell).
|
||||
*
|
||||
* Stock CLIs: `strip-full` = claude, codex, gemini (Ink TUIs); `strip-mux-and-mouse` =
|
||||
* opencode (a full-screen TUI that enables tracking itself); `strip-mux-only` =
|
||||
* opencode and copilot (full-screen TUIs that enable tracking themselves); `strip-mux-only` =
|
||||
* antigravity, grok, deepseek, omp; `preserve` = shell, pi.
|
||||
*
|
||||
* A fourth combination is the point to split this into flags; three is still cheaper
|
||||
@@ -513,8 +539,16 @@ export interface CliCapabilities {
|
||||
/** Names a PREDICT_PROFILES key. Unknown or absent degrades to 'buffer', never to broken. */
|
||||
predictProfile?: string;
|
||||
};
|
||||
/** Wheel forwarding policy. Every enabled policy also requires observed CLI mouse tracking. */
|
||||
wheelForward: { mode: 'never' | 'version-gated' | 'mouse-tracking'; minVersion?: string };
|
||||
/**
|
||||
* Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback;
|
||||
* 'version-gated' forwards from `minVersion` on; 'always' forwards at any version. Either
|
||||
* forwarding mode applies only while the CLI has mouse tracking on (`cliMouseTracking`).
|
||||
*
|
||||
* ⚠️ Declared only: nothing reads this field yet (see DECLARED-FOR-LATER below). The browser's
|
||||
* gate is `_shouldForwardWheelToApp` in terminal-ui.js, which keeps its own hand-written
|
||||
* per-CLI rules, so changing a value here changes nothing until that gate is changed too.
|
||||
*/
|
||||
wheelForward: { mode: 'never' | 'version-gated' | 'always'; minVersion?: string };
|
||||
/**
|
||||
* The CLI pages its OWN transcript on PageUp/PageDown, so a wheel or touch scroll over
|
||||
* a pane with no local history can be turned into those keys instead of doing nothing
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1131,6 +1131,19 @@ export async function readPlanUsageTelemetryEnabled(): Promise<boolean> {
|
||||
return settings.showPlanUsageLimits !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether every Claude session is launched with `--name` set to its tab name
|
||||
* (the `pinCliSessionName` setting). Read FRESH from settings.json at every
|
||||
* claude create and respawn, like {@link readPlanUsageTelemetryEnabled}, so a
|
||||
* toggle applies from the next spawn with no server restart. An ABSENT key
|
||||
* means OFF: only a name the user chose is pinned, and Claude titles the
|
||||
* placeholder- and auto-named conversations itself (see `Session.cliPinnedName`).
|
||||
*/
|
||||
export async function readPinCliSessionNameEnabled(): Promise<boolean> {
|
||||
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
|
||||
return settings.pinCliSessionName === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the statusLine command to pass as an EPHEMERAL `claude --settings`
|
||||
* CLI flag for this one process (see buildSpawnCommandFromRegistry in
|
||||
|
||||
+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: {
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
* `mcpSyncTargets()` (routes/mcp-sync-routes.ts) takes the registry's enabled CLIs that declare an
|
||||
* `mcpConfig`. Some tools read an MCP server list worth keeping in step with the others but are not
|
||||
* something Codeman launches, so they have no registry entry (and no id to branch on): GitHub
|
||||
* Copilot CLI is the first. They are plain data here, take part only when installed or when their
|
||||
* config file already exists (an absent tool is reported `absent`, never created), and sort after
|
||||
* the registry CLIs, so when two definitions of a name differ the registry CLI's is the one copied.
|
||||
* Copilot CLI is the first. It is also a run mode that declares its own `mcpConfig`, so while that
|
||||
* entry is enabled this row is skipped (`mcpSyncOnlyTargets` drops ids the registry already
|
||||
* supplies); it applies when the run mode is disabled but the tool is still on the machine. They are
|
||||
* plain data here, take part only when installed or when their config file already exists (an
|
||||
* absent tool is reported `absent`, never created), and sort after the registry CLIs, so when two
|
||||
* definitions of a name differ the registry CLI's is the one copied.
|
||||
*
|
||||
* @module mcp-sync-targets
|
||||
*/
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
AntigravityConfig,
|
||||
PiConfig,
|
||||
GrokConfig,
|
||||
CopilotConfig,
|
||||
DeepSeekConfig,
|
||||
OmpConfig,
|
||||
SessionRemote,
|
||||
@@ -97,7 +98,8 @@ export interface CreateSessionOptions {
|
||||
* Name pinned on a claude spawn as `--name` (version-gated, sanitized, local only).
|
||||
* Deliberately NOT `name`: `--name` owns the prompt-box label, the `/resume` picker
|
||||
* entry and the terminal title, and a pinned title stops Claude generating its own,
|
||||
* so only a user-chosen name belongs here (see `Session.cliPinnedName`).
|
||||
* so by default only a user-chosen name belongs here; the opt-in `pinCliSessionName`
|
||||
* setting pins every tab name (see `Session.cliPinnedName`).
|
||||
*/
|
||||
cliName?: string;
|
||||
niceConfig?: NiceConfig;
|
||||
@@ -110,6 +112,7 @@ export interface CreateSessionOptions {
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
piConfig?: PiConfig;
|
||||
grokConfig?: GrokConfig;
|
||||
copilotConfig?: CopilotConfig;
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
ompConfig?: OmpConfig;
|
||||
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
|
||||
@@ -141,7 +144,8 @@ export interface RespawnPaneOptions {
|
||||
* Name pinned on a respawned claude as `--name` (version-gated, sanitized, local only).
|
||||
* Deliberately NOT `name`: `--name` owns the prompt-box label, the `/resume` picker
|
||||
* entry and the terminal title, and a pinned title stops Claude generating its own,
|
||||
* so only a user-chosen name belongs here (see `Session.cliPinnedName`).
|
||||
* so by default only a user-chosen name belongs here; the opt-in `pinCliSessionName`
|
||||
* setting pins every tab name (see `Session.cliPinnedName`).
|
||||
*/
|
||||
cliName?: string;
|
||||
niceConfig?: NiceConfig;
|
||||
@@ -154,6 +158,7 @@ export interface RespawnPaneOptions {
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
piConfig?: PiConfig;
|
||||
grokConfig?: GrokConfig;
|
||||
copilotConfig?: CopilotConfig;
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
ompConfig?: OmpConfig;
|
||||
/** Resume a previous Claude conversation when respawning */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
EffortLevel,
|
||||
GeminiConfig,
|
||||
GrokConfig,
|
||||
CopilotConfig,
|
||||
OmpConfig,
|
||||
OpenCodeConfig,
|
||||
PiConfig,
|
||||
@@ -50,6 +51,7 @@ export interface SpawnBridgeOptions {
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
piConfig?: PiConfig;
|
||||
grokConfig?: GrokConfig;
|
||||
copilotConfig?: CopilotConfig;
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
ompConfig?: OmpConfig;
|
||||
resumeSessionId?: string;
|
||||
|
||||
@@ -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
|
||||
|
||||
+246
-17
@@ -56,6 +56,7 @@ import {
|
||||
type AntigravityConfig,
|
||||
type PiConfig,
|
||||
type GrokConfig,
|
||||
type CopilotConfig,
|
||||
type DeepSeekConfig,
|
||||
type OmpConfig,
|
||||
type SessionRemote,
|
||||
@@ -89,7 +90,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,
|
||||
@@ -494,6 +500,8 @@ export class Session extends EventEmitter {
|
||||
|
||||
private _name: string;
|
||||
private _nameSource: SessionNameSource;
|
||||
/** `pinCliSessionName` as of the last spawn (see {@link _refreshCliNamePin}). */
|
||||
private _pinEveryCliName = false;
|
||||
/**
|
||||
* Reconstructs the composer draft from USER keystrokes so the first real
|
||||
* prompt can name the tab. Fed only when a write says `fromUser`, and never
|
||||
@@ -563,6 +571,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 +605,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`
|
||||
@@ -725,6 +751,8 @@ export class Session extends EventEmitter {
|
||||
private _piConfig: PiConfig | undefined;
|
||||
// Grok configuration (only for mode === 'grok')
|
||||
private _grokConfig: GrokConfig | undefined;
|
||||
// GitHub Copilot configuration (only for mode === 'copilot')
|
||||
private _copilotConfig: CopilotConfig | undefined;
|
||||
|
||||
// DeepSeek Harness configuration (only for mode === 'deepseek')
|
||||
private _deepSeekConfig: DeepSeekConfig | undefined;
|
||||
@@ -852,6 +880,7 @@ export class Session extends EventEmitter {
|
||||
piConfig?: PiConfig;
|
||||
/** Grok configuration (only for mode === 'grok') */
|
||||
grokConfig?: GrokConfig;
|
||||
copilotConfig?: CopilotConfig;
|
||||
/** DeepSeek Harness configuration (only for mode === 'deepseek') */
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
/** OMP configuration (only for mode === 'omp') */
|
||||
@@ -1014,6 +1043,11 @@ export class Session extends EventEmitter {
|
||||
this._grokConfig = config.grokConfig;
|
||||
}
|
||||
|
||||
// Apply GitHub Copilot configuration
|
||||
if (config.copilotConfig) {
|
||||
this._copilotConfig = config.copilotConfig;
|
||||
}
|
||||
|
||||
// Apply env overrides (exported at spawn, not persisted to disk).
|
||||
// Legacy migration: pre-0.7.2 carried effort as the CLAUDE_CODE_EFFORT_LEVEL env var,
|
||||
// which hard-locks /effort switching. Extract it into _effort (--settings soft default)
|
||||
@@ -1429,6 +1463,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
|
||||
@@ -1773,16 +1817,31 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* The name to pin on the Claude CLI as `--name`, or undefined to let Claude
|
||||
* title the conversation itself. `--name` is the prompt-box label, the
|
||||
* `/resume` picker entry and the terminal title all at once, and a pinned
|
||||
* title stops Claude generating its own, so only a name the user chose is
|
||||
* worth pinning. Pinning the `w1-myapp` placeholder gave every conversation
|
||||
* in a case the same `/resume` entry; an auto name is a cut of the first
|
||||
* prompt, which Claude's own generated title already beats.
|
||||
* The name to pin on the Claude CLI as `--name`, or undefined to let Claude title the
|
||||
* conversation itself. `--name` is the prompt-box label, the `/resume` picker entry and the
|
||||
* terminal title all at once, and a pinned title stops Claude generating its own.
|
||||
*
|
||||
* By default only a name the user chose is pinned. Pinning the `w1-myapp` placeholder gave
|
||||
* every conversation in a case the same `/resume` entry (the tab number restarts once a
|
||||
* case's tabs are closed), and an auto name is a cut of the first prompt, which Claude's
|
||||
* own generated title already beats.
|
||||
*
|
||||
* With the opt-in `pinCliSessionName` setting every non-blank tab name is pinned, so the
|
||||
* CLI's labels read the same as the tabs, at the cost of those generated titles. A rename
|
||||
* in Codeman then holds only until the next turn: a process started with `--name`
|
||||
* re-appends its in-memory title (see claude-session-title.ts). The setting is re-read
|
||||
* at each spawn and respawn by {@link _refreshCliNamePin}.
|
||||
*/
|
||||
get cliPinnedName(): string | undefined {
|
||||
return this._nameSource === 'manual' ? this._name : undefined;
|
||||
if (!this._pinEveryCliName) return this._nameSource === 'manual' ? this._name : undefined;
|
||||
return this._name.trim() ? this._name : undefined;
|
||||
}
|
||||
|
||||
/** Re-read `pinCliSessionName` so `cliPinnedName` decides per launch, with no server restart. */
|
||||
private async _refreshCliNamePin(): Promise<void> {
|
||||
// Dynamic: hooks-config -> route-helpers imports Session, so a static import would be a cycle.
|
||||
const { readPinCliSessionNameEnabled } = await import('./hooks-config.js');
|
||||
this._pinEveryCliName = await readPinCliSessionNameEnabled();
|
||||
}
|
||||
|
||||
setAutoClear(enabled: boolean, threshold?: number): void {
|
||||
@@ -1976,6 +2035,7 @@ export class Session extends EventEmitter {
|
||||
antigravityConfig: this._antigravityConfig,
|
||||
piConfig: this._piConfig,
|
||||
grokConfig: this._grokConfig,
|
||||
copilotConfig: this._copilotConfig,
|
||||
deepSeekConfig: this._deepSeekConfig,
|
||||
ompConfig: this._ompConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
@@ -2348,6 +2408,7 @@ export class Session extends EventEmitter {
|
||||
antigravityConfig: this._antigravityConfig,
|
||||
piConfig: this._piConfig,
|
||||
grokConfig: this._grokConfig,
|
||||
copilotConfig: this._copilotConfig,
|
||||
deepSeekConfig: this._deepSeekConfig,
|
||||
// OMP resolution/pinning does NOT happen here. This object is built
|
||||
// EAGERLY — including on every boot-recovery reattach, before anyone
|
||||
@@ -2442,6 +2503,7 @@ export class Session extends EventEmitter {
|
||||
* still inside it and renders no local `--session-id` to collide.
|
||||
*/
|
||||
private async _buildRespawnPaneOptionsWithResumePin(): Promise<import('./mux-interface.js').RespawnPaneOptions> {
|
||||
await this._refreshCliNamePin();
|
||||
const options = this._buildRespawnPaneOptions();
|
||||
if (this._remote || this._docker) return options;
|
||||
const entry = getCli(this.mode);
|
||||
@@ -2706,11 +2768,14 @@ export class Session extends EventEmitter {
|
||||
throw new Error('Session is being closed');
|
||||
}
|
||||
|
||||
await this._refreshCliNamePin();
|
||||
|
||||
// Bounds the workspace-trust scan (see _maybeAcceptTrustDialog). Stamped here
|
||||
// rather than at PTY spawn so a slow mux attach still counts as startup.
|
||||
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;
|
||||
@@ -2832,6 +2897,7 @@ export class Session extends EventEmitter {
|
||||
antigravityConfig: this._antigravityConfig,
|
||||
piConfig: this._piConfig,
|
||||
grokConfig: this._grokConfig,
|
||||
copilotConfig: this._copilotConfig,
|
||||
deepSeekConfig: this._deepSeekConfig,
|
||||
ompConfig: this._ompConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
@@ -3081,6 +3147,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 +3213,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 +3265,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 +3349,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;
|
||||
@@ -3357,6 +3462,7 @@ export class Session extends EventEmitter {
|
||||
antigravityConfig: this._antigravityConfig,
|
||||
piConfig: this._piConfig,
|
||||
grokConfig: this._grokConfig,
|
||||
copilotConfig: this._copilotConfig,
|
||||
deepSeekConfig: this._deepSeekConfig,
|
||||
ompConfig: this._ompConfig,
|
||||
};
|
||||
@@ -3462,6 +3568,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 +3734,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 +4225,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 +4240,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 +4756,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
|
||||
@@ -4584,7 +4813,7 @@ export class Session extends EventEmitter {
|
||||
: this._mux.capturePaneText?.(this._muxSession.muxName),
|
||||
sendEnter: () => this._mux?.sendInput(this.id, '\r'),
|
||||
// ⚠ NO fallback glyph here, unlike the screen-reading probe elsewhere in this file.
|
||||
// Only claude, codex, pi, opencode, omp and gemini declare a promptGlyph; the other modes would fall back
|
||||
// Only claude, codex, pi, opencode, omp, gemini and antigravity declare a promptGlyph; the other modes would fall back
|
||||
// to claude's `❯`, which is ALSO starship's default shell prompt (and pure's, and
|
||||
// spaceship's, and p10k lean's). On a shell session the line `❯ npm run build` sits
|
||||
// on screen for as long as the command runs, promptStillInComposer() reads that as
|
||||
|
||||
+135
-24
@@ -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
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
type AntigravityConfig,
|
||||
type PiConfig,
|
||||
type GrokConfig,
|
||||
type CopilotConfig,
|
||||
type DeepSeekConfig,
|
||||
type OmpConfig,
|
||||
type SessionRemote,
|
||||
@@ -436,6 +437,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.
|
||||
@@ -1003,6 +1082,7 @@ export function buildSpawnCommand(options: {
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
piConfig?: PiConfig;
|
||||
grokConfig?: GrokConfig;
|
||||
copilotConfig?: CopilotConfig;
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
ompConfig?: OmpConfig;
|
||||
resumeSessionId?: string;
|
||||
@@ -1011,7 +1091,7 @@ export function buildSpawnCommand(options: {
|
||||
advisorModel?: string;
|
||||
/** Resolved by resolveStatusLineCliCommand (hooks-config.ts) — undefined skips the exporter. Claude only. */
|
||||
statusLineCommand?: string;
|
||||
/** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). Only a user-chosen name: see `Session.cliPinnedName`. */
|
||||
/** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). By default only a user-chosen name; `pinCliSessionName` pins every tab name: see `Session.cliPinnedName`. */
|
||||
sessionName?: string;
|
||||
/**
|
||||
* Claude CLI version for the `--name` gate. Omitted = probe the local CLI
|
||||
@@ -2239,6 +2319,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
copilotConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
resumeSessionId,
|
||||
@@ -2325,6 +2406,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
copilotConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
resumeSessionId,
|
||||
@@ -2574,6 +2656,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
copilotConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
resumeSessionId,
|
||||
@@ -2615,6 +2698,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
copilotConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
resumeSessionId,
|
||||
@@ -3738,10 +3822,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 +3862,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 +4029,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 +4181,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 +4198,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 +4210,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.
|
||||
|
||||
+34
-4
@@ -8,7 +8,7 @@
|
||||
* - SessionConfig — creation-time config (id, workingDir, createdAt)
|
||||
* - SessionOutput — captured stdout/stderr/exitCode
|
||||
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
|
||||
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' (which CLI backend)
|
||||
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot' (which CLI backend)
|
||||
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
|
||||
* - SessionColor — visual differentiation color
|
||||
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
|
||||
@@ -17,6 +17,7 @@
|
||||
* - AntigravityConfig — Antigravity CLI (agy) settings (model, dangerouslySkipPermissions, resumeConversationId)
|
||||
* - PiConfig — Pi CLI (pi.dev) settings (model, provider, thinking, resume/continue, project trust)
|
||||
* - GrokConfig — Grok Build CLI (xAI `grok`) settings (model, alwaysApprove, resume/continue)
|
||||
* - CopilotConfig — GitHub Copilot CLI settings (model, allowAll, resume/continue)
|
||||
* - DeepSeekConfig — DeepSeek Harness (`dsh`) settings (profile, permissionMode, resume, status bridge)
|
||||
*
|
||||
* Cross-domain relationships:
|
||||
@@ -56,7 +57,8 @@ export type SessionMode =
|
||||
| 'pi'
|
||||
| 'grok'
|
||||
| 'deepseek'
|
||||
| 'omp';
|
||||
| 'omp'
|
||||
| 'copilot';
|
||||
|
||||
/**
|
||||
* Who owns a session's name. `placeholder`: Codeman's own `w<n>-<case>` (or no
|
||||
@@ -78,7 +80,7 @@ export interface SessionWriteOptions {
|
||||
|
||||
export type RemoteCommandMode = Extract<
|
||||
SessionMode,
|
||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp'
|
||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot'
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -213,7 +215,7 @@ export interface RemoteSessionInfo {
|
||||
/** Which CLI backends a Docker case can run (same set as remote). */
|
||||
export type DockerCommandMode = Extract<
|
||||
SessionMode,
|
||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp'
|
||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot'
|
||||
>;
|
||||
|
||||
/** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */
|
||||
@@ -525,6 +527,32 @@ export interface GrokConfig {
|
||||
resumeSessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Copilot CLI (`copilot`) session configuration.
|
||||
*
|
||||
* Copilot asks before running tools ("Manual Approval") and before touching paths
|
||||
* outside the trusted folder. `--yolo` is its allow-all switch (tools, paths and URLs),
|
||||
* the one the Run button opts into like the other agent CLIs' bypass switches. Verified
|
||||
* against copilot 1.0.94.
|
||||
*/
|
||||
export interface CopilotConfig {
|
||||
/** Model ID passed via --model (e.g. "claude-sonnet-5.5", or "auto"). */
|
||||
model?: string;
|
||||
/**
|
||||
* Allow every tool, path and URL without asking (passes --yolo). Absent = Copilot's own
|
||||
* Manual Approval mode. Multi-user: forced off for non-granted owners by the only-if-sent
|
||||
* clamp branch, like codex/antigravity/grok.
|
||||
*/
|
||||
allowAll?: boolean;
|
||||
/** Resume the most recent session (--continue). Skipped when resumeSessionId is set. */
|
||||
continueSession?: boolean;
|
||||
/**
|
||||
* Resume a specific session (--resume <value>): an id, id prefix or session name, as ONE plain word
|
||||
* starting with a letter or digit (a leading `-` would make the value its own flag).
|
||||
*/
|
||||
resumeSessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DeepSeek Harness (`dsh`) session configuration.
|
||||
*
|
||||
@@ -893,6 +921,8 @@ export interface SessionState {
|
||||
piConfig?: PiConfig;
|
||||
/** Grok-specific configuration (only for mode === 'grok') */
|
||||
grokConfig?: GrokConfig;
|
||||
/** GitHub Copilot CLI configuration (only for mode === 'copilot') */
|
||||
copilotConfig?: CopilotConfig;
|
||||
/** DeepSeek Harness configuration (only for mode === 'deepseek') */
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
/** OMP-specific configuration (only for mode === 'omp') */
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* and the badge could disagree with the Run menu.
|
||||
*
|
||||
* Every probe is a memoized resolver, so this is cheap to call per request. Dynamic imports
|
||||
* keep the nine resolvers out of any module that never asks.
|
||||
* keep the ten resolvers out of any module that never asks.
|
||||
*/
|
||||
|
||||
import type { CliEntry } from '../config/cli-registry/types.js';
|
||||
@@ -30,6 +30,7 @@ export async function probeStockCliAvailability(): Promise<Record<string, boolea
|
||||
{ isGrokAvailable },
|
||||
{ isDeepSeekRunnable },
|
||||
{ isOmpAvailable },
|
||||
{ isCopilotAvailable },
|
||||
] = await Promise.all([
|
||||
import('./claude-cli-resolver.js'),
|
||||
import('./opencode-cli-resolver.js'),
|
||||
@@ -40,6 +41,7 @@ export async function probeStockCliAvailability(): Promise<Record<string, boolea
|
||||
import('./grok-cli-resolver.js'),
|
||||
import('./deepseek-cli-resolver.js'),
|
||||
import('./omp-cli-resolver.js'),
|
||||
import('./copilot-cli-resolver.js'),
|
||||
]);
|
||||
return {
|
||||
claude: isClaudeAvailable(),
|
||||
@@ -51,6 +53,7 @@ export async function probeStockCliAvailability(): Promise<Record<string, boolea
|
||||
grok: isGrokAvailable(),
|
||||
deepseek: isDeepSeekRunnable(),
|
||||
omp: isOmpAvailable(),
|
||||
copilot: isCopilotAvailable(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* @fileoverview Resolve the GitHub Copilot CLI binary across common install paths.
|
||||
*
|
||||
* Uses the shared `createCliExecutableResolver` (cli-executable-resolver.ts),
|
||||
* same as the sibling claude/opencode/codex/gemini/antigravity/pi resolvers:
|
||||
* server process PATH first, then common install directories, then — last,
|
||||
* because it is the only step that spawns anything — an interactive login
|
||||
* shell, which is what finds nvm/Homebrew/user-npm installs when Codeman runs
|
||||
* as a systemd/launchd service with a minimal PATH.
|
||||
*
|
||||
* Provides an augmented PATH directory for tmux sessions.
|
||||
*
|
||||
* @module utils/copilot-cli-resolver
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import { getCli } from '../config/cli-registry/registry.js';
|
||||
import { expandHome } from './cli-resolver.js';
|
||||
import {
|
||||
createCliExecutableResolver,
|
||||
formatCliNotFoundMessage,
|
||||
type CliResolverHost,
|
||||
} from './cli-executable-resolver.js';
|
||||
|
||||
/**
|
||||
* Directories probed after `which`, read from this CLI's registry entry so the spawn
|
||||
* path, `codeman doctor` and this resolver cannot disagree about where to look.
|
||||
* `~` is expanded by `expandHome`; nothing else is interpreted.
|
||||
*/
|
||||
const COPILOT_SEARCH_DIRS = (): string[] => (getCli('copilot')?.discovery.searchDirs ?? []).map(expandHome);
|
||||
|
||||
/**
|
||||
* A real `copilot --version` prints `GitHub Copilot CLI 1.0.94.`. Anchoring on the product
|
||||
* name keeps an unrelated `copilot` binary on PATH from being accepted.
|
||||
*/
|
||||
export const COPILOT_VERSION_REGEX = /GitHub Copilot CLI (\d+\.\d+\.\d+)/;
|
||||
|
||||
const COPILOT_NOT_FOUND = 'GitHub Copilot CLI not found. Install with: npm install -g @github/copilot';
|
||||
|
||||
/**
|
||||
* Run `copilot --version` on a candidate path and return the trimmed version when
|
||||
* it is the GitHub Copilot CLI. Returns null for anything else — a missing
|
||||
* binary, a non-zero exit, a hang (timeout), or output that is not
|
||||
* `GitHub Copilot CLI <semver>`-shaped (which is how an unrelated `copilot` on PATH gets rejected).
|
||||
*
|
||||
* Never runs under vitest: the suites must stay hermetic and must not depend on
|
||||
* whether the dev box happens to have copilot installed. The shared resolver host
|
||||
* is already inert under vitest, so this gate is defense in depth for any
|
||||
* opted-in host that still carries the default probe.
|
||||
*/
|
||||
function probeCopilotVersion(binPath: string): string | null {
|
||||
if (process.env.VITEST) return null;
|
||||
try {
|
||||
const out = execFileSync(binPath, ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
// A stuck or hostile `copilot` that ignores SIGTERM would survive the timeout
|
||||
// and block the server (execFileSync keeps waiting after the signal).
|
||||
killSignal: 'SIGKILL',
|
||||
}).trim();
|
||||
const candidate = COPILOT_VERSION_REGEX.exec(out)?.[1];
|
||||
if (candidate) return candidate;
|
||||
console.warn(
|
||||
`[CopilotResolver] Ignoring ${binPath}: "copilot --version" printed ${JSON.stringify(out.slice(0, 80))}`
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(`[CopilotResolver] Ignoring ${binPath}: "copilot --version" failed (${(err as Error).message})`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type CopilotVersionProbe = (binPath: string) => string | null;
|
||||
|
||||
function createCopilotResolver(
|
||||
host?: CliResolverHost,
|
||||
versionProbe: CopilotVersionProbe = probeCopilotVersion,
|
||||
now?: () => number
|
||||
) {
|
||||
return createCliExecutableResolver<string>(
|
||||
{
|
||||
binary: 'copilot',
|
||||
searchDirs: COPILOT_SEARCH_DIRS,
|
||||
validateCandidate: (binPath) => {
|
||||
const version = versionProbe(binPath);
|
||||
return version ? { accepted: true, metadata: version } : { accepted: false };
|
||||
},
|
||||
now,
|
||||
},
|
||||
host
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an isolated Copilot wrapper around an injected host, version probe and
|
||||
* clock. Omitting `versionProbe` keeps the ambient (VITEST-gated) probe, which
|
||||
* is exactly what the hermeticity test exercises.
|
||||
*/
|
||||
export function createCopilotResolverForTest(
|
||||
host: CliResolverHost,
|
||||
versionProbe?: CopilotVersionProbe,
|
||||
now?: () => number
|
||||
) {
|
||||
return createCopilotResolver(host, versionProbe ?? probeCopilotVersion, now);
|
||||
}
|
||||
|
||||
const copilotResolver = createCopilotResolver();
|
||||
|
||||
/**
|
||||
* Finds the directory containing a verified `copilot` binary.
|
||||
* Checks `which copilot` first, then falls back to common install locations. Every
|
||||
* candidate must pass the `copilot --version` sanity probe before it is accepted.
|
||||
*
|
||||
* @returns Directory path, or null if not found
|
||||
*/
|
||||
export function resolveCopilotDir(): string | null {
|
||||
return copilotResolver.resolve()?.directory ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the GitHub Copilot CLI is available on the system.
|
||||
*/
|
||||
export function isCopilotAvailable(): boolean {
|
||||
return resolveCopilotDir() !== null;
|
||||
}
|
||||
|
||||
export function getCopilotNotFoundMessage(): string {
|
||||
return formatCliNotFoundMessage(COPILOT_NOT_FOUND, copilotResolver.diagnostics());
|
||||
}
|
||||
|
||||
/**
|
||||
* Version reported by the resolved `copilot` binary, or null when copilot is
|
||||
* unavailable. Surfaced through `GET /api/copilot/status` so a misresolution is
|
||||
* diagnosable from the UI.
|
||||
*/
|
||||
export function getCopilotCliVersion(): string | null {
|
||||
return copilotResolver.resolve()?.metadata ?? null;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -69,6 +69,12 @@ export type { DeepSeekProfile, DeepSeekProfileKind } from './deepseek-cli-resolv
|
||||
export { compileFileQuery, matchFileQuery } from './file-query.js';
|
||||
export type { FileQueryMatcher } from './file-query.js';
|
||||
export { resolveOmpDir, isOmpAvailable, getOmpNotFoundMessage, getOmpCliVersion } from './omp-cli-resolver.js';
|
||||
export {
|
||||
resolveCopilotDir,
|
||||
isCopilotAvailable,
|
||||
getCopilotNotFoundMessage,
|
||||
getCopilotCliVersion,
|
||||
} from './copilot-cli-resolver.js';
|
||||
export {
|
||||
boundedPathExists,
|
||||
describeUnknownPath,
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
|
||||
**Spreadsheet preview (XLSX)** (`spreadsheet-preview.js` + `spreadsheet-preview-worker.js`; moved from File-path links in the root file): ⚠️ **XLSX previews parse in the BROWSER**, never on the server: `spreadsheet-preview.js` fetches the raw route with `?preview=true` (413 above `MAX_XLSX_BROWSER_PREVIEW_BYTES`, 10 MB) and hands the bytes to `spreadsheet-preview-worker.js`, the only place the pinned `exceljs`/`fflate` vendor bundles load (never on page load). `admitXlsx()` caps the ZIP before ExcelJS runs, counting what ExcelJS will EXPAND as well as what it reads (a merge costs its area, a `<col>` past 16384 is refused, validations are never parsed via `ignoreNodes`, and defined names are never expanded: the worker stubs `_definedNames.model`). ⚠️ The INDEX a row or sheet claims is bounded too, since ExcelJS allocates and walks up to it: a `<row r>` outside 1-1048576 and an `xl/workbook.xml` `<sheet sheetId>` above `LIMITS.maxSheetId` (65535) are refused, and `sendTile` reuses the merges read at load (`mergesById`), never `sheet.model`, which rebuilds the whole sheet. ⚠️ The COLUMN index costs the same way (a row's cells live at `_cells[col - 1]`, so one `XFD` cell makes ExcelJS's `eachRow`/`eachCell`/`hasValues` visit 16,384 slots per row): `worksheetMetadata` builds each sheet's row and cell index from the keys that exist (`Object.keys` of `_rows` and `_cells`, skipping falsy and `Null`-type cells as `eachCell({ includeEmpty: false })` does), reads row heights in that pass and merges from `sheet._merges`, and `sendTile` reads cells from that index (`populatedRowsById`); never call ExcelJS's dense `eachRow`/`eachCell` there. `parseThemePalette` returns the default palette for a theme above 64 KB (64 * 1024 characters), since its patterns are quadratic on unclosed tags. ⚠️ Merges are capped per sheet (`LIMITS.maxMergesPerSheet`, 2,000) AND workbook-wide (`LIMITS.maxMerges`, 10,000), refused as `merge-limit` before ExcelJS loads, since ExcelJS's `_mergeCellsInternal` checks each merge against every earlier one on its sheet (quadratic). Every `<numFmt>` in `xl/styles.xml` is read with `readTagAttributes()` and refused (`number-format`) when its decoded `formatCode` is over 255 characters or has a `[` after its last `]`: ExcelJS's `isDateFmt` rescans to the end of the code per unclosed `[` once per numeric cell, and the code is echoed into the notice bar. ⚠️ `formatCellValue` caps every cell's display text at `LIMITS.maxCellTextChars` (1,000, ellipsis, never splitting a surrogate pair) for every value shape (rich text, hyperlink text, formula source, errors), since structured clone copies each tile cell's whole string to the page and the load timeout no longer covers tiles. `richTextPrefix` also visits at most `maxCellTextChars + 1` runs, not only keeps that much text: an empty `<r/>` adds nothing, so a shared string of a million empty runs was walked whole per cell, per tile (56 s a tile). ⚠️ Every part ExcelJS parses (all but the pure-bytes `xl/media/<name>.<ext>`) is also charged per START TAG against `LIMITS.maxElements` (2,000,000, refused as `element-limit`): ExcelJS builds an object per element in sharedStrings, styles, comments, drawings, VML and tables too, and empty stored blocks pad a deflate stream past the ratio cap, so 8.3M empty `<r/>` runs in a 375 KB file cost 570 MB of heap. ⚠️ Admission keys every entry on the name ExcelJS will SEE (`excelJsEntryName()`: JSZip's `.`/`..`/empty-segment resolution, then one leading `/` stripped), refuses two entries that land on one name, and treats anything matching ExcelJS's UNANCHORED `xl/worksheets/sheet<N>.xml` as a worksheet, so `/xl/...` or `xl/./...` cannot skip a counter, and ExcelJS parses only a STORE-only archive rebuilt from the entries admission inflated (`buildAdmittedArchive()`), never the fetched bytes; cell text goes through `textContent`, formulas are never evaluated. Bumping either package or editing the worker/core changes `SPREADSHEET_ASSET_VERSION`, which `npm run check:public-assets` pins. xls/ods stay download-only.
|
||||
|
||||
**Security notices** (`src/web/public/security-notices.json`; placed by `checkWhatsNew()` in settings-ui.js through the pure `CodemanSecurityNotices.select()`/`.plan()` in constants.js; drawn by `showSecurityNotice()`/`showUpdateNotice()` in panels-ui.js, CSS `.security-notice*` beside the toast rules): a designed warning card (shield, "Security notice" eyebrow, title, message, the notice's link, Dismiss, ×) about something outside Codeman its users should act on (the first: OpenCode's Console breach, Oct 2026). ⚠️ The file is HAND-maintained and ships with a release; never put a notice in `whats-new.json`, which `npm run version-packages` rewrites. Adding and retiring one: `docs/releasing.md`. ⚠️ A notice naming a `cli` shows ONLY when `window.__codemanCliAvailable[cli] === true`: absent, unknown and false all mean "not installed", the opposite of `isCliAvailable()` (which offers a launch on a doubt), because a warning about software you do not have is noise. The id lives only in the JSON as data; no code branches on it (`test/frontend-cli-no-id-branching.test.ts` scans constants.js). ⚠️ ONE notification per notice per load, decided only by `plan()`: when the once-per-release update toast shows (an existing install on a new version), every selected notice rides INSIDE it as a warning section beside "See what's new"; otherwise (a fresh install, a same-version reload) each gets its own card. With no notice selected the update toast is the plain `showToast` it always was, pinned byte for byte by the test. ⚠️ A detached session window (`/session/:id`) is rendered WITHOUT `__codemanCliAvailable`, so it cannot select a CLI notice: while any notice is pending (every CLI the file names counted as installed), it shows and records nothing and leaves the update toast and the notice to the dashboard, or it would record the version and the dashboard would show the notice as a separate card; with none pending it shows the plain toast as before. ⚠️ A notice is recorded as seen (`codeman-security-notices-seen`, a JSON array) ONLY on a user action (×, Dismiss, its link), never when it appears; "See what's new" opens the release notes and leaves an unanswered warning up. Storage blocked shows neither the notices nor the update toast. ⚠️ The cards sit in the SAME `.toast-container` (never their own layer), use theme tokens only (small amber text and the primary button use `--notice-ink`, which the light skins darken a step because their `--yellow` is under 4.5:1 for that text on white; the primary button's label is `--bg-card`), are capped to the room below the stack's top and scroll inside themselves (the container is fixed and cannot scroll, so the accent bar is a background layer, never a positioned child), animate opacity and transform only, and nothing moves under `prefers-reduced-motion`. A closing card is inert and its buttons do nothing during the fade; links open with `noopener,noreferrer` and the https rule is checked again at the button (`CodemanSecurityNotices.urlOk`). The notice's own text is textContent with `data-i18n-skip` (it carries its own zh-CN); the card's own copy translates as usual. The server serves `.json` from `public/` as `no-cache`, never `immutable` (`test/static-cache-headers.test.ts`). Tests: `test/security-notices.test.ts`.
|
||||
|
||||
## Frontend modules and UI subsystems
|
||||
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order is the `<script>` order in `index.html` (each module's `@loadorder` tag mirrors it). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). ⚠️ Every xterm that takes keyboard input wires its OWN controller from this module: the primary pane (terminal-ui.js `initTerminal()`) and each `TerminalTile` (terminal-tile.js `_createKeyCode229Recovery()`, so every grid tile and the split's Pane B, which a wide Android tablet reaches), each on its own textarea, composition helper and session; in both, `handleKeyEvent` must run ABOVE the custom key handler's keyCode-229 early return, and `notifyCanonicalData` sits in the onData lambda, never in the send path the recovered bytes also take. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
|
||||
|
||||
@@ -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);
|
||||
|
||||
+13
-4
@@ -12,8 +12,10 @@
|
||||
* its symlink-resolved form, against both the given and the symlink-resolved roots (a home reached
|
||||
* through a link, macOS's `/etc` -> `/private/etc`), so a link into a blocked tree is not a way
|
||||
* around it;
|
||||
* - it must not be, or be inside, a cases directory: a case there is a plain Create New, and the same
|
||||
* folder listed both as a local case and as a linked one would make deleting it remove files;
|
||||
* - it must not be a cases directory or a direct child of one: those are what `GET /api/cases` lists
|
||||
* as local cases, so a case there is a plain Create New, and the same folder listed both as a local
|
||||
* case and as a linked one would make deleting it remove files. A folder further down is not
|
||||
* listed and is allowed;
|
||||
* - its parent must already exist (one folder is created, never a whole chain), and the folder
|
||||
* itself must not exist or must be an EMPTY directory (a folder with contents is Link Existing's
|
||||
* job, and silently scaffolding into someone's project is the one thing this must never do);
|
||||
@@ -98,9 +100,16 @@ export function blockedReason(
|
||||
const firstSegment = absPath.slice(ctx.home.length + 1).split(sep)[0];
|
||||
if (/^\.codeman/.test(firstSegment)) return "Codeman's own data folder cannot be a case";
|
||||
}
|
||||
// Only the cases folder itself and its DIRECT children are listed as local cases (GET /api/cases reads
|
||||
// that one level), so only those would be registered twice. A deeper folder is not listed and is a
|
||||
// perfectly good linked case: with CODEMAN_CASES_PATH pointing at a broad root such as /mnt/user/Scripts,
|
||||
// refusing everything below it blocked most of the tree.
|
||||
for (const dir of ctx.casesDirs ?? []) {
|
||||
if (isWithin(absPath, dir)) {
|
||||
return 'That folder is inside the cases folder; create a case there with plain Create New (no custom folder)';
|
||||
if (absPath === dir) {
|
||||
return `That is the cases folder itself (${dir}); create a case inside it with plain Create New (no custom folder)`;
|
||||
}
|
||||
if (dirname(absPath) === dir) {
|
||||
return `That folder is directly in the cases folder (${dir}), where it is listed as a local case; create it there with plain Create New (no custom folder)`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
+152
-44
@@ -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',
|
||||
@@ -688,6 +699,12 @@ const DEFAULT_SHORTCUTS = [
|
||||
* setInterval fire on every frame, and constants.js is cached independently.
|
||||
*/
|
||||
const SIDEBAR_RICH_CLOCK_MS = 20000;
|
||||
// Header connection pill: a queue above CONNECTION_BACKLOG_HINT_BYTES is only worth a " · NB queued"
|
||||
// suffix once it has STAYED above it for CONNECTION_BACKLOG_HOLD_MS. A paste, an IME commit or a fast
|
||||
// word sits above the threshold for a few milliseconds until its ACK lands, and surfacing that made the
|
||||
// pill grow to the right and shrink back on every such burst.
|
||||
const CONNECTION_BACKLOG_HINT_BYTES = 4;
|
||||
const CONNECTION_BACKLOG_HOLD_MS = 1200;
|
||||
|
||||
/**
|
||||
* How long a `#session=<id>` link waits for the session list to name its id
|
||||
@@ -1388,19 +1405,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 +1452,7 @@ class CodemanApp {
|
||||
this.closeAllPanels();
|
||||
this.closeHelp();
|
||||
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
|
||||
this.closeWorktreeModal?.();
|
||||
this.closeSessionManager();
|
||||
this.closeCommandPalette?.();
|
||||
this.closeShortcutOverlay?.();
|
||||
@@ -1463,7 +1469,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 +1496,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),
|
||||
@@ -3017,9 +3014,11 @@ class CodemanApp {
|
||||
? 'DeepSeek'
|
||||
: mode === 'omp'
|
||||
? 'OMP'
|
||||
: mode === 'opencode'
|
||||
? 'OpenCode'
|
||||
: 'Claude';
|
||||
: mode === 'copilot'
|
||||
? 'GitHub Copilot'
|
||||
: mode === 'opencode'
|
||||
? 'OpenCode'
|
||||
: 'Claude';
|
||||
}
|
||||
|
||||
async toggleResponseViewer() {
|
||||
@@ -4373,19 +4372,26 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Pure render of the header connection indicator: reads only `this.*` state,
|
||||
// Pure render of the header connection indicator: reads only `this.*` state (and
|
||||
// the pending totals it is handed, which default to a fresh `_pendingBytes()`),
|
||||
// touches NO DOM. Returns the exact { display, dotClass, text, title } tuple the
|
||||
// writer applies. When hidden (display:'none') the other three are normalized to
|
||||
// '' so the cache compare in _updateConnectionIndicator() is well-defined.
|
||||
// Every branch/string here must stay byte-identical to what's rendered today.
|
||||
_computeConnectionDescriptor() {
|
||||
const { bytes: totalBytes, count } = this._pendingBytes();
|
||||
_computeConnectionDescriptor(pending = this._pendingBytes()) {
|
||||
const { bytes: totalBytes, count } = pending;
|
||||
const hasQueue = count > 0;
|
||||
// Only surface a backlog once it's more than a few bytes. A single keystroke
|
||||
// (1B) ACKs in milliseconds, so without this the label flickered "sending 1B"
|
||||
// on every key press. Above this threshold means input is genuinely backing up.
|
||||
const BACKLOG_HINT_BYTES = 4;
|
||||
const showBacklog = totalBytes > BACKLOG_HINT_BYTES;
|
||||
// on every key press. Above this threshold means input is genuinely backing up,
|
||||
// and only once it has stayed there (see CONNECTION_BACKLOG_HOLD_MS): a burst
|
||||
// that drains in a moment must not widen the pill. `_backlogOverSince` is kept
|
||||
// by _updateConnectionIndicator; `undefined` (never tracked, as when this pure
|
||||
// compute is called directly) means no hold.
|
||||
const since = this._backlogOverSince;
|
||||
const heldLongEnough =
|
||||
since === undefined || (since !== null && Date.now() - since >= CONNECTION_BACKLOG_HOLD_MS);
|
||||
const showBacklog = totalBytes > CONNECTION_BACKLOG_HINT_BYTES && heldLongEnough;
|
||||
const formatBytes = (b) => (b < 1024 ? `${b}B` : `${(b / 1024).toFixed(1)}KB`);
|
||||
const queuedSuffix = showBacklog ? ` · ${formatBytes(totalBytes)} queued` : '';
|
||||
|
||||
@@ -4501,10 +4507,35 @@ class CodemanApp {
|
||||
const text = this.$('connectionText');
|
||||
if (!indicator || !dot || !text) return;
|
||||
|
||||
// Track how long the queue has stayed above the hint threshold, and make sure
|
||||
// the pill is re-rendered when the hold elapses even if no keystroke or ACK
|
||||
// arrives to trigger it.
|
||||
// ⚠️ This runs before _drainSession() on every keystroke, so it must never throw: a throw here
|
||||
// once skipped immediate delivery and made typing lag (COD-133; the combinatorial never-throws
|
||||
// test in test/connection-indicator.test.ts pins it). The queue is walked ONCE per call and the
|
||||
// same totals are handed to the compute below, since this is the hot input path.
|
||||
const pending = this._pendingBytes();
|
||||
if (pending.bytes > CONNECTION_BACKLOG_HINT_BYTES) {
|
||||
if (this._backlogOverSince == null) this._backlogOverSince = Date.now();
|
||||
if (!this._backlogHoldTimer && Date.now() - this._backlogOverSince < CONNECTION_BACKLOG_HOLD_MS) {
|
||||
const wait = CONNECTION_BACKLOG_HOLD_MS - (Date.now() - this._backlogOverSince) + 25;
|
||||
this._backlogHoldTimer = setTimeout(() => {
|
||||
this._backlogHoldTimer = null;
|
||||
this._updateConnectionIndicator();
|
||||
}, wait);
|
||||
}
|
||||
} else {
|
||||
this._backlogOverSince = null;
|
||||
if (this._backlogHoldTimer) {
|
||||
clearTimeout(this._backlogHoldTimer);
|
||||
this._backlogHoldTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Called on EVERY keystroke (_reliableSend) and EVERY ACK (_ackDelivery).
|
||||
// During fast typing the rendered tuple is usually identical, so skip the DOM
|
||||
// writes when nothing changed (COD-136) — the compute above is DOM-free.
|
||||
const next = this._computeConnectionDescriptor();
|
||||
const next = this._computeConnectionDescriptor(pending);
|
||||
const prev = this._lastIndicatorDescriptor;
|
||||
// The tile's value word is written in the UI language, so a language
|
||||
// switch counts as a change too.
|
||||
@@ -4843,6 +4874,8 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
if (data.version) this.checkWhatsNew?.(data.version);
|
||||
|
||||
// Stop any active voice recording on reconnect
|
||||
VoiceInput.cleanup();
|
||||
|
||||
@@ -10336,9 +10369,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 +10450,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 +10465,7 @@ class CodemanApp {
|
||||
this.renderSessionTabs();
|
||||
}
|
||||
this.showToast('Failed to close session', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Request confirmation before closing a session
|
||||
@@ -10445,6 +10480,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) {
|
||||
@@ -10464,7 +10513,9 @@ class CodemanApp {
|
||||
? 'Kill Tmux & DeepSeek'
|
||||
: session.mode === 'omp'
|
||||
? 'Kill Tmux & OMP'
|
||||
: 'Kill Tmux & Claude Code';
|
||||
: session.mode === 'copilot'
|
||||
? 'Kill Tmux & GitHub Copilot'
|
||||
: 'Kill Tmux & Claude Code';
|
||||
}
|
||||
|
||||
document.getElementById('closeConfirmModal').classList.add('active');
|
||||
@@ -10472,15 +10523,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 +10758,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.
|
||||
|
||||
|
||||
+156
-2
@@ -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|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|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];
|
||||
@@ -3436,3 +3436,157 @@ if (typeof window !== 'undefined') {
|
||||
TERMINAL_MIN_ROWS,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Security notices
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// A security notice is a sticky warning toast about something outside Codeman that
|
||||
// its users should act on (the first: OpenCode's Console breach, October 2026). The
|
||||
// notices are DATA in the hand-maintained src/web/public/security-notices.json, never
|
||||
// in whats-new.json (`npm run version-packages` rewrites that one every release), and
|
||||
// they ship with a release. This is the pure half: which notices this browser shows, and
|
||||
// on which surface. The wiring (fetches, the seen lists) is checkWhatsNew() in settings-ui.js,
|
||||
// the cards are showSecurityNotice() / showUpdateNotice() in panels-ui.js.
|
||||
|
||||
/** `YYYY-MM-DD`, and a real calendar day (no 2026-02-30). */
|
||||
const SECURITY_NOTICE_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
/** An https URL with a plain host: no other scheme, no userinfo, no whitespace. */
|
||||
const SECURITY_NOTICE_URL_PATTERN = /^https:\/\/[A-Za-z0-9.-]+(?::\d{1,5})?(?:[/?#]\S*)?$/;
|
||||
|
||||
/**
|
||||
* The local-time bounds of one `YYYY-MM-DD` day, or null when the string is not one.
|
||||
* Local time on purpose: a notice dated the 18th starts on the viewer's 18th.
|
||||
*/
|
||||
function securityNoticeDay(value) {
|
||||
const match = typeof value === 'string' ? SECURITY_NOTICE_DATE_PATTERN.exec(value) : null;
|
||||
if (!match) return null;
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]) - 1;
|
||||
const day = Number(match[3]);
|
||||
const start = new Date(year, month, day);
|
||||
if (start.getFullYear() !== year || start.getMonth() !== month || start.getDate() !== day) return null;
|
||||
const end = new Date(year, month, day + 1).getTime() - 1;
|
||||
return { start: start.getTime(), end };
|
||||
}
|
||||
|
||||
function securityNoticeText(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The notices to show, in file order, each as `{ id, title, message, label, url }`
|
||||
* (`title` is '' when the entry has none).
|
||||
*
|
||||
* - A malformed entry is skipped, never repaired: no id, message or link label, a
|
||||
* link that is not https, a date that is not a real `YYYY-MM-DD` day, a `title`
|
||||
* that is present but not a non-blank string, or a `cli` that is not a registry
|
||||
* id. A repeated id shows its first entry only.
|
||||
* - Shown from the start of the `published` day to the END of the `expires` day.
|
||||
* The expiry is what keeps an old notice from alarming a browser that first opens
|
||||
* Codeman months later.
|
||||
* - An id in `seen` is skipped (the user closed it, or followed its link).
|
||||
* - A notice naming a `cli` is shown ONLY when `available[cli] === true`. Absent,
|
||||
* unknown and false all mean "not installed" here, unlike isCliAvailable() in
|
||||
* settings-ui.js (which offers a launch on a doubt): a warning about software you
|
||||
* do not have is noise. A notice with no `cli` is for everyone.
|
||||
* - `lang` 'zh-CN' takes the entry's `i18n['zh-CN']` title, message and label where
|
||||
* they exist, each falling back to the English one on its own.
|
||||
*
|
||||
* The CLI id is data from the JSON, compared as a key; no code here names one.
|
||||
*
|
||||
* @param {Array<object>} notices - `notices` from security-notices.json
|
||||
* @param {{available?: Record<string, boolean>, seen?: Iterable<string>, now?: number, lang?: string}} [opts]
|
||||
* `available` is window.__codemanCliAvailable, `now` epoch milliseconds (default Date.now()).
|
||||
* @returns {Array<{id: string, title: string, message: string, label: string, url: string}>}
|
||||
*/
|
||||
function selectSecurityNotices(notices, opts = {}) {
|
||||
if (!Array.isArray(notices)) return [];
|
||||
const available = opts.available && typeof opts.available === 'object' ? opts.available : {};
|
||||
const seenSource = opts.seen;
|
||||
const seen = new Set(
|
||||
seenSource && typeof seenSource !== 'string' && typeof seenSource[Symbol.iterator] === 'function'
|
||||
? Array.from(seenSource, String)
|
||||
: []
|
||||
);
|
||||
const now = typeof opts.now === 'number' && Number.isFinite(opts.now) ? opts.now : Date.now();
|
||||
const lang = opts.lang === 'zh-CN' ? 'zh-CN' : 'en';
|
||||
const taken = new Set();
|
||||
const shown = [];
|
||||
for (const entry of notices) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
||||
const id = securityNoticeText(entry.id);
|
||||
const title = securityNoticeText(entry.title);
|
||||
const message = securityNoticeText(entry.message);
|
||||
const link = entry.link && typeof entry.link === 'object' ? entry.link : null;
|
||||
const label = securityNoticeText(link?.label);
|
||||
const url = typeof link?.url === 'string' ? link.url.trim() : '';
|
||||
const published = securityNoticeDay(entry.published);
|
||||
const expires = securityNoticeDay(entry.expires);
|
||||
if (!id || !message || !label || !SECURITY_NOTICE_URL_PATTERN.test(url) || !published || !expires) continue;
|
||||
if (entry.title !== undefined && !title) continue;
|
||||
if (entry.cli !== undefined && (typeof entry.cli !== 'string' || !CLI_ID_PATTERN.test(entry.cli))) continue;
|
||||
if (taken.has(id)) continue;
|
||||
taken.add(id);
|
||||
if (now < published.start || now > expires.end) continue;
|
||||
if (seen.has(id)) continue;
|
||||
if (entry.cli !== undefined && available[entry.cli] !== true) continue;
|
||||
const localized = lang === 'en' ? null : entry.i18n?.[lang];
|
||||
shown.push({
|
||||
id,
|
||||
title: securityNoticeText(localized?.title) || title,
|
||||
message: securityNoticeText(localized?.message) || message,
|
||||
label: securityNoticeText(localized?.label) || label,
|
||||
url,
|
||||
});
|
||||
}
|
||||
return shown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which surface carries each selected notice on this page load, decided together with
|
||||
* the once-per-release update toast (checkWhatsNew in settings-ui.js) so a browser gets
|
||||
* ONE notification, never a card and a toast for the same notice.
|
||||
*
|
||||
* - The update toast shows exactly as checkWhatsNew always decided: this browser saw an
|
||||
* older version (`seenVersion` set and different), and `notes` are this version's with
|
||||
* at least one highlight. Then every selected notice rides INSIDE it (`updateNotices`)
|
||||
* and no standalone card is shown.
|
||||
* - Otherwise (a fresh install, a reload on the same version, notes missing or empty)
|
||||
* each notice gets its own card (`cards`).
|
||||
* - `recordVersion`: store `runningVersion` as seen, as checkWhatsNew always did: on a
|
||||
* fresh install (so it is not greeted with "what's new") and when the toast shows.
|
||||
* Notes that are missing or empty record nothing, so the next load tries again.
|
||||
*
|
||||
* @param {{seenVersion?: string|null, runningVersion: string, notes?: object|null, notices?: Array<object>}} state
|
||||
* `notices` is the output of selectSecurityNotices().
|
||||
* @returns {{recordVersion: boolean, update: boolean, updateNotices: Array<object>, cards: Array<object>}}
|
||||
*/
|
||||
function planStartupNotices(state = {}) {
|
||||
const seenVersion = typeof state.seenVersion === 'string' && state.seenVersion ? state.seenVersion : null;
|
||||
const runningVersion = state.runningVersion;
|
||||
const notices = Array.isArray(state.notices) ? state.notices : [];
|
||||
const notes = state.notes;
|
||||
const update = Boolean(
|
||||
seenVersion &&
|
||||
seenVersion !== runningVersion &&
|
||||
notes &&
|
||||
notes.version === runningVersion &&
|
||||
Array.isArray(notes.highlights) &&
|
||||
notes.highlights.length > 0
|
||||
);
|
||||
return {
|
||||
recordVersion: !seenVersion || update,
|
||||
update,
|
||||
updateNotices: update ? notices.slice() : [],
|
||||
cards: update ? [] : notices.slice(),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CodemanSecurityNotices = {
|
||||
select: selectSecurityNotices,
|
||||
plan: planStartupNotices,
|
||||
// The same https-only rule select() applies, for the card's link button to check again.
|
||||
urlOk: (url) => typeof url === 'string' && SECURITY_NOTICE_URL_PATTERN.test(url),
|
||||
};
|
||||
}
|
||||
+82
-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': '没有活动会话',
|
||||
@@ -254,6 +254,7 @@
|
||||
'Run Grok': '运行 Grok',
|
||||
'Run DeepSeek': '运行 DeepSeek',
|
||||
'Run OMP': '运行 OMP',
|
||||
'Run GitHub Copilot': '运行 GitHub Copilot',
|
||||
'Run Shell': '运行 Shell',
|
||||
'More tools': '更多工具',
|
||||
'Select AI backend': '选择 AI 后端',
|
||||
@@ -265,6 +266,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': '最近会话',
|
||||
@@ -634,6 +679,9 @@
|
||||
'Claude Model': 'Claude 模型',
|
||||
'1M Opus Context': 'Opus 100 万上下文',
|
||||
'Remote auto-reconnect': '远程自动重连',
|
||||
'Pin tab names on the CLI': '在 CLI 上固定标签名',
|
||||
"Launch every Claude session with its tab name, so the CLI's prompt label and /resume list match your tabs. Claude then stops generating its own titles for those sessions, and a rename in Codeman holds only until the next turn. Applies from the next session start or respawn, no server restart needed.":
|
||||
'以标签名启动每个 Claude 会话,让 CLI 的提示框标签和 /resume 列表与你的标签一致。Claude 随后不再为这些会话生成自己的标题,在 Codeman 中的重命名也只保留到下一轮。从下一次会话启动或重新生成时生效,无需重启服务器。',
|
||||
'Thinking Effort': '思考强度',
|
||||
Low: '低',
|
||||
Medium: '中',
|
||||
@@ -691,7 +739,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 +846,19 @@
|
||||
|
||||
// Dynamic common status / toasts
|
||||
'Settings saved': '设置已保存',
|
||||
// The once-per-release toast (settings-ui.js checkWhatsNew); its highlights stay English.
|
||||
"See what's new": '查看更新内容',
|
||||
// The security-notice cards' own eyebrow (panels-ui.js showSecurityNotice/showUpdateNotice);
|
||||
// each notice's title, message and link label carry their own zh-CN text and are skipped.
|
||||
'Security notice': '安全提示',
|
||||
'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 +1237,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 +1304,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 +1316,18 @@
|
||||
[/^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}`],
|
||||
// A pick, drop or paste that mixed admitted files with refused ones (image-input.js).
|
||||
[/^(\d+) files? skipped: unsupported type$/, (_m, n) => `已跳过 ${n} 个不支持类型的文件`],
|
||||
// Group names are user text: they pass through untranslated.
|
||||
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
|
||||
// Session names are user text too.
|
||||
@@ -1287,6 +1363,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
|
||||
|
||||
@@ -101,12 +101,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!this.activeSessionId) return;
|
||||
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
|
||||
|
||||
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');
|
||||
return;
|
||||
}
|
||||
this._uploadAndInsertImages(mediaFiles);
|
||||
const mediaFiles = this._admitPromptAttachments(Array.from(e.dataTransfer.files));
|
||||
if (mediaFiles.length > 0) this._uploadAndInsertImages(mediaFiles);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -122,11 +118,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Without the overlay (local echo off) there is nothing to buffer into, so the
|
||||
* text goes straight out. The trailing space keeps the next word off the path. */
|
||||
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');
|
||||
return;
|
||||
}
|
||||
const media = this._admitPromptAttachments(files);
|
||||
if (media.length === 0) return;
|
||||
if (!sessionId) return; // no captured session: refuse rather than guess at one
|
||||
const paths = await this._uploadAndInsertImages(media, { insert: false, sessionId });
|
||||
if (paths.length === 0) return;
|
||||
@@ -230,15 +223,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (pasteConsumed) return;
|
||||
pasteConsumed = true;
|
||||
|
||||
// Check for images or videos in clipboard items
|
||||
var imageFiles = [];
|
||||
// Check for images, videos or documents in clipboard items
|
||||
var pastedFiles = [];
|
||||
var items = e.clipboardData && e.clipboardData.items;
|
||||
if (items) {
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var blob = items[i].getAsFile();
|
||||
if (blob && self._promptAttachKind(blob)) imageFiles.push(blob);
|
||||
if (blob) pastedFiles.push(blob);
|
||||
}
|
||||
}
|
||||
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
|
||||
var imageFiles = self._admitPromptAttachments(pastedFiles, { textPasted: !!text });
|
||||
|
||||
// Clean up the trap
|
||||
setTimeout(function() {
|
||||
@@ -256,7 +251,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
// via raw sendInput() strips those markers and makes pasted input
|
||||
// indistinguishable from typed input, weakening the CLI's
|
||||
// prompt-injection defenses.
|
||||
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
|
||||
if (text && terminal) terminal.paste(text);
|
||||
}
|
||||
});
|
||||
@@ -266,21 +260,46 @@ 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;
|
||||
},
|
||||
|
||||
/** The files of a pick, drop or paste that _promptAttachKind admits, telling the
|
||||
* user about any it refuses: all of them is "Unsupported file type", some of
|
||||
* them a count of what was skipped while the rest still upload. A clipboard
|
||||
* paste that also carries text passes `textPasted`: when none of its files is
|
||||
* admitted the text is pasted instead, so that case stays quiet. */
|
||||
_admitPromptAttachments(files, { textPasted = false } = {}) {
|
||||
const admitted = files.filter((f) => this._promptAttachKind(f));
|
||||
const skipped = files.length - admitted.length;
|
||||
if (skipped > 0 && admitted.length > 0) {
|
||||
this.showToast(`${skipped} file${skipped > 1 ? 's' : ''} skipped: unsupported type`, 'info');
|
||||
} else if (skipped > 0 && !textPasted) {
|
||||
this.showToast('Unsupported file type', 'error');
|
||||
}
|
||||
return admitted;
|
||||
},
|
||||
|
||||
// Max images accepted in one batch (paste / drop / mobile picker). Each is
|
||||
// uploaded as its own request, so 20 stays under the server's 30 uploads/min
|
||||
// rate limit while covering "select a bunch of photos at once".
|
||||
|
||||
@@ -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">
|
||||
@@ -1078,6 +1078,7 @@
|
||||
<option value="grok">Grok</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="omp">OMP</option>
|
||||
<option value="copilot">GitHub Copilot</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
|
||||
@@ -1693,6 +1694,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 +1713,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>
|
||||
@@ -2729,6 +2788,13 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsWorkspaceHooks"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="pin cli session name resume title tab label">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Pin tab names on the CLI</span>
|
||||
<span class="set-row-desc">Launch every Claude session with its tab name, so the CLI's prompt label and /resume list match your tabs. Claude then stops generating its own titles for those sessions, and a rename in Codeman holds only until the next turn. Applies from the next session start or respawn, no server restart needed.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsPinCliSessionName"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="remote auto reconnect ssh">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Remote auto-reconnect</span>
|
||||
@@ -3400,6 +3466,7 @@
|
||||
<option value="grok" data-cli="grok">Grok</option>
|
||||
<option value="deepseek" data-cli="deepseek">DeepSeek</option>
|
||||
<option value="omp" data-cli="omp">OMP</option>
|
||||
<option value="copilot" data-cli="copilot">GitHub Copilot</option>
|
||||
<option value="shell">Shell (no agent)</option>
|
||||
</select>
|
||||
<span class="form-hint">Which CLI to point the Run button at once the clone finishes. Changeable any time from the Run dropdown.</span>
|
||||
@@ -3578,7 +3645,7 @@
|
||||
<div class="form-row docker-create-only">
|
||||
<label>Image</label>
|
||||
<input type="text" id="dockerImage" placeholder="codeman/agent:base" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/opencode/codex/gemini/agy/pi/grok/dsh/omp + tmux.</span>
|
||||
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/opencode/codex/gemini/agy/pi/grok/dsh/omp/copilot + tmux.</span>
|
||||
</div>
|
||||
<div class="form-row docker-create-only">
|
||||
<label>Network</label>
|
||||
@@ -3663,6 +3730,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>
|
||||
`;
|
||||
|
||||
@@ -1339,8 +1339,9 @@ const KeyboardAccessoryBar = {
|
||||
this._syncComposerDraftIndicator();
|
||||
close({ preserveDraft: false });
|
||||
};
|
||||
const handleImages = async (files) => {
|
||||
const images = Array.from(files || []).filter((file) => app._promptAttachKind(file));
|
||||
// Takes files already admitted by app._admitPromptAttachments (the picker and
|
||||
// the paste below), which also toasts whatever it refused.
|
||||
const handleImages = async (images) => {
|
||||
if (images.length === 0 || typeof app._uploadAndInsertImages !== 'function') return;
|
||||
saveDraft();
|
||||
this._composerUploads.set(sessionId, (this._composerUploads.get(sessionId) || 0) + 1);
|
||||
@@ -1354,7 +1355,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');
|
||||
@@ -1381,7 +1382,6 @@ const KeyboardAccessoryBar = {
|
||||
if (remaining > 0) this._composerUploads.set(sessionId, remaining);
|
||||
else this._composerUploads.delete(sessionId);
|
||||
syncUploadUi();
|
||||
if (overlay.isConnected) fileInput.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1389,9 +1389,11 @@ const KeyboardAccessoryBar = {
|
||||
textarea.addEventListener('paste', (event) => {
|
||||
const items = event.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const images = Array.from(items)
|
||||
const files = Array.from(items)
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file) => file && app._promptAttachKind(file));
|
||||
.filter(Boolean);
|
||||
// Pasted text with no admitted file lands in the textarea natively: no toast.
|
||||
const images = app._admitPromptAttachments(files, { textPasted: !!event.clipboardData.getData('text/plain') });
|
||||
if (images.length > 0) {
|
||||
event.preventDefault();
|
||||
void handleImages(images);
|
||||
@@ -1404,7 +1406,12 @@ const KeyboardAccessoryBar = {
|
||||
}
|
||||
});
|
||||
imageButton.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', () => void handleImages(fileInput.files));
|
||||
fileInput.addEventListener('change', () => {
|
||||
const files = Array.from(fileInput.files || []);
|
||||
// Reset so picking the same refused file again still fires change (and its toast).
|
||||
fileInput.value = '';
|
||||
void handleImages(app._admitPromptAttachments(files));
|
||||
});
|
||||
overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => {
|
||||
// Hand the text BACK to the terminal prompt so both surfaces hold the same
|
||||
// prompt, the mirror of the adoption composePrompt() does on open. Only a
|
||||
@@ -1469,13 +1476,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 +1495,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,10 +1526,11 @@ 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.
|
||||
const handleImages = (files) => {
|
||||
const images = Array.from(files || []).filter((f) => app._promptAttachKind(f));
|
||||
// Filter to what the server admits (app._admitPromptAttachments toasts what it
|
||||
// refuses), close the dialog, and hand off to the shared upload+insert
|
||||
// pipeline. Returns true if any file was handled.
|
||||
const handleImages = (files, options) => {
|
||||
const images = app._admitPromptAttachments(Array.from(files || []), options);
|
||||
if (images.length === 0) return false;
|
||||
close();
|
||||
// Upload to, and insert into, the shell this overlay was opened for.
|
||||
@@ -1530,23 +1538,26 @@ 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));
|
||||
fileInput.addEventListener('change', () => {
|
||||
const files = Array.from(fileInput.files || []);
|
||||
// Reset so picking the same refused file again still fires change (and its toast).
|
||||
fileInput.value = '';
|
||||
handleImages(files);
|
||||
});
|
||||
|
||||
// Best-effort: capture images pasted straight into the textarea.
|
||||
textarea.addEventListener('paste', (e) => {
|
||||
const items = e.clipboardData && e.clipboardData.items;
|
||||
if (!items) return;
|
||||
const imageFiles = [];
|
||||
const files = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const blob = items[i].getAsFile();
|
||||
if (blob && app._promptAttachKind(blob)) imageFiles.push(blob);
|
||||
}
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
handleImages(imageFiles);
|
||||
if (blob) files.push(blob);
|
||||
}
|
||||
// Pasted text with no admitted file lands in the textarea natively: no toast.
|
||||
if (handleImages(files, { textPasted: !!e.clipboardData.getData('text/plain') })) e.preventDefault();
|
||||
});
|
||||
|
||||
overlay.querySelector('.paste-cancel').addEventListener('click', close);
|
||||
|
||||
@@ -58,6 +58,7 @@ const MOBILE_OVERVIEW_RUN_MODES = [
|
||||
{ mode: 'grok', label: 'Grok', short: 'Grok' },
|
||||
{ mode: 'deepseek', label: 'DeepSeek', short: 'DeepSeek' },
|
||||
{ mode: 'omp', label: 'OMP', short: 'OMP' },
|
||||
{ mode: 'copilot', label: 'GitHub Copilot', short: 'GitHub Copilot' },
|
||||
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
|
||||
];
|
||||
|
||||
|
||||
@@ -1240,6 +1240,20 @@ html.mobile-init .file-browser-panel {
|
||||
border-color: rgba(129, 140, 248, 0.5) !important;
|
||||
}
|
||||
|
||||
/* GitHub Copilot mode colors on mobile. Same `!important` rationale as the pi/grok/deepseek/omp blocks above. */
|
||||
.btn-toolbar.btn-run.mode-copilot,
|
||||
.btn-toolbar.btn-run-gear.mode-copilot {
|
||||
background: #2e1065 !important;
|
||||
border-color: rgba(163, 113, 247, 0.3) !important;
|
||||
color: #ede9fe !important;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-run.mode-copilot:active,
|
||||
.btn-toolbar.btn-run-gear.mode-copilot:active {
|
||||
background: #6d28d9 !important;
|
||||
border-color: rgba(163, 113, 247, 0.5) !important;
|
||||
}
|
||||
|
||||
/* Run mode dropdown menu — positioned above toolbar on mobile */
|
||||
.run-mode-menu {
|
||||
bottom: 100%;
|
||||
@@ -3337,6 +3351,12 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-copilot, .btn-toolbar.btn-run-gear.mode-copilot) {
|
||||
background: linear-gradient(135deg, #6d28d9, #8b5cf6);
|
||||
border-color: #5b21b6;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-grok, .btn-toolbar.btn-run-gear.mode-grok) {
|
||||
background: linear-gradient(135deg, #27272a, #52525b);
|
||||
border-color: #18181b;
|
||||
|
||||
+185
-11
@@ -533,7 +533,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
_buildCommandPaletteNewSessionItem(query = '') {
|
||||
const mode = this.runMode || this._runMode || 'claude';
|
||||
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek', omp: 'OMP' };
|
||||
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek', omp: 'OMP', copilot: 'Copilot' };
|
||||
const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
|
||||
return {
|
||||
id: 'new-session',
|
||||
@@ -6249,16 +6249,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
closeBtn.onclick = (e) => { e.stopPropagation(); dismiss(); };
|
||||
toast.appendChild(closeBtn);
|
||||
|
||||
// Cache toast container reference
|
||||
if (!this._toastContainer) {
|
||||
this._toastContainer = document.querySelector('.toast-container');
|
||||
if (!this._toastContainer) {
|
||||
this._toastContainer = document.createElement('div');
|
||||
this._toastContainer.className = 'toast-container';
|
||||
document.body.appendChild(this._toastContainer);
|
||||
}
|
||||
}
|
||||
this._toastContainer.appendChild(toast);
|
||||
this._toastStack().appendChild(toast);
|
||||
|
||||
requestAnimationFrame(() => toast.classList.add('show'));
|
||||
|
||||
@@ -6272,6 +6263,189 @@ Object.assign(CodemanApp.prototype, {
|
||||
return { dismiss, setMessage: (text) => { msgSpan.textContent = text; } };
|
||||
},
|
||||
|
||||
/** The fixed toast stack (`.toast-container`), created on first use and cached. */
|
||||
_toastStack() {
|
||||
if (!this._toastContainer) {
|
||||
this._toastContainer = document.querySelector('.toast-container');
|
||||
if (!this._toastContainer) {
|
||||
this._toastContainer = document.createElement('div');
|
||||
this._toastContainer.className = 'toast-container';
|
||||
document.body.appendChild(this._toastContainer);
|
||||
}
|
||||
}
|
||||
return this._toastContainer;
|
||||
},
|
||||
|
||||
/**
|
||||
* A security notice (security-notices.json) as its own card in the toast stack: shield,
|
||||
* "Security notice" eyebrow, the notice's title and message, its link as the primary
|
||||
* button, a quiet Dismiss and the ×. Same container as every toast, so it never fights
|
||||
* the z-index stack. Sticky: only the user ends it.
|
||||
*
|
||||
* The notice's own text (title, message, link label) is already in the UI language, so it
|
||||
* is set with textContent and carries `data-i18n-skip`; the card's own copy ("Security
|
||||
* notice", "Dismiss", the × label) is application text and translates as usual.
|
||||
*
|
||||
* `opts.onClose(reason)` fires ONCE, and only on a user action: `'link'`, `'dismiss'` or
|
||||
* `'close'`. Never on the handle's own `dismiss()`, which does not mean the user saw it.
|
||||
*
|
||||
* @param {{id: string, title: string, message: string, label: string, url: string}} notice
|
||||
* @param {{onClose?: (reason: string) => void}} [opts]
|
||||
*/
|
||||
showSecurityNotice(notice, opts = {}) {
|
||||
const card = this._noticeCard('security-notice', opts.onClose);
|
||||
const main = this._noticeEl('div', 'security-notice-main');
|
||||
main.append(this._noticeEl('div', 'security-notice-eyebrow', 'Security notice'));
|
||||
main.append(...this._noticeTextBlock(notice));
|
||||
const actions = this._noticeEl('div', 'security-notice-actions');
|
||||
const link = this._noticeLinkButton(notice, card, () => card.close('link'));
|
||||
if (link) actions.append(link);
|
||||
actions.append(this._noticeButton('security-notice-quiet', 'Dismiss', () => card.close('dismiss')));
|
||||
main.append(actions);
|
||||
card.el.append(this._noticeIcon('shield', 'security-notice-icon'), main, card.closeButton);
|
||||
card.mount();
|
||||
return { dismiss: () => card.close(null) };
|
||||
},
|
||||
|
||||
/**
|
||||
* The once-per-release update toast when security notices ride INSIDE it
|
||||
* (CodemanSecurityNotices.plan in constants.js decides that; with no notice the plain
|
||||
* showToast toast is used, unchanged). Styled as the security card: the release text as
|
||||
* before (translated by the same i18n.js pattern), one warning section per notice, then
|
||||
* each notice's link button beside "See what's new".
|
||||
*
|
||||
* Sticky, since it carries a warning. `onNoticeSeen(id)` records a notice: the × records
|
||||
* every notice in the card, a notice's link records that one and closes the card. "See
|
||||
* what's new" opens the release notes and leaves the card up, because the warning in it
|
||||
* has not been answered yet.
|
||||
*
|
||||
* @param {{text: string, releaseUrl: string, notices: Array<object>, onNoticeSeen: (id: string) => void}} opts
|
||||
*/
|
||||
showUpdateNotice({ text, releaseUrl, notices, onNoticeSeen }) {
|
||||
const seen = (id) => { try { onNoticeSeen?.(id); } catch (err) { console.error('[notice] onNoticeSeen failed:', err); } };
|
||||
const card = this._noticeCard('security-notice update-notice', (reason) => {
|
||||
if (reason === 'close') for (const notice of notices) seen(notice.id);
|
||||
});
|
||||
const main = this._noticeEl('div', 'security-notice-main');
|
||||
main.append(this._noticeEl('p', 'update-notice-text', text));
|
||||
const actions = this._noticeEl('div', 'security-notice-actions');
|
||||
for (const notice of notices) {
|
||||
const section = this._noticeEl('section', 'security-notice-section');
|
||||
const head = this._noticeEl('div', 'security-notice-section-head');
|
||||
head.append(this._noticeIcon('shield'), this._noticeEl('span', 'security-notice-eyebrow', 'Security notice'));
|
||||
section.append(head, ...this._noticeTextBlock(notice));
|
||||
main.append(section);
|
||||
const link = this._noticeLinkButton(notice, card, () => { seen(notice.id); card.close('link'); });
|
||||
if (link) actions.append(link);
|
||||
}
|
||||
actions.append(
|
||||
this._noticeButton(
|
||||
'security-notice-secondary',
|
||||
"See what's new",
|
||||
card.act(() => window.open(releaseUrl, '_blank', 'noopener,noreferrer'))
|
||||
)
|
||||
);
|
||||
main.append(actions);
|
||||
card.el.append(this._noticeIcon('update', 'security-notice-icon update-notice-icon'), main, card.closeButton);
|
||||
card.mount();
|
||||
return { dismiss: () => card.close(null) };
|
||||
},
|
||||
|
||||
/**
|
||||
* The shell both notice cards share: role=alert, the ×, mount and a once-only close.
|
||||
* A closing card stays in the DOM for its 200 ms fade, so close() also makes it inert, and
|
||||
* `act(fn)` wraps a button's handler to do nothing once the card is closing (a double click
|
||||
* on a link used to open it twice; the × and Dismiss go through close(), already once-only).
|
||||
*/
|
||||
_noticeCard(className, onUserClose) {
|
||||
const el = this._noticeEl('div', className);
|
||||
el.setAttribute('role', 'alert');
|
||||
el.setAttribute('aria-live', 'assertive');
|
||||
let closed = false;
|
||||
const close = (reason) => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
el.inert = true;
|
||||
el.classList.remove('show');
|
||||
setTimeout(() => el.remove(), 200);
|
||||
if (reason && typeof onUserClose === 'function') {
|
||||
try { onUserClose(reason); } catch (err) { console.error('[notice] onClose failed:', err); }
|
||||
}
|
||||
};
|
||||
const closeButton = this._noticeButton('security-notice-close', '×', () => close('close'));
|
||||
closeButton.setAttribute('aria-label', 'Close');
|
||||
const mount = () => {
|
||||
this._toastStack().appendChild(el);
|
||||
requestAnimationFrame(() => el.classList.add('show'));
|
||||
};
|
||||
const act = (fn) => () => { if (!closed) fn(); };
|
||||
return { el, closeButton, close, mount, act };
|
||||
},
|
||||
|
||||
/** The notice's own title (when it has one) and message: textContent, never translated. */
|
||||
_noticeTextBlock(notice) {
|
||||
const parts = [];
|
||||
if (notice.title) parts.push(this._noticeEl('div', 'security-notice-title', notice.title, true));
|
||||
parts.push(this._noticeEl('p', 'security-notice-body', notice.message, true));
|
||||
return parts;
|
||||
},
|
||||
|
||||
/**
|
||||
* The notice's link as the primary button, or null when the link is not https.
|
||||
* selectSecurityNotices() already drops such a notice; the URL is checked again here, at the
|
||||
* sink, because showSecurityNotice/showUpdateNotice take any object. Opened with noopener
|
||||
* AND noreferrer: noopener alone still sends this instance's origin (a tailnet name, a tunnel
|
||||
* URL) to the linked site as the Referer.
|
||||
*/
|
||||
_noticeLinkButton(notice, card, after) {
|
||||
const url = notice.url;
|
||||
if (!window.CodemanSecurityNotices?.urlOk?.(url)) return null;
|
||||
return this._noticeButton(
|
||||
'security-notice-primary',
|
||||
notice.label,
|
||||
card.act(() => { window.open(url, '_blank', 'noopener,noreferrer'); after(); }),
|
||||
true
|
||||
);
|
||||
},
|
||||
|
||||
_noticeButton(className, label, onClick, untranslated = false) {
|
||||
const btn = this._noticeEl('button', className, label, untranslated);
|
||||
btn.type = 'button';
|
||||
btn.onclick = (e) => { e.stopPropagation(); onClick(); };
|
||||
return btn;
|
||||
},
|
||||
|
||||
_noticeEl(tag, className, text, untranslated = false) {
|
||||
const el = document.createElement(tag);
|
||||
el.className = className;
|
||||
if (text !== undefined) el.textContent = text;
|
||||
if (untranslated) el.setAttribute('data-i18n-skip', '');
|
||||
return el;
|
||||
},
|
||||
|
||||
/** Inline SVG icons (no asset): `shield` (with an exclamation) or `update` (arrow up). */
|
||||
_noticeIcon(kind, wrapperClass) {
|
||||
const NS = 'http://www.w3.org/2000/svg';
|
||||
const svg = document.createElementNS(NS, 'svg');
|
||||
for (const [k, v] of Object.entries({
|
||||
viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2',
|
||||
'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true', focusable: 'false',
|
||||
})) svg.setAttribute(k, v);
|
||||
const shapes = kind === 'shield'
|
||||
? [['path', { d: 'M12 3 4.5 6v5.2c0 4.5 3.1 8.4 7.5 9.8 4.4-1.4 7.5-5.3 7.5-9.8V6L12 3z' }], ['path', { d: 'M12 8.5v4.5' }], ['path', { d: 'M12 16.5h.01' }]]
|
||||
: [['circle', { cx: '12', cy: '12', r: '9' }], ['path', { d: 'M12 16.5v-9' }], ['path', { d: 'm8.5 11 3.5-3.5 3.5 3.5' }]];
|
||||
for (const [tag, attrs] of shapes) {
|
||||
const shape = document.createElementNS(NS, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) shape.setAttribute(k, v);
|
||||
svg.appendChild(shape);
|
||||
}
|
||||
if (!wrapperClass) return svg;
|
||||
const wrap = this._noticeEl('span', wrapperClass);
|
||||
wrap.setAttribute('aria-hidden', 'true');
|
||||
wrap.appendChild(svg);
|
||||
return wrap;
|
||||
},
|
||||
|
||||
/**
|
||||
* A prominent, screen-centred status banner — for the small set of messages that are
|
||||
* genuinely worth interrupting the eye for rather than living in the corner with every
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"notices": [
|
||||
{
|
||||
"id": "opencode-console-breach-2026-10",
|
||||
"severity": "warning",
|
||||
"cli": "opencode",
|
||||
"published": "2026-10-17",
|
||||
"expires": "2026-12-31",
|
||||
"title": "OpenCode Console breach: check your API keys",
|
||||
"message": "OpenCode reported unauthorized access to a legacy system on 17 October. Provider API keys (OpenAI, Anthropic and others) saved in some OpenCode Console workspaces may have been exposed. If OpenCode emailed you about it, revoke and replace those keys and check their recent usage. If you got no email, OpenCode says you were not affected.",
|
||||
"link": { "label": "Read OpenCode's notice", "url": "https://x.com/opencode/status/2111519637049319818" },
|
||||
"i18n": {
|
||||
"zh-CN": {
|
||||
"title": "OpenCode Console 遭入侵:请检查你的 API 密钥",
|
||||
"message": "OpenCode 于 10 月 17 日报告,其一个旧系统遭到未授权访问,部分用户保存在 OpenCode Console 工作区中的提供商 API 密钥(OpenAI、Anthropic 等)可能已泄露。如果你收到了 OpenCode 的相关邮件,请撤销并更换这些密钥,同时检查近期用量。OpenCode 表示,没有收到邮件的用户不受影响。",
|
||||
"label": "查看 OpenCode 公告"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+540
-57
@@ -15,7 +15,7 @@
|
||||
* PR B2: the single source for every non-Claude, non-Shell run mode's launch
|
||||
* shape, consumed by `_runCliMode()` below. Before this table existed, each of
|
||||
* `runOpenCode`/`runCodex`/`runGemini`/`runAntigravity`/`runPi`/`runOmp`/
|
||||
* `runGrok`/`runDeepSeek` was a ~45-line copy of the same probe/launch/select
|
||||
* `runGrok`/`runDeepSeek`/`runCopilot` was a ~45-line copy of the same probe/launch/select
|
||||
* skeleton with only the CLI-specific pieces below actually differing — eight
|
||||
* near-identical bodies guaranteed to drift, exactly what the CLI registry's
|
||||
* own no-id-branching rule exists to prevent server-side.
|
||||
@@ -79,6 +79,16 @@ const RUN_MODE_LAUNCH = {
|
||||
supportsCustomModel: true,
|
||||
buildConfig: () => null,
|
||||
},
|
||||
copilot: {
|
||||
label: 'GitHub Copilot',
|
||||
installHint: 'GitHub Copilot CLI not found. Install with: npm install -g @github/copilot',
|
||||
// Custom endpoints go in through Copilot's BYOK env vars (COPILOT_PROVIDER_*).
|
||||
supportsCustomModel: true,
|
||||
// Sends `copilotConfig: { allowAll: true }` the way grok sends `alwaysApprove: true`:
|
||||
// Codeman sessions exist for autonomous work, so the Run button opts into `--yolo`.
|
||||
// The multi-user clamp forces it back off for non-granted owners server-side.
|
||||
buildConfig: () => ({ copilotConfig: { allowAll: true } }),
|
||||
},
|
||||
grok: {
|
||||
label: 'Grok',
|
||||
installHint: 'Grok CLI not found. Install with: curl -fsSL https://x.ai/cli/install.sh | bash',
|
||||
@@ -92,7 +102,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 +138,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 +637,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 +728,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 +1965,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 +1983,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 +2081,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 +2090,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 +2197,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 +2272,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 +2397,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 +2438,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) || {}),
|
||||
@@ -2432,6 +2503,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
return this._runCliMode('deepseek');
|
||||
},
|
||||
|
||||
async runCopilot() {
|
||||
return this._runCliMode('copilot');
|
||||
},
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Options Modal
|
||||
@@ -4833,6 +4908,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 +5092,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', {
|
||||
|
||||
+495
-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,130 @@ 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".
|
||||
*
|
||||
* The same pass places the security notices (/security-notices.json, see
|
||||
* _loadSecurityNotices), so a load shows ONE notification per notice:
|
||||
* CodemanSecurityNotices.plan() (constants.js) puts every selected notice INSIDE the update
|
||||
* toast when that toast shows (showUpdateNotice), and gives each its own card otherwise, a
|
||||
* fresh install or a reload on the same version (showSecurityNotice). With no notice
|
||||
* selected the update toast is the plain one it always was. Storage blocked shows neither:
|
||||
* nothing could remember a close.
|
||||
*
|
||||
* ⚠️ A detached session window (/session/:id) is rendered without __codemanCliAvailable, so
|
||||
* it can never select a notice naming a CLI. Were it to take the update toast anyway, it
|
||||
* would record the version and the dashboard would later show that notice as a separate
|
||||
* card, not inside the update toast. So while any notice is pending (_loadSecurityNotices
|
||||
* counts every CLI the file names as installed there), a solo window shows and records
|
||||
* nothing and leaves both to the dashboard; with none pending it shows the plain update
|
||||
* toast as it always did.
|
||||
*/
|
||||
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 and the notices */ return; }
|
||||
const remember = () => { try { localStorage.setItem(KEY, runningVersion); } catch { /* ignore */ } };
|
||||
const [notes, notices] = await Promise.all([
|
||||
seen && seen !== runningVersion ? this._fetchWhatsNewNotes() : null,
|
||||
this._loadSecurityNotices(),
|
||||
]);
|
||||
if (this.isSoloWindow && notices.length) return;
|
||||
const plan = window.CodemanSecurityNotices.plan({ seenVersion: seen, runningVersion, notes, notices });
|
||||
if (plan.recordVersion) remember();
|
||||
if (plan.update) {
|
||||
const shown = notes.highlights.slice(0, 3).join(' · ');
|
||||
const more = notes.highlights.length > 3 ? ` (+${notes.highlights.length - 3} more)` : '';
|
||||
const text = `Codeman updated to v${runningVersion}: ${shown}${more}`;
|
||||
const releaseUrl = `https://github.com/Ark0N/Codeman/releases/tag/codeman%40${runningVersion}`;
|
||||
if (plan.updateNotices.length) {
|
||||
this.showUpdateNotice({
|
||||
text,
|
||||
releaseUrl,
|
||||
notices: plan.updateNotices,
|
||||
onNoticeSeen: (id) => this._rememberSecurityNotice(id),
|
||||
});
|
||||
} else {
|
||||
this.showToast(text, 'info', {
|
||||
duration: 20000,
|
||||
action: {
|
||||
label: "See what's new",
|
||||
onClick: () => window.open(releaseUrl, '_blank', 'noopener'),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const notice of plan.cards) {
|
||||
this.showSecurityNotice(notice, { onClose: () => this._rememberSecurityNotice(notice.id) });
|
||||
}
|
||||
},
|
||||
|
||||
/** This release's notes, or null (offline or malformed: try again next load). */
|
||||
async _fetchWhatsNewNotes() {
|
||||
try {
|
||||
const res = await fetch('/whats-new.json', { cache: 'no-cache' });
|
||||
return res.ok ? await res.json() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* The security notices this browser should see now, from the HAND-maintained
|
||||
* /security-notices.json (docs/releasing.md says how to add and retire one), selected by
|
||||
* CodemanSecurityNotices.select() in constants.js: dates, the seen list, and for a notice
|
||||
* naming a CLI, `window.__codemanCliAvailable[cli] === true` only (unknown means not
|
||||
* installed). [] on any failure, and when storage is blocked (a close could not be
|
||||
* remembered, so the card would return on every load). In a solo window every CLI the file
|
||||
* names counts as installed: there the answer only decides whether to leave the release to
|
||||
* the dashboard (see checkWhatsNew), and is never shown.
|
||||
*/
|
||||
async _loadSecurityNotices() {
|
||||
let seen;
|
||||
try { seen = this._readSecurityNoticesSeen(); } catch { /* storage blocked: show nothing */ return []; }
|
||||
try {
|
||||
const res = await fetch(CodemanBase.url('/security-notices.json'), { cache: 'no-cache' });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
const list = Array.isArray(data?.notices) ? data.notices : [];
|
||||
const available = this.isSoloWindow
|
||||
? Object.fromEntries(list.filter((entry) => typeof entry?.cli === 'string').map((entry) => [entry.cli, true]))
|
||||
: window.__codemanCliAvailable;
|
||||
return window.CodemanSecurityNotices.select(data?.notices, {
|
||||
available,
|
||||
seen,
|
||||
now: Date.now(),
|
||||
lang: window.CodemanI18n?.language || window.__codemanLanguage,
|
||||
});
|
||||
} catch { /* offline or malformed: try again next load */ return []; }
|
||||
},
|
||||
|
||||
/** The ids the user closed or followed (`codeman-security-notices-seen`). Throws when storage is blocked. */
|
||||
_readSecurityNoticesSeen() {
|
||||
const raw = localStorage.getItem('codeman-security-notices-seen');
|
||||
let ids = [];
|
||||
try { ids = raw ? JSON.parse(raw) : []; } catch { /* a corrupt value counts as nothing seen */ }
|
||||
return Array.isArray(ids) ? ids.filter((id) => typeof id === 'string') : [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Record a notice as seen. Only a user action calls this (a close, Dismiss, its link), never
|
||||
* the card appearing, so one shown while nobody was looking comes back on the next load.
|
||||
* Re-reads first: another tab may have recorded a notice since this one loaded.
|
||||
*/
|
||||
_rememberSecurityNotice(id) {
|
||||
try {
|
||||
const ids = this._readSecurityNoticesSeen();
|
||||
if (!ids.includes(id)) ids.push(id);
|
||||
localStorage.setItem('codeman-security-notices-seen', JSON.stringify(ids));
|
||||
} catch { /* storage blocked since load: it shows again next load */ }
|
||||
},
|
||||
|
||||
openAppSettings() {
|
||||
// Load current settings
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
@@ -551,6 +679,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Default ON: an absent key is a user who has never seen this setting, and OFF
|
||||
// for them means no tab alerts in any workspace Codeman did not scaffold.
|
||||
document.getElementById('appSettingsWorkspaceHooks').checked = settings.workspaceHooksEnabled !== false;
|
||||
document.getElementById('appSettingsPinCliSessionName').checked = settings.pinCliSessionName === true;
|
||||
document.getElementById('appSettingsClaudeModel').value = settings.claudeModel ?? '';
|
||||
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
|
||||
document.getElementById('appSettingsRemoteAutoReconnect').checked = settings.remoteAutoReconnect ?? true;
|
||||
@@ -674,6 +803,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 +1976,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 +2076,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 +2407,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 +2471,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',
|
||||
@@ -2642,6 +2800,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
|
||||
agentSkillEnabled: document.getElementById('appSettingsAgentSkill').checked,
|
||||
workspaceHooksEnabled: document.getElementById('appSettingsWorkspaceHooks').checked,
|
||||
pinCliSessionName: document.getElementById('appSettingsPinCliSessionName').checked,
|
||||
claudeVoiceEnabled: document.getElementById('appSettingsClaudeVoice').checked,
|
||||
claudeModel: document.getElementById('appSettingsClaudeModel').value,
|
||||
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
|
||||
@@ -2655,6 +2814,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 +2843,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 +3071,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 +3108,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 +3119,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 +3156,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 +3167,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 +3358,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 +3377,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 +4681,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 +4737,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 +4790,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 +4812,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 +5092,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 +5116,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 +5189,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 +5232,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?.();
|
||||
|
||||
@@ -5785,6 +5785,22 @@ html[data-skin="og"] .welcome-primary:hover {
|
||||
color: #eef2ff;
|
||||
}
|
||||
|
||||
/* GitHub Copilot mode colors */
|
||||
.btn-toolbar.btn-run.mode-copilot,
|
||||
.btn-toolbar.btn-run-gear.mode-copilot {
|
||||
background: linear-gradient(135deg, #2e1065 0%, #6d28d9 55%, #7c3aed 100%);
|
||||
border-color: rgba(163, 113, 247, 0.5);
|
||||
color: #ede9fe;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.btn-toolbar.btn-run.mode-copilot:hover,
|
||||
.btn-toolbar.btn-run-gear.mode-copilot:hover {
|
||||
background: linear-gradient(135deg, #3b0f8c 0%, #7c3aed 55%, #8b5cf6 100%);
|
||||
box-shadow: 0 0 12px rgba(163, 113, 247, 0.35), 0 2px 8px rgba(109, 40, 217, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(196, 167, 252, 0.6);
|
||||
color: #f5f3ff;
|
||||
}
|
||||
|
||||
/* Grok mode colors. Same cascade note as pi above: this base-sheet pair only
|
||||
renders on the `og` skin — the nested `html:not([data-skin="og"])` block
|
||||
re-declares `.btn-toolbar.btn-run` at a HIGHER specificity, so grok also
|
||||
@@ -5944,6 +5960,7 @@ html[data-skin="og"] .welcome-primary:hover {
|
||||
.run-mode-dot.opencode,
|
||||
.run-mode-dot.pi,
|
||||
.run-mode-dot.grok,
|
||||
.run-mode-dot.copilot,
|
||||
.run-mode-dot.shell,
|
||||
.run-mode-dot.web {
|
||||
width: 1.15em;
|
||||
@@ -5964,6 +5981,7 @@ html[data-skin="og"] .welcome-primary:hover {
|
||||
.run-mode-dot.opencode,
|
||||
.run-mode-dot.pi,
|
||||
.run-mode-dot.grok,
|
||||
.run-mode-dot.copilot,
|
||||
.run-mode-dot.shell,
|
||||
.run-mode-dot.web {
|
||||
background-color: currentColor;
|
||||
@@ -5979,6 +5997,7 @@ html[data-skin="og"] .welcome-primary:hover {
|
||||
.run-mode-dot.opencode { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M16 6H8v12h8V6zm4 16H4V2h16v20z'/%3E%3C/svg%3E"); }
|
||||
.run-mode-dot.pi { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath clip-rule='evenodd' d='M1 1h16.5v11H12v5.5H6.5V23H1V1zm5.5 5.5V12H12V6.5H6.5z'/%3E%3Cpath d='M17.5 12H23v11h-5.5V12z'/%3E%3C/svg%3E"); }
|
||||
.run-mode-dot.grok { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815'/%3E%3C/svg%3E"); }
|
||||
.run-mode-dot.copilot { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 3C7 3 3 6.5 3 11v4a3 3 0 003 3h12a3 3 0 003-3v-4c0-4.5-4-8-9-8zM8.5 11.5a1.5 2 0 110 4 1.5 2 0 010-4zm7 0a1.5 2 0 110 4 1.5 2 0 010-4z'/%3E%3C/svg%3E"); }
|
||||
.run-mode-dot.shell { --run-mode-logo: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2.5' y='3.5' width='19' height='17' rx='3'/%3E%3Cpath d='M7 9.5l3 2.5-3 2.5M12.5 15h4.5'/%3E%3C/svg%3E"); }
|
||||
.run-mode-dot.web { --run-mode-logo: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M2 12h20M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20'/%3E%3C/svg%3E"); }
|
||||
|
||||
@@ -7793,6 +7812,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 +8170,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;
|
||||
@@ -9477,6 +9742,292 @@ kbd {
|
||||
.toast-warning { border-color: rgba(234, 179, 8, 0.4); }
|
||||
.toast-info { border-color: rgba(59, 130, 246, 0.4); }
|
||||
|
||||
/* Security notice card (security-notices.json; showSecurityNotice / showUpdateNotice in
|
||||
panels-ui.js). Its own card in the toast stack, never the plain pill, and in the SAME
|
||||
container, so it shares the toasts' position and z-index. Theme tokens only, so every skin
|
||||
reads. Small amber text and the primary button use --notice-ink: --yellow on the dark
|
||||
skins, and on the light skins --yellow darkened a step, because their --yellow alone is
|
||||
under 4.5:1 for 11-12px text on white (3.9 to 4.4 measured). The primary button's label is
|
||||
--bg-card (dark on the dark skins' light amber, white on the light skins' dark amber). The
|
||||
entrance animates opacity and transform only, and nothing moves under
|
||||
prefers-reduced-motion.
|
||||
|
||||
The card is capped to the room below the stack's top and scrolls inside itself: the
|
||||
container is fixed and does not scroll, so on a short screen (a phone in landscape) the
|
||||
buttons at the bottom were otherwise off-screen and unreachable. The accent bar down the
|
||||
left edge (amber running into red) is therefore a background layer, not a positioned
|
||||
child: a background stays put while the content scrolls. */
|
||||
.security-notice {
|
||||
--notice-ink: var(--yellow);
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
column-gap: 0.75rem;
|
||||
align-items: start;
|
||||
box-sizing: border-box;
|
||||
width: min(420px, calc(100vw - 1rem));
|
||||
max-height: calc(var(--app-height, 100dvh) - var(--header-height) - 1rem);
|
||||
padding: 0.85rem 0.55rem 0.9rem 1.05rem;
|
||||
background:
|
||||
linear-gradient(180deg, var(--yellow), var(--red)) left top / 4px 100% no-repeat,
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--yellow) 9%, transparent), transparent 6rem),
|
||||
var(--bg-card);
|
||||
border: 1px solid color-mix(in srgb, var(--yellow) 32%, var(--border));
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28), 0 2px 6px rgba(0, 0, 0, 0.16);
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
pointer-events: auto;
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(0.985);
|
||||
transition: opacity 0.22s ease, transform 0.22s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.security-notice.show {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .security-notice {
|
||||
--notice-ink: color-mix(in srgb, var(--yellow) 85%, black);
|
||||
}
|
||||
|
||||
/* The update toast carrying a notice is still an update: its frame takes the accent colour,
|
||||
and the amber is left to the warning section inside it. */
|
||||
.security-notice.update-notice {
|
||||
background:
|
||||
linear-gradient(var(--accent), var(--accent)) left top / 4px 100% no-repeat,
|
||||
var(--bg-card);
|
||||
border-color: color-mix(in srgb, var(--accent) 30%, var(--border));
|
||||
}
|
||||
|
||||
.security-notice-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
color: var(--notice-ink);
|
||||
background: color-mix(in srgb, var(--yellow) 15%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--yellow) 30%, transparent);
|
||||
}
|
||||
|
||||
.security-notice-icon svg {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
}
|
||||
|
||||
/* The update toast carrying a notice leads with the release, in the accent colour. */
|
||||
.security-notice-icon.update-notice-icon {
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent);
|
||||
}
|
||||
|
||||
.security-notice-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
padding-top: 0.1rem;
|
||||
}
|
||||
|
||||
.security-notice-eyebrow {
|
||||
color: var(--notice-ink);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.09em;
|
||||
line-height: 1.3;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.security-notice-title {
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
overflow-wrap: anywhere;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Chinese has no spaces, so a balanced title would break inside a word (遭入 / 侵).
|
||||
Break only at the spaces and punctuation the title has; overflow-wrap above still
|
||||
rescues a run that cannot fit. */
|
||||
:lang(zh) .security-notice-title {
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.security-notice-body,
|
||||
.update-notice-text {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.security-notice-body {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.update-notice-text {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* One warning section per notice inside the update toast. */
|
||||
.security-notice-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.4rem;
|
||||
padding: 0.6rem 0.7rem 0.65rem;
|
||||
border: 1px solid color-mix(in srgb, var(--yellow) 26%, transparent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--yellow) 7%, transparent);
|
||||
}
|
||||
|
||||
.security-notice-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: var(--notice-ink);
|
||||
}
|
||||
|
||||
.security-notice-section-head svg {
|
||||
flex-shrink: 0;
|
||||
width: 0.95rem;
|
||||
height: 0.95rem;
|
||||
}
|
||||
|
||||
.security-notice-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem 0.8rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* The update toast's buttons share their row, and when it wraps (a phone, a long label)
|
||||
each fills its own, so the two never sit at odd widths one under the other. */
|
||||
.update-notice .security-notice-actions {
|
||||
column-gap: 0.6rem;
|
||||
}
|
||||
|
||||
.update-notice .security-notice-actions > button {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.security-notice-primary,
|
||||
.security-notice-secondary,
|
||||
.security-notice-quiet {
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.security-notice-primary {
|
||||
padding: 0.45rem 0.85rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--btn-radius);
|
||||
background: var(--notice-ink);
|
||||
color: var(--bg-card);
|
||||
}
|
||||
|
||||
.security-notice-primary:hover {
|
||||
background: color-mix(in srgb, var(--notice-ink) 86%, var(--text));
|
||||
}
|
||||
|
||||
.security-notice-secondary {
|
||||
padding: 0.45rem 0.85rem;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--btn-radius);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.security-notice-secondary:hover {
|
||||
background: var(--control-bg-hover);
|
||||
}
|
||||
|
||||
.security-notice-quiet {
|
||||
padding: 0.45rem 0.15rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.security-notice-quiet:hover {
|
||||
color: var(--text);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.security-notice-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
margin-top: -0.3rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 1.15rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.security-notice-close:hover {
|
||||
background: var(--control-bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.security-notice button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Phones: the full width minus the stack's 0.5rem gutters, and every button grows to fill
|
||||
its row (so one that wraps fills its own instead of sitting stray at the left). On a touch
|
||||
pointer, below, buttons are at least --touch-target-min tall and the × gets a touch-sized
|
||||
box, its negative margin keeping the glyph where it sits on a desktop. */
|
||||
@media (max-width: 599px) {
|
||||
.security-notice {
|
||||
width: calc(100vw - 1rem);
|
||||
}
|
||||
|
||||
.security-notice-actions > button {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch sizing follows the pointer, not the width: a phone turned to landscape is wider
|
||||
than 599px and still needs fingertip-sized targets. */
|
||||
@media (pointer: coarse) {
|
||||
.security-notice-actions > button {
|
||||
min-height: var(--touch-target-min);
|
||||
}
|
||||
|
||||
.security-notice-close {
|
||||
width: var(--touch-target-min);
|
||||
height: var(--touch-target-min);
|
||||
margin: -0.75rem -0.5rem 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.security-notice {
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Notification Bell Button */
|
||||
.btn-icon-header.btn-notifications {
|
||||
position: relative;
|
||||
@@ -16129,6 +16680,12 @@ html:not([data-skin="og"]) {
|
||||
color: #e0e7ff;
|
||||
}
|
||||
.btn-toolbar.btn-run.mode-omp:hover { box-shadow: 0 0 14px -2px rgba(129, 140, 248, 0.45); }
|
||||
.btn-toolbar.btn-run.mode-copilot {
|
||||
background: linear-gradient(135deg, #6d28d9, #8b5cf6);
|
||||
border-color: #5b21b6;
|
||||
color: #ede9fe;
|
||||
}
|
||||
.btn-toolbar.btn-run.mode-copilot:hover { box-shadow: 0 0 14px -2px rgba(163, 113, 247, 0.45); }
|
||||
.btn-toolbar.btn-run.mode-pi {
|
||||
background: linear-gradient(135deg, #be185d, #f472b6);
|
||||
border-color: #be185d;
|
||||
|
||||
@@ -328,6 +328,7 @@
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (ev.type === 'keydown' && global.app?.isUserBoundShortcutEvent?.(ev)) return false;
|
||||
if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -773,6 +773,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
|
||||
@@ -2378,15 +2381,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!buffer?.getLine) return false;
|
||||
const pos = this._clientPointToCell(clientX, clientY);
|
||||
if (!pos) return false;
|
||||
const rows = Math.max(1, this.terminal.rows || 1);
|
||||
const cursorRow = Math.max(0, Math.min(rows - 1, buffer.cursorY || 0));
|
||||
const tappedRow = pos.row - 1;
|
||||
if (tappedRow === cursorRow) return true;
|
||||
let start = cursorRow;
|
||||
while (start > 0 && buffer.getLine(buffer.viewportY + start)?.isWrapped) start--;
|
||||
let end = cursorRow;
|
||||
while (end + 1 < rows && buffer.getLine(buffer.viewportY + end + 1)?.isWrapped) end++;
|
||||
return tappedRow >= start && tappedRow <= end;
|
||||
// Compare ABSOLUTE buffer lines: cursorY is baseY-relative and the tap is
|
||||
// viewport-relative, so they only agree while the viewport sits at the bottom
|
||||
// (a tab switch parks it above, see scrollToLastNonEmptyLine).
|
||||
const cursorLine = buffer.baseY + (buffer.cursorY || 0);
|
||||
const tappedLine = buffer.viewportY + pos.row - 1;
|
||||
let start = cursorLine;
|
||||
while (start > 0 && buffer.getLine(start)?.isWrapped) start--;
|
||||
let end = cursorLine;
|
||||
// Bounded by length: xterm's getLine wraps around its circular list past the end.
|
||||
while (end + 1 < buffer.length && buffer.getLine(end + 1)?.isWrapped) end++;
|
||||
return tappedLine >= start && tappedLine <= end;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -3692,6 +3697,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
opencode: 'openCodeConfig',
|
||||
pi: 'piConfig',
|
||||
grok: 'grokConfig',
|
||||
copilot: 'copilotConfig',
|
||||
omp: 'ompConfig',
|
||||
}[effectiveMode];
|
||||
// codex names a thread by an id of its own, not by Codeman's session id,
|
||||
@@ -5838,6 +5844,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (mode && mode !== 'none') return false;
|
||||
const session = this.sessions?.get(target.sessionId || this.activeSessionId);
|
||||
const sessionMode = session?.mode || 'claude';
|
||||
// GitHub Copilot CLI scrolls its own transcript on SGR wheel reports (checked with xterm
|
||||
// encoding them natively). Its mouse DECSETs are stripped server-side so a drag selects
|
||||
// locally (strip-mux-and-mouse), which also means xterm no longer encodes the wheel: send the
|
||||
// same reports by hand, but only while the CLI is actually tracking the mouse. Not gated on a
|
||||
// version; a stale-false flag falls through to the PageUp/PageDown fallback like claude's.
|
||||
if (sessionMode === 'copilot') return session?.cliMouseTracking === true;
|
||||
if (sessionMode !== 'claude' && sessionMode !== 'codex') return false;
|
||||
if (sessionMode === 'claude' && !this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false;
|
||||
// Only while Claude is actually listening for the mouse. In its default
|
||||
|
||||
@@ -346,12 +346,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!sessionId || files.length === 0) return;
|
||||
// The one classifier every pick and drop filter reads (image-input.js):
|
||||
// MIME first, then the name's extension, so documents and videos count too.
|
||||
const media = files.filter((f) => this._promptAttachKind?.(f));
|
||||
if (media.length === 0) {
|
||||
this.showToast?.('Unsupported file type', 'error');
|
||||
return;
|
||||
}
|
||||
this._uploadAndInsertImages?.(media, { sessionId });
|
||||
// It toasts what it refuses.
|
||||
const media = this._admitPromptAttachments(files);
|
||||
if (media.length > 0) this._uploadAndInsertImages?.(media, { sessionId });
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "1.43.0",
|
||||
"highlights": [
|
||||
"Security notices for the CLIs you run",
|
||||
"GitHub Copilot CLI is a run mode (#592)",
|
||||
"Pin tab names on the CLI (#646, optional)",
|
||||
"Antigravity tabs show when they are working (#648)",
|
||||
"Custom-folder cases below the cases folder (#567)",
|
||||
"A steady WS pill (#647)",
|
||||
"Clickable diff and patch files (#644)",
|
||||
"Mixed uploads say what they skipped (#651)"
|
||||
]
|
||||
}
|
||||
@@ -11,7 +11,65 @@ export interface ResponseViewerTranscriptBlock {
|
||||
// Keep in lockstep with isExternalCliMode() in src/session.ts. Importing it here
|
||||
// would drag node-pty and the whole session layer into this pure module, so the
|
||||
// list is duplicated and test/response-viewer-transcript.test.ts pins the parity.
|
||||
const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok', 'deepseek']);
|
||||
const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok', 'deepseek', 'copilot']);
|
||||
|
||||
/**
|
||||
* A pane that is not Codex-shaped (`›` prompt, `•` bullets) describes its own dialect as data, so
|
||||
* a new CLI adds a row here instead of another branch in the Codex-oriented loop below.
|
||||
*/
|
||||
interface PaneDialect {
|
||||
/** Group 1 is the prompt text. A bare composer glyph with nothing after it must NOT match. */
|
||||
prompt: RegExp;
|
||||
/** A right-aligned timestamp the TUI appends to a submitted prompt. */
|
||||
promptTrailer?: RegExp;
|
||||
/** The glyph that opens an answer. */
|
||||
responseBullet: RegExp;
|
||||
/** Chrome that can sit between turns: banner notes, the cwd/credits row, the footer, spinners. */
|
||||
status: RegExp[];
|
||||
/** Rows that carry nothing (the empty composer), dropped and treated as a block boundary. */
|
||||
ignore: RegExp[];
|
||||
/**
|
||||
* The composer row, the one between two rules at the bottom of the screen. Only the LAST rule,
|
||||
* composer row, rule triple counts: an answer can hold a rule-delimited paragraph or a quoted
|
||||
* prompt of its own, and that is answer text.
|
||||
*/
|
||||
composer: RegExp;
|
||||
/** Furniture drawn at the end of a row (a scrollbar gutter), removed before anything else reads it. */
|
||||
lineTrailer?: RegExp;
|
||||
/**
|
||||
* Text that is on screen only before the first prompt has scrolled the screen: the banner, a
|
||||
* trust dialog. Its presence is how "nothing has been asked yet" is told apart from "the prompt
|
||||
* row has scrolled off the top".
|
||||
*/
|
||||
startup: RegExp[];
|
||||
}
|
||||
|
||||
const PANE_DIALECTS: Record<string, PaneDialect> = {
|
||||
// GitHub Copilot CLI, measured on 1.0.95 inside tmux: ` ❯ <prompt> HH:MM`, then ` ● <answer>`
|
||||
// with 3-space continuation lines, a cwd + `Session: N AIC used` row, the composer between two
|
||||
// rules, and a `← open sidebar · … · / commands · ? help` footer. Tool-call rows are not
|
||||
// modelled: they open with the same bullet and read as part of the answer.
|
||||
copilot: {
|
||||
// Copilot draws a submitted prompt at column 0 or 1; the continuation rows of an answer are
|
||||
// indented three spaces, so a shell prompt inside a code block (` ❯ npm test`) is not a prompt.
|
||||
prompt: /^ ?❯\s+(\S.*)$/,
|
||||
promptTrailer: /\s{2,}\d{1,2}:\d{2}\s*$/,
|
||||
responseBullet: /^\s*●\s+/,
|
||||
status: [
|
||||
/^\s*(?:!|●)\s+(?:Tip:|MCP Servers?\b|Model\b)/,
|
||||
/^\s*!\s+/,
|
||||
/\bSession: .*(?:AIC used|credits unavailable)/,
|
||||
/^\s*←\s+open sidebar/,
|
||||
/\/ commands · \? help/,
|
||||
/^\s*[◉◎]\s+Working\b/,
|
||||
],
|
||||
ignore: [/^ ?❯\s*$/],
|
||||
composer: /^ ?❯/,
|
||||
// The right-hand scrollbar gutter, `┃` on a row (or alone on a blank one).
|
||||
lineTrailer: /\s*[┃▐]\s*$/,
|
||||
startup: [/Copilot v\d+\.\d+\.\d+ uses AI/, /Confirm folder trust/, /Do you trust the files in this folder/],
|
||||
},
|
||||
};
|
||||
|
||||
function isPromptLine(line: string): boolean {
|
||||
return /^\s*›\s*/.test(line);
|
||||
@@ -265,6 +323,111 @@ export function isExternalCliTranscriptMode(mode: string | null | undefined): bo
|
||||
return EXTERNAL_CLI_MODES.has(String(mode || ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this mode's pane is a repainting full-screen TUI whose output stream is absolute cursor
|
||||
* moves rather than lines. The segmenter needs the SCREEN for those (tmux capture-pane), because
|
||||
* stripping the escapes from the stream concatenates every row into one run of text.
|
||||
*/
|
||||
export function transcriptNeedsScreenCapture(mode: string | null | undefined): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(PANE_DIALECTS, String(mode || ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Segment a pane that has its own dialect. Everything before the first prompt is the banner (or a
|
||||
* trust dialog) and is dropped, so a session that has not been asked anything answers "nothing
|
||||
* yet" rather than serving its logo as a reply.
|
||||
*
|
||||
* The screen is all there is to read, so a long answer scrolls the prompt row off the top. When no
|
||||
* prompt row is on screen AND no startup text is either, the session has already been asked
|
||||
* something: the rows above the composer are the tail of the last answer, not "nothing yet".
|
||||
*/
|
||||
function parseDialectTranscript(cleaned: string, dialect: PaneDialect): ResponseViewerTranscriptBlock[] {
|
||||
const blocks: ResponseViewerTranscriptBlock[] = [];
|
||||
let kind: ResponseViewerTranscriptKind | null = null;
|
||||
let current: string[] = [];
|
||||
const rows = cleaned.split('\n').map((row) => (dialect.lineTrailer ? row.replace(dialect.lineTrailer, '') : row));
|
||||
// The composer sits between two rules and holds whatever is typed but not sent. That is not a
|
||||
// prompt row: otherwise typing under a tall answer would make the answer's tail read as banner.
|
||||
let composerIndex = -1;
|
||||
for (let i = rows.length - 2; i > 0; i -= 1) {
|
||||
if (
|
||||
dialect.composer.test(rows[i] ?? '') &&
|
||||
isDividerOnlyLine(rows[i - 1] ?? '') &&
|
||||
isDividerOnlyLine(rows[i + 1] ?? '')
|
||||
) {
|
||||
composerIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const isComposerRow = (i: number) => i === composerIndex;
|
||||
const isComposerRule = (i: number) => composerIndex !== -1 && (i === composerIndex - 1 || i === composerIndex + 1);
|
||||
const hasPrompt = rows.some((row, i) => dialect.prompt.test(row) && !isComposerRow(i));
|
||||
const startupVisible = rows.some((row) => dialect.startup.some((re) => re.test(row)));
|
||||
let sawPrompt = !hasPrompt && !startupVisible;
|
||||
// A rule drawn INSIDE an answer (a markdown `---`, a quoted passage between two rules) is part
|
||||
// of the answer, so it must not end the block, or `agent read` returns only the text after the
|
||||
// last rule. It is held until more of the same answer follows, so a rule that only separates
|
||||
// the answer from the footer never trails it. The composer's own two rules stay boundaries.
|
||||
let pendingRule = false;
|
||||
|
||||
const flush = () => {
|
||||
if (sawPrompt) pushBlock(blocks, kind, current);
|
||||
kind = null;
|
||||
current = [];
|
||||
pendingRule = false;
|
||||
};
|
||||
|
||||
for (const [index, line] of rows.entries()) {
|
||||
if (kind === 'response' && isDividerOnlyLine(line) && !isComposerRule(index)) {
|
||||
pendingRule = true;
|
||||
continue;
|
||||
}
|
||||
if (isDividerOnlyLine(line) || isComposerRow(index) || dialect.ignore.some((re) => re.test(line))) {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
const prompt = dialect.prompt.exec(line);
|
||||
if (prompt) {
|
||||
flush();
|
||||
sawPrompt = true;
|
||||
kind = 'prompt';
|
||||
current = [dialect.promptTrailer ? prompt[1].replace(dialect.promptTrailer, '').trim() : prompt[1].trim()];
|
||||
continue;
|
||||
}
|
||||
if (!sawPrompt) continue;
|
||||
if (dialect.status.some((re) => re.test(line))) {
|
||||
if (kind !== 'status') flush();
|
||||
kind = 'status';
|
||||
current.push(line.trim());
|
||||
continue;
|
||||
}
|
||||
// A wrapped warning or tip: indented under its status row.
|
||||
if (kind === 'status' && /^\s{3,}\S/.test(line)) {
|
||||
current.push(line.trim());
|
||||
continue;
|
||||
}
|
||||
if (!line.trim()) {
|
||||
if (kind) current.push('');
|
||||
continue;
|
||||
}
|
||||
const bullet = dialect.responseBullet.exec(line);
|
||||
if (bullet) {
|
||||
flush();
|
||||
kind = 'response';
|
||||
current = [line.slice(bullet[0].length)];
|
||||
continue;
|
||||
}
|
||||
if (!kind) kind = 'response';
|
||||
if (pendingRule) {
|
||||
current.push('', '---', '');
|
||||
pendingRule = false;
|
||||
}
|
||||
current.push(line);
|
||||
}
|
||||
flush();
|
||||
return blocks.filter((block) => block.text.trim().length > 0);
|
||||
}
|
||||
|
||||
export function parseExternalCliTranscript(
|
||||
buffer: string,
|
||||
mode: string | null | undefined
|
||||
@@ -275,6 +438,9 @@ export function parseExternalCliTranscript(
|
||||
const cleaned = cleanTerminalTranscript(buffer);
|
||||
if (!cleaned) return [];
|
||||
|
||||
const dialect = PANE_DIALECTS[resolvedMode];
|
||||
if (dialect) return parseDialectTranscript(cleaned, dialect);
|
||||
|
||||
const lines = trimLeadingStartup(cleaned.split('\n'));
|
||||
const blocks: ResponseViewerTranscriptBlock[] = [];
|
||||
let currentKind: ResponseViewerTranscriptKind | null = null;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
* only, never env values, headers or file content (a parse failure is reported by position).
|
||||
*
|
||||
* A CLI takes part when it is ENABLED in the registry, declares an `mcpConfig`, and is installed
|
||||
* or already has its config file (Copilot CLI, which is not a registry CLI, takes part when installed
|
||||
* or when its config file exists); one that is enabled but absent from the machine is reported
|
||||
* or already has its config file (Copilot CLI declares its own `mcpConfig`; with its run mode disabled it still
|
||||
* takes part as a sync-only tool when installed or when its config file exists); one that is enabled but absent from the machine is reported
|
||||
* `absent` and never created. Its file is located with this process's env (the env the CLIs
|
||||
* Codeman spawns inherit), so a relocation var such as `CODEX_HOME` is followed.
|
||||
*/
|
||||
@@ -39,8 +39,8 @@ export async function readMcpSyncEnabled(): Promise<boolean> {
|
||||
|
||||
/**
|
||||
* Enabled CLIs that declare an MCP config file, in registry order (first definition wins), then the
|
||||
* sync-only tools (src/mcp-sync-targets.ts: Copilot CLI), which come last so a registry CLI's
|
||||
* definition wins a same-name difference.
|
||||
* sync-only tools (src/mcp-sync-targets.ts: Copilot CLI while its run mode is disabled), which come
|
||||
* last so a registry CLI's definition wins a same-name difference.
|
||||
*/
|
||||
export function mcpSyncTargets(availability: Record<string, boolean>): McpSyncTarget[] {
|
||||
const registry = enabledClis()
|
||||
|
||||
@@ -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,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
type AntigravityConfig,
|
||||
type PiConfig,
|
||||
type GrokConfig,
|
||||
type CopilotConfig,
|
||||
type DeepSeekConfig,
|
||||
type OmpConfig,
|
||||
type RemoteHost,
|
||||
@@ -181,12 +183,14 @@ 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';
|
||||
import { scanOmpSessionsHistory } from '../../omp-transcript.js';
|
||||
import { scanCodexSessionsHistory, codexThreadBySessionId } from '../../codex-transcript.js';
|
||||
import {
|
||||
transcriptNeedsScreenCapture,
|
||||
getLastTranscriptResponse,
|
||||
isExternalCliTranscriptMode,
|
||||
parseExternalCliTranscript,
|
||||
@@ -323,18 +327,53 @@ 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']);
|
||||
|
||||
/**
|
||||
* 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). The browser
|
||||
* keeps its own copies (test/upload-document-list-parity.test.ts pins them).
|
||||
*/
|
||||
export const ALLOWED_DOCUMENT_EXTS: ReadonlySet<string> = new Set([
|
||||
'.pdf',
|
||||
'.docx',
|
||||
'.pptx',
|
||||
'.xlsx',
|
||||
'.zip',
|
||||
...TEXT_UPLOAD_EXTS,
|
||||
]);
|
||||
|
||||
/**
|
||||
* 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 +467,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.
|
||||
@@ -575,7 +630,8 @@ export async function _clampExternalCliBypassForOwner(
|
||||
antigravityConfig?: AntigravityConfig,
|
||||
piConfig?: PiConfig,
|
||||
grokConfig?: GrokConfig,
|
||||
deepSeekConfig?: DeepSeekConfig
|
||||
deepSeekConfig?: DeepSeekConfig,
|
||||
copilotConfig?: CopilotConfig
|
||||
): Promise<{
|
||||
codexConfig: CodexConfig | undefined;
|
||||
geminiConfig: GeminiConfig | undefined;
|
||||
@@ -583,6 +639,7 @@ export async function _clampExternalCliBypassForOwner(
|
||||
piConfig: PiConfig | undefined;
|
||||
grokConfig: GrokConfig | undefined;
|
||||
deepSeekConfig: DeepSeekConfig | undefined;
|
||||
copilotConfig: CopilotConfig | undefined;
|
||||
}> {
|
||||
const out = await clampExternalCliBypassForOwner(owner, {
|
||||
codexConfig,
|
||||
@@ -591,6 +648,7 @@ export async function _clampExternalCliBypassForOwner(
|
||||
piConfig,
|
||||
grokConfig,
|
||||
deepSeekConfig,
|
||||
copilotConfig,
|
||||
});
|
||||
return out as {
|
||||
codexConfig: CodexConfig | undefined;
|
||||
@@ -599,6 +657,7 @@ export async function _clampExternalCliBypassForOwner(
|
||||
piConfig: PiConfig | undefined;
|
||||
grokConfig: GrokConfig | undefined;
|
||||
deepSeekConfig: DeepSeekConfig | undefined;
|
||||
copilotConfig: CopilotConfig | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -678,6 +737,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
|
||||
@@ -1291,6 +1371,7 @@ export function registerSessionRoutes(
|
||||
piConfig: gatedPiConfig,
|
||||
grokConfig: gatedGrokConfig,
|
||||
deepSeekConfig: gatedDeepSeekConfig,
|
||||
copilotConfig: gatedCopilotConfig,
|
||||
} = await _clampExternalCliBypassForOwner(
|
||||
owner,
|
||||
launchBody.codexConfig,
|
||||
@@ -1298,7 +1379,8 @@ export function registerSessionRoutes(
|
||||
launchBody.antigravityConfig,
|
||||
launchBody.piConfig,
|
||||
launchBody.grokConfig,
|
||||
launchBody.deepSeekConfig
|
||||
launchBody.deepSeekConfig,
|
||||
launchBody.copilotConfig
|
||||
);
|
||||
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||
const session = new Session({
|
||||
@@ -1317,6 +1399,7 @@ export function registerSessionRoutes(
|
||||
antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined,
|
||||
piConfig: mode === 'pi' ? gatedPiConfig : undefined,
|
||||
grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
|
||||
copilotConfig: mode === 'copilot' ? gatedCopilotConfig : undefined,
|
||||
deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined,
|
||||
ompConfig: resolveOmpConfigForCreate(mode, workingDir, launchBody.ompConfig),
|
||||
resumeSessionId: validatedResumeId,
|
||||
@@ -1998,7 +2081,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 +2099,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 +2122,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 +2267,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 {
|
||||
@@ -2751,7 +2836,14 @@ export function registerSessionRoutes(
|
||||
// where a real rollout file is the better source.
|
||||
if (isExternalCliTranscriptMode(session.mode)) {
|
||||
const externalQuery = req.query as { context?: string };
|
||||
const blocks = parseExternalCliTranscript(session.terminalBuffer, session.mode);
|
||||
// A repainting full-screen TUI (copilot) streams absolute cursor moves, not lines, so the
|
||||
// buffer cannot be segmented; read the pane's visible screen instead, and fall back to the
|
||||
// buffer when there is no tmux pane to ask (direct PTY, tests).
|
||||
const screen =
|
||||
transcriptNeedsScreenCapture(session.mode) && session.muxName
|
||||
? (ctx.mux.capturePaneText?.(session.muxName) ?? null)
|
||||
: null;
|
||||
const blocks = parseExternalCliTranscript(screen ?? session.terminalBuffer, session.mode);
|
||||
return {
|
||||
text: getLastTranscriptResponse(blocks),
|
||||
timestamp: '',
|
||||
@@ -3666,6 +3758,7 @@ export function registerSessionRoutes(
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
copilotConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
envOverrides,
|
||||
@@ -3675,6 +3768,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 +3802,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');
|
||||
@@ -3726,6 +3830,7 @@ export function registerSessionRoutes(
|
||||
antigravityConfig ||
|
||||
piConfig ||
|
||||
grokConfig ||
|
||||
copilotConfig ||
|
||||
deepSeekConfig ||
|
||||
ompConfig ||
|
||||
openCodeConfig ||
|
||||
@@ -3796,6 +3901,7 @@ export function registerSessionRoutes(
|
||||
antigravityConfig ||
|
||||
piConfig ||
|
||||
grokConfig ||
|
||||
copilotConfig ||
|
||||
deepSeekConfig ||
|
||||
ompConfig ||
|
||||
openCodeConfig ||
|
||||
@@ -3896,6 +4002,7 @@ export function registerSessionRoutes(
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
copilotConfig,
|
||||
deepSeekConfig,
|
||||
} as unknown as Record<string, unknown>)
|
||||
);
|
||||
@@ -3949,6 +4056,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 +4135,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 +4149,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 +4182,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
|
||||
@@ -4077,6 +4205,7 @@ export function registerSessionRoutes(
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
copilotConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
};
|
||||
@@ -4106,6 +4235,7 @@ export function registerSessionRoutes(
|
||||
piConfig: qsGatedPiConfig,
|
||||
grokConfig: qsGatedGrokConfig,
|
||||
deepSeekConfig: qsGatedDeepSeekConfig,
|
||||
copilotConfig: qsGatedCopilotConfig,
|
||||
} = await _clampExternalCliBypassForOwner(
|
||||
owner,
|
||||
qsLaunchConfigs.codexConfig,
|
||||
@@ -4113,11 +4243,12 @@ export function registerSessionRoutes(
|
||||
qsLaunchConfigs.antigravityConfig,
|
||||
qsLaunchConfigs.piConfig,
|
||||
qsLaunchConfigs.grokConfig,
|
||||
qsLaunchConfigs.deepSeekConfig
|
||||
qsLaunchConfigs.deepSeekConfig,
|
||||
qsLaunchConfigs.copilotConfig
|
||||
);
|
||||
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 +4354,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 +4395,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,
|
||||
@@ -4280,6 +4411,7 @@ export function registerSessionRoutes(
|
||||
antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined,
|
||||
piConfig: mode === 'pi' ? qsGatedPiConfig : undefined,
|
||||
grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined,
|
||||
copilotConfig: mode === 'copilot' ? qsGatedCopilotConfig : undefined,
|
||||
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
|
||||
ompConfig: qsResolvedOmpConfig,
|
||||
envOverrides: qsCustomModelEnvOverrides,
|
||||
@@ -4322,7 +4454,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 +4518,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 } : {}),
|
||||
};
|
||||
@@ -5359,6 +5497,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 +5643,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 +5708,51 @@ 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.
|
||||
// A text refusal names the encoding, since an Excel or Notepad export
|
||||
// (UTF-16 with a BOM, or Windows-1252) is not a corrupt file.
|
||||
const isText = TEXT_UPLOAD_EXTS.has(origExt);
|
||||
const bom = imageBytes.length >= 2 ? imageBytes.readUInt16BE(0) : 0;
|
||||
const utf16 = bom === 0xfffe || bom === 0xfeff;
|
||||
const detail = isText
|
||||
? `reason=${utf16 ? 'utf16-bom' : 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,
|
||||
isText
|
||||
? `This ${origExt} file is ${utf16 ? 'UTF-16 text, not UTF-8' : 'not UTF-8 text'}; save it as UTF-8 and attach it again`
|
||||
: `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 +5793,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 +5813,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 });
|
||||
@@ -712,13 +762,30 @@ export function registerSystemRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ========== GitHub Copilot ==========
|
||||
|
||||
// Carries `version` like pi/grok/omp: `copilot` is a generic binary name, so the resolver
|
||||
// only accepts one whose `--version` says "GitHub Copilot CLI".
|
||||
app.get('/api/copilot/status', async () => {
|
||||
const { isCopilotAvailable, resolveCopilotDir, getCopilotCliVersion } =
|
||||
await import('../../utils/copilot-cli-resolver.js');
|
||||
return {
|
||||
available: isCopilotAvailable(),
|
||||
path: resolveCopilotDir(),
|
||||
version: getCopilotCliVersion(),
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// State & Lifecycle (cleanup, lifecycle log, stats)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== 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 +933,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 +1005,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 +1017,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 +1169,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 } };
|
||||
}
|
||||
);
|
||||
}
|
||||
+79
-3
@@ -194,7 +194,7 @@ function sessionModeSchema(): z.ZodType<SessionMode> {
|
||||
/**
|
||||
* Allowlisted env var key prefixes, contributed by the ENABLED CLIs in the registry
|
||||
* (`env.allowedPrefixes`) — `CLAUDE_CODE_`, `OPENCODE_`, `CODEX_`, `GEMINI_`, `GOOGLE_`,
|
||||
* `ANTIGRAVITY_`, `PI_`, `GROK_`, `XAI_`, `DSH_`, `DEEPSEEK_` as shipped.
|
||||
* `ANTIGRAVITY_`, `PI_`, `GROK_`, `XAI_`, `DSH_`, `DEEPSEEK_`, `OMP_`, `COPILOT_` as shipped.
|
||||
*
|
||||
* ⚠️ Resolved AT PARSE TIME, not at module load. This used to be a frozen array computed
|
||||
* once when the module was imported, which meant a CLI enabled while the server was running
|
||||
@@ -259,7 +259,7 @@ const safeEnvOverridesSchema = z
|
||||
},
|
||||
{
|
||||
message:
|
||||
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_*, DSH_*, DEEPSEEK_*, OMP_* keys and CLAUDE_CONFIG_DIR are allowed.',
|
||||
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_*, DSH_*, DEEPSEEK_*, OMP_*, COPILOT_* keys and CLAUDE_CONFIG_DIR are allowed.',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -439,6 +439,28 @@ const GrokConfigSchema = z
|
||||
})
|
||||
.optional();
|
||||
|
||||
const CopilotConfigSchema = z
|
||||
.object({
|
||||
// A leading alphanumeric, like resumeSessionId below: a value like `--yolo` would otherwise be read
|
||||
// as its own flag after `--model` (bypassing the allowAll clamp). `/` stays for provider/id.
|
||||
model: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/)
|
||||
.optional(),
|
||||
allowAll: z.boolean().optional(),
|
||||
continueSession: z.boolean().optional(),
|
||||
// A single plain word with a leading alphanumeric: --resume also matches session NAMES, so
|
||||
// `my-feature` is accepted, but a value like `--yolo` must not be, because --resume's value is
|
||||
// optional and the next token would be read as its own flag (bypassing the allowAll clamp).
|
||||
resumeSessionId: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/)
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
/**
|
||||
* Schema for OMP CLI-specific configuration.
|
||||
*/
|
||||
@@ -596,6 +618,7 @@ export const CreateSessionSchema = z.object({
|
||||
antigravityConfig: AntigravityConfigSchema,
|
||||
piConfig: PiConfigSchema,
|
||||
grokConfig: GrokConfigSchema,
|
||||
copilotConfig: CopilotConfigSchema,
|
||||
deepSeekConfig: DeepSeekConfigSchema,
|
||||
ompConfig: OmpConfigSchema,
|
||||
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
|
||||
@@ -707,7 +730,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 +791,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 +1199,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
|
||||
@@ -1151,6 +1218,7 @@ export const QuickStartSchema = z.object({
|
||||
antigravityConfig: AntigravityConfigSchema,
|
||||
piConfig: PiConfigSchema,
|
||||
grokConfig: GrokConfigSchema,
|
||||
copilotConfig: CopilotConfigSchema,
|
||||
deepSeekConfig: DeepSeekConfigSchema,
|
||||
ompConfig: OmpConfigSchema,
|
||||
envOverrides: safeEnvOverridesSchema,
|
||||
@@ -1381,6 +1449,14 @@ export const SettingsUpdateSchema = z
|
||||
* never added — for a user who wants Codeman to leave their repos alone.
|
||||
*/
|
||||
workspaceHooksEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* Launch every Claude session with `--name` set to its tab name (default OFF: only a
|
||||
* name the user chose is pinned). Nothing acts on it in `PUT /api/settings`: it is read
|
||||
* fresh from disk by readPinCliSessionNameEnabled() (hooks-config.ts) at every claude
|
||||
* create and respawn, like readPlanUsageTelemetryEnabled(), so a toggle applies from the
|
||||
* next spawn with no restart.
|
||||
*/
|
||||
pinCliSessionName: z.boolean().optional(),
|
||||
/**
|
||||
* Let browser dictation transcribe through this machine's Claude Code login,
|
||||
* the same speech-to-text service the CLI's own `/voice` mode uses
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
+22
-1
@@ -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,
|
||||
@@ -962,7 +964,7 @@ export class WebServer extends EventEmitter {
|
||||
// `ServerResponse` to a `FastifyReply`, so it is `reply.header()` here and
|
||||
// NOT `res.setHeader()`. A v9-style body throws TypeError on every static
|
||||
// request, which is every page load. See the v10.0.0 release notes.
|
||||
setHeaders: (reply) => {
|
||||
setHeaders: (reply, filePath) => {
|
||||
// ⚠️ That same change ALSO flipped precedence, and silently. Under v9 this
|
||||
// callback wrote to the raw response and Fastify's staged reply headers then
|
||||
// overwrote it, so a route that set its own Cache-Control before .sendFile()
|
||||
@@ -971,6 +973,15 @@ export class WebServer extends EventEmitter {
|
||||
// no-store` its route asks for — a service worker that can never update.
|
||||
// So: a route that already decided keeps its answer.
|
||||
if (reply.getHeader('Cache-Control') !== undefined) return;
|
||||
// A JSON file here is DATA the page fetches at runtime (whats-new.json is rewritten every
|
||||
// release, security-notices.json by hand, manifest.json), never content-hashed and never
|
||||
// `?v=`-busted, so it must revalidate. Under `immutable` a browser may keep last release's
|
||||
// copy for a year, and the page's `fetch(..., { cache: 'no-cache' })` is the only thing
|
||||
// asking it not to; the response header is what every browser honours.
|
||||
if (filePath.endsWith('.json')) {
|
||||
reply.header('Cache-Control', 'no-cache');
|
||||
return;
|
||||
}
|
||||
reply.header('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
},
|
||||
});
|
||||
@@ -1153,6 +1164,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 +2847,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;
|
||||
}
|
||||
@@ -3579,6 +3599,7 @@ export class WebServer extends EventEmitter {
|
||||
antigravityConfig: muxSession.mode === 'antigravity' ? savedState?.antigravityConfig : undefined,
|
||||
piConfig: muxSession.mode === 'pi' ? savedState?.piConfig : undefined,
|
||||
grokConfig: muxSession.mode === 'grok' ? savedState?.grokConfig : undefined,
|
||||
copilotConfig: muxSession.mode === 'copilot' ? savedState?.copilotConfig : undefined,
|
||||
deepSeekConfig: muxSession.mode === 'deepseek' ? savedState?.deepSeekConfig : undefined,
|
||||
ompConfig: muxSession.mode === 'omp' ? savedState?.ompConfig : undefined,
|
||||
envOverrides: savedEnvOverrides,
|
||||
|
||||
@@ -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