mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-01 04:59:41 +02:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a164c07f92 | ||
|
|
4f2dfb4e6d | ||
|
|
344e93c824 | ||
|
|
f1b7283393 | ||
|
|
a49be03f96 | ||
|
|
2f9663e389 | ||
|
|
327e440607 | ||
|
|
8285fff91c | ||
|
|
51957e2ed4 |
@@ -1,5 +1,18 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.26.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Codex sessions no longer report idle for their entire life, and Codex conversations now appear in Past Sessions and can be resumed.
|
||||
|
||||
**Per-CLI work detection (#385, irisitymichaelgrundberg).** The composer glyph and the working status line are now registry data (`capabilities.workDetect`) rather than Claude constants. Claude keeps its exact current pair, Codex declares `›` plus its `esc to interrupt` footer, and any CLI that declares neither falls back to Claude's, which is what every session used before. Work detection had been gated Claude-mode-only on the reasoning that an external CLI has no `❯`, which was true and still left every Codex session reporting `idle` from the moment it started. `workingLine` is config-supplied and its compiled pattern runs on the PTY hot path, so it goes through `compileVersionRegex()` in both the schema refine and the runtime compile: a nested quantifier there would backtrack on the event loop for the whole server. The Codex footer is matched case-insensitively on the E, so a future version capitalising it cannot make the fix silently inert.
|
||||
|
||||
**Codex conversations in Past Sessions (#386, irisitymichaelgrundberg).** A bounded scanner reads codex's `~/.codex/sessions` rollout store, so the unified session list now merges three transcript stores rather than one (Claude's `~/.claude/projects`, omp's `~/.omp/agent/sessions`, codex's `~/.codex/sessions`). A scanned row carries a `resumeId`, the rollout's own thread id, which lets it resume through `codexConfig.resumeSessionId`; a live session never carries one, so a row without it stays a genuinely fresh session. Live and resumed Codex sessions fold into their rollout row through the existing alias map, including a `session_meta.originator` match for fresh panes, so a conversation never shows up twice. The phone overview carries `resumeId` through its own row projection, without which a tapped Codex past row started a fresh session on a thread already on disk.
|
||||
|
||||
### Thanks
|
||||
- @irisitymichaelgrundberg for both PRs (#385, #386), and for turning a full review round on #386 in a day.
|
||||
|
||||
## 1.26.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -20,7 +20,7 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
||||
|
||||
### 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). 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 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`.
|
||||
|
||||
**DeepSeek Harness (`dsh`) specifics** — the mode that breaks three of the assumptions the six above share, so read this before changing anything about it.
|
||||
|
||||
@@ -98,7 +98,7 @@ Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/do
|
||||
|
||||
### Unified session list and Session Manager
|
||||
|
||||
**Unified session list** (COD-160/#139): `GET /api/sessions/unified?limit=&q=` merges live sessions, persisted state, lifecycle-log history, and Claude transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). Transcript rows are keyed by conversation UUID and folded into their owning session via a `claudeSessionId → Codeman id` alias map (resumed//clear-respawned sessions must not appear twice); lifecycle name/mode resolution is first-seen-wins (the log returns entries NEWEST-first). No terminal buffers in the response (unlike `/api/sessions`). Consumed by the Cmd+K Session Manager (#146). Session Manager polish (COD-162/#157, 1.6.0): **pinning** via `POST /api/sessions/:id/pin` (`session:pinned` SSE; killing a pinned session demotes it to a lightweight stopped record that stays visible/resumable, and cleanup skips pinned records); **cross-device tab order** via `PUT /api/session-order` (`session:orderChanged` SSE, persisted in `state.json`; pure `normalizeSessionOrder`/`mergeSessionOrder` in `src/session-order.ts`: pushing device wins, server-only ids fall to the end, never dropped); resume from the manager keeps the original session name (COD-143); `firstPrompt` is backfilled for sessions whose id != transcript UUID and the most recent prompt (`lastPrompt`) is shown + searched (COD-140/145).
|
||||
**Unified session list** (COD-160/#139): `GET /api/sessions/unified?limit=&q=` merges live sessions, persisted state, lifecycle-log history, and transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). ⚠️ **Transcript history is three stores** (#386): Claude's `~/.claude/projects`, omp's `~/.omp/agent/sessions` and codex's `~/.codex/sessions`. Rows are keyed by whatever id that CLI names the conversation with and folded into their owning session via the `claudeSessionId → Codeman id` alias map (resumed//clear-respawned sessions must not appear twice; the field keeps its Claude-era name and is not Claude-only). A codex row additionally carries `resumeId`, the rollout's own thread id, set by the scanner and never by a live session, which is what lets a row be resumed through `codexConfig.resumeSessionId` while a row without one stays a fresh session; the alias chain therefore includes `config.codexConfig?.resumeSessionId`, and a fresh codex pane is matched by `session_meta.originator` (`codeman_<sessionId>` for every pane Codeman spawns); lifecycle name/mode resolution is first-seen-wins (the log returns entries NEWEST-first). No terminal buffers in the response (unlike `/api/sessions`). Consumed by the Cmd+K Session Manager (#146). Session Manager polish (COD-162/#157, 1.6.0): **pinning** via `POST /api/sessions/:id/pin` (`session:pinned` SSE; killing a pinned session demotes it to a lightweight stopped record that stays visible/resumable, and cleanup skips pinned records); **cross-device tab order** via `PUT /api/session-order` (`session:orderChanged` SSE, persisted in `state.json`; pure `normalizeSessionOrder`/`mergeSessionOrder` in `src/session-order.ts`: pushing device wins, server-only ids fall to the end, never dropped); resume from the manager keeps the original session name (COD-143); `firstPrompt` is backfilled for sessions whose id != transcript UUID and the most recent prompt (`lastPrompt`) is shown + searched (COD-140/145).
|
||||
|
||||
### Session lineage lines (tab → tab it spawned)
|
||||
|
||||
|
||||
@@ -36,12 +36,19 @@ interface CliEntry {
|
||||
launch: CliLaunch; // the structured argv template
|
||||
env: CliEnv; // exports, tmux setenv keys, the env-override allowlist
|
||||
capabilities: CliCapabilities; // what every call site reads instead of the id
|
||||
// .workDetect?: { promptGlyph, workingLine } — how this CLI's pane shows work
|
||||
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
|
||||
}
|
||||
```
|
||||
|
||||
`capabilities` is the important part. It is what `isExternalCliMode()`, `isAltScreenStripMode()`, `hooksAvailableForMode()` and every other former per-mode branch actually read.
|
||||
|
||||
### Regexes that come from config
|
||||
|
||||
Two capability fields carry a regular expression an override file can set: `discovery.version.regex` and `capabilities.workDetect.workingLine`. Both go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
|
||||
|
||||
`workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused.
|
||||
|
||||
### Three capabilities that must stay independent
|
||||
|
||||
`external`, `hooks` and `altScreen` describe three different, deliberately unequal sets, and deriving any one from another has already shipped a bug. `shell` has no hooks but is **not** an external CLI, so a hooks predicate written as `!isExternalCliMode()` accepted `until=stop` on a shell session and then blocked the caller for their entire timeout. `deepseek` is the mirror image: it IS external and it DOES have hooks.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.26.0",
|
||||
"version": "1.26.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.26.0",
|
||||
"version": "1.26.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.26.0",
|
||||
"version": "1.26.1",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* @fileoverview Scan `~/.codex/sessions/<yyyy>/<mm>/<dd>/rollout-*.jsonl` for Past
|
||||
* Sessions rows, the codex analog of what `scanOmpSessionsHistory()`
|
||||
* (omp-transcript.ts) does for omp and `scanProjectDir()` (session-routes.ts)
|
||||
* does for Claude's own `~/.claude/projects` transcripts.
|
||||
*
|
||||
* Without this a codex conversation is invisible to Codeman the moment its
|
||||
* session record goes away, even though codex itself never forgot it: the
|
||||
* unified list is built from `~/.claude/projects` plus omp's own store, and
|
||||
* codex writes to neither. A user who wanted to pick a codex thread back up had
|
||||
* to find its id by hand and pass `codexConfig.resumeSessionId` to the API.
|
||||
*
|
||||
* ## Why this reads windows rather than whole files
|
||||
*
|
||||
* An omp session file is the conversation only, so its scanner reads each file
|
||||
* whole. A codex rollout is not comparable: it carries every reasoning block and
|
||||
* every tool call, and its `session_meta` line alone embeds the full base
|
||||
* instructions. Measured on a real store of 519 rollouts, the median file is
|
||||
* 407 KiB, the 90th percentile 1.3 MiB and the largest 25 MiB, for 381 MiB in
|
||||
* total. So this reads a head window for the identity and the opening prompt,
|
||||
* and a tail window for the most recent one.
|
||||
*
|
||||
* The head budget is 128 KiB because `session_meta` runs to roughly 19 KiB and
|
||||
* the first real user message lands near 69 KiB behind it, both measured on
|
||||
* codex 0.152.1.
|
||||
*
|
||||
* ## Where the prompt text comes from
|
||||
*
|
||||
* Codex has emitted user input under three shapes, and this reads all of them,
|
||||
* preferring the ones that carry real input only:
|
||||
*
|
||||
* - `event_msg` / `item_completed` with an `item.type` of `UserMessage`, which
|
||||
* is what codex 0.152.1 writes.
|
||||
* - `event_msg` / `user_message`, which older versions wrote.
|
||||
* - `response_item` rows with `role: 'user'`, the last resort. These mix real
|
||||
* input with injected context (AGENTS.md, environment context, compaction
|
||||
* summaries), so they are read only when neither shape above appears, and
|
||||
* the obvious injections are dropped.
|
||||
*
|
||||
* @module codex-transcript
|
||||
*/
|
||||
|
||||
import { open, readdir, stat } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { LRUMap } from './utils/lru-map.js';
|
||||
|
||||
/** Covers `session_meta` (~19 KiB) plus the first user message (~69 KiB behind it). */
|
||||
const HEAD_BYTES = 131072;
|
||||
|
||||
/** Enough to hold the last few turns' worth of lines without re-reading the file. */
|
||||
const TAIL_BYTES = 65536;
|
||||
|
||||
/**
|
||||
* Newest rollouts to REPORT. Counted in emitted rows, not files scanned: the
|
||||
* store is mostly sub-agent threads this never returns, so capping files first
|
||||
* would spend the budget on rows nobody sees.
|
||||
*/
|
||||
const MAX_ROLLOUTS = 400;
|
||||
|
||||
/**
|
||||
* How many emitted rows also get a tail read for `lastPrompt`. The head read is
|
||||
* cached (see below) but the tail cannot be, because appending to a rollout is
|
||||
* exactly what changes it, so this is the one genuinely per-request cost and it
|
||||
* stays bounded. Counted in emitted rows for the same reason as above — against
|
||||
* file index a store of sub-agent threads spends the whole budget before the
|
||||
* first row that needed it.
|
||||
*/
|
||||
const MAX_TAIL_READS = 100;
|
||||
|
||||
/** Directory nesting under `sessions/` is year/month/day; stop well past that. */
|
||||
const MAX_WALK_DEPTH = 5;
|
||||
|
||||
/** A rollout shorter than this cannot hold a complete `session_meta` line. */
|
||||
const MIN_ROLLOUT_BYTES = 100;
|
||||
|
||||
export interface CodexHistorySession {
|
||||
/** The rollout's own thread id — the token `codex resume <id>` expects. */
|
||||
sessionId: string;
|
||||
/**
|
||||
* `session_meta.originator`, which codex stamps from
|
||||
* CODEX_INTERNAL_ORIGINATOR_OVERRIDE — `codeman_<sessionId>` for every pane
|
||||
* Codeman spawns. The only link between a FRESH codex pane and the rollout it
|
||||
* is writing, since such a pane knows no thread id of its own.
|
||||
*/
|
||||
originator?: string;
|
||||
workingDir: string;
|
||||
sizeBytes: number;
|
||||
/** ISO timestamp, from the file's own mtime. */
|
||||
lastModified: string;
|
||||
firstPrompt?: string;
|
||||
lastPrompt?: string;
|
||||
}
|
||||
|
||||
/** The half of a rollout that never changes once codex has written it. */
|
||||
interface RolloutIdentity {
|
||||
threadId?: string;
|
||||
cwd?: string;
|
||||
/** `'subagent'` marks a thread codex spawned for itself. */
|
||||
threadSource?: string;
|
||||
/** `codeman_<sessionId>` for a pane Codeman spawned; codex's own default otherwise. */
|
||||
originator?: string;
|
||||
firstPrompt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `session_meta` is written once and never rewritten — the same fact
|
||||
* `readCodexRolloutMetaCached()` in session-routes.ts relies on — so a path's
|
||||
* identity is cached, and a rescan costs a `stat` per file plus head reads for
|
||||
* rollouts this process has not seen before.
|
||||
*
|
||||
* ⚠️ The first user message is NOT written up front: codex writes it when the
|
||||
* user submits. Caching before then pins `firstPrompt: undefined` for the life
|
||||
* of the process, and every scan of the home screen, the command palette and the
|
||||
* search-index refresh can land in that window — so the row reads as having no
|
||||
* prompt until a restart. `shouldCacheIdentity()` is the guard.
|
||||
*
|
||||
* Bounded, unlike a plain Map: this process runs for days and every sub-agent
|
||||
* rollout adds an entry. Same reason and same size as `codexRolloutMetaCache`.
|
||||
*/
|
||||
const identityCache = new LRUMap<string, RolloutIdentity>({ maxSize: 4096 });
|
||||
|
||||
/**
|
||||
* Is this identity settled enough to keep?
|
||||
*
|
||||
* A known `firstPrompt` settles it. So does a head read that FILLED its window,
|
||||
* which means the prompt is genuinely not in the first `HEAD_BYTES` rather than
|
||||
* not written yet. A short file with no prompt is the ambiguous case — codex is
|
||||
* still to write one — so that one is re-read next scan.
|
||||
*/
|
||||
function shouldCacheIdentity(identity: RolloutIdentity, fileSize: number): boolean {
|
||||
if (!identity.threadId) return false;
|
||||
return identity.firstPrompt !== undefined || fileSize >= HEAD_BYTES;
|
||||
}
|
||||
|
||||
function codexSessionsRoot(): string {
|
||||
const home = process.env.CODEX_HOME || join(homedir(), '.codex');
|
||||
return join(home, 'sessions');
|
||||
}
|
||||
|
||||
/** Read at most `bytes` from the front of a file. Returns '' when unreadable. */
|
||||
async function readHead(path: string, bytes: number): Promise<string> {
|
||||
const fh = await open(path, 'r').catch(() => null);
|
||||
if (!fh) return '';
|
||||
try {
|
||||
const buf = Buffer.alloc(bytes);
|
||||
const { bytesRead } = await fh.read(buf, 0, bytes, 0);
|
||||
return buf.subarray(0, bytesRead).toString('utf-8');
|
||||
} catch {
|
||||
return '';
|
||||
} finally {
|
||||
await fh.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read at most `bytes` from the end of a file, dropping the leading partial
|
||||
* line so every line handed back parses.
|
||||
*/
|
||||
async function readTail(path: string, size: number, bytes: number): Promise<string> {
|
||||
const fh = await open(path, 'r').catch(() => null);
|
||||
if (!fh) return '';
|
||||
try {
|
||||
const want = Math.min(bytes, size);
|
||||
const buf = Buffer.alloc(want);
|
||||
const { bytesRead } = await fh.read(buf, 0, want, size - want);
|
||||
const text = buf.subarray(0, bytesRead).toString('utf-8');
|
||||
if (want >= size) return text; // whole file, nothing was cut
|
||||
const nl = text.indexOf('\n');
|
||||
return nl === -1 ? '' : text.slice(nl + 1);
|
||||
} catch {
|
||||
return '';
|
||||
} finally {
|
||||
await fh.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** Flatten codex's message content, which is a string or an array of text blocks. */
|
||||
function contentText(content: unknown): string {
|
||||
if (typeof content === 'string') return content.trim();
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content
|
||||
.filter(
|
||||
(b): b is { text: string } => !!b && typeof b === 'object' && typeof (b as { text?: unknown }).text === 'string'
|
||||
)
|
||||
.map((b) => b.text)
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** One line's user-prompt text, whichever of the three shapes it is. */
|
||||
function userPromptFromLine(entry: {
|
||||
type?: string;
|
||||
payload?: {
|
||||
type?: string;
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
message?: unknown;
|
||||
item?: { type?: string; content?: unknown };
|
||||
};
|
||||
}): { text: string; injectionProne: boolean } | null {
|
||||
const p = entry.payload;
|
||||
if (!p) return null;
|
||||
|
||||
if (entry.type === 'event_msg' && p.type === 'item_completed' && p.item?.type === 'UserMessage') {
|
||||
const text = contentText(p.item.content);
|
||||
return text ? { text, injectionProne: false } : null;
|
||||
}
|
||||
if (entry.type === 'event_msg' && p.type === 'user_message') {
|
||||
const text = typeof p.message === 'string' ? p.message.trim() : contentText(p.message);
|
||||
return text ? { text, injectionProne: false } : null;
|
||||
}
|
||||
if (entry.type === 'response_item' && p.role === 'user') {
|
||||
const text = contentText(p.content);
|
||||
return text ? { text, injectionProne: true } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injected context rather than something the user typed. Codex prepends the
|
||||
* repository's AGENTS.md and wraps environment context in a tag, and both arrive
|
||||
* as `response_item` user rows.
|
||||
*/
|
||||
function isInjectedContext(text: string): boolean {
|
||||
return text.startsWith('#') || text.startsWith('<');
|
||||
}
|
||||
|
||||
/** Collapse to one line and cap, so a row carries a title rather than an essay. */
|
||||
function asPreview(text: string): string {
|
||||
const flat = text.replace(/\s+/g, ' ').trim();
|
||||
return flat.length > 200 ? `${flat.slice(0, 200)}…` : flat;
|
||||
}
|
||||
|
||||
/** Parse a head window into the facts about a rollout that never change. */
|
||||
function parseIdentity(head: string): RolloutIdentity {
|
||||
const out: RolloutIdentity = {};
|
||||
let fallback: string | undefined;
|
||||
for (const line of head.split('\n')) {
|
||||
if (!line) continue;
|
||||
let entry: {
|
||||
type?: string;
|
||||
payload?: {
|
||||
id?: string;
|
||||
session_id?: string;
|
||||
cwd?: string;
|
||||
thread_source?: string;
|
||||
originator?: string;
|
||||
type?: string;
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
message?: unknown;
|
||||
item?: { type?: string; content?: unknown };
|
||||
};
|
||||
};
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
continue; // truncated tail of the window, or a malformed line
|
||||
}
|
||||
const p = entry.payload;
|
||||
if (entry.type === 'session_meta' && p) {
|
||||
out.threadId ??= p.id || p.session_id;
|
||||
out.cwd ??= p.cwd;
|
||||
out.threadSource ??= p.thread_source;
|
||||
out.originator ??= p.originator;
|
||||
} else if (entry.type === 'turn_context' && p) {
|
||||
out.cwd ??= p.cwd;
|
||||
}
|
||||
if (out.firstPrompt) continue;
|
||||
const prompt = userPromptFromLine(entry);
|
||||
if (!prompt) continue;
|
||||
if (!prompt.injectionProne) {
|
||||
out.firstPrompt = asPreview(prompt.text);
|
||||
} else if (!fallback && !isInjectedContext(prompt.text)) {
|
||||
fallback = asPreview(prompt.text);
|
||||
}
|
||||
}
|
||||
out.firstPrompt ??= fallback;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The most recent user prompt in a tail window, or undefined. */
|
||||
function parseLastPrompt(tail: string): string | undefined {
|
||||
let best: string | undefined;
|
||||
let fallback: string | undefined;
|
||||
for (const line of tail.split('\n')) {
|
||||
if (!line) continue;
|
||||
try {
|
||||
const prompt = userPromptFromLine(JSON.parse(line));
|
||||
if (!prompt) continue;
|
||||
if (!prompt.injectionProne) best = asPreview(prompt.text);
|
||||
else if (!isInjectedContext(prompt.text)) fallback = asPreview(prompt.text);
|
||||
} catch {
|
||||
// Malformed line — keep scanning.
|
||||
}
|
||||
}
|
||||
return best ?? fallback;
|
||||
}
|
||||
|
||||
/** Every rollout file under `sessions/`, newest first. */
|
||||
async function listRollouts(root: string): Promise<Array<{ path: string; mtimeMs: number; size: number }>> {
|
||||
const files: Array<{ path: string; mtimeMs: number; size: number }> = [];
|
||||
const walk = async (dir: string, depth: number): Promise<void> => {
|
||||
if (depth > MAX_WALK_DEPTH) return;
|
||||
const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
|
||||
if (!entries) return;
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(full, depth + 1);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
|
||||
const st = await stat(full).catch(() => null);
|
||||
if (!st || st.size < MIN_ROLLOUT_BYTES) continue;
|
||||
files.push({ path: full, mtimeMs: st.mtimeMs, size: st.size });
|
||||
}
|
||||
};
|
||||
await walk(root, 0);
|
||||
files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex conversations on this host, newest first, for the unified session list.
|
||||
*
|
||||
* Sub-agent threads are left out: codex spawns them for itself, they are not
|
||||
* something a person picks back up, and on a real store they outnumber the
|
||||
* threads that are.
|
||||
*/
|
||||
export async function scanCodexSessionsHistory(): Promise<CodexHistorySession[]> {
|
||||
const files = await listRollouts(codexSessionsRoot());
|
||||
const out: CodexHistorySession[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (out.length >= MAX_ROLLOUTS) break;
|
||||
|
||||
let identity = identityCache.get(file.path);
|
||||
if (!identity) {
|
||||
identity = parseIdentity(await readHead(file.path, HEAD_BYTES));
|
||||
if (shouldCacheIdentity(identity, file.size)) identityCache.set(file.path, identity);
|
||||
}
|
||||
if (!identity.threadId || identity.threadSource === 'subagent') continue;
|
||||
// A row with no directory has nowhere to resume INTO, and emitting an empty
|
||||
// one makes a click post `workingDir: ''`. omp drops such a row; so does this.
|
||||
if (!identity.cwd) continue;
|
||||
|
||||
const lastPrompt =
|
||||
out.length < MAX_TAIL_READS ? parseLastPrompt(await readTail(file.path, file.size, TAIL_BYTES)) : undefined;
|
||||
|
||||
out.push({
|
||||
sessionId: identity.threadId,
|
||||
originator: identity.originator,
|
||||
workingDir: identity.cwd,
|
||||
sizeBytes: file.size,
|
||||
lastModified: new Date(file.mtimeMs).toISOString(),
|
||||
firstPrompt: identity.firstPrompt,
|
||||
lastPrompt: lastPrompt ?? identity.firstPrompt,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which codex thread each Codeman-spawned pane is writing, keyed by Codeman
|
||||
* session id.
|
||||
*
|
||||
* Codeman spawns every codex pane with
|
||||
* CODEX_INTERNAL_ORIGINATOR_OVERRIDE=codeman_<sessionId>, and codex stamps that
|
||||
* into `session_meta.originator`. That is the ONLY link between a fresh codex
|
||||
* pane and the rollout it is writing: such a pane knows no thread id of its own,
|
||||
* so it cannot be folded into its own Past-Sessions row from its own side.
|
||||
*
|
||||
* Newest wins. `/new` typed inside the codex TUI leaves several rollouts sharing
|
||||
* one originator, and the pane is on the most recent — so this expects `rows`
|
||||
* newest-first, as `scanCodexSessionsHistory()` returns them.
|
||||
*/
|
||||
export function codexThreadBySessionId(rows: CodexHistorySession[]): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
for (const row of rows) {
|
||||
const owner = /^codeman_(.+)$/.exec(row.originator ?? '')?.[1];
|
||||
if (owner && !out.has(owner)) out.set(owner, row.sessionId);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Test seam: drop the per-path identity cache. */
|
||||
export function __clearCodexIdentityCache(): void {
|
||||
identityCache.clear();
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { TOKEN_PATTERNS } from './patterns.js';
|
||||
import { compileVersionRegex, TOKEN_PATTERNS } from './patterns.js';
|
||||
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
|
||||
|
||||
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
|
||||
@@ -274,6 +274,23 @@ const capabilitiesSchema = z
|
||||
effort: z.boolean(),
|
||||
agentSkillInjection: z.boolean(),
|
||||
statusLineTelemetry: z.boolean(),
|
||||
workDetect: z
|
||||
.object({
|
||||
promptGlyph: z.string().min(1).max(8),
|
||||
// Config-supplied regex, so it goes through the same guard as `version.regex`:
|
||||
// ~/.codeman/clis.json can set this, and the compiled pattern runs on the PTY
|
||||
// hot path, where a nested quantifier would be a ReDoS against the event loop.
|
||||
// A broken pattern must also fail at LOAD time rather than inside a data handler.
|
||||
workingLine: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(src) => compileVersionRegex(src) !== null,
|
||||
'workingLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
|
||||
),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
model: z
|
||||
.object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() })
|
||||
.strict(),
|
||||
|
||||
@@ -183,6 +183,13 @@ const CLAUDE: CliEntry = {
|
||||
},
|
||||
capabilities: {
|
||||
external: false,
|
||||
// The historical hard-coded pair, now stated as data. `workingLine` matches both the
|
||||
// `✻ Actualizing… (39s · ↓ 2.0k tokens)` status line and the bare `esc to interrupt`
|
||||
// footer, because tmux repaints partially and only one of the two may land in a chunk.
|
||||
workDetect: {
|
||||
promptGlyph: '❯',
|
||||
workingLine: String.raw`…\s*\((?:\d+h\s+)?(?:\d+m\s+)?\d+s\b|esc to interrupt`,
|
||||
},
|
||||
requiresMux: false,
|
||||
// Claude installs Codeman's own hooks block into every workspace it runs in, so its
|
||||
// stop/idle signals are unconditional — no per-session veto, unlike deepseek's bridge.
|
||||
@@ -418,6 +425,11 @@ const CODEX: CliEntry = {
|
||||
},
|
||||
capabilities: {
|
||||
...agentDefaults(),
|
||||
// Codex draws `› Ask Codex to do anything` on its composer row and
|
||||
// `Working (2m 49s • esc to interrupt)` above it while a turn runs. It animates no
|
||||
// braille spinner, and it never prints `esc to interrupt` at rest, so that phrase
|
||||
// alone separates a running turn from an idle one.
|
||||
workDetect: { promptGlyph: '›', workingLine: '[Ee]sc to interrupt' },
|
||||
transcript: 'codex-rollout',
|
||||
altScreen: 'strip-full',
|
||||
echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' },
|
||||
|
||||
@@ -306,6 +306,29 @@ export interface CliCapabilities {
|
||||
* independent — see this interface's own doc comment.
|
||||
*/
|
||||
external: boolean;
|
||||
/**
|
||||
* How to read this CLI's own TUI for whether it is mid-turn.
|
||||
*
|
||||
* Codeman infers a working agent from the pane, so the two strings it needs are the
|
||||
* ones that differ per CLI: the glyph on the composer row, and the status line the CLI
|
||||
* draws while a turn runs. Holding them here is what lets a non-Claude CLI report work
|
||||
* at all — `external` used to gate the whole detector, so every external CLI reported
|
||||
* itself permanently idle even mid-turn.
|
||||
*
|
||||
* `promptGlyph` only ARMS the idle confirmation and is never on its own evidence that a
|
||||
* turn ended, because a CLI redraws its composer throughout a turn. `workingLine` is
|
||||
* the evidence, and `_confirmIdle` consults it before believing the pane went quiet.
|
||||
*
|
||||
* An entry that omits this field keeps Codeman's historical behaviour: the Claude glyph
|
||||
* arms the confirmation and the Claude working line answers it. Leave it out for a CLI
|
||||
* whose TUI nobody has characterised, and its sessions report work exactly as before.
|
||||
*/
|
||||
workDetect?: {
|
||||
/** The glyph this CLI draws on its composer row, e.g. Claude's `❯`, Codex's `›`. */
|
||||
promptGlyph: string;
|
||||
/** Source of a regex matching the status line this CLI draws while a turn runs. */
|
||||
workingLine: string;
|
||||
};
|
||||
/** No direct-PTY fallback: the CLI must run inside tmux (secrets ride tmux setenv). */
|
||||
requiresMux: boolean;
|
||||
/**
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
* @fileoverview Pure merge/filter logic for the unified session list (COD-121).
|
||||
*
|
||||
* Combines four read-only views of a session — live (in-memory `Session`),
|
||||
* persisted (`state.json`), transcript history (`~/.claude/projects`), and the
|
||||
* lifecycle audit log — plus mux process stats, into one de-duplicated list
|
||||
* keyed by sessionId. Transcript-history rows are keyed by the Claude
|
||||
* conversation UUID (the `.jsonl` filename stem), which diverges from the
|
||||
* Codeman id for resumed sessions — an alias map (claudeSessionId → Codeman id,
|
||||
* built from the live/persisted views) folds them into the owning session item.
|
||||
* persisted (`state.json`), transcript history, and the lifecycle audit log —
|
||||
* plus mux process stats, into one de-duplicated list keyed by sessionId.
|
||||
*
|
||||
* Transcript history is not one source but three, because the CLIs keep their
|
||||
* conversations in their own stores: Claude's `~/.claude/projects`, omp's
|
||||
* `~/.omp/agent/sessions` and codex's `~/.codex/sessions`. Each row is keyed by
|
||||
* whatever id that CLI names the conversation with, which diverges from the
|
||||
* Codeman id for a resumed session and for every non-Claude one — an alias map
|
||||
* (claudeSessionId → Codeman id, built from the live/persisted views) folds them
|
||||
* into the owning session item.
|
||||
* Higher-precedence sources overwrite scalar fields when present
|
||||
* (history < lifecycle < persisted < live), while the `sources` array
|
||||
* always accumulates every contributing view. A "meaningfulness floor" drops
|
||||
@@ -39,6 +43,11 @@ export type UnifiedSessionItem = {
|
||||
/** Main repo root a worktree belongs to (#266). */
|
||||
worktreeRepo?: string;
|
||||
remote?: boolean;
|
||||
/**
|
||||
* Token this row's CLI resumes by, when that is not `sessionId`. Set only from
|
||||
* a transcript scanner — see the field of the same name on `HistoryInput`.
|
||||
*/
|
||||
resumeId?: string;
|
||||
/** Pinned to the top of the session manager list (COD-139). */
|
||||
pinned?: boolean;
|
||||
/** When the session was pinned (epoch ms) — orders the pinned group desc. */
|
||||
@@ -100,12 +109,21 @@ export type HistoryInput = {
|
||||
worktreeName?: string;
|
||||
worktreeRepo?: string;
|
||||
/**
|
||||
* Set only by a non-claude transcript source (currently omp); the Claude
|
||||
* scanner never stamps this; the meaningfulness floor below still counts a
|
||||
* row with a `mode` as real, since that also signals "not claude" — see
|
||||
* where it's read below for the isReal check this touches.
|
||||
* Set only by a non-claude transcript source (currently omp and codex); the
|
||||
* Claude scanner never stamps this; the meaningfulness floor below still
|
||||
* counts a row with a `mode` as real, since that also signals "not claude" —
|
||||
* see where it's read below for the isReal check this touches.
|
||||
*/
|
||||
mode?: string;
|
||||
/**
|
||||
* The token this CLI's own resume command expects, when it is NOT the row's
|
||||
* `sessionId`. Codex names a thread by an id of its own that lives in the
|
||||
* rollout, and a live codex session's `sessionId` is Codeman's uuid instead —
|
||||
* so a resume that reused `sessionId` would ask codex for a thread that does
|
||||
* not exist. Only a transcript scanner sets this, which is what keeps the two
|
||||
* kinds of row apart.
|
||||
*/
|
||||
resumeId?: string;
|
||||
};
|
||||
|
||||
/** Mux process-stat view. */
|
||||
@@ -186,6 +204,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
// transcript source (currently only omp) does, so a history-only row
|
||||
// still gets a mode badge instead of reading as claude by default.
|
||||
overwrite(item, 'mode', h.mode);
|
||||
overwrite(item, 'resumeId', h.resumeId);
|
||||
const ms = Date.parse(h.lastModified);
|
||||
if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms;
|
||||
}
|
||||
|
||||
+90
-40
@@ -106,6 +106,7 @@ import {
|
||||
import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js';
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
import { getCli } from './config/cli-registry/registry.js';
|
||||
import { compileVersionRegex } from './config/cli-registry/patterns.js';
|
||||
import { resolveSessionCliVersion } from './utils/cli-resolver.js';
|
||||
import {
|
||||
buildInteractiveArgs,
|
||||
@@ -481,6 +482,8 @@ 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)
|
||||
/** Lazily compiled `capabilities.workDetect.workingLine`. See _workingLinePattern(). */
|
||||
private _workingLineRe: RegExp | undefined = undefined;
|
||||
private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up)
|
||||
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
|
||||
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
|
||||
@@ -721,13 +724,20 @@ export class Session extends EventEmitter {
|
||||
this._wireActivityAt = config.lastActivityAt || Date.now();
|
||||
this._wireActivitySettleUntil = config.lastActivityAt ? Date.now() + WIRE_ACTIVITY_SETTLE_MS : 0;
|
||||
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
|
||||
// For omp, `claudeSessionId` doubles as the generic "external transcript id"
|
||||
// alias key mergeUnifiedSessions() folds a history row into its owning
|
||||
// session by: omp mints its OWN uuid, unrelated to this Codeman id, so
|
||||
// without this an omp conversation's Past-Sessions row (keyed by omp's
|
||||
// id) would never merge with its own live/persisted row (keyed by this
|
||||
// id) — it would just show up a second time.
|
||||
this._claudeSessionId = config.resumeSessionId || config.ompConfig?.resumeSessionId || this.id;
|
||||
// For omp and codex, `claudeSessionId` doubles as the generic "external
|
||||
// transcript id" alias key mergeUnifiedSessions() folds a history row into
|
||||
// its owning session by: each mints its OWN thread id, unrelated to this
|
||||
// Codeman id, so without this the conversation's Past-Sessions row (keyed by
|
||||
// that thread id) would never merge with its own live/persisted row (keyed
|
||||
// by this id) — it would just show up a second time. For codex a duplicate
|
||||
// is worse than cosmetic: the stale row still resumes, so clicking it starts
|
||||
// a SECOND `codex resume` on a thread already open in another pane.
|
||||
//
|
||||
// This covers a RESUMED codex session, which knows its thread id up front. A
|
||||
// fresh one learns its id only once codex writes the rollout, so it is folded
|
||||
// from the other side — see the originator stamping in `gatherUnifiedInputs()`.
|
||||
this._claudeSessionId =
|
||||
config.resumeSessionId || config.ompConfig?.resumeSessionId || config.codexConfig?.resumeSessionId || this.id;
|
||||
// Restored from state.json on boot recovery. start() resets _claudeSessionId
|
||||
// to the launch id even when re-attaching to a mux session whose CLI has
|
||||
// moved on (a `/clear` before the restart), so this anchor is what lets the
|
||||
@@ -2065,16 +2075,23 @@ export class Session extends EventEmitter {
|
||||
// this to omp's own session uuid — that already-resolved id must win
|
||||
// over the generic `this.id` fallback, or this line clobbers it back
|
||||
// to the Codeman id
|
||||
// on every single respawn.
|
||||
// on every single respawn. codex needs the same fallback for the same
|
||||
// reason: its thread id lives in `_codexConfig`, so without it every
|
||||
// respawn drops a resumed codex session's alias and its Past-Sessions
|
||||
// row springs back as a duplicate that still resumes.
|
||||
// ⚠️ A RESTORED mux session is the one case where the launch id is a
|
||||
// lie: the CLI never stopped, so a `/clear` before the Codeman restart
|
||||
// already moved it to a conversation `this.id` knows nothing about. The
|
||||
// persisted chain's tail is that conversation, reported first-hand by
|
||||
// the CLI's own hook, so it outranks the fallback here. A NEW pane has
|
||||
// an empty chain and falls through to exactly today's expression.
|
||||
// the CLI's own hook, so it outranks every fallback here. A NEW pane has
|
||||
// an empty chain and falls through to the resume/alias fallbacks.
|
||||
restoredConversation = isRestored ? this._claudeSessionChain[this._claudeSessionChain.length - 1] : undefined;
|
||||
this._claudeSessionId =
|
||||
restoredConversation || this._resumeSessionId || this._ompConfig?.resumeSessionId || this.id;
|
||||
restoredConversation ||
|
||||
this._resumeSessionId ||
|
||||
this._ompConfig?.resumeSessionId ||
|
||||
this._codexConfig?.resumeSessionId ||
|
||||
this.id;
|
||||
|
||||
// For NEW mux sessions: wait for readiness then clean buffer
|
||||
// For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch
|
||||
@@ -2175,15 +2192,19 @@ export class Session extends EventEmitter {
|
||||
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
|
||||
// Mirrors the mux branch above and must not clobber it: this line runs
|
||||
// unconditionally after both the mux and direct-PTY paths, so it also needs
|
||||
// the ompConfig fallback or it stomps the mux branch's correctly-resolved
|
||||
// OMP alias back to this.id on every mux/plain-reattach boot recovery
|
||||
// (the "third reset point" — see DECISIONS.md). For the same reason it needs
|
||||
// `restoredConversation`: on a RESTORED mux attach the CLI never stopped and
|
||||
// may have `/clear`ed before the restart, so the launch id is a lie and the
|
||||
// chain's tail is the live conversation. Empty on every other path, which
|
||||
// leaves this expression exactly as it was.
|
||||
// the ompConfig and codexConfig fallbacks or it stomps the mux branch's
|
||||
// correctly-resolved OMP/codex alias back to this.id on every mux/plain-
|
||||
// reattach boot recovery (the "third reset point" — see DECISIONS.md).
|
||||
// For the same reason it needs `restoredConversation`: on a RESTORED mux
|
||||
// attach the CLI never stopped and may have `/clear`ed before the restart,
|
||||
// so the launch id is a lie and the chain's tail is the live conversation.
|
||||
// It is empty on every other path, so those paths keep the alias chain.
|
||||
this._claudeSessionId =
|
||||
restoredConversation || this._resumeSessionId || this._ompConfig?.resumeSessionId || this.id;
|
||||
restoredConversation ||
|
||||
this._resumeSessionId ||
|
||||
this._ompConfig?.resumeSessionId ||
|
||||
this._codexConfig?.resumeSessionId ||
|
||||
this.id;
|
||||
|
||||
this._pid = this.ptyProcess.pid;
|
||||
console.log('[Session] Interactive PTY spawned with PID:', this._pid);
|
||||
@@ -2388,12 +2409,14 @@ export class Session extends EventEmitter {
|
||||
* @param data raw PTY chunk, ANSI included
|
||||
*/
|
||||
private _detectInteractiveActivity(data: string): void {
|
||||
// The prompt line contains "❯" when Claude is waiting for input. It only ARMS
|
||||
// the check and is NOT evidence the turn ended: Claude redraws the composer
|
||||
// about once a second all the way through a turn, which is exactly how a
|
||||
// working session used to flip to idle two seconds in. _confirmIdle() waits
|
||||
// for the pane to actually go quiet before believing it.
|
||||
if (data.includes('❯')) {
|
||||
const workDetect = getCli(this.mode)?.capabilities.workDetect;
|
||||
// The composer row carries this glyph when the CLI is waiting for input. It only
|
||||
// ARMS the check and is NOT evidence the turn ended: a CLI redraws its composer
|
||||
// about once a second all the way through a turn, which is exactly how a working
|
||||
// session used to flip to idle two seconds in. _confirmIdle() waits for the pane to
|
||||
// actually go quiet before believing it. A CLI that declares no glyph keeps Claude's,
|
||||
// which is the glyph every such session has been armed by until now.
|
||||
if (data.includes(workDetect?.promptGlyph ?? '❯')) {
|
||||
// Only start a new timeout if we're not already awaiting idle confirmation.
|
||||
// This prevents status bar redraws (which include the prompt) from resetting it.
|
||||
if (!this._awaitingIdleConfirmation) {
|
||||
@@ -2412,9 +2435,10 @@ export class Session extends EventEmitter {
|
||||
// new status line does not rescue it either (tmux repaints partially, so the
|
||||
// complete line reaches the PTY only every few tens of seconds). An unbroken run
|
||||
// of repaints is the signal that survives. See session-activity.ts for the
|
||||
// measurement. Claude only: an external CLI's TUI has no ❯, so nothing would
|
||||
// ever arm the idle confirmation and such a session would latch busy forever.
|
||||
if (!isExternalCliMode(this.mode)) {
|
||||
// measurement. This needs a pane Codeman can read: without a glyph to arm the idle
|
||||
// confirmation, a session latches busy forever. A CLI that declares work detection
|
||||
// supplies its own glyph, and the non-external modes keep the run they always had.
|
||||
if (workDetect || !isExternalCliMode(this.mode)) {
|
||||
this._activityStreak = trackActivityStreak(this._activityStreak, Date.now());
|
||||
// A streak is the TRIGGER to look, not the verdict: typing into the composer
|
||||
// also produces a steady stream of repaints. The screen settles it, and only
|
||||
@@ -2449,10 +2473,30 @@ 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;
|
||||
this._lastPaneProbeWorking = text === null ? null : CLAUDE_WORKING_LINE_PATTERN.test(text);
|
||||
this._lastPaneProbeWorking = text === null ? null : this._workingLinePattern().test(text);
|
||||
return this._lastPaneProbeWorking;
|
||||
}
|
||||
|
||||
/**
|
||||
* The regex matching this CLI's "a turn is running" status line.
|
||||
*
|
||||
* Compiled once per session and cached: `_probePaneWorking` runs it against a whole
|
||||
* pane capture on a timer, and the throttled text detector runs it against every
|
||||
* accumulated chunk. A CLI that declares no pattern falls back to Claude's, which is
|
||||
* the pattern every session used before the registry carried one.
|
||||
*/
|
||||
private _workingLinePattern(): RegExp {
|
||||
if (this._workingLineRe === undefined) {
|
||||
const src = getCli(this.mode)?.capabilities.workDetect?.workingLine;
|
||||
// Same guard the schema applies, not a second opinion: `compileVersionRegex()` is
|
||||
// what keeps a nested quantifier out of this pattern, and this one runs on the PTY
|
||||
// hot path. It returns null rather than throwing, and Claude's pattern is the
|
||||
// fallback every session used before the registry carried one.
|
||||
this._workingLineRe = (src ? compileVersionRegex(src) : null) ?? CLAUDE_WORKING_LINE_PATTERN;
|
||||
}
|
||||
return this._workingLineRe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the pane as working. Idempotent: `working` is emitted on the transition
|
||||
* only, so the per-chunk detectors can all call it freely.
|
||||
@@ -2541,10 +2585,6 @@ export class Session extends EventEmitter {
|
||||
* PTY data chunk. Receives accumulated raw data to process in one batch.
|
||||
*/
|
||||
private _processExpensiveParsers(rawData: string): void {
|
||||
// Skip Claude-specific parsers for external CLI sessions (Ralph tracker,
|
||||
// BashToolParser, token + CLI-info parsing all depend on Claude's output format).
|
||||
if (isExternalCliMode(this.mode)) return;
|
||||
|
||||
// Lazy ANSI strip: only compute cleanData when a consumer actually needs it.
|
||||
let _cleanData: string | null = null;
|
||||
const getCleanData = (): string => {
|
||||
@@ -2554,6 +2594,19 @@ export class Session extends EventEmitter {
|
||||
return _cleanData;
|
||||
};
|
||||
|
||||
// Work detection by status line, ahead of the external-CLI gate below. The pattern
|
||||
// comes from the CLI's own registry entry, so this is the one parser here that is not
|
||||
// Claude-specific — and it sat under that gate, which is why an external CLI reported
|
||||
// itself idle through an entire turn. Guarded on the descriptor so a CLI without one
|
||||
// still skips the ANSI strip the gate used to save it.
|
||||
if (!this._isWorking && getCli(this.mode)?.capabilities.workDetect) {
|
||||
if (this._workingLinePattern().test(getCleanData())) this._markWorking();
|
||||
}
|
||||
|
||||
// Skip Claude-specific parsers for external CLI sessions (Ralph tracker,
|
||||
// BashToolParser, token + CLI-info parsing all depend on Claude's output format).
|
||||
if (isExternalCliMode(this.mode)) return;
|
||||
|
||||
// Forward to Ralph tracker to detect Ralph loops and todos
|
||||
// (opencode sessions already returned early at line 1209)
|
||||
if (this._ralphTracker.enabled || !this._ralphTracker.autoEnableDisabled) {
|
||||
@@ -2585,16 +2638,13 @@ export class Session extends EventEmitter {
|
||||
this.parseTaskDescriptionsFromTerminalData(getCleanData());
|
||||
}
|
||||
|
||||
// Work detection (text-based, needs clean data: the status line is coloured,
|
||||
// so raw data has escape sequences between the `…` and the elapsed timer).
|
||||
// Only check if a faster path didn't already trigger working state.
|
||||
// Legacy gerunds, Claude-only. The status-line pattern above already ran for every
|
||||
// CLI that declares one, so this adds only the older wording. Current Claude
|
||||
// randomizes the word ("Actualizing…", "Finagling…"), so these catch a fraction of
|
||||
// turns; the pattern above and the activity streak carry the rest.
|
||||
if (!this._isWorking) {
|
||||
const cleanData = getCleanData();
|
||||
if (
|
||||
CLAUDE_WORKING_LINE_PATTERN.test(cleanData) ||
|
||||
// Legacy gerunds. Current Claude randomizes the word ("Actualizing…",
|
||||
// "Finagling…"), so these catch only a fraction of turns; the pattern
|
||||
// above and the activity streak carry the rest.
|
||||
cleanData.includes('Thinking') ||
|
||||
cleanData.includes('Writing') ||
|
||||
cleanData.includes('Reading') ||
|
||||
|
||||
@@ -228,6 +228,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
name: item.name || '',
|
||||
title: title || item.name || dir.split('/').pop() || item.sessionId.slice(0, 8),
|
||||
mode: item.mode || 'claude',
|
||||
// Only the scanner sets this, and only for a codex rollout. Dropping it here
|
||||
// is not cosmetic: resumeMobileOverviewSession() passes row.resumeId on to
|
||||
// resumeHistorySession(), so without it a tapped Codex row starts a FRESH
|
||||
// session on a thread that is already on disk.
|
||||
resumeId: item.resumeId || undefined,
|
||||
caseName: matched ? matched.name : '',
|
||||
dir: this._shortenHomePath ? this._shortenHomePath(dir) : dir,
|
||||
at: item.lastActivityAt || item.createdAt || 0,
|
||||
@@ -389,7 +394,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
async resumeMobileOverviewSession(sessionId) {
|
||||
const row = (this._mobileOverviewPastRows || []).find((r) => r.id === sessionId);
|
||||
if (!row || !row.workingDir) return;
|
||||
await this.resumeHistorySession(row.claudeSessionId || row.id, row.workingDir, row.name || undefined, row.mode);
|
||||
await this.resumeHistorySession(
|
||||
row.claudeSessionId || row.id,
|
||||
row.workingDir,
|
||||
row.name || undefined,
|
||||
row.mode,
|
||||
row.resumeId
|
||||
);
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -670,7 +670,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
} else if (record.workingDir) {
|
||||
// History rows are keyed by the Claude conversation UUID; resumed
|
||||
// sessions carry theirs separately as claudeSessionId.
|
||||
void this.resumeHistorySession(s.claudeSessionId || s.sessionId, record.workingDir, undefined, s.mode);
|
||||
void this.resumeHistorySession(
|
||||
s.claudeSessionId || s.sessionId,
|
||||
record.workingDir,
|
||||
undefined,
|
||||
s.mode,
|
||||
s.resumeId
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -804,7 +804,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
btn.append(...parts);
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.resumeHistorySession(s.sessionId, s.workingDir, s.name, s.mode);
|
||||
this.resumeHistorySession(s.sessionId, s.workingDir, s.name, s.mode, s.resumeId);
|
||||
});
|
||||
container.appendChild(btn);
|
||||
}
|
||||
|
||||
@@ -2193,7 +2193,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (isLive && this.sessions.has(s.sessionId)) {
|
||||
this.selectSession(s.sessionId);
|
||||
} else {
|
||||
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name, s.mode);
|
||||
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name, s.mode, s.resumeId);
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -2436,7 +2436,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
} else {
|
||||
// Resume by the Claude conversation UUID when present (resumed sessions
|
||||
// carry theirs separately from their Codeman id).
|
||||
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name, s.mode);
|
||||
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name, s.mode, s.resumeId);
|
||||
}
|
||||
this.closeSessionManager?.();
|
||||
closeMenu();
|
||||
@@ -2904,7 +2904,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
return `w${startNumber}-${dirName}`;
|
||||
},
|
||||
|
||||
async resumeHistorySession(sessionId, workingDir, existingName, mode) {
|
||||
async resumeHistorySession(sessionId, workingDir, existingName, mode, resumeId) {
|
||||
// Close the run mode menu if open
|
||||
document.getElementById('runModeMenu')?.classList.remove('active');
|
||||
// Close folder history modal if open
|
||||
@@ -2942,19 +2942,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
grok: 'grokConfig',
|
||||
omp: 'ompConfig',
|
||||
}[effectiveMode];
|
||||
// codex/gemini/antigravity have no wired continuation here yet (their
|
||||
// configs use an exact conversation id, not a "continue most recent"
|
||||
// flag, and the row's own `sessionId` is not verified to carry that
|
||||
// id for these three modes) — `continuesSomething` below is what keeps
|
||||
// their row from being retired for a resume that didn't actually
|
||||
// continue anything.
|
||||
// codex names a thread by an id of its own, not by Codeman's session id,
|
||||
// so it continues only when the row carried that id: `resumeId` is set by
|
||||
// the rollout scanner (codex-transcript.ts) and by nothing else, which is
|
||||
// what stops a LIVE codex row — whose sessionId is Codeman's uuid — from
|
||||
// asking codex for a thread that does not exist.
|
||||
//
|
||||
// gemini/antigravity still have no wired continuation here (same reason
|
||||
// codex used to have none: an exact conversation id nothing supplies) —
|
||||
// `continuesSomething` below is what keeps their row from being retired
|
||||
// for a resume that didn't actually continue anything.
|
||||
const codexResumeId = effectiveMode === 'codex' ? resumeId : undefined;
|
||||
const modeConfig =
|
||||
modeConfigKey
|
||||
? { [modeConfigKey]: { continueSession: true } }
|
||||
: effectiveMode === 'deepseek'
|
||||
? { deepSeekConfig: { resumeSession: true } }
|
||||
: {};
|
||||
const continuesSomething = Boolean(modeConfigKey) || effectiveMode === 'deepseek';
|
||||
: codexResumeId
|
||||
? { codexConfig: { resumeSessionId: codexResumeId } }
|
||||
: {};
|
||||
const continuesSomething =
|
||||
Boolean(modeConfigKey) || effectiveMode === 'deepseek' || Boolean(codexResumeId);
|
||||
const createRes = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -2982,11 +2990,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
// as a duplicate — click it 3 times, see the same name 3 times. Claude
|
||||
// rows are left alone: `sessionId` there is a claudeSessionId, which
|
||||
// usually has no live/persisted Codeman session of its own to delete.
|
||||
// Gated on `continuesSomething`: for codex/gemini/antigravity (no
|
||||
// continuation wired above), this is really a FRESH session with no
|
||||
// Gated on `continuesSomething`: for gemini/antigravity, and for a codex
|
||||
// row carrying no `resumeId`, this is really a FRESH session with no
|
||||
// relation to the old row's conversation, so retiring it would discard
|
||||
// the old conversation with no recovery — worse than the duplicate row
|
||||
// this guard exists to prevent for the modes that DO continue.
|
||||
//
|
||||
// A codex row that DOES continue passes this gate, but the DELETE is a
|
||||
// no-op for it: `sessionId` there is codex's thread id and no Codeman
|
||||
// session carries that id. Its duplicate is cleared from the other side
|
||||
// instead, by the alias fold in gatherUnifiedInputs()/Session.
|
||||
if (effectiveMode !== 'claude' && continuesSomething && sessionId !== newSessionId) {
|
||||
fetch(`/api/sessions/${sessionId}?killMux=true`, { method: 'DELETE' }).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -148,6 +148,7 @@ import {
|
||||
import { LRUMap } from '../../utils/lru-map.js';
|
||||
import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
|
||||
import { scanOmpSessionsHistory } from '../../omp-transcript.js';
|
||||
import { scanCodexSessionsHistory, codexThreadBySessionId } from '../../codex-transcript.js';
|
||||
import {
|
||||
getLastTranscriptResponse,
|
||||
isExternalCliTranscriptMode,
|
||||
@@ -4164,6 +4165,13 @@ export function registerSessionRoutes(
|
||||
// Persisted sessions (state.json). resumeSessionId is the Claude
|
||||
// conversation UUID a resumed session continues — feed it to the merge's
|
||||
// alias map so its transcript row folds into this session.
|
||||
//
|
||||
// codex keeps its thread id in `codexConfig` instead, and state.json stores
|
||||
// that, so read it here as well. Without it a resumed codex session that has
|
||||
// been demoted to a persisted-only record loses its alias and duplicates: the
|
||||
// originator fallback below cannot rescue that one, because a RESUMED rollout
|
||||
// keeps the original session_meta (see findActiveCodexFile) and so still
|
||||
// names whichever pane first created the thread.
|
||||
const persisted: PersistedSessionInput[] = Object.values(ctx.store.getState().sessions).map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
@@ -4172,7 +4180,7 @@ export function registerSessionRoutes(
|
||||
workingDir: p.workingDir,
|
||||
createdAt: p.createdAt,
|
||||
lastActivityAt: p.lastActivityAt,
|
||||
claudeSessionId: p.resumeSessionId,
|
||||
claudeSessionId: p.resumeSessionId || p.codexConfig?.resumeSessionId,
|
||||
pinned: p.pinned,
|
||||
pinnedAt: p.pinnedAt,
|
||||
}));
|
||||
@@ -4240,6 +4248,49 @@ export function registerSessionRoutes(
|
||||
// Best-effort, same as the claude scan above.
|
||||
}
|
||||
|
||||
// Codex's own rollout store (~/.codex/sessions) — the same treatment omp
|
||||
// gets above, and for the same reason: codex writes no Claude transcript, so
|
||||
// without this a codex conversation disappears from the list as soon as its
|
||||
// session record does. `resumeId` is the rollout's own thread id, which is
|
||||
// what `codex resume` takes; see codex-transcript.ts.
|
||||
try {
|
||||
const codexRows = await scanCodexSessionsHistory();
|
||||
for (const h of codexRows) {
|
||||
history.push({
|
||||
sessionId: h.sessionId,
|
||||
workingDir: h.workingDir,
|
||||
sizeBytes: h.sizeBytes,
|
||||
lastModified: h.lastModified,
|
||||
firstPrompt: h.firstPrompt,
|
||||
lastPrompt: h.lastPrompt,
|
||||
mode: 'codex',
|
||||
resumeId: h.sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
// Fold a FRESH codex pane into its own rollout row. A resumed one already
|
||||
// folds, because Session sets `claudeSessionId` from the resume id it was
|
||||
// given; a fresh one has no thread id until codex writes the rollout, so
|
||||
// the link has to come from the other side. Codeman spawns every codex pane
|
||||
// with CODEX_INTERNAL_ORIGINATOR_OVERRIDE=codeman_<sessionId>, and codex
|
||||
// stamps that into session_meta.originator, so the rollout names the pane.
|
||||
//
|
||||
// Newest rollout wins: `/new` typed inside the TUI leaves several rollouts
|
||||
// carrying the same originator, and the pane is on the most recent one.
|
||||
// Rows arrive newest-first, so the first match is it.
|
||||
//
|
||||
// Never overwrites an id a session already knows — that one came from the
|
||||
// resume path and is authoritative.
|
||||
const codexThreads = codexThreadBySessionId(codexRows);
|
||||
for (const row of [...live, ...persisted]) {
|
||||
if (row.claudeSessionId && row.claudeSessionId !== row.id) continue;
|
||||
const threadId = codexThreads.get(row.id);
|
||||
if (threadId) row.claudeSessionId = threadId;
|
||||
}
|
||||
} catch {
|
||||
// Best-effort, same as the two scans above.
|
||||
}
|
||||
|
||||
// Mux process stats (best-effort; guard against mocks lacking the method).
|
||||
let mux: MuxStatInput[] = [];
|
||||
try {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CliEntrySchema } from '../src/config/cli-registry/schema.js';
|
||||
import { compileVersionRegex } from '../src/config/cli-registry/patterns.js';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
import type { CliEntry } from '../src/config/cli-registry/types.js';
|
||||
|
||||
@@ -75,6 +76,34 @@ describe('strictness', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('workDetect.workingLine is guarded like every other config regex', () => {
|
||||
it('rejects a nested quantifier', () => {
|
||||
expectRejected((e) => {
|
||||
(e.capabilities as Record<string, unknown>).workDetect = { promptGlyph: '>', workingLine: '(a+)+b' };
|
||||
}, 'this pattern is compiled once and then run against every accumulated PTY chunk, so catastrophic backtracking here freezes the event loop for the whole server');
|
||||
});
|
||||
|
||||
it('rejects a source longer than compileVersionRegex() will compile', () => {
|
||||
expectRejected((e) => {
|
||||
(e.capabilities as Record<string, unknown>).workDetect = { promptGlyph: '>', workingLine: 'a'.repeat(201) };
|
||||
}, 'the schema must not accept a pattern the runtime will then refuse to compile, or the CLI silently falls back to the Claude pattern');
|
||||
});
|
||||
|
||||
it('rejects a pattern that is not a regex at all', () => {
|
||||
expectRejected((e) => {
|
||||
(e.capabilities as Record<string, unknown>).workDetect = { promptGlyph: '>', workingLine: '([unclosed' };
|
||||
}, 'a broken pattern must fail at LOAD time, not inside the PTY data handler');
|
||||
});
|
||||
|
||||
it('accepts both shipped patterns unchanged', () => {
|
||||
for (const entry of STOCK_CLIS) {
|
||||
const src = entry.capabilities.workDetect?.workingLine;
|
||||
if (!src) continue;
|
||||
expect(compileVersionRegex(src), `${entry.id} declares a workingLine the guard refuses`).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('no shell text can reach the command line', () => {
|
||||
it('rejects a literal carrying shell metacharacters', () => {
|
||||
for (const evil of ['pi; rm -rf /', 'pi && curl evil.sh', 'pi`whoami`', 'pi $(id)', 'pi | tee', 'pi > /etc/x']) {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @fileoverview A resumed codex session must keep its thread-id alias across
|
||||
* `start()`, not just at construction.
|
||||
*
|
||||
* `claudeSessionId` doubles as the generic "external transcript id" the unified
|
||||
* list folds a Past-Sessions row into its owning session by. For codex that id
|
||||
* is the rollout's thread id, and losing it is not cosmetic: the stale row stays
|
||||
* in PAST and still resumes, so clicking it starts a SECOND `codex resume` on a
|
||||
* thread already open in the live pane.
|
||||
*
|
||||
* The bug this pins: the alias was wired into the constructor only. `start()`
|
||||
* recomputes `claudeSessionId` at two further points — the mux branch and the
|
||||
* unconditional "third reset point" that runs after both the mux and direct-PTY
|
||||
* paths — and both listed only Claude's `resumeSessionId` and omp's. For codex
|
||||
* both are undefined, so every mux reattach and every boot recovery reset the
|
||||
* alias back to the Codeman id and the duplicate came back. The existing comment
|
||||
* at the third reset point already warned that omitting omp's fallback there
|
||||
* "stomps the mux branch's correctly-resolved OMP alias"; codex needed the same.
|
||||
*
|
||||
* Mirrors `test/omp-fresh-run-no-resume.test.ts`, which drives a real `Session`
|
||||
* against the in-memory tmux layer that vitest substitutes.
|
||||
*/
|
||||
import { mkdirSync, rmSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { Session } from '../src/session.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
|
||||
describe('codex: a resumed thread id survives start()', () => {
|
||||
const workingDir = join(homedir(), 'codeman-cases', 'codex-resume-alias');
|
||||
const THREAD_ID = '01a060f0-0361-7f91-abde-b283020db0d7';
|
||||
const sessions: Session[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const s of sessions.splice(0)) s.stop();
|
||||
rmSync(workingDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makeSession(useMux: boolean): Session {
|
||||
mkdirSync(workingDir, { recursive: true });
|
||||
const session = new Session({
|
||||
workingDir,
|
||||
mode: 'codex',
|
||||
codexConfig: { resumeSessionId: THREAD_ID },
|
||||
mux: new TmuxManager(),
|
||||
useMux,
|
||||
});
|
||||
sessions.push(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
it('carries the thread id from construction', () => {
|
||||
expect(makeSession(true).claudeSessionId).toBe(THREAD_ID);
|
||||
});
|
||||
|
||||
it('still carries it after starting under mux', async () => {
|
||||
const session = makeSession(true);
|
||||
await session.startInteractive();
|
||||
expect(session.claudeSessionId).toBe(THREAD_ID);
|
||||
});
|
||||
|
||||
it('refuses to start without mux at all, so the mux path is the only one to cover', async () => {
|
||||
// codex declares `requiresMux`, so there is no direct-PTY codex session for
|
||||
// the third reset point to run against on its own — the assertion above is
|
||||
// the whole surface.
|
||||
await expect(makeSession(false).startInteractive()).rejects.toThrow(/require tmux/i);
|
||||
});
|
||||
|
||||
it('a fresh codex session keeps the Codeman id, having no thread of its own', async () => {
|
||||
mkdirSync(workingDir, { recursive: true });
|
||||
const session = new Session({ workingDir, mode: 'codex', mux: new TmuxManager(), useMux: true });
|
||||
sessions.push(session);
|
||||
|
||||
await session.startInteractive();
|
||||
|
||||
// Nothing to alias to yet — codex has not written the rollout. Such a
|
||||
// session is folded from the other side, by originator (codexThreadBySessionId).
|
||||
expect(session.claudeSessionId).toBe(session.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* Reading codex's own rollout store for Past Sessions rows.
|
||||
*
|
||||
* Three of these assertions exist because the obvious implementation was
|
||||
* measured to be wrong against real files (codex CLI 0.152.1):
|
||||
*
|
||||
* - codex 0.152.1 emits NO `event_msg`/`user_message` rows at all. It writes
|
||||
* `event_msg`/`item_completed` carrying an `item.type` of `UserMessage`
|
||||
* instead, so a scanner that knew only the older shape found a prompt for
|
||||
* July rollouts and nothing for September ones.
|
||||
* - the `response_item` fallback sees codex's injected context, and on a real
|
||||
* store the FIRST such row is the repository's AGENTS.md every time. Taking
|
||||
* it literally titled every row with the same instructions block.
|
||||
* - codex spawns sub-agent threads into the same store, stamped
|
||||
* `thread_source: 'subagent'`. On the store this was built against they
|
||||
* outnumbered the threads a person can actually resume.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||
import { appendFile, mkdtemp, mkdir, writeFile, rm, utimes } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import {
|
||||
scanCodexSessionsHistory,
|
||||
codexThreadBySessionId,
|
||||
__clearCodexIdentityCache,
|
||||
} from '../src/codex-transcript.js';
|
||||
|
||||
let home: string;
|
||||
let prevCodexHome: string | undefined;
|
||||
|
||||
/** A rollout's opening line, as codex writes it. */
|
||||
const sessionMeta = (opts: { id: string; cwd?: string; threadSource?: string; originator?: string }) =>
|
||||
JSON.stringify({
|
||||
timestamp: '2026-09-02T07:05:37.421Z',
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: opts.id,
|
||||
session_id: opts.id,
|
||||
...(opts.cwd ? { cwd: opts.cwd } : {}),
|
||||
originator: opts.originator ?? 'codex-tui',
|
||||
...(opts.threadSource ? { thread_source: opts.threadSource } : {}),
|
||||
// The real thing embeds full base instructions here; padded so the file
|
||||
// clears the size floor and exercises the head window.
|
||||
base_instructions: { text: 'x'.repeat(500) },
|
||||
},
|
||||
});
|
||||
|
||||
/** codex 0.152.1's user-input row. */
|
||||
const itemCompletedUser = (text: string) =>
|
||||
JSON.stringify({
|
||||
type: 'event_msg',
|
||||
payload: { type: 'item_completed', item: { type: 'UserMessage', id: 'i1', content: [{ type: 'text', text }] } },
|
||||
});
|
||||
|
||||
/** The shape older codex versions wrote. */
|
||||
const legacyUserMessage = (text: string) =>
|
||||
JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: text } });
|
||||
|
||||
/** The last-resort shape, which also carries codex's injected context. */
|
||||
const responseItemUser = (text: string) =>
|
||||
JSON.stringify({ type: 'response_item', payload: { role: 'user', content: [{ type: 'text', text }] } });
|
||||
|
||||
async function writeRollout(id: string, lines: string[], mtime?: Date): Promise<string> {
|
||||
const dir = join(home, 'sessions', '2026', '09', '02');
|
||||
await mkdir(dir, { recursive: true });
|
||||
const path = join(dir, `rollout-2026-09-02T09-05-37-${id}.jsonl`);
|
||||
await writeFile(path, lines.join('\n') + '\n', 'utf-8');
|
||||
if (mtime) await utimes(path, mtime, mtime);
|
||||
return path;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await mkdtemp(join(tmpdir(), 'codex-transcript-'));
|
||||
prevCodexHome = process.env.CODEX_HOME;
|
||||
process.env.CODEX_HOME = home;
|
||||
__clearCodexIdentityCache();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevCodexHome === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = prevCodexHome;
|
||||
await rm(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('scanCodexSessionsHistory', () => {
|
||||
it('returns nothing when the store does not exist', async () => {
|
||||
process.env.CODEX_HOME = join(home, 'nope');
|
||||
expect(await scanCodexSessionsHistory()).toEqual([]);
|
||||
});
|
||||
|
||||
it('reads the thread id, working directory and opening prompt', async () => {
|
||||
await writeRollout('01a060f0-0361-7f91-abde-b283020db0d7', [
|
||||
sessionMeta({ id: '01a060f0-0361-7f91-abde-b283020db0d7', cwd: '/repo/one' }),
|
||||
itemCompletedUser('Continue the audit log architecture'),
|
||||
]);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].sessionId).toBe('01a060f0-0361-7f91-abde-b283020db0d7');
|
||||
expect(rows[0].workingDir).toBe('/repo/one');
|
||||
expect(rows[0].firstPrompt).toBe('Continue the audit log architecture');
|
||||
expect(rows[0].sizeBytes).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('still reads the prompt shape older codex versions wrote', async () => {
|
||||
await writeRollout('11111111-1111-7111-8111-111111111111', [
|
||||
sessionMeta({ id: '11111111-1111-7111-8111-111111111111', cwd: '/repo/two' }),
|
||||
legacyUserMessage('$pr-review-comment-fixer 349'),
|
||||
]);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows[0].firstPrompt).toBe('$pr-review-comment-fixer 349');
|
||||
});
|
||||
|
||||
it('skips injected context when only the fallback shape is present', async () => {
|
||||
await writeRollout('22222222-2222-7222-8222-222222222222', [
|
||||
sessionMeta({ id: '22222222-2222-7222-8222-222222222222', cwd: '/repo/three' }),
|
||||
responseItemUser('# AGENTS.md instructions for /repo/three\n<INSTRUCTIONS> ...'),
|
||||
responseItemUser('<environment_context>cwd=/repo/three</environment_context>'),
|
||||
responseItemUser('Replace PanicOnError with Require().NoError'),
|
||||
]);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows[0].firstPrompt).toBe('Replace PanicOnError with Require().NoError');
|
||||
});
|
||||
|
||||
it('prefers a real user row over the injection-prone fallback', async () => {
|
||||
await writeRollout('33333333-3333-7333-8333-333333333333', [
|
||||
sessionMeta({ id: '33333333-3333-7333-8333-333333333333', cwd: '/repo/four' }),
|
||||
responseItemUser('Some earlier response_item row'),
|
||||
itemCompletedUser('The prompt the user actually typed'),
|
||||
]);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows[0].firstPrompt).toBe('The prompt the user actually typed');
|
||||
});
|
||||
|
||||
it('leaves out sub-agent threads, which nobody resumes', async () => {
|
||||
await writeRollout('44444444-4444-7444-8444-444444444444', [
|
||||
sessionMeta({ id: '44444444-4444-7444-8444-444444444444', cwd: '/repo/five' }),
|
||||
itemCompletedUser('a real conversation'),
|
||||
]);
|
||||
await writeRollout('55555555-5555-7555-8555-555555555555', [
|
||||
sessionMeta({ id: '55555555-5555-7555-8555-555555555555', cwd: '/repo/five', threadSource: 'subagent' }),
|
||||
itemCompletedUser('work codex gave itself'),
|
||||
]);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows.map((r) => r.sessionId)).toEqual(['44444444-4444-7444-8444-444444444444']);
|
||||
});
|
||||
|
||||
it('reports the most recent prompt as well as the first', async () => {
|
||||
await writeRollout('66666666-6666-7666-8666-666666666666', [
|
||||
sessionMeta({ id: '66666666-6666-7666-8666-666666666666', cwd: '/repo/six' }),
|
||||
itemCompletedUser('the opening question'),
|
||||
itemCompletedUser('a follow-up'),
|
||||
itemCompletedUser('the latest thing asked'),
|
||||
]);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows[0].firstPrompt).toBe('the opening question');
|
||||
expect(rows[0].lastPrompt).toBe('the latest thing asked');
|
||||
});
|
||||
|
||||
it('orders rows newest first', async () => {
|
||||
await writeRollout(
|
||||
'77777777-7777-7777-8777-777777777777',
|
||||
[sessionMeta({ id: '77777777-7777-7777-8777-777777777777', cwd: '/repo/old' }), itemCompletedUser('older')],
|
||||
new Date('2026-08-01T00:00:00Z')
|
||||
);
|
||||
await writeRollout(
|
||||
'88888888-8888-7888-8888-888888888888',
|
||||
[sessionMeta({ id: '88888888-8888-7888-8888-888888888888', cwd: '/repo/new' }), itemCompletedUser('newer')],
|
||||
new Date('2026-09-05T00:00:00Z')
|
||||
);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows.map((r) => r.workingDir)).toEqual(['/repo/new', '/repo/old']);
|
||||
});
|
||||
|
||||
it('ignores a file too short to hold a session_meta line', async () => {
|
||||
const dir = join(home, 'sessions', '2026', '09', '02');
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, 'rollout-2026-09-02T09-05-37-short.jsonl'), '{}\n', 'utf-8');
|
||||
|
||||
expect(await scanCodexSessionsHistory()).toEqual([]);
|
||||
});
|
||||
|
||||
it('survives a rollout whose lines are malformed', async () => {
|
||||
await writeRollout('99999999-9999-7999-8999-999999999999', [
|
||||
sessionMeta({ id: '99999999-9999-7999-8999-999999999999', cwd: '/repo/seven' }),
|
||||
'{not json at all',
|
||||
itemCompletedUser('still found me'),
|
||||
]);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows[0].firstPrompt).toBe('still found me');
|
||||
});
|
||||
|
||||
it('reports the originator, which is how a fresh pane finds its own rollout', async () => {
|
||||
await writeRollout('bbbbbbbb-bbbb-7bbb-8bbb-bbbbbbbbbbbb', [
|
||||
sessionMeta({
|
||||
id: 'bbbbbbbb-bbbb-7bbb-8bbb-bbbbbbbbbbbb',
|
||||
cwd: '/repo/nine',
|
||||
originator: 'codeman_2f1c9a44-1111-2222-3333-444455556666',
|
||||
}),
|
||||
itemCompletedUser('hello'),
|
||||
]);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows[0].originator).toBe('codeman_2f1c9a44-1111-2222-3333-444455556666');
|
||||
});
|
||||
|
||||
it('drops a rollout that records no working directory', async () => {
|
||||
// Emitting workingDir: '' would make a click post an empty directory.
|
||||
await writeRollout('cccccccc-cccc-7ccc-8ccc-cccccccccccc', [
|
||||
sessionMeta({ id: 'cccccccc-cccc-7ccc-8ccc-cccccccccccc' }),
|
||||
itemCompletedUser('nowhere to resume into'),
|
||||
]);
|
||||
|
||||
expect(await scanCodexSessionsHistory()).toEqual([]);
|
||||
});
|
||||
|
||||
it('picks up a prompt written after an earlier scan saw none', async () => {
|
||||
// The bug this pins: the identity cache was written as soon as the thread id
|
||||
// was known, but codex writes the first UserMessage only when the user
|
||||
// submits. Any scan in that window — the home screen, the command palette,
|
||||
// the search-index refresh — pinned `firstPrompt: undefined` until restart.
|
||||
const id = 'dddddddd-dddd-7ddd-8ddd-dddddddddddd';
|
||||
const path = await writeRollout(id, [sessionMeta({ id, cwd: '/repo/ten' })]);
|
||||
|
||||
const before = await scanCodexSessionsHistory();
|
||||
expect(before).toHaveLength(1);
|
||||
expect(before[0].firstPrompt).toBeUndefined();
|
||||
|
||||
await appendFile(path, itemCompletedUser('the prompt, typed a moment later') + '\n', 'utf-8');
|
||||
|
||||
const after = await scanCodexSessionsHistory();
|
||||
expect(after[0].firstPrompt).toBe('the prompt, typed a moment later');
|
||||
});
|
||||
|
||||
it('does not spend the lastPrompt budget on rollouts it never returns', async () => {
|
||||
// The budget used to count file index, so a store whose newest files are all
|
||||
// sub-agent threads exhausted it before the first row that needed it.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await writeRollout(
|
||||
`eeeeeeee-eeee-7eee-8eee-00000000000${i}`,
|
||||
[
|
||||
sessionMeta({ id: `eeeeeeee-eeee-7eee-8eee-00000000000${i}`, cwd: '/repo/sub', threadSource: 'subagent' }),
|
||||
itemCompletedUser('subagent work'),
|
||||
],
|
||||
new Date('2026-09-05T00:00:00Z')
|
||||
);
|
||||
}
|
||||
await writeRollout(
|
||||
'ffffffff-ffff-7fff-8fff-ffffffffffff',
|
||||
[
|
||||
sessionMeta({ id: 'ffffffff-ffff-7fff-8fff-ffffffffffff', cwd: '/repo/real' }),
|
||||
itemCompletedUser('opening'),
|
||||
itemCompletedUser('the latest thing asked'),
|
||||
],
|
||||
new Date('2026-09-04T00:00:00Z')
|
||||
);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].lastPrompt).toBe('the latest thing asked');
|
||||
});
|
||||
|
||||
it('collapses a long prompt to a single capped line', async () => {
|
||||
await writeRollout('aaaaaaaa-aaaa-7aaa-8aaa-aaaaaaaaaaaa', [
|
||||
sessionMeta({ id: 'aaaaaaaa-aaaa-7aaa-8aaa-aaaaaaaaaaaa', cwd: '/repo/eight' }),
|
||||
itemCompletedUser('line one\nline two\n' + 'y'.repeat(500)),
|
||||
]);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows[0].firstPrompt).not.toContain('\n');
|
||||
expect(rows[0].firstPrompt!.length).toBeLessThanOrEqual(201);
|
||||
expect(rows[0].firstPrompt!.endsWith('…')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('codexThreadBySessionId', () => {
|
||||
const row = (sessionId: string, originator?: string) =>
|
||||
({ sessionId, originator, workingDir: '/w', sizeBytes: 1, lastModified: '2026-09-02T00:00:00.000Z' }) as never;
|
||||
|
||||
it('maps a Codeman-spawned pane to the thread it is writing', () => {
|
||||
const map = codexThreadBySessionId([row('thread-a', 'codeman_sess-1')]);
|
||||
expect(map.get('sess-1')).toBe('thread-a');
|
||||
});
|
||||
|
||||
it('ignores a rollout codex started on its own', () => {
|
||||
expect(codexThreadBySessionId([row('thread-a', 'codex-tui')]).size).toBe(0);
|
||||
expect(codexThreadBySessionId([row('thread-a', undefined)]).size).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps the newest rollout when a pane has several', () => {
|
||||
// `/new` inside the codex TUI leaves the pane's originator on more than one
|
||||
// rollout; the pane is on the most recent, and rows arrive newest-first.
|
||||
const map = codexThreadBySessionId([row('thread-new', 'codeman_sess-1'), row('thread-old', 'codeman_sess-1')]);
|
||||
expect(map.get('sess-1')).toBe('thread-new');
|
||||
});
|
||||
});
|
||||
@@ -397,11 +397,13 @@ describe('Session Manager unified list', () => {
|
||||
expect(app.selectSession).toHaveBeenCalledWith('sess-alpha');
|
||||
expect(app.resumeHistorySession).not.toHaveBeenCalled();
|
||||
|
||||
// History row → resume by conversation UUID.
|
||||
// History row → resume by conversation UUID. The trailing `resumeId` is the
|
||||
// CLI's own thread token, which only a non-claude transcript scanner sets;
|
||||
// a Claude row carries none, so it arrives undefined here.
|
||||
const [historyRecord, , historyOptions] = app._buildHistoryItem.mock.calls[1];
|
||||
expect(historyRecord).toMatchObject({ sessionId: 'conv-uuid-1', sizeBytes: 2048, firstPrompt: 'old prompt' });
|
||||
historyOptions.onActivate();
|
||||
expect(app.resumeHistorySession).toHaveBeenCalledWith('conv-uuid-1', '/repo/old', undefined, undefined);
|
||||
expect(app.resumeHistorySession).toHaveBeenCalledWith('conv-uuid-1', '/repo/old', undefined, undefined, undefined);
|
||||
});
|
||||
|
||||
it('surfaces an error message instead of an empty list when the endpoint fails', async () => {
|
||||
|
||||
@@ -224,6 +224,37 @@ describe('mobile overview model', () => {
|
||||
expect(model.past[0].title).toBe('w4-claudeman');
|
||||
});
|
||||
|
||||
it('carries resumeId through to the past row so a Codex tap resumes its thread', () => {
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({
|
||||
sessions: [],
|
||||
cases: CASES,
|
||||
history: [
|
||||
{
|
||||
sessionId: 'rollout-1',
|
||||
workingDir: '/home/arkon/codeman-cases/beta',
|
||||
firstPrompt: 'port the parser',
|
||||
mode: 'codex',
|
||||
resumeId: 'codex-thread-id',
|
||||
lastActivityAt: 300,
|
||||
},
|
||||
// A claude row carries none, and must not grow one.
|
||||
{
|
||||
sessionId: 'claude-1',
|
||||
workingDir: '/home/arkon/default/claudeman',
|
||||
claudeSessionId: 'claude-uuid-a',
|
||||
lastActivityAt: 200,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// resumeMobileOverviewSession() reads row.resumeId off exactly this projection and
|
||||
// hands it to resumeHistorySession(); an undefined here is a fresh codex session on
|
||||
// a thread that already exists, which is the phone-only half of the resume feature.
|
||||
expect(model.past[0]).toMatchObject({ mode: 'codex', resumeId: 'codex-thread-id' });
|
||||
expect(model.past[1].resumeId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not title a past row with the transcript reader placeholder', () => {
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('resumeHistorySession: row retirement is gated on actual continuation',
|
||||
fetchMock = stubFetch('new-session-id');
|
||||
});
|
||||
|
||||
it.each(['codex', 'gemini', 'antigravity'])(
|
||||
it.each(['gemini', 'antigravity'])(
|
||||
'does NOT retire the old row for %s (no continuation is wired for it)',
|
||||
async (mode) => {
|
||||
const app = makeApp();
|
||||
@@ -104,6 +104,44 @@ describe('resumeHistorySession: row retirement is gated on actual continuation',
|
||||
}
|
||||
);
|
||||
|
||||
// codex continues only when the row carried its thread id. A row without one
|
||||
// is a live session's row, whose sessionId is Codeman's own uuid — sending
|
||||
// THAT to `codex resume` asks for a thread that does not exist, so it must
|
||||
// stay a fresh session and must not retire the row it came from.
|
||||
it('does NOT continue or retire a codex row that carries no resumeId', async () => {
|
||||
const app = makeApp();
|
||||
await app.resumeHistorySession.call(app, 'codeman-uuid', '/repo', 'w1-repo', 'codex');
|
||||
|
||||
expect(createBody(fetchMock)).toMatchObject({ mode: 'codex' });
|
||||
expect(createBody(fetchMock).codexConfig).toBeUndefined();
|
||||
expect(deleteCalls(fetchMock)).toEqual([]);
|
||||
});
|
||||
|
||||
it('resumes a codex row by the thread id the row carried', async () => {
|
||||
const app = makeApp();
|
||||
await app.resumeHistorySession.call(
|
||||
app,
|
||||
'01a060f0-0361-7f91-abde-b283020db0d7',
|
||||
'/repo',
|
||||
'w1-repo',
|
||||
'codex',
|
||||
'01a060f0-0361-7f91-abde-b283020db0d7'
|
||||
);
|
||||
|
||||
expect(createBody(fetchMock)).toMatchObject({
|
||||
mode: 'codex',
|
||||
codexConfig: { resumeSessionId: '01a060f0-0361-7f91-abde-b283020db0d7' },
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a resumeId on a row that is not codex', async () => {
|
||||
const app = makeApp();
|
||||
await app.resumeHistorySession.call(app, 'old-id', '/repo', 'w1-repo', 'gemini', 'some-thread-id');
|
||||
|
||||
expect(createBody(fetchMock).codexConfig).toBeUndefined();
|
||||
expect(deleteCalls(fetchMock)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['opencode', 'openCodeConfig'],
|
||||
['pi', 'piConfig'],
|
||||
|
||||
@@ -14,6 +14,94 @@ import {
|
||||
} from '../../src/services/unified-session-service.js';
|
||||
|
||||
describe('mergeUnifiedSessions', () => {
|
||||
// A codex conversation showing twice is worse than cosmetic: the stale PAST row
|
||||
// still resumes, so clicking it starts a SECOND `codex resume` on a thread
|
||||
// already open in another pane. Both folds below are what prevent that.
|
||||
it('folds a RESUMED codex session into its own rollout row', () => {
|
||||
// Session sets claudeSessionId from codexConfig.resumeSessionId, so the live
|
||||
// row already names the thread the rollout is keyed by.
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 'codeman-uuid', status: 'idle', mode: 'codex', claudeSessionId: 'codex-thread-id' }],
|
||||
history: [
|
||||
{
|
||||
sessionId: 'codex-thread-id',
|
||||
workingDir: '/w',
|
||||
sizeBytes: 4000,
|
||||
lastModified: '2026-09-02T00:00:00.000Z',
|
||||
mode: 'codex',
|
||||
resumeId: 'codex-thread-id',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0].sessionId).toBe('codeman-uuid');
|
||||
expect([...merged[0].sources].sort()).toEqual(['history', 'live']);
|
||||
});
|
||||
|
||||
it('folds a FRESH codex session once its rollout has been matched by originator', () => {
|
||||
// A fresh pane knows no thread id, so gatherUnifiedInputs() stamps one onto
|
||||
// the live row from session_meta.originator (see codexThreadBySessionId).
|
||||
// This is that stamped row.
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 'codeman-uuid', status: 'busy', mode: 'codex', claudeSessionId: 'fresh-thread-id' }],
|
||||
persisted: [{ id: 'codeman-uuid', status: 'idle', mode: 'codex', claudeSessionId: 'fresh-thread-id' }],
|
||||
history: [
|
||||
{
|
||||
sessionId: 'fresh-thread-id',
|
||||
workingDir: '/w',
|
||||
sizeBytes: 900,
|
||||
lastModified: '2026-09-02T00:00:00.000Z',
|
||||
mode: 'codex',
|
||||
resumeId: 'fresh-thread-id',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0].sessionId).toBe('codeman-uuid');
|
||||
expect(merged[0].status).toBe('busy');
|
||||
});
|
||||
|
||||
it('leaves an unrelated codex rollout as its own row', () => {
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 'codeman-uuid', status: 'idle', mode: 'codex', claudeSessionId: 'thread-one' }],
|
||||
history: [
|
||||
{
|
||||
sessionId: 'thread-two',
|
||||
workingDir: '/w',
|
||||
sizeBytes: 4000,
|
||||
lastModified: '2026-09-02T00:00:00.000Z',
|
||||
mode: 'codex',
|
||||
resumeId: 'thread-two',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(merged.map((m) => m.sessionId).sort()).toEqual(['codeman-uuid', 'thread-two']);
|
||||
});
|
||||
|
||||
it("carries a transcript row's own resume token, and stamps none on a live row", () => {
|
||||
// codex names a thread by an id in its rollout, not by Codeman's session id.
|
||||
// The scanner sets `resumeId`; a live session never does, which is what stops
|
||||
// a resume from asking codex for a thread whose id is really Codeman's uuid.
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 'codeman-uuid', status: 'idle', mode: 'codex' }],
|
||||
history: [
|
||||
{
|
||||
sessionId: 'codex-thread-id',
|
||||
workingDir: '/w',
|
||||
sizeBytes: 4000,
|
||||
lastModified: '2026-09-02T00:00:00.000Z',
|
||||
mode: 'codex',
|
||||
resumeId: 'codex-thread-id',
|
||||
},
|
||||
],
|
||||
});
|
||||
const fromTranscript = merged.find((m) => m.sessionId === 'codex-thread-id');
|
||||
const fromLive = merged.find((m) => m.sessionId === 'codeman-uuid');
|
||||
expect(fromTranscript?.resumeId).toBe('codex-thread-id');
|
||||
expect(fromTranscript?.mode).toBe('codex');
|
||||
expect(fromLive?.resumeId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('dedupes the same sessionId across live + persisted into one item', () => {
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 's1', status: 'working', isWorking: true }],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Working/idle detection for an interactive Claude pane.
|
||||
* Working/idle detection for an interactive agent pane, Claude's and Codex's.
|
||||
*
|
||||
* The bug this pins: Claude redraws the composer (`❯`) about once a second all
|
||||
* the way through a turn, so the old "saw a ❯, wait 2s, call it idle" rule
|
||||
@@ -7,11 +7,17 @@
|
||||
* worker: `GET /api/sessions` reported `idle` for a session that had been
|
||||
* running for 17 minutes and was mid-tool-call.
|
||||
*
|
||||
* A second bug this pins: work detection read Claude's glyph and Claude's status line
|
||||
* for every CLI, so a Codex session reported itself idle through an entire turn. Each CLI
|
||||
* now names its own pair in `capabilities.workDetect`, and a CLI that names none reports
|
||||
* work exactly as before.
|
||||
*
|
||||
* The status-line fixtures below are verbatim captures from live panes
|
||||
* (`tmux -L codeman capture-pane -p`) on Claude Code 2.1.220.
|
||||
* (`tmux -L codeman capture-pane -p`) on Claude Code 2.1.220 and Codex CLI 0.152.1.
|
||||
*/
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import { getCli } from '../src/config/cli-registry/index.js';
|
||||
import { CLAUDE_WORKING_LINE_PATTERN } from '../src/utils/regex-patterns.js';
|
||||
import {
|
||||
trackActivityStreak,
|
||||
@@ -38,7 +44,7 @@ function feed(session: Session, data: string): void {
|
||||
* A session whose mux reports a fixed (or scripted) screen, so the pane probe has
|
||||
* something to read. Only `capturePaneText` is exercised by these paths.
|
||||
*/
|
||||
function withFakePane(screen: string | (() => string)): Session {
|
||||
function withFakePane(screen: string | (() => string), mode: 'claude' | 'codex' = 'claude'): Session {
|
||||
const read = typeof screen === 'function' ? screen : () => screen;
|
||||
const mux = {
|
||||
isAvailable: () => true,
|
||||
@@ -46,12 +52,26 @@ function withFakePane(screen: string | (() => string)): Session {
|
||||
} as unknown as NonNullable<Parameters<typeof Session.prototype.constructor>[0]>['mux'];
|
||||
return new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude',
|
||||
mode,
|
||||
mux,
|
||||
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
|
||||
} as ConstructorParameters<typeof Session>[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex's pane, verbatim, while a turn runs and once it has finished. Codex draws `›` on
|
||||
* its composer row through the whole turn, exactly as Claude draws `❯`, and prints
|
||||
* `esc to interrupt` only while the turn is live.
|
||||
*/
|
||||
const CODEX_WORKING =
|
||||
'Working (2m 49s • esc to interrupt)\n› Ask Codex to do anything\n' +
|
||||
' gpt-5.6-sol high · Context 59% left · ~/innovi/irisplus-ent-2 · main\n';
|
||||
const CODEX_FINISHED =
|
||||
'─ Worked for 3m 47s ────────────────────\n› Ask Codex to do anything\n' +
|
||||
' gpt-5.6-sol high · Context 57% left · ~/innovi/irisplus-ent-2 · main\n';
|
||||
/** Codex's own composer repaint, the frame that arms the idle confirmation. */
|
||||
const CODEX_COMPOSER_REPAINT = '\x1b[31;1H\x1b[38;5;246m›\xa0\x1b[39m\x1b[0m';
|
||||
|
||||
/** A composer repaint: the frame Claude ships roughly once a second while working. */
|
||||
const COMPOSER_REPAINT =
|
||||
'\x1b[31;1H\x1b[38;5;246m❯\xa0\x1b[39m\x1b[0m\x1b[33;1H \x1b[38;5;246mOpus 5 in:143,699 out:669 ctx:14%\x1b[39m';
|
||||
@@ -230,11 +250,13 @@ describe('Session interactive idle detection', () => {
|
||||
expect(session.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('does not mark an external CLI pane working off raw activity', () => {
|
||||
it('does not mark an uncharacterised CLI working off raw activity', () => {
|
||||
vi.useFakeTimers();
|
||||
// Codex/Gemini/OpenCode render their own TUIs and have no ❯, so nothing would
|
||||
// arm the idle confirmation, so a session marked working here would never recover.
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
|
||||
// Gemini and OpenCode render their own TUIs, and Codeman knows neither one's glyph,
|
||||
// so nothing would arm the idle confirmation and a session marked working here would
|
||||
// never recover. A CLI that names no glyph therefore reports no work at all.
|
||||
expect(getCli('gemini')?.capabilities.workDetect).toBeUndefined();
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'gemini' });
|
||||
const events: string[] = [];
|
||||
session.on('working', () => events.push('working'));
|
||||
|
||||
@@ -245,6 +267,59 @@ describe('Session interactive idle detection', () => {
|
||||
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it('marks a Codex pane working, and lets the turn end', () => {
|
||||
vi.useFakeTimers();
|
||||
let screen = CODEX_WORKING;
|
||||
const session = withFakePane(() => screen, 'codex');
|
||||
const events: string[] = [];
|
||||
session.on('working', () => events.push('working'));
|
||||
session.on('idle', () => events.push('idle'));
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
feed(session, CODEX_COMPOSER_REPAINT);
|
||||
vi.advanceTimersByTime(1000);
|
||||
}
|
||||
vi.advanceTimersByTime(20_000);
|
||||
|
||||
// The old code reported this session idle for the whole turn.
|
||||
expect(events).toEqual(['working']);
|
||||
expect(session.status).toBe('busy');
|
||||
|
||||
// Turn over: the working footer gives way to the finished line, which must NOT
|
||||
// read as work — it sits on screen for the whole idle period afterwards.
|
||||
screen = CODEX_FINISHED;
|
||||
vi.advanceTimersByTime(20_000);
|
||||
|
||||
expect(events).toEqual(['working', 'idle']);
|
||||
expect(session.status).toBe('idle');
|
||||
});
|
||||
});
|
||||
|
||||
describe("codex's work-detection descriptor", () => {
|
||||
const codex = getCli('codex')?.capabilities.workDetect;
|
||||
|
||||
it('matches the footer Codex prints while a turn runs', () => {
|
||||
expect(new RegExp(codex!.workingLine).test(CODEX_WORKING)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match the finished line, nor the idle footer', () => {
|
||||
expect(new RegExp(codex!.workingLine).test(CODEX_FINISHED)).toBe(false);
|
||||
});
|
||||
|
||||
it('matches the footer case-insensitively on the E', () => {
|
||||
// Characterised on codex-cli 0.152.1, which prints a lowercase `esc`. A future
|
||||
// version capitalising it would otherwise make the whole fix silently inert:
|
||||
// the pane would simply never look like it was working.
|
||||
expect(new RegExp(codex!.workingLine).test(CODEX_WORKING.replace('esc to interrupt', 'Esc to interrupt'))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('names the glyph Codex actually draws on its composer row', () => {
|
||||
expect(CODEX_COMPOSER_REPAINT).toContain(codex!.promptGlyph);
|
||||
expect(CODEX_WORKING).toContain(codex!.promptGlyph);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wire activity stamp across recovery', () => {
|
||||
|
||||
@@ -100,9 +100,11 @@ describe('Session claude conversation chain', () => {
|
||||
// before this existed. Every assignment built from the launch-id fallback
|
||||
// must therefore carry `restoredConversation` first.
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/session.ts'), 'utf8');
|
||||
const fallbackAssignments = source.match(
|
||||
/_claudeSessionId =\s*\n?\s*[^;]*?_resumeSessionId \|\| this\._ompConfig\?\.resumeSessionId \|\| this\.id;/g
|
||||
);
|
||||
// The tail of the chain grows as each CLI gains a resume alias of its own
|
||||
// (omp, then codex), so the pattern pins the two ends and lets the middle
|
||||
// widen. A `[^;]` run cannot cross a statement boundary, so each match is
|
||||
// still one assignment.
|
||||
const fallbackAssignments = source.match(/_claudeSessionId =[^;]*?_resumeSessionId[^;]*?this\.id;/g);
|
||||
expect(fallbackAssignments).not.toBeNull();
|
||||
expect(fallbackAssignments!.length).toBeGreaterThanOrEqual(2);
|
||||
for (const assignment of fallbackAssignments!) {
|
||||
|
||||
Reference in New Issue
Block a user