Compare commits

...
Author SHA1 Message Date
github-actions[bot]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 90ac13da1a chore: version packages (#412)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-14 12:59:48 +02:00
Codeman maintainer 48f30f3055 style: drop em-dashes from the text added in c2114615
House style, and these land in the changelog. Only the sentences added in the
previous commit are touched; the em-dashes in contributor text and in the
pre-existing COD-54/COD-115 comments are left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 12:46:24 +02:00
Codeman maintainer c211461500 fix: merge-time follow-ups for #409, #404 and #399
#409 (Claude truecolor). The changeset becomes the changelog, and its premise
does not hold on tmux 3.2 or newer. Measured here on tmux 3.4: `default-terminal`
sits at its compiled default of `tmux-256color`, a live claude pane reports
`TERM=tmux-256color`, and supports-color reads that as 256 colors, where
rgb(55,55,55) lands on ESC[48;5;237m — visible, just not the color the theme
named. The invisible block the PR describes needs TERM to resolve to a 16-color
entry: tmux older than 3.2, or a ~/.tmux.conf setting `default-terminal screen`,
which Codeman's own tmux server does read (it passes no -f). Both the changeset
and the invariants paragraph now say that, so the next report here gets paired
with the reporter's tmux -V instead of being read as universal. The change itself
stands on the simpler argument: claude was one of two entries not asking for
truecolor while twelve do.

Also reorders buildClaudeEnv(). It applied the registry's unset/exports AFTER the
whole env was built, so a clis.json entry naming CODEMAN_HOOK_SECRET_FILE or PATH
would strip it on the direct-PTY path while the tmux pane kept it — buildEnvExports()
emits `...cliEnv` ahead of `export CODEMAN_MUX=1` and cannot. The block now runs
first and Codeman's own keys are assigned on top, matching the pane.

#404 (Ctrl+Z trap). Adds the missing changeset, and records what the trap does
not cover: an agent CLI already holds its tty with ISIG off (verified on three
live panes: `susp = ^Z -isig -icanon`), so this is defence for the startup window
rather than a fix for the steady state, and two input paths still reach the PTY
unfiltered — the mobile accessory bar's one-shot Ctrl and the CJK textarea.

#399 (path picker sort). The server sorts by name and cuts at 500, so the client
sorting those 500 by date gives "the newest of the first 500 by name", which is
wrong in exactly the >500-entry folder the date sort exists for. The status line
now says "(first 500 by name)" so the cut is legible, with the reasoning parked
on _sortEntries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 12:45:41 +02:00
Ark0N 37929cb671 Merge pull request #404 from timkjr/pr-ctrlz-suspend-trap
fix(terminal): trap Ctrl+Z in non-shell sessions to prevent accidental suspend
2026-09-14 12:35:33 +02:00
Ark0N e0ebbbdc91 Merge pull request #409 from irisitymichaelgrundberg/fix/claude-truecolor-in-panes
fix(terminal): let Claude use truecolor so its themed backgrounds render
2026-09-14 12:35:28 +02:00
Ark0N 8c237223b0 Merge pull request #399 from shenlvkang-collab/pr/path-picker-sort-jump
feat(files): let the path picker jump to a typed path and sort by name or date
2026-09-14 12:35:23 +02:00
Michael GrundbergandClaude Opus 5 dae2ac580f fix(terminal): read the colour env from the registry on every local spawn path
buildClaudeEnv(), the direct-PTY fallback taken when mux creation fails, now
reads getCli('claude').env and applies its unset and exports lists. It used to
delete COLORTERM and CLAUDECODE from a hand-maintained list of its own, which
left it contradicting the registry entry that the tmux pane and the attach
client both read. An engine value needing a mux name has nothing to resolve
against on this path, so it is skipped rather than guessed.

Claude no longer unsets NO_COLOR. The invisible-background bug does not need
it, and unsetting it overrides a preference the user set deliberately, so a
user who exports NO_COLOR globally keeps monochrome panes. The other seven
truecolor CLIs still unset it; that inconsistency is intentional and the
comment on the entry says so.

The invariants doc gains a Terminal colour env paragraph under Session launch
modes, where a reader looking up Claude will find it — the previous sentence
sat under a heading that lists only the non-Claude CLIs. It now says the lists
are the stock catalog and a clis.json override replaces them wholesale, and
that the declarations reach the tmux pane, its attach client and the direct
PTY but not a remote pane, whose command carries no env exports at all. Docker
hands COLORTERM=truecolor to every mode, including the two the registry says
must unset it.

The changeset named six peer CLIs and there are seven: deepseek also exports
truecolor. A test beside the existing OpenCode assertion pins the new
behaviour, so a future registry edit cannot make the backgrounds vanish again
in silence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 14:42:30 +02:00
Michael GrundbergandClaude Opus 5 7767b16d4f fix(terminal): let Claude use truecolor so its themed backgrounds render
Claude draws the user's own messages as a block of background color, and
inside a Codeman pane that block was invisible. tmux hands each pane
TERM=screen, which supports-color reads as 16 colors, and Claude's registry
entry deleted COLORTERM on top of that. Claude therefore quantized every RGB
color its theme asked for down to the basic palette, where rgb(55, 55, 55)
and every other dark background becomes ESC[40m, the terminal's own black.
Changing the color in a custom Claude theme moved nothing on screen.

Claude now exports COLORTERM=truecolor and unsets NO_COLOR, matching codex,
gemini, antigravity, pi, grok and omp. CLAUDECODE stays unset, because Claude
reads it as a signal that it is running nested inside itself. Both the tmux
session and the attach client read this one registry entry, so they cannot
disagree.

PR #3 introduced the unset in February, citing xterm.js#484 for the claim
that xterm.js mishandles truecolor. xterm.js closed that issue in April 2019,
Codeman now depends on @xterm/xterm 6, and TmuxManager already sets
terminal-overrides ",*:Tc" on its own tmux server, so 24-bit color reaches
the browser today for every CLI that asks for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 12:15:28 +02:00
timkjrandClaude Sonnet 5 0cedf05d13 fix(terminal): trap Ctrl+Z in non-shell sessions to prevent accidental suspend
Ctrl+Z (SIGTSTP) suspends the foreground job on the pane's tty. In a plain
shell session that's the user's own job-control tool (suspend, fg back),
but in claude/omp/pi/codex/etc. sessions it stops an unattended agent loop
dead with no visible output — the same failure shape as an XOFF freeze,
just via job control instead of tty flow control. Ink-based TUIs usually
run in raw mode (ISIG off) where ^Z is inert, but that only holds once the
CLI is actually running and stays in raw mode; it's live at the shell
prompt before launch and during any raw-mode toggle.

Swallow it client-side in attachCustomKeyEventHandler, mode-gated so shell
sessions keep normal job control, mirroring the existing Ctrl+V/Ctrl+Backspace
interception pattern in the same handler. Case-insensitive key match (Caps
Lock flips ev.key to 'Z' without setting shiftKey, so a plain === 'z' check
let the exact suspend keystroke this exists to catch slip through).

Also cover subagent/teammate terminal windows (panels-ui.js's
initTeammateTerminal), which render a separate xterm instance with no
custom key handler at all and are always running an agent CLI — never a
shell — so the trap there is unconditional.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 18:30:48 -05:00
shenlvkang-collabandClaude Fable 5.1 58b4cb06d8 feat(files): let the path picker jump to a typed path and sort by name or date
The picker's current-folder line was a read-only breadcrumb, so reaching a
deep folder meant tapping through every level, and the listing was fixed to
name order, so the file an agent had just written was somewhere in a
500-entry list.

The current folder is now an editable field: Enter or Go jumps there, a full
file path lands in its folder with that file selected, and a path that does
not resolve keeps the listing you had and says so, instead of the reset to
the root that a stale initialPath gets. A Sort control orders the listing by
name or modified time in either direction, folders always first, and the
choice is remembered per device like the hidden toggle. Each entry shows a
compact modified time (time of day today, month-day this year, else the
date).

GET /api/filesystem/browse stamps every entry with mtimeMs to make that
possible; the stat that already fetched a file's size now serves both, so
it is still one stat per entry. Entries without an mtime (an older server,
the in-container listing) sort after dated ones and then by name.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McLWqCWBuQYGuPMScb4Aou
2026-09-10 12:42:33 +08:00
16 changed files with 794 additions and 40 deletions
+72
View File
@@ -1,5 +1,77 @@
# aicodeman
## 1.28.0
### Minor Changes
- 58b4cb0: feat(files): let the path picker jump to a typed path and sort by name or date
The picker's current-folder line was read-only, so reaching a deep folder meant tapping
through every level, and its listing was fixed to name order, so the file an agent had
just written was somewhere in a 500-entry list. The current folder is now an editable
field (Enter or Go jumps there, a full file path lands in its folder with the file
selected, and a typo keeps the listing you had instead of resetting to the root), the
listing can be sorted by name or modified time in either direction with folders always
first (the choice is remembered per device), and each entry shows a compact modified
time. `GET /api/filesystem/browse` entries carry `mtimeMs` to make that possible, with
one stat per entry.
### Patch Changes
- c211461: fix(terminal): swallow Ctrl+Z in agent sessions so it cannot suspend a running CLI
Ctrl+Z raises SIGTSTP on the pane's tty. In a `shell` session that is ordinary job control and
is left alone, but in an agent session suspending the CLI stops an unattended loop dead with no
visible output, the same failure shape as an XOFF freeze. The key is now swallowed in
`attachCustomKeyEventHandler` for every non-shell mode, and unconditionally in the
subagent/teammate terminals, which always run an agent CLI. The match is case-insensitive,
because Caps Lock flips `ev.key` to `'Z'` without setting `shiftKey` and a plain `=== 'z'`
check would let exactly the keystroke this exists to catch through.
This is defence in depth rather than a fix for the steady state: an agent CLI holds its tty in
raw mode with ISIG off, where ^Z is already inert. It covers the moments that are not the
steady state: the window before the CLI takes the tty at startup, and any point where it hands
the tty back. Two input paths are deliberately not covered and still reach the PTY: the mobile
keyboard accessory bar's one-shot Ctrl, and the CJK composition textarea when `cjkInputEnabled`
is on. Both are separate choke points to the PTY, and both are worth covering if this ever
turns out to matter in practice.
- 7767b16: fix(terminal): let Claude use truecolor so its themed backgrounds render
Claude draws the user's own messages as a block of background color, and it renders as an
approximation of the theme color at best. Claude's registry entry deleted `COLORTERM`, which
left it the only agent CLI here besides `opencode` not asking for 24-bit color, so every RGB
color its theme asks for was quantized down to whatever palette `TERM` alone implies. Claude
now exports `COLORTERM=truecolor` like codex, gemini, antigravity, pi, grok, deepseek and omp
already do, and the block renders in the color the theme actually names.
How bad the quantization was depends on `TERM`, which is why this looks different on different
machines. On tmux 3.2 and newer, whose `default-terminal` defaults to `tmux-256color`,
supports-color reports 256 colors and `rgb(55, 55, 55)` lands on `ESC[48;5;237m`: visible, but
not the color the theme asked for. Where `TERM` resolves to a 16-color entry instead (tmux
older than 3.2, or a `~/.tmux.conf` setting `default-terminal screen`, which Codeman's tmux
server does read), every dark background collapses to `ESC[40m`, the terminal's own black, and
the block disappears entirely. That is the case this was reported from, and a custom Claude
theme could change the color there with nothing on screen moving.
Those seven CLIs also unset `NO_COLOR`; Claude does not, so a user who exports `NO_COLOR`
globally keeps the monochrome panes they asked for. `CLAUDECODE` stays unset, because Claude
reads it as a signal that it is running nested inside itself.
`buildClaudeEnv()`, the direct-PTY fallback used when tmux is unavailable, now reads the same
registry entry as the tmux pane and its attach client instead of deleting `COLORTERM` from a
hand-maintained list of its own. It applies that entry before assigning Codeman's own
variables, mirroring `buildEnvExports()`, so a `clis.json` override naming one of them cannot
strip it on this path while the tmux pane keeps it. A remote pane still exports nothing,
because `buildRemoteLaunchCommand()` never carried these declarations, so an SSH-remote Claude
session keeps the old rendering.
PR #3 introduced the `unset COLORTERM` in February, citing xterm.js#484 for the claim that
xterm.js mishandles truecolor, and aiming to fall back to 256-color mode. xterm.js closed that
issue in April 2019, Codeman now depends on `@xterm/xterm` 6, and `TmuxManager` sets
`terminal-overrides ",*:Tc"` on its own tmux server, so 24-bit color already reaches the
browser for the CLIs that ask for it.
## 1.27.0
### Minor Changes
+3 -1
View File
@@ -18,9 +18,11 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
## Session launch modes
**Terminal colour env** — the stock registry decides each CLI's colour vars. Claude, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor`; `shell` and `opencode` unset it. All of those except Claude also unset `NO_COLOR`, so a user who exports `NO_COLOR` globally keeps monochrome Claude panes. The variable matters because a CLI inheriting no `COLORTERM` quantizes every RGB color it draws down to whatever palette `TERM` alone implies, and the pane's `TERM` is not a constant. ⚠️ Codeman sets no `default-terminal` and passes tmux no `-f`, so it is tmux's default (`tmux-256color` since 3.2) unless the user's own `~/.tmux.conf` says otherwise, which Codeman's server DOES read. At `tmux-256color` supports-color reports 256 colors and `rgb(55, 55, 55)` lands on `ESC[48;5;237m`: visible, but not the color the theme named. Where `TERM` resolves to a 16-color entry instead (tmux older than 3.2, or a conf setting `default-terminal screen`) every dark background collapses to `ESC[40m`, the terminal's own black, and the block disappears entirely. That is why the same Claude theme looks different on two machines, and why a bug report here is worth pairing with the reporter's `tmux -V` and their pane's real `TERM`. ⚠️ These declarations reach the local tmux pane via `buildEnvExports()`, its attach client via `cliExportsTruecolor()`, and the direct-PTY fallback via `buildClaudeEnv()` — they do NOT reach a remote pane, which `buildRemoteLaunchCommand()` builds with no env exports at all. A Docker pane takes `COLORTERM=truecolor` from the hardcoded `envCreate`/`execEnv` in `tmux-manager.ts`, which apply to every mode including the two the registry says must unset it. A `~/.codeman/clis.json` override replaces these arrays wholesale (`deepMerge`), so a custom entry can drop either list. That is why both consumers apply the entry BEFORE Codeman's own variables rather than after: `buildEnvExports()` emits `...cliEnv` ahead of `export CODEMAN_MUX=1`, and `buildClaudeEnv()` assigns `PATH`/`TERM`/`CODEMAN_*` after its unset/export loop. Reversed, a config-supplied `unset` naming `CODEMAN_HOOK_SECRET_FILE` would strip it on one path and not the other.
### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume <id>` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`.
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume <id>` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`.
**DeepSeek Harness (`dsh`) specifics** — the mode that breaks three of the assumptions the six above share, so read this before changing anything about it.
+8 -4
View File
@@ -117,10 +117,14 @@ For choosing a path rather than typing one. It appears in two places:
- **Browse** in **Add Case → Link Existing**.
- The **📁 Path** key on the mobile keyboard bar.
It browses one directory at a time and can show hidden entries on request. The picker
inserts the path into your prompt **without** pressing Enter, so nothing is submitted by
accident. Its sibling **⌫ All** key clears the unsent prompt, and never sends the agent's
`/clear` command.
It browses one directory at a time and can show hidden entries on request. The current
folder is an editable field: type or paste a path and press Enter (or **Go**) to jump
straight there, and a full file path lands in its folder with that file selected. The
**Sort** control orders each listing by name or by modified time (newest first is the
quick way to the file an agent just wrote), with folders always ahead of files; the
choice is remembered per device. The picker inserts the path into your prompt
**without** pressing Enter, so nothing is submitted by accident. Its sibling **⌫ All**
key clears the unsent prompt, and never sends the agent's `/clear` command.
This is a separate file-serving surface from the viewer, with its own rules: it allowlists
your home directory, the cases directory, and anything in `CODEMAN_FILE_PICKER_ROOTS`, and
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.27.0",
"version": "1.28.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.27.0",
"version": "1.28.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.27.0",
"version": "1.28.0",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+13 -2
View File
@@ -174,8 +174,19 @@ const CLAUDE: CliEntry = {
legacyConfigAliases: { resumeId: 'resumeSessionId' },
},
env: {
exports: [],
unset: ['CLAUDECODE', 'COLORTERM'],
// Claude asks for truecolor, like every CLI here except `shell` and `opencode`.
// tmux hands the pane TERM=screen, which supports-color reads as 16 colors, and
// Claude then quantizes every RGB color its theme asks for down to that palette.
// Each dark background lands on ESC[40m, the terminal's own black, so the block
// Claude draws behind the user's own messages renders invisible. PR #3 unset
// COLORTERM here against xterm.js#484, which xterm.js had already closed in 2019,
// and Codeman now ships @xterm/xterm 6 and sets `terminal-overrides *:Tc` itself.
// The other truecolor CLIs also unset NO_COLOR. Claude does not, so a user who
// exports NO_COLOR globally keeps the monochrome panes they asked for.
// CLAUDECODE stays unset, because Claude reads it as a signal that it is running
// nested inside itself.
exports: [{ name: 'COLORTERM', value: 'truecolor' }],
unset: ['CLAUDECODE'],
tmuxSetenvKeys: [],
dockerExecEnvNames: [],
allowedPrefixes: ['CLAUDE_CODE_'],
+32 -5
View File
@@ -13,6 +13,7 @@ import { isEffortLevel } from './types.js';
import { getAugmentedPath } from './utils/index.js';
import { compareVersions } from './utils/dependency-checker.js';
import { dataPath } from './config/instance.js';
import { getCli } from './config/cli-registry/registry.js';
/**
* Build Claude CLI permission flags based on the configured mode.
@@ -169,6 +170,36 @@ export function buildClaudeEnv(sessionId: string): Record<string, string | undef
...process.env,
LANG: 'en_US.UTF-8',
LC_ALL: 'en_US.UTF-8',
};
// The colour and identity vars come from the registry entry, the same source
// buildEnvExports() and buildMuxAttachEnv() read, so this fallback cannot drift from
// the tmux pane the way a hand-maintained list here did.
// ⚠️ This block runs BEFORE Codeman's own keys are assigned, mirroring
// buildEnvExports(), where `...cliEnv` is emitted ahead of `export CODEMAN_MUX=1`.
// Applied afterwards it would outrank them: `unset` and `exports` are config
// (`~/.codeman/clis.json` overrides any entry), so an entry naming
// CODEMAN_HOOK_SECRET_FILE or PATH would strip or rewrite it on this path while the
// tmux pane, where Codeman's exports come last, kept its own value.
// COD-115: `delete`, not `= undefined` — node-pty serializes a present-with-undefined
// key as the literal string "KEY=undefined" (see buildMuxAttachEnv below).
const cliEnv = getCli('claude')?.env;
for (const name of cliEnv?.unset ?? []) delete env[name];
for (const item of cliEnv?.exports ?? []) {
// A direct PTY has no mux, so `muxName` has no value to resolve against. Claude
// declares literals only; an unresolvable engine value is skipped, never guessed.
const value =
typeof item.value === 'string'
? item.value
: item.value.engine === 'sessionId'
? sessionId
: item.value.engine === 'codemanPrefixedSessionId'
? `codeman_${sessionId}`
: undefined;
if (value !== undefined) env[item.name] = value;
}
Object.assign(env, {
PATH: getAugmentedPath(),
TERM: 'xterm-256color',
// Inform Claude it's running within Codeman (helps prevent self-termination)
@@ -180,11 +211,7 @@ export function buildClaudeEnv(sessionId: string): Record<string, string | undef
// as the literal "CODEMAN_API_URL=undefined" (COD-115).
// Path only (not the secret value) — hook curls cat it at execution time (COD-54)
CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'),
};
// COD-115: `delete`, not `= undefined` — node-pty serializes a present-with-undefined
// key as the literal string "KEY=undefined" (see buildMuxAttachEnv below).
delete env.COLORTERM;
delete env.CLAUDECODE;
});
return env;
}
+2
View File
@@ -77,6 +77,8 @@ export interface FilesystemBrowseEntry {
path: string;
type: 'file' | 'directory';
size?: number;
/** Last-modified time (ms since epoch) of the entry's target; lets the picker sort by date. */
mtimeMs?: number;
symlink?: boolean;
previewKind?: FilesystemPreviewKind;
}
+182 -13
View File
@@ -47,11 +47,24 @@
// the picker browses Home and every configured root, so wanting dotfiles in a
// project does not imply wanting them in ~.
const PATH_PICKER_SHOW_HIDDEN_KEY = 'codeman:pathPickerShowHidden';
// Per-device like the hidden toggle: how you scan a folder is a habit of the
// hand, not of the workspace.
const PATH_PICKER_SORT_KEY = 'codeman:pathPickerSort';
const PATH_PICKER_SORT_MODES = [
{ value: 'name-asc', label: 'Name A→Z' },
{ value: 'name-desc', label: 'Name Z→A' },
{ value: 'mtime-desc', label: 'Newest first' },
{ value: 'mtime-asc', label: 'Oldest first' },
];
const PATH_PICKER_DEFAULT_SORT = 'name-asc';
const PathPicker = {
overlay: null,
_options: null,
_selectedPath: '',
_currentPath: '',
_entries: [],
_truncated: false,
_previousFocus: null,
_keydownHandler: null,
_loadSequence: 0,
@@ -59,6 +72,7 @@ const PathPicker = {
_previewRequestSequence: 0,
_previewPreviousFocus: null,
_showHidden: false,
_sortMode: PATH_PICKER_DEFAULT_SORT,
/**
* Open the lazy filesystem browser.
@@ -69,7 +83,10 @@ const PathPicker = {
this.close(false);
this._options = options;
this._selectedPath = '';
this._currentPath = '';
this._entries = [];
this._showHidden = this._loadShowHidden();
this._sortMode = this._loadSortMode();
this._previousFocus = document.activeElement;
this._previousFocus?.blur?.();
@@ -90,11 +107,19 @@ const PathPicker = {
</div>
<div class="path-picker-nav">
<button type="button" class="path-picker-up" title="Parent folder" aria-label="Parent folder">&#x2191;</button>
<div class="path-picker-current" title="Current folder"></div>
<form class="path-picker-jump" title="Current folder — edit and press Enter to jump">
<input type="text" class="path-picker-current" aria-label="Current folder path" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go">
<button type="submit" class="path-picker-go" title="Go to this path" aria-label="Go to this path">Go</button>
</form>
<button type="button" class="path-picker-hidden" title="Show hidden files and folders" aria-label="Show hidden files and folders" aria-pressed="false">.*</button>
<button type="button" class="path-picker-refresh" title="Refresh" aria-label="Refresh">&#x21BB;</button>
</div>
<div class="path-picker-status" aria-live="polite">Loading...</div>
<div class="path-picker-toolbar">
<div class="path-picker-status" aria-live="polite">Loading...</div>
<label class="path-picker-sort-label">Sort
<select class="path-picker-sort" aria-label="Sort entries"></select>
</label>
</div>
<div class="path-picker-list" role="listbox"></div>
<div class="path-picker-selection">
<span class="path-picker-selection-label">Selected</span>
@@ -114,12 +139,27 @@ const PathPicker = {
overlay.querySelector('.path-picker-cancel').addEventListener('click', () => this.close(true));
overlay.querySelector('.path-picker-confirm').addEventListener('click', () => this.confirm());
overlay.querySelector('.path-picker-current-select').addEventListener('click', () => {
const current = overlay.querySelector('.path-picker-current').textContent;
if (current) this.select(current);
if (this._currentPath) this.select(this._currentPath);
});
overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load());
overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load(this._currentPath));
overlay.querySelector('.path-picker-hidden').addEventListener('click', () => this.toggleHidden());
this._syncHiddenButton();
// Typing a path is the fast way there. The listing is loaded ONLY on Enter/Go,
// never on each keystroke: a half-typed path is a 404 the server has to
// answer for nothing, and jumping mid-edit would yank the field around.
overlay.querySelector('.path-picker-jump').addEventListener('submit', (event) => {
event.preventDefault();
this.jumpTo(overlay.querySelector('.path-picker-current').value);
});
const sortSelect = overlay.querySelector('.path-picker-sort');
for (const mode of PATH_PICKER_SORT_MODES) {
const option = document.createElement('option');
option.value = mode.value;
option.textContent = mode.label;
sortSelect.appendChild(option);
}
sortSelect.value = this._sortMode;
sortSelect.addEventListener('change', (event) => this.setSortMode(event.target.value));
overlay.querySelector('.path-picker-up').addEventListener('click', () => {
const parent = overlay.querySelector('.path-picker-up').dataset.parent;
if (parent) this.load(parent);
@@ -169,15 +209,97 @@ const PathPicker = {
// OFF inside a hidden folder makes the current path unbrowsable again; the
// server answers 403 and load()'s catch falls back to the default root,
// which is the only place left to stand.
this.load(this.overlay.querySelector('.path-picker-current').textContent || '');
this.load(this._currentPath || '');
},
async load(path) {
_loadSortMode() {
try {
const stored = localStorage.getItem(PATH_PICKER_SORT_KEY);
return PATH_PICKER_SORT_MODES.some((mode) => mode.value === stored) ? stored : PATH_PICKER_DEFAULT_SORT;
} catch {
return PATH_PICKER_DEFAULT_SORT;
}
},
setSortMode(mode) {
if (!PATH_PICKER_SORT_MODES.some((candidate) => candidate.value === mode)) return;
this._sortMode = mode;
try {
localStorage.setItem(PATH_PICKER_SORT_KEY, mode);
} catch {}
const select = this.overlay?.querySelector('.path-picker-sort');
if (select && select.value !== mode) select.value = mode;
// Re-order what is already on screen; no round trip, no lost selection.
if (this.overlay) this.renderEntries();
},
/**
* Order entries for display. Folders always come first, whatever the mode:
* a date sort is for finding the file you just made, and the folders are the
* way past it, not the thing being looked for. An entry without an mtime (an
* older server, the in-container source) sorts after every dated one and then
* by name, so a listing never degrades into an unstable order.
*
* ⚠️ This re-orders the listing the SERVER returned, and the server cuts at
* FILESYSTEM_PICKER_ENTRY_LIMIT (500) after sorting by name. So in a folder past
* that limit, "Newest first" is the newest of the first 500 BY NAME, not the newest
* in the folder, which is the one case this sort exists for. The status line says
* "(first 500 by name)" rather than "(first 500)" so the cut is legible; ordering
* before the cut would have to happen server-side, and would cost a stat on every
* entry in the directory rather than on the 500 that are returned.
*/
_sortEntries(entries) {
const [key, direction] = this._sortMode.split('-');
const sign = direction === 'desc' ? -1 : 1;
const byName = (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
return entries.slice().sort((a, b) => {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
if (key === 'mtime') {
const aTime = typeof a.mtimeMs === 'number' ? a.mtimeMs : null;
const bTime = typeof b.mtimeMs === 'number' ? b.mtimeMs : null;
if (aTime !== null && bTime !== null && aTime !== bTime) return sign * (aTime - bTime);
if (aTime === null && bTime !== null) return 1;
if (aTime !== null && bTime === null) return -1;
return byName(a, b);
}
return sign * byName(a, b);
});
},
/** Compact modified-time label: time of day today, month-day this year, else the date. */
_formatModified(mtimeMs) {
if (typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs)) return '';
const date = new Date(mtimeMs);
if (Number.isNaN(date.getTime())) return '';
const now = new Date();
const pad = (n) => String(n).padStart(2, '0');
if (date.toDateString() === now.toDateString()) return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
if (date.getFullYear() === now.getFullYear()) return `${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
},
/**
* Go to a path the user typed. A file path lands in its folder with the
* file selected, so pasting a full path from a log or a message is one Enter
* away from Select. A path that does not resolve lands in its parent folder
* when that exists (the closest place to stand) and otherwise keeps the
* current listing, and says so either way — unlike a stale initialPath, a
* typo is not a reason to throw the user back to the root.
*/
jumpTo(rawPath) {
const path = String(rawPath || '').trim();
if (!path) return;
this.load(path, { typed: true });
},
async load(path, options = {}) {
if (!this.overlay || !this._options) return;
const loadSequence = ++this._loadSequence;
const list = this.overlay.querySelector('.path-picker-list');
const status = this.overlay.querySelector('.path-picker-status');
list.replaceChildren();
const typed = !!options.typed;
if (!typed) list.replaceChildren();
status.classList.remove('error');
status.textContent = 'Loading...';
const params = new URLSearchParams();
@@ -194,13 +316,40 @@ const PathPicker = {
if (!result?.success) throw new Error(result?.error || 'Failed to browse this folder');
if (!this.overlay || loadSequence !== this._loadSequence) return;
this.render(result.data);
if (options.selectIfListed) {
// Landed in the typed path's folder: select the entry if it is there
// (a file path), otherwise say what the server said about the full
// path — the listing is still the closest place to stand.
if (this._entries.some((entry) => entry.path === options.selectIfListed)) {
this.select(options.selectIfListed);
} else {
status.textContent = options.failMessage || 'Path not found';
status.classList.add('error');
}
}
} catch (error) {
if (!this.overlay || loadSequence !== this._loadSequence) return;
const message = error.message || 'Failed to browse this folder';
if (typed) {
// The browse endpoint answers a FILE path with "not found" (it resolves
// folders only), so one retry lands in the parent folder and selects
// the entry from the listing. Only one level: a typo two segments up
// is an error, not a reason to climb to the root.
const slash = path.lastIndexOf('/');
if (slash > 0 && !options.selectIfListed) {
this.load(path.slice(0, slash), { typed: true, selectIfListed: path, failMessage: message });
return;
}
this.renderEntries();
status.textContent = options.failMessage || message;
status.classList.add('error');
return;
}
if (path) {
this.load('');
return;
}
status.textContent = error.message || 'Failed to browse this folder';
status.textContent = message;
status.classList.add('error');
}
},
@@ -216,19 +365,29 @@ const PathPicker = {
rootSelect.appendChild(option);
}
this.overlay.querySelector('.path-picker-current').textContent = data.path;
this._currentPath = data.path;
this.overlay.querySelector('.path-picker-current').value = data.path;
const up = this.overlay.querySelector('.path-picker-up');
up.dataset.parent = data.parent || '';
up.disabled = !data.parent;
this._entries = Array.isArray(data.entries) ? data.entries : [];
this._truncated = !!data.truncated;
this.renderEntries();
},
/** (Re)build the list from the last listing in the current sort order. */
renderEntries() {
if (!this.overlay) return;
const entries = this._sortEntries(this._entries);
const status = this.overlay.querySelector('.path-picker-status');
status.classList.remove('error');
status.textContent = data.entries.length === 0
status.textContent = entries.length === 0
? 'This folder is empty'
: `${data.entries.length} item${data.entries.length === 1 ? '' : 's'}${data.truncated ? ' (first 500)' : ''}`;
: `${entries.length} item${entries.length === 1 ? '' : 's'}${this._truncated ? ' (first 500 by name)' : ''}`;
const list = this.overlay.querySelector('.path-picker-list');
list.replaceChildren();
for (const entry of data.entries) {
for (const entry of entries) {
const row = document.createElement('div');
row.className = 'path-picker-item';
if (entry.type === 'file' && this._options.directoriesOnly && !entry.previewKind) {
@@ -248,6 +407,14 @@ const PathPicker = {
name.className = 'path-picker-item-name';
name.textContent = entry.name;
open.append(icon, name);
const modified = this._formatModified(entry.mtimeMs);
if (modified) {
const meta = document.createElement('span');
meta.className = 'path-picker-item-meta';
meta.textContent = modified;
meta.title = new Date(entry.mtimeMs).toLocaleString();
open.appendChild(meta);
}
if (entry.symlink) {
const link = document.createElement('span');
link.className = 'path-picker-item-link';
@@ -417,6 +584,8 @@ const PathPicker = {
this._previousFocus = null;
this._options = null;
this._selectedPath = '';
this._currentPath = '';
this._entries = [];
if (restoreFocus) previousFocus?.focus?.();
},
};
+21
View File
@@ -2314,6 +2314,27 @@ Object.assign(CodemanApp.prototype, {
return;
}
// Ctrl+Z (SIGTSTP/job-control suspend): a teammate/subagent pane is always
// running an agent CLI (Task-tool dispatched, never a plain shell), so
// unlike the main terminal's mode-gated trap this one is unconditional.
// Mirrors the main terminal's guard in terminal-ui.js's
// attachCustomKeyEventHandler — case-insensitive so Caps Lock (which
// flips ev.key to 'Z' without setting shiftKey) can't slip a suspend past it.
terminal.attachCustomKeyEventHandler((ev) => {
if (
ev.type === 'keydown' &&
ev.key.toLowerCase() === 'z' &&
ev.ctrlKey &&
!ev.altKey &&
!ev.metaKey &&
!ev.shiftKey
) {
ev.preventDefault();
return false;
}
return true;
});
// Wait for terminal renderer to fully initialize before any writes.
// xterm.js needs a few frames after open() before write() is safe.
setTimeout(() => {
+80 -9
View File
@@ -13617,24 +13617,95 @@ body.touch-device.cjk-input-visible .main {
cursor: default;
}
.path-picker-current {
/* The current path is an editable field: type or paste a path and press Enter
* (or Go) to jump there. The Go button is part of the same rounded control so
* the field keeps the width the read-only breadcrumb had. */
.path-picker-jump {
display: flex;
flex: 1;
min-width: 0;
padding: 9px 11px;
overflow-x: auto;
color: var(--accent);
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
white-space: nowrap;
margin: 0;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
}
.path-picker-status {
padding: 0 14px 8px;
.path-picker-jump:focus-within {
border-color: var(--accent);
}
.path-picker-current {
flex: 1;
min-width: 0;
height: 36px;
padding: 0 10px;
color: var(--accent);
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
white-space: nowrap;
background: transparent;
border: 0;
outline: none;
}
.path-picker-go {
flex: 0 0 auto;
padding: 0 10px;
color: var(--text-dim);
font-size: 0.7rem;
font-weight: 600;
background: transparent;
border: 0;
border-left: 1px solid var(--border);
cursor: pointer;
}
.path-picker-go:hover {
color: var(--accent);
}
.path-picker-toolbar {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
padding: 0 14px 8px;
}
.path-picker-status {
min-width: 0;
overflow: hidden;
color: var(--text-dim);
font-size: 0.7rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.path-picker-sort-label {
display: inline-flex;
flex: 0 0 auto;
gap: 6px;
align-items: center;
color: var(--text-dim);
font-size: 0.7rem;
}
.path-picker-sort {
padding: 3px 6px;
color: var(--text);
font-size: 0.7rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 6px;
}
.path-picker-item-meta {
flex: 0 0 auto;
margin-left: 8px;
color: var(--text-dim);
font-family: var(--font-mono, monospace);
font-size: 0.65rem;
white-space: nowrap;
}
.path-picker-status.error {
+23
View File
@@ -383,6 +383,29 @@ Object.assign(CodemanApp.prototype, {
return false;
}
// Ctrl+Z (SIGTSTP/job-control suspend): in a plain shell session this is the
// user's own job-control tool (suspend a foreground command, `fg` it back) —
// leave it alone. In every other mode (claude/omp/pi/codex/... — Ink/TUI apps
// that normally run in raw mode with ISIG off, so ^Z is usually inert there
// already) suspending the CLI stops an unattended agent loop dead with no
// visible output — the same failure shape as an XOFF freeze. Swallow it
// before xterm can send \x1a into the PTY rather than relying on every CLI's
// raw-mode state holding at every instant (startup, raw-mode toggles, etc).
if (
ev.type === 'keydown' &&
ev.key.toLowerCase() === 'z' &&
ev.ctrlKey &&
!ev.altKey &&
!ev.metaKey &&
!ev.shiftKey
) {
const activeCtrlZSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
if (activeCtrlZSession && activeCtrlZSession.mode !== 'shell') {
ev.preventDefault();
return false;
}
}
// Shift+Enter / Ctrl+Enter: insert newline for multi-line input.
// xterm.js sends plain \r for all Enter variants, so Claude Code (Ink) can't
// distinguish them. We use tmux send-keys -H to send a line feed byte (0x0a)
+10 -3
View File
@@ -876,6 +876,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
let type: FilesystemBrowseEntry['type'];
let size: number | undefined;
let mtimeMs: number | undefined;
const symlink = entry.isSymbolicLink();
if (entry.isDirectory()) {
type = 'directory';
@@ -886,6 +887,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
const targetStat = await fs.stat(targetPath);
type = targetStat.isDirectory() ? 'directory' : 'file';
if (type === 'file') size = targetStat.size;
mtimeMs = targetStat.mtimeMs;
} catch {
continue;
}
@@ -894,11 +896,15 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
}
if (isBlockedPickerPath(targetPath, blockedTrees, type === 'directory')) continue;
if (type === 'file' && size === undefined) {
if (mtimeMs === undefined) {
// One stat per entry: the modified time lets the picker sort by date, and
// a file's size rides along on the same call.
try {
size = (await fs.stat(targetPath)).size;
const targetStat = await fs.stat(targetPath);
mtimeMs = targetStat.mtimeMs;
if (type === 'file') size = targetStat.size;
} catch {
// The path is still selectable even when a size lookup races a change.
// The path is still selectable even when a stat races a change.
}
}
entries.push({
@@ -906,6 +912,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
path: visiblePath,
type,
size,
mtimeMs,
symlink: symlink || undefined,
previewKind: type === 'file' ? getFilesystemPreviewKind(entry.name) : undefined,
});
+294
View File
@@ -0,0 +1,294 @@
/**
* @fileoverview Path picker: sort order and the editable path field.
*
* Same jsdom harness as path-picker-hidden.test.ts: keyboard-accessory.js is
* evaluated against a jsdom window with a scripted fetch, so the assertions
* run against the real DOM the picker builds rather than string matches.
* Port: N/A
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { JSDOM } from 'jsdom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const accessoryJs = readFileSync(resolve(PUBLIC, 'keyboard-accessory.js'), 'utf8');
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
const SORT_KEY = 'codeman:pathPickerSort';
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://localhost/' });
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
const jsdomDocument = jsdomWindow.document;
function loadPathPicker(fetchImpl: (url: string) => Promise<unknown>): any {
const MobileDetection = { isTouchDevice: () => false };
const factory = new Function(
'window',
'document',
'localStorage',
'fetch',
'MobileDetection',
`${accessoryJs}\nreturn PathPicker;`
);
return factory(jsdomWindow, jsdomDocument, jsdomWindow.localStorage, fetchImpl, MobileDetection);
}
type Entry = { name: string; type: 'file' | 'directory'; mtimeMs?: number };
function browseResponse(entries: Entry[], path = '/home/dev/project') {
return {
ok: true,
json: async () => ({
success: true,
data: {
path,
parent: path === '/home/dev' ? null : '/home/dev',
root: '/home/dev',
roots: [{ label: 'Home', path: '/home/dev' }],
entries: entries.map((e) => ({ ...e, path: `${path}/${e.name}` })),
truncated: false,
},
}),
};
}
function errorResponse(error: string) {
return { ok: false, json: async () => ({ success: false, error }) };
}
const DAY = 24 * 60 * 60 * 1000;
const NOW = Date.now();
const LISTING: Entry[] = [
{ name: 'zeta.txt', type: 'file', mtimeMs: NOW - 3 * DAY },
{ name: 'alpha.txt', type: 'file', mtimeMs: NOW - 1 * DAY },
{ name: 'mid.txt', type: 'file', mtimeMs: NOW - 2 * DAY },
{ name: 'old-dir', type: 'directory', mtimeMs: NOW - 30 * DAY },
{ name: 'new-dir', type: 'directory', mtimeMs: NOW - 1000 },
];
describe('PathPicker sort order', () => {
let PathPicker: any;
let urls: string[];
let respond: (url: string) => unknown;
beforeEach(() => {
jsdomWindow.localStorage.clear();
jsdomDocument.body.replaceChildren();
urls = [];
respond = () => browseResponse(LISTING);
PathPicker = loadPathPicker(async (url: string) => {
urls.push(url);
return respond(url);
});
});
afterEach(() => {
PathPicker?.close?.(false);
jsdomDocument.body.replaceChildren();
});
const open = async (options: Record<string, unknown> = {}) => {
PathPicker.open({ onSelect: () => {}, ...options });
await vi.waitFor(() => expect(jsdomDocument.querySelectorAll('.path-picker-item').length).toBeGreaterThan(0));
};
const names = () => Array.from(jsdomDocument.querySelectorAll('.path-picker-item-name')).map((el) => el.textContent);
const sortSelect = () => jsdomDocument.querySelector('.path-picker-sort') as HTMLSelectElement;
const setSort = (value: string) => {
sortSelect().value = value;
sortSelect().dispatchEvent(new jsdomWindow.Event('change', { bubbles: true }));
};
it('sorts by name with folders first by default', async () => {
await open();
expect(sortSelect().value).toBe('name-asc');
expect(names()).toEqual(['new-dir', 'old-dir', 'alpha.txt', 'mid.txt', 'zeta.txt']);
});
it('re-orders the listing without another request, keeping folders first', async () => {
await open();
const requests = urls.length;
setSort('mtime-desc');
expect(names()).toEqual(['new-dir', 'old-dir', 'alpha.txt', 'mid.txt', 'zeta.txt']);
setSort('mtime-asc');
expect(names()).toEqual(['old-dir', 'new-dir', 'zeta.txt', 'mid.txt', 'alpha.txt']);
setSort('name-desc');
expect(names()).toEqual(['old-dir', 'new-dir', 'zeta.txt', 'mid.txt', 'alpha.txt']);
expect(urls.length).toBe(requests);
});
it('remembers the sort mode across reopenings', async () => {
await open();
setSort('mtime-desc');
expect(jsdomWindow.localStorage.getItem(SORT_KEY)).toBe('mtime-desc');
PathPicker.close(false);
await open();
expect(sortSelect().value).toBe('mtime-desc');
});
it('ignores a corrupt stored mode and a localStorage that throws', async () => {
jsdomWindow.localStorage.setItem(SORT_KEY, 'bogus');
await open();
expect(sortSelect().value).toBe('name-asc');
PathPicker.close(false);
const getItem = vi.spyOn(jsdomWindow.localStorage.__proto__, 'getItem').mockImplementation(() => {
throw new Error('private mode');
});
try {
await open();
expect(sortSelect().value).toBe('name-asc');
setSort('mtime-asc');
expect(names()[0]).toBe('old-dir');
} finally {
getItem.mockRestore();
}
});
it('places entries without a modified time after dated ones on a date sort', async () => {
respond = () =>
browseResponse([
{ name: 'undated.txt', type: 'file' },
{ name: 'dated.txt', type: 'file', mtimeMs: NOW - DAY },
]);
await open();
setSort('mtime-desc');
expect(names()).toEqual(['dated.txt', 'undated.txt']);
setSort('mtime-asc');
expect(names()).toEqual(['dated.txt', 'undated.txt']);
});
it('shows a compact modified time only when the server supplied one', async () => {
respond = () =>
browseResponse([
{ name: 'undated.txt', type: 'file' },
{ name: 'today.txt', type: 'file', mtimeMs: NOW },
]);
await open();
const rows = Array.from(jsdomDocument.querySelectorAll('.path-picker-item'));
const meta = (row: Element) => row.querySelector('.path-picker-item-meta')?.textContent ?? null;
expect(meta(rows[0])).toMatch(/^\d{2}:\d{2}$/);
expect(meta(rows[1])).toBeNull();
});
it('styles the sort control and the modified column', () => {
expect(stylesCss).toContain('.path-picker-sort {');
expect(stylesCss).toContain('.path-picker-item-meta {');
});
});
describe('PathPicker editable path', () => {
let PathPicker: any;
let urls: string[];
let respond: (url: string) => unknown;
beforeEach(() => {
jsdomWindow.localStorage.clear();
jsdomDocument.body.replaceChildren();
urls = [];
respond = () => browseResponse(LISTING);
PathPicker = loadPathPicker(async (url: string) => {
urls.push(url);
return respond(url);
});
});
afterEach(() => {
PathPicker?.close?.(false);
jsdomDocument.body.replaceChildren();
});
const open = async (options: Record<string, unknown> = {}) => {
PathPicker.open({ onSelect: () => {}, ...options });
await vi.waitFor(() => expect(jsdomDocument.querySelectorAll('.path-picker-item').length).toBeGreaterThan(0));
};
const field = () => jsdomDocument.querySelector('.path-picker-current') as HTMLInputElement;
const submit = (value: string) => {
field().value = value;
(jsdomDocument.querySelector('.path-picker-jump') as HTMLFormElement).dispatchEvent(
new jsdomWindow.Event('submit', { bubbles: true, cancelable: true })
);
};
const pathParam = (url: string) => new URL(url, 'https://localhost').searchParams.get('path');
const status = () => jsdomDocument.querySelector('.path-picker-status') as HTMLElement;
it('shows the current folder in an editable field and jumps on Enter', async () => {
await open({ initialPath: '/home/dev/project' });
expect(field().value).toBe('/home/dev/project');
const before = urls.length;
// Typing alone never fetches.
field().value = '/home/dev/oth';
field().dispatchEvent(new jsdomWindow.Event('input', { bubbles: true }));
expect(urls.length).toBe(before);
respond = () => browseResponse([{ name: 'readme.md', type: 'file' }], '/home/dev/other');
submit(' /home/dev/other ');
await vi.waitFor(() => expect(field().value).toBe('/home/dev/other'));
expect(pathParam(urls[urls.length - 1])).toBe('/home/dev/other');
expect(jsdomDocument.querySelector('.path-picker-item-name')?.textContent).toBe('readme.md');
});
it('keeps the current listing and reports the error when neither a typed path nor its parent resolves', async () => {
await open({ initialPath: '/home/dev/project' });
respond = () => errorResponse('Path not found: /home/dev/nope/deeper');
submit('/home/dev/nope/deeper');
await vi.waitFor(() => expect(status().classList.contains('error')).toBe(true));
expect(status().textContent).toBe('Path not found: /home/dev/nope/deeper');
// One retry on the parent, then stop: never a climb to the root.
expect(urls.slice(-2).map(pathParam)).toEqual(['/home/dev/nope/deeper', '/home/dev/nope']);
expect(jsdomDocument.querySelectorAll('.path-picker-item').length).toBe(LISTING.length);
// The typed text stays in the field so the typo can be corrected in place.
expect(field().value).toBe('/home/dev/nope/deeper');
});
it('lands in the parent folder, unselected, when only the last segment is wrong', async () => {
await open({ initialPath: '/home/dev' });
respond = (url) =>
pathParam(url) === '/home/dev/project/typo.txt'
? errorResponse('Path not found: /home/dev/project/typo.txt')
: browseResponse(LISTING);
submit('/home/dev/project/typo.txt');
await vi.waitFor(() => expect(field().value).toBe('/home/dev/project'));
await vi.waitFor(() => expect(status().classList.contains('error')).toBe(true));
expect(status().textContent).toBe('Path not found: /home/dev/project/typo.txt');
expect(jsdomDocument.querySelector('.path-picker-selection-value')?.textContent).toBe('None');
expect((jsdomDocument.querySelector('.path-picker-confirm') as HTMLButtonElement).disabled).toBe(true);
});
it('lands a typed file path in its folder with the file selected', async () => {
await open({ initialPath: '/home/dev/project' });
respond = (url) =>
pathParam(url) === '/home/dev/project/alpha.txt'
? errorResponse('Path not found: /home/dev/project/alpha.txt')
: browseResponse(LISTING);
submit('/home/dev/project/alpha.txt');
await vi.waitFor(() =>
expect(jsdomDocument.querySelector('.path-picker-selection-value')?.textContent).toBe(
'/home/dev/project/alpha.txt'
)
);
expect(field().value).toBe('/home/dev/project');
expect(jsdomDocument.querySelector('.path-picker-item.selected .path-picker-item-name')?.textContent).toBe(
'alpha.txt'
);
expect((jsdomDocument.querySelector('.path-picker-confirm') as HTMLButtonElement).disabled).toBe(false);
});
it('selects the current folder from the field value and refreshes in place', async () => {
await open({ initialPath: '/home/dev/project' });
(jsdomDocument.querySelector('.path-picker-current-select') as HTMLButtonElement).click();
expect(jsdomDocument.querySelector('.path-picker-selection-value')?.textContent).toBe('/home/dev/project');
const before = urls.length;
(jsdomDocument.querySelector('.path-picker-refresh') as HTMLButtonElement).click();
await vi.waitFor(() => expect(urls.length).toBe(before + 1));
expect(pathParam(urls[urls.length - 1])).toBe('/home/dev/project');
});
});
+44
View File
@@ -115,6 +115,50 @@ describe('file-routes', () => {
]);
});
it('stamps every entry with its modified time so the picker can sort by date', async () => {
mockedReaddir.mockResolvedValueOnce([
{ name: 'notes.txt', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
{ name: 'src', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
{ name: 'link', isDirectory: () => false, isFile: () => false, isSymbolicLink: () => true },
] as never);
mockedStat.mockImplementation(async (candidate) => {
const target = String(candidate);
if (target.endsWith('/notes.txt')) {
return { size: 42, mtimeMs: 1_700_000_000_000, isFile: () => true, isDirectory: () => false } as never;
}
if (target.endsWith('/src')) {
return { size: 4096, mtimeMs: 1_700_000_001_000, isFile: () => false, isDirectory: () => true } as never;
}
if (target.endsWith('/link')) {
return { size: 7, mtimeMs: 1_700_000_002_000, isFile: () => true, isDirectory: () => false } as never;
}
return { size: 0, mtimeMs: 0, isFile: () => false, isDirectory: () => true } as never;
});
const path = harness.ctx._session.workingDir;
const res = await harness.app.inject({
method: 'GET',
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
});
expect(res.statusCode).toBe(200);
const entries = JSON.parse(res.body).data.entries as Array<{
name: string;
type: string;
size?: number;
mtimeMs?: number;
}>;
expect(entries.map((entry) => [entry.name, entry.type, entry.size, entry.mtimeMs])).toEqual([
['src', 'directory', undefined, 1_700_000_001_000],
['link', 'file', 7, 1_700_000_002_000],
['notes.txt', 'file', 42, 1_700_000_000_000],
]);
// One stat per entry: the date and the size ride on the same call.
const statsFor = (name: string) =>
mockedStat.mock.calls.filter(([candidate]) => String(candidate).endsWith(`/${name}`)).length;
expect([statsFor('notes.txt'), statsFor('src'), statsFor('link')]).toEqual([1, 1, 1]);
});
it('defaults to the Codeman Cases root, not Home, when linking a case with no path chosen yet', async () => {
// The "Link Existing" case picker opens with an empty path and no
// sessionId. `Home` and `Codeman Cases` are unrelated bind mounts under
+7
View File
@@ -295,6 +295,13 @@ describe('TmuxManager (unit)', () => {
expect(callBuildEnvExports('opencode')).toContain('unset COLORTERM');
});
// Claude renders its themed backgrounds as RGB. Without this the pane inherits
// tmux's TERM=screen, supports-color reads 16 colors, and every dark background
// quantizes to ESC[40m — the terminal's own black — so the block goes invisible.
it('exports truecolor for Claude sessions', () => {
expect(callBuildEnvExports('claude')).toContain('export COLORTERM=truecolor');
});
it('exports the server-stamped CODEMAN_API_URL verbatim', () => {
const original = process.env.CODEMAN_API_URL;
process.env.CODEMAN_API_URL = 'https://127.0.0.1:3199';