mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 05:29:42 +02:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6f428e687 | ||
|
|
19aabe34d2 | ||
|
|
869a507482 | ||
|
|
854bcb99aa | ||
|
|
9ee6bf113b | ||
|
|
66d4c483c7 | ||
|
|
ff13234b3d | ||
|
|
0af80b417c | ||
|
|
52d113ab12 | ||
|
|
74662dd788 |
@@ -0,0 +1,80 @@
|
|||||||
|
# Contributing to Codeman
|
||||||
|
|
||||||
|
Thanks for wanting to help! Codeman is a small project with a fast loop: issues usually get a response within a day, good PRs get reviewed quickly, and every release credits its contributors and bug reporters by name in the release notes. This guide gets you from clone to merged PR without stepping on the traps.
|
||||||
|
|
||||||
|
## The short version
|
||||||
|
|
||||||
|
1. **Bugs**: open an issue with your OS, install method (installer / npm / git clone), browser, and which CLI + version the session was running.
|
||||||
|
2. **Questions and ideas**: use [Discussions](https://github.com/Ark0N/Codeman/discussions), not issues.
|
||||||
|
3. **Small fixes** (docs, typos, a new skin, a translation): just send the PR.
|
||||||
|
4. **Anything bigger**: open an issue or Discussion first and get a nod before building. Codeman has strong architectural invariants, and a design chat up front is what turns a big idea into a merged PR instead of a stalled one. This flow works: features like Clone Repo (#236) went idea, then design discussion, then review, then shipped.
|
||||||
|
5. **Security issues**: never a public issue. See [SECURITY.md](SECURITY.md).
|
||||||
|
|
||||||
|
## Dev setup
|
||||||
|
|
||||||
|
Requirements: Node.js 22+ (see `.nvmrc`), tmux, and at least one supported agent CLI on your PATH (Claude Code is the primary one).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Ark0N/Codeman.git
|
||||||
|
cd Codeman
|
||||||
|
npm install # postinstall builds the vendored xterm addon bundles
|
||||||
|
npm run dev # dev server on http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
The frontend is plain JS served from `src/web/public/` with no bundler in dev: edit a `.js`/`.css` file and reload the page. The one exception is `index.html`, which is read once at server start, so markup changes need a server restart.
|
||||||
|
|
||||||
|
## Before you push
|
||||||
|
|
||||||
|
CI runs all of these, so save yourself a round trip:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck # tsc --noEmit, strict mode
|
||||||
|
npm run lint
|
||||||
|
npm run format:check
|
||||||
|
npm run check:frontend-syntax # syntax-checks the plain-JS frontend modules
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- test/<file>.test.ts # one file (the normal way)
|
||||||
|
npm run test:ci # the full CI sweep
|
||||||
|
```
|
||||||
|
|
||||||
|
**Never run bare `npm test`.** The default config includes browser-driven Playwright suites that need a live server, Chromium, and environment-specific baselines; they will hang or fail on a normal machine. `test:ci` is the honest "run everything" command, it is exactly what CI runs.
|
||||||
|
|
||||||
|
If you add a test that binds a port, pick a unique one at 3150 or above (search the repo for `const PORT =` first). Never 3000.
|
||||||
|
|
||||||
|
Tests are tmux-safe by design: under vitest, the tmux layer becomes an in-memory mock, so tests cannot touch real sessions.
|
||||||
|
|
||||||
|
## Finding your way around
|
||||||
|
|
||||||
|
- Every source file starts with a `@fileoverview` JSDoc block. Read it before diving into the file, it is the map.
|
||||||
|
- [`CLAUDE.md`](../CLAUDE.md) at the repo root is the densest architecture primer in the repo. It is written for AI coding agents, but the invariants and gotchas in it apply to humans exactly the same, and most review feedback on PRs traces back to something already written there.
|
||||||
|
- Deep mechanisms and the history behind each rule live in [`docs/architecture-invariants.md`](../docs/architecture-invariants.md).
|
||||||
|
- Third-party extension surfaces are documented in [`docs/extending-codeman.md`](../docs/extending-codeman.md).
|
||||||
|
|
||||||
|
## Great first contributions
|
||||||
|
|
||||||
|
These are well-fenced areas where a first PR is genuinely easy to get right:
|
||||||
|
|
||||||
|
- **A new theme skin.** A skin is four things kept in sync: the `html[data-skin="…"]` token block in `styles.css`, the xterm ANSI palette in `terminal-ui.js`, the pre-paint allowlist and the settings picker (both in `index.html`). `test/skin-themes.test.ts` statically checks the sync, so if the test passes, your skin works.
|
||||||
|
- **A new language.** `src/web/public/i18n.js` is dependency-free, English is the canonical source, and `zh-CN` is a complete example to copy. Add your language's entries and register it in `SUPPORTED_LANGUAGES`.
|
||||||
|
- **Docs.** If you got stuck on something and then figured it out, the sentence that would have unstuck you is a PR.
|
||||||
|
- Anything labeled [`good first issue`](https://github.com/Ark0N/Codeman/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
|
||||||
|
|
||||||
|
Bigger extension points worth discussing first: new CLI backends (the pluggable resolver pattern has absorbed six CLIs so far; `docs/extending-codeman.md` and `docs/opencode-integration.md` show the shape), and real-device testing reports, especially mobile, which always find things emulation cannot.
|
||||||
|
|
||||||
|
## PR expectations
|
||||||
|
|
||||||
|
- **One change per PR.** Small and focused reviews fast; a grab-bag stalls.
|
||||||
|
- Target the `master` branch.
|
||||||
|
- **Keep your branch mergeable.** A PR with conflicts silently gets no CI runs at all (GitHub quirk), so rebase or merge master when conflicts appear.
|
||||||
|
- Include or update tests when you change behavior. Route handlers have a lightweight pattern in `test/routes/` using `app.inject()` (no live server needed).
|
||||||
|
- Formatting is Prettier with a deliberately narrow scope (`npm run format`), several frontend files are hand-formatted on purpose and excluded via `.prettierignore`. Don't "fix" a file by adding it back into Prettier's scope.
|
||||||
|
- Don't bump versions or touch `CHANGELOG.md`; releases are handled by the maintainer via changesets after merge.
|
||||||
|
- AI-assisted contributions are welcome (much of Codeman is built that way), with one condition: you must understand what you're submitting and have actually run it. "The model said it works" is not a test.
|
||||||
|
|
||||||
|
## Conduct
|
||||||
|
|
||||||
|
Be kind, be direct, assume good faith. Report unacceptable behavior privately via the contact in [SECURITY.md](SECURITY.md).
|
||||||
@@ -1,5 +1,23 @@
|
|||||||
# aicodeman
|
# aicodeman
|
||||||
|
|
||||||
|
## 1.18.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Faster agent-skill workers, retuned multi-color lineage arcs, a per-tab pop-out option, reliable tab alerts, and the community launch.
|
||||||
|
- Agent skill: SKILL.md now forbids the standalone preamble check and the pre-spawn reconnaissance turns that were costing whole model turns; the same two-worker spawn measured at 28.6s end to end now runs 20.2s cold and 12.8s warm, with the spawn machinery itself unchanged.
|
||||||
|
- Session lineage lines: arcs now hang from the tab strip's bottom edge (dip cap 104px to 64px, no stacked row offsets), fixing the deep bow on wrapped tab strips and keeping same-row arcs off the second row's tab labels; each spawned worker's arc gets its own color (skin blue first, then matrix green, pink, violet, red, turquoise, orange), assigned per child and stable across re-renders.
|
||||||
|
- Session Options > Session: new "Pop-out button on this tab" per-tab override on top of the general App Settings toggle (per-device).
|
||||||
|
- Tab alerts: pending permission/question alerts now survive page reloads regardless of the Approvals Inbox setting (the alert state machine seeds from the server-side approval store on every load), stay visible on the selected tab until the prompt is actually resolved (the alert paints on a ::before overlay the active tab's styling cannot bury), and render as a steady red/yellow ring with glow and a colored status dot instead of a blink that spent half of every cycle looking like a normal tab. The README carries a live capture of the new alerts.
|
||||||
|
- Community launch: README Community section, .github/CONTRIBUTING.md (dev setup, test safety, great first contributions, PR expectations), and GitHub Discussions.
|
||||||
|
- docs: worker warm-pool design sketch with the measured baselines.
|
||||||
|
|
||||||
|
## 1.18.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Fix skill-spawned workers losing their lineage arcs and spawning slowly: a stale user-level agent skill copy (`~/.claude/skills/codeman`, written once by `codeman skill install`) shadowed the fresh per-case injections, so agents ran old recipes (serial spawns with pid polls, no `X-Codeman-Parent-Session` header). Session create now refreshes a marker-owned user-level copy (refresh-only, never installs, foreign/symlink copies untouched) and pre-seeds the skill's preamble into `${XDG_CACHE_HOME:-~/.cache}/codeman-agent-<id>.sh` (0600, local claude sessions only), single-sourced from the new `skills/codeman/preamble.sh` and pinned byte-identical to the SKILL.md heredoc by test. The skill's bootstrap is now a two-line loader with the full block as fallback, cutting measured prompt-to-workers-spawned time from 35s to 10.6s; `spawn_worker` also sends `parentSessionId` in the request body as defense in depth, and the preamble stamp is bumped to 1.18.3 so pre-fix cached preambles self-heal.
|
||||||
|
|
||||||
## 1.18.2
|
## 1.18.2
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ When user says "COM":
|
|||||||
|
|
||||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||||
|
|
||||||
**Version**: 1.18.2 (must match `package.json`)
|
**Version**: 1.18.4 (must match `package.json`)
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Input**: `session.writeViaMux()` for programmatic/curl input via tmux `send-keys -l` + `send-keys Enter`, single-line only. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience)
|
**Input**: `session.writeViaMux()` for programmatic/curl input via tmux `send-keys -l` + `send-keys Enter`, single-line only. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience)
|
||||||
|
|
||||||
**Agent wait primitives**: bounded long-polls so an agent driving Codeman from a shell can block instead of poll: `GET /api/sessions/:id/wait` (lifecycle signal), `GET /api/sessions/:id/wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST /api/sessions/:id/input`. Registry in `session-wait-registry.ts` (pure, no `Session` reference), bounds in `config/agent-wait.ts`. ⚠️ **A timeout is a 200** (`wait.timedOut`), never an error, so callers loop over short waits. ⚠️ `stop`/`blocked` come from Claude Code hooks and therefore fire for **`claude` mode ONLY** (`shell` installs none either); asking for one explicitly on another mode is a 400, the default set silently drops them. ⚠️ Send-and-wait registers the waiter BEFORE the write (a separate POST-then-wait races and reports the PREVIOUS turn), and both teardown paths must `notifySignal('exit')` BEFORE `cancelAll()`. ⚠️ Client-hangup abort listens on **`reply.raw`** guarded by `writableFinished`: on `req.raw`, `close` fires when the request BODY ends, which on a POST killed every send-and-wait instantly and no `app.inject()` test could see it. ⚠️ Worker liveness cannot come from `session.pid` — for a tmux session that is the local attach client, which outlives a worker dying inside its pane — so it is probed at the mux layer (`isPaneDead`, ~750 ms cache) on blocking waits only, never on the input hot path. ⚠️ Signals are edge-triggered with no history: one that fires with no waiter registered is unobservable afterwards, so gather fan-outs with send-and-wait or latched `wait-output` markers, never fire-and-forget-then-sequential-signal-waits. The primitives are packaged as the **`skills/codeman` agent skill**: installable via `codeman skill install [--case <name>]` / `skill uninstall`, or auto-injected into a case's `.claude/skills/` on Claude session create behind `agentSkillEnabled` (SYNCED, default OFF). Injection is ADD-ONLY at create, marker-owned (`applyAgentSkill` in `hooks-config.ts` never touches an unmarked user copy) and refuses symlinks (this repo's own `.claude/skills/codeman` is a symlink to the source, which the injector must never write through). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
|
**Agent wait primitives**: bounded long-polls so an agent driving Codeman from a shell can block instead of poll: `GET /api/sessions/:id/wait` (lifecycle signal), `GET /api/sessions/:id/wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST /api/sessions/:id/input`. Registry in `session-wait-registry.ts` (pure, no `Session` reference), bounds in `config/agent-wait.ts`. ⚠️ **A timeout is a 200** (`wait.timedOut`), never an error, so callers loop over short waits. ⚠️ `stop`/`blocked` come from Claude Code hooks and therefore fire for **`claude` mode ONLY** (`shell` installs none either); asking for one explicitly on another mode is a 400, the default set silently drops them. ⚠️ Send-and-wait registers the waiter BEFORE the write (a separate POST-then-wait races and reports the PREVIOUS turn), and both teardown paths must `notifySignal('exit')` BEFORE `cancelAll()`. ⚠️ Client-hangup abort listens on **`reply.raw`** guarded by `writableFinished`: on `req.raw`, `close` fires when the request BODY ends, which on a POST killed every send-and-wait instantly and no `app.inject()` test could see it. ⚠️ Worker liveness cannot come from `session.pid` — for a tmux session that is the local attach client, which outlives a worker dying inside its pane — so it is probed at the mux layer (`isPaneDead`, ~750 ms cache) on blocking waits only, never on the input hot path. ⚠️ Signals are edge-triggered with no history: one that fires with no waiter registered is unobservable afterwards, so gather fan-outs with send-and-wait or latched `wait-output` markers, never fire-and-forget-then-sequential-signal-waits. The primitives are packaged as the **`skills/codeman` agent skill**: installable via `codeman skill install [--case <name>]` / `skill uninstall`, or auto-injected into a case's `.claude/skills/` on Claude session create behind `agentSkillEnabled` (SYNCED, default OFF). Injection is ADD-ONLY at create, marker-owned (`applyAgentSkill` in `hooks-config.ts` never touches an unmarked user copy) and refuses symlinks (this repo's own `.claude/skills/codeman` is a symlink to the source, which the injector must never write through). ⚠️ Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`, written once by `codeman skill install` with no `--case`) over the per-case copy, and nothing used to refresh it: a stale Aug-9 user copy shadowed every fresh injection (2026-08-14: agents ran the old recipes, spawned workers serially and lost their lineage arcs), so session create now also refreshes a marker-owned user copy (`refreshUserAgentSkill`; refresh-only, never installs, foreign/symlink refused). Session create additionally pre-seeds the skill's §0 preamble cache (`seedAgentSessionPreamble` → `${XDG_CACHE_HOME:-~/.cache}/codeman-agent-<id>.sh`, local claude sessions only), single-sourced from `skills/codeman/preamble.sh` and pinned byte-identical to SKILL.md's §0 heredoc by `test/agent-skill.test.ts`, so the skill's bootstrap is a two-line loader instead of a ~150-line paste the model types out (~47 s of generation, measured live). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
|
||||||
|
|
||||||
**Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`.
|
**Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`.
|
||||||
|
|
||||||
@@ -204,13 +204,13 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w<n>-<case>` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
|
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w<n>-<case>` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
|
||||||
|
|
||||||
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name the session that spawned it, as a `parentSessionId` body field on `POST /api/sessions` / `POST /api/quick-start` or the `X-Codeman-Parent-Session` header (the agent skill sets that once on its shared curl invocation, so every spawn recipe carries it). `resolveParentSessionId()` (route-helpers.ts) **resolves rather than trusts** it: exact id, else a UNIQUE ≥8-char prefix (ids reach agents truncated), it must be a live session the caller can see AND carry the same owner, and **anything unresolvable is DROPPED, never a 400** — a cosmetic field must not be able to fail a worker spawn. It rides `toState()` into `session_created`, so there is no new SSE event. ⚠️ Rendering is an ADDITIONAL LAYER on the existing SVG pass (`_appendLineageConnectionLines` called at the tail of `_updateConnectionLinesImmediate()`, exactly like ultracode), sharing one batched read→write reflow and the `tab:<id>` rect cache; geometry is pure in `computeLineagePath()` (constants.js). ⚠️ **ONE shape, and the second one was the bug**: every pair (flat strip or wrapped) gets a U-bridge hanging below the strip, anchored on both tabs' BOTTOM edges. A wrapped strip used to get a parent-bottom → child-TOP bezier with a ~14px row gap to bend in, which drew a flat line hidden in the gap with siblings overprinting; the dip is also clamped at 104px rather than 44, since a skill worker lands at the END of the strip where the old cap flattened the arc into a straight thread. ⚠️ **Desktop only**: the overlay is `z-index: 999` and the desktop header is 100 (arcs paint over it, which is what lets them touch tab bottoms), but under 1024px mobile.css makes the header `fixed; z-index: 1200` and would bury them. ⚠️ Paths carry `data-agent-id="lineage:<childId>"` because that is what `_applyLineEntrances()` queries — that one attribute is what gives them the entrance animation and its negative-`animation-delay` resume across `svg.innerHTML=''`. ⚠️ `.session-tabs` is `overflow-x: auto`, so a scrolled-out tab still HAS a rect (over the logo); edges with an endpoint outside the strip are skipped, and a passive `scroll` listener re-anchors the rest.
|
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name the session that spawned it, as a `parentSessionId` body field on `POST /api/sessions` / `POST /api/quick-start` or the `X-Codeman-Parent-Session` header (the agent skill sets that once on its shared curl invocation, so every spawn recipe carries it). `resolveParentSessionId()` (route-helpers.ts) **resolves rather than trusts** it: exact id, else a UNIQUE ≥8-char prefix (ids reach agents truncated), it must be a live session the caller can see AND carry the same owner, and **anything unresolvable is DROPPED, never a 400** — a cosmetic field must not be able to fail a worker spawn. It rides `toState()` into `session_created`, so there is no new SSE event. ⚠️ Rendering is an ADDITIONAL LAYER on the existing SVG pass (`_appendLineageConnectionLines` called at the tail of `_updateConnectionLinesImmediate()`, exactly like ultracode), sharing one batched read→write reflow and the `tab:<id>` rect cache; geometry is pure in `computeLineagePath()` (constants.js). ⚠️ **ONE shape, and the second one was the bug**: every pair (flat strip or wrapped) gets a U-bridge hanging below the strip, anchored on both tabs' BOTTOM edges. A wrapped strip used to get a parent-bottom → child-TOP bezier with a ~14px row gap to bend in, which drew a flat line hidden in the gap with siblings overprinting. ⚠️ The dip is a **mis-tuned-in-both-directions corridor** (44px cap = straight thread at strip-wide spans, #285; 104px cap + full row offset = ~106px over-bow into the terminal, 2026-08-15): it now hangs from the **STRIP's bottom edge** (fallback: lower tab bottom), capped at 64px, with NO per-row offsets stacked on top — the strip-bottom baseline is also what keeps a row-1 pair's arc from drawing through row 2's tab labels. Colors cycle per CHILD in first-seen order from `CodemanLineage.COLORS` (first entry empty = the skin-tuned `--session-blue`; the rest vivid fixed hexes), set inline as `--lineage-color` so styles.css keeps owning opacity/glow/dash. ⚠️ **Desktop only**: the overlay is `z-index: 999` and the desktop header is 100 (arcs paint over it, which is what lets them touch tab bottoms), but under 1024px mobile.css makes the header `fixed; z-index: 1200` and would bury them. ⚠️ Paths carry `data-agent-id="lineage:<childId>"` because that is what `_applyLineEntrances()` queries — that one attribute is what gives them the entrance animation and its negative-`animation-delay` resume across `svg.innerHTML=''`. ⚠️ `.session-tabs` is `overflow-x: auto`, so a scrolled-out tab still HAS a rect (over the logo); edges with an endpoint outside the strip are skipped, and a passive `scroll` listener re-anchors the rest.
|
||||||
|
|
||||||
**Unified session list**: `GET /api/sessions/unified` 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 fold into their owning session via a `claudeSessionId → Codeman id` alias map, so resumed and `/clear`-respawned sessions do not appear twice. No terminal buffers in the response, unlike `/api/sessions`. Backs the Cmd+K Session Manager, plus pinning and cross-device tab order (`PUT /api/session-order`; pure merge helpers in `src/session-order.ts`, pushing device wins and server-only ids are never dropped). → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
|
**Unified session list**: `GET /api/sessions/unified` 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 fold into their owning session via a `claudeSessionId → Codeman id` alias map, so resumed and `/clear`-respawned sessions do not appear twice. No terminal buffers in the response, unlike `/api/sessions`. Backs the Cmd+K Session Manager, plus pinning and cross-device tab order (`PUT /api/session-order`; pure merge helpers in `src/session-order.ts`, pushing device wins and server-only ids are never dropped). → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
|
||||||
|
|
||||||
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`.
|
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`.
|
||||||
|
|
||||||
**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), claude-mode only, in-memory. Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). ⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409). ⚠️ Resolution on the heuristic `working` signal is restricted to `idle` items; permission/question items clear only on definitive signals (`stop`, `elicitation_complete`/`elicitation_response`, exit/delete, answer, supersede, 12h TTL). The frontend seeds from `GET /api/approvals` in `handleInit` (which is what makes tab alerts survive reloads), but only with the setting ON; push Approve/Deny buttons are also gated on it (`sendPushNotifications` strips `actions`/`approvalId` when OFF) and are answered from `sw.js` directly so they work with no tab open. Surfaces (all gated on the setting): header bell (marker-hidden until count > 0, phones never show it) + drawer (`approvals-ui.js`), phone overview NEEDS YOU answer strips (`mobile-overview.js`). Design: `docs/approvals-inbox-plan.md`.
|
**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), claude-mode only, in-memory. Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). ⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409). ⚠️ Resolution on the heuristic `working` signal is restricted to `idle` items; permission/question items clear only on definitive signals (`stop`, `elicitation_complete`/`elicitation_response`, exit/delete, answer, supersede, 12h TTL). The frontend seeds from `GET /api/approvals` in `handleInit` **regardless of the setting**: the seed re-arms the tab-alert state machine (`setPendingHook`) unconditionally, and only populating `this.approvals` (the inbox surfaces) is gated — seeding used to be gated wholesale, which left a reloaded page with NO red tab while a permission dialog sat blocking a session (2026-08-15); `_onApprovalResolved` clears the pending-hook alert unconditionally for the same reason. ⚠️ The red/yellow tab alert itself is a STEADY border/background/dot with a pulse on top: the original keyframes swung to transparent at 0%/100%, so half of every cycle looked like a normal tab. Push Approve/Deny buttons stay gated on the setting (`sendPushNotifications` strips `actions`/`approvalId` when OFF) and are answered from `sw.js` directly so they work with no tab open. Surfaces (all gated on the setting): header bell (marker-hidden until count > 0, phones never show it) + drawer (`approvals-ui.js`), phone overview NEEDS YOU answer strips (`mobile-overview.js`). Design: `docs/approvals-inbox-plan.md`.
|
||||||
|
|
||||||
**Read My Mind intent profiles** (phase 1 of `docs/readmymind-plan.md`; `readMyMindEnabled`, SYNCED, default OFF): per-CASE profiles (user-stated `goals` + the user's recent real prompts), keyed by owner + realpath(workingDir) so they survive `/clear`/respawns and multi-user scoping is structural. Capture rides the transcript (`transcript:user_prompt` from `transcript-watcher.ts`), NOT the input paths: `POST /input` sees only programmatic prompts and the WS channel is raw keystrokes. The listener lives inside `startTranscriptWatcher()`'s `if (!watcher)` block (outside it would duplicate per hook event) and is claude-only + gated on the setting per event. Store: `src/intent-store.ts` singleton, `intents.json` written 0600 tmp+rename (prompts can contain secrets; never fed to `/api/search`). Endpoints: GET/PUT/DELETE `/api/sessions/:id/intent` + POST `/api/sessions/:id/readmymind` (`readmymind-routes.ts`, ownership via `findSessionOrFail` WITH `req`; registrations stay the bare `app.<method>('path')` shape, the endpoints.md drift scanner cannot see generics). **Phase 2 (predictor + 🧠 button)**: `readmymind-context.ts` is the PURE budgeted assembler (9 ranked sources, drop order siblings→away→workspace→tools, sections 1-4 truncate only); IO lives in `readmymind-collectors.ts` (transcript TAIL read — the live watcher keeps only a 500-char snippet — + git signals, skipped for remote-SSH cases) and the route; `readmymind-predictor.ts` reuses the AiCheckerBase spawn mechanics standalone (verdict-shaped base vs freeform JSON) as a mutable singleton routes call and tests stub. Claude-mode only (400), one in flight per session (409 CONFLICT), model = `readMyMindModel` setting defaulting to `AI_CHECK_MODEL` (opus, decided). Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`) until the setting is ON; phones hide it in mobile.css and get a keyboard-accessory 🧠 key instead (ships in BOTH bar templates, revealed by the `rmm-enabled` class on the BAR element — setMode() rebuilds button innerHTML, so per-key state would be wiped; synced at init + every `applyHeaderVisibilitySettings()`). Alternate suggestions render as tappable rows that swap into the editable field without losing edits; Rethink rejects the whole shown set and carries the optional steer note (`#readMyMindSteer`, sent as `steer`, shown in ready + empty-result phases, cleared on each open). Suggestions render via value/`textContent` ONLY and Send/Insert go through `POST /input` (server-side, so the sendEnterKey/local-echo trap does not apply) — nothing auto-sends, ever. User guide: `docs/readmymind.md`.
|
**Read My Mind intent profiles** (phase 1 of `docs/readmymind-plan.md`; `readMyMindEnabled`, SYNCED, default OFF): per-CASE profiles (user-stated `goals` + the user's recent real prompts), keyed by owner + realpath(workingDir) so they survive `/clear`/respawns and multi-user scoping is structural. Capture rides the transcript (`transcript:user_prompt` from `transcript-watcher.ts`), NOT the input paths: `POST /input` sees only programmatic prompts and the WS channel is raw keystrokes. The listener lives inside `startTranscriptWatcher()`'s `if (!watcher)` block (outside it would duplicate per hook event) and is claude-only + gated on the setting per event. Store: `src/intent-store.ts` singleton, `intents.json` written 0600 tmp+rename (prompts can contain secrets; never fed to `/api/search`). Endpoints: GET/PUT/DELETE `/api/sessions/:id/intent` + POST `/api/sessions/:id/readmymind` (`readmymind-routes.ts`, ownership via `findSessionOrFail` WITH `req`; registrations stay the bare `app.<method>('path')` shape, the endpoints.md drift scanner cannot see generics). **Phase 2 (predictor + 🧠 button)**: `readmymind-context.ts` is the PURE budgeted assembler (9 ranked sources, drop order siblings→away→workspace→tools, sections 1-4 truncate only); IO lives in `readmymind-collectors.ts` (transcript TAIL read — the live watcher keeps only a 500-char snippet — + git signals, skipped for remote-SSH cases) and the route; `readmymind-predictor.ts` reuses the AiCheckerBase spawn mechanics standalone (verdict-shaped base vs freeform JSON) as a mutable singleton routes call and tests stub. Claude-mode only (400), one in flight per session (409 CONFLICT), model = `readMyMindModel` setting defaulting to `AI_CHECK_MODEL` (opus, decided). Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`) until the setting is ON; phones hide it in mobile.css and get a keyboard-accessory 🧠 key instead (ships in BOTH bar templates, revealed by the `rmm-enabled` class on the BAR element — setMode() rebuilds button innerHTML, so per-key state would be wiped; synced at init + every `applyHeaderVisibilitySettings()`). Alternate suggestions render as tappable rows that swap into the editable field without losing edits; Rethink rejects the whole shown set and carries the optional steer note (`#readMyMindSteer`, sent as `steer`, shown in ready + empty-result phases, cleared on each open). Suggestions render via value/`textContent` ONLY and Send/Insert go through `POST /input` (server-side, so the sendEnterKey/local-echo trap does not apply) — nothing auto-sends, ever. User guide: `docs/readmymind.md`.
|
||||||
|
|
||||||
@@ -262,7 +262,9 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
|||||||
|
|
||||||
**Phone overview home screen** (`mobile-overview.js`, phones only, per-device `mobileOverviewEnabled`, default ON): under 430px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in `showWelcome()`/`hideWelcome()` (terminal-ui.js) behind `shouldUseMobileOverview()`, which is **width-driven** (`getDeviceType() === 'mobile'`) because this is a layout decision, unlike the settings namespace which stays handheld-based. ⚠️ The container ships with the `hidden` attribute and only this module removes it: never give `.mobile-overview` a bare `display` rule, since desktop does not load `mobile.css` (`media="(max-width: 1023px)"`) and would then render it unstyled. Live re-renders ride on the tail of `_renderSessionTabsImmediate()` (every state change it needs already funnels there); PAST rows come from one `_fetchUnifiedSessions(60)` per home-screen visit and resume through the shared `resumeHistorySession()`, so they behave exactly like the welcome screen's Resume list. ⚠️ Two things must stay in lockstep with surfaces outside this module, because divergence reads as a bug rather than a style: the split Run button carries the **toolbar's own classes** (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) so the per-backend gradient and the light-skin overrides apply unchanged (mobile.css must therefore set no `background`/`color` on it), and row status uses the **session-tab language** (green dot when fine, `pulse` while working, yellow blinking row when waiting for input, red blinking row when a question is pending, mirroring `tab-alert-idle`/`tab-alert-action`). The picker mirrors the toolbar run-mode menu (`setRunMode()` + `run()`, `openWebviewFromMenu()` for saved dashboards) and deliberately omits its Recent-Sessions block, since PAST SESSIONS is that. Status pills carry `data-i18n-skip` (generic words like "idle" collide with state strings elsewhere).
|
**Phone overview home screen** (`mobile-overview.js`, phones only, per-device `mobileOverviewEnabled`, default ON): under 430px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in `showWelcome()`/`hideWelcome()` (terminal-ui.js) behind `shouldUseMobileOverview()`, which is **width-driven** (`getDeviceType() === 'mobile'`) because this is a layout decision, unlike the settings namespace which stays handheld-based. ⚠️ The container ships with the `hidden` attribute and only this module removes it: never give `.mobile-overview` a bare `display` rule, since desktop does not load `mobile.css` (`media="(max-width: 1023px)"`) and would then render it unstyled. Live re-renders ride on the tail of `_renderSessionTabsImmediate()` (every state change it needs already funnels there); PAST rows come from one `_fetchUnifiedSessions(60)` per home-screen visit and resume through the shared `resumeHistorySession()`, so they behave exactly like the welcome screen's Resume list. ⚠️ Two things must stay in lockstep with surfaces outside this module, because divergence reads as a bug rather than a style: the split Run button carries the **toolbar's own classes** (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) so the per-backend gradient and the light-skin overrides apply unchanged (mobile.css must therefore set no `background`/`color` on it), and row status uses the **session-tab language** (green dot when fine, `pulse` while working, yellow blinking row when waiting for input, red blinking row when a question is pending, mirroring `tab-alert-idle`/`tab-alert-action`). The picker mirrors the toolbar run-mode menu (`setRunMode()` + `run()`, `openWebviewFromMenu()` for saved dashboards) and deliberately omits its Recent-Sessions block, since PAST SESSIONS is that. Status pills carry `data-i18n-skip` (generic words like "idle" collide with state strings elsewhere).
|
||||||
|
|
||||||
**Desktop home tab rail** (`home-sessions.js`, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it carries the open tabs as a rail **docked flush to the left edge, full height** (a vertically centered card floating mid-gutter read as debris). Rows are in **tab order**, not sorted by urgency like the phone overview, because the row badges are the Alt+1..9 indices, and each carries **created / last-active** stamps. State classification is REUSED from mobile-overview.js (`_mobileOverviewState`/`_mobileOverviewCaseFor`), which is why the module loads after it. ⚠️ The rail is `position: absolute` so the centered content never moves, which is exactly why it needs a **width gate in two places** — `HOME_SESSIONS_MIN_WIDTH` (1180) in the JS plus a `max-width: 1179px` media query as the backstop for a resize that outruns the matchMedia listener; drift between them means a rail overlapping the search panel, and `test/home-sessions.test.ts` pins them equal. ⚠️ `.home-sessions` is `display: flex`, so `[hidden]` must be re-asserted as `display: none` or the module's only visibility lever does nothing. ⚠️ Size scales with the viewport off **one knob**: `width: clamp(250px, 19vw, 430px)` plus a fluid `font-size` on `.home-sessions`, with every child sized in `em` — reintroducing `rem`/px type inside the block silently breaks the scaling, and widening the clamp past the gutter reintroduces the overlap the gate exists to prevent. The age stamps are refreshed **in place** by a 20s clock (`_tickHomeSessionsTimes()`, disarmed in `hideHomeSessions()`), never by re-rendering, which would restart every row's blink and working ring. Working state is deliberately byte-identical to the phone's: pulsing green dot + the `tab-load-spin` ring reused from the tab strip + the same green halo (added to `.mobile-overview-dot--working` at the same time), so "working" reads the same on every surface; **idle** is deliberately NOT that green — dot and pill mix toward `--text-muted` so a glance separates running from sitting. Live re-renders ride the tail of `_renderSessionTabsImmediate()` alongside the phone overview.
|
**Desktop home tab rail** (`home-sessions.js`, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it carries the open tabs as a rail **docked flush to the left edge, full height** (a vertically centered card floating mid-gutter read as debris). Rows are in **overview order** (see below), and each carries a **created** stamp plus the **state duration** the order is computed from (`created 3d ago · working 12m`, word and anchor from `_mobileOverviewSince()` so both home screens say the same thing). A rail sorted by a number it does not show reads as arbitrarily shuffled, and a working row's plain last-active stamp always says "just now". ⚠️ The number badge is the **Alt+1..9 index**, i.e. the position in the TAB STRIP, so on a sorted rail it deliberately does NOT run 1,2,3 downward: it names a shortcut, not a row position, and renumbering it to look tidy would make every badge lie. State classification is REUSED from mobile-overview.js (`_mobileOverviewState`/`_mobileOverviewCaseFor`), which is why the module loads after it. ⚠️ The rail is `position: absolute` so the centered content never moves, which is exactly why it needs a **width gate in two places** — `HOME_SESSIONS_MIN_WIDTH` (1180) in the JS plus a `max-width: 1179px` media query as the backstop for a resize that outruns the matchMedia listener; drift between them means a rail overlapping the search panel, and `test/home-sessions.test.ts` pins them equal. ⚠️ `.home-sessions` is `display: flex`, so `[hidden]` must be re-asserted as `display: none` or the module's only visibility lever does nothing. ⚠️ Size scales with the viewport off **one knob**: `width: clamp(250px, 19vw, 430px)` plus a fluid `font-size` on `.home-sessions`, with every child sized in `em` — reintroducing `rem`/px type inside the block silently breaks the scaling, and widening the clamp past the gutter reintroduces the overlap the gate exists to prevent. The age stamps are refreshed **in place** by a 20s clock (`_tickHomeSessionsTimes()`, disarmed in `hideHomeSessions()`), never by re-rendering, which would restart every row's blink and working ring. Working state is deliberately byte-identical to the phone's: pulsing green dot + the `tab-load-spin` ring reused from the tab strip + the same green halo (added to `.mobile-overview-dot--working` at the same time), so "working" reads the same on every surface; **idle** is deliberately NOT that green — dot and pill mix toward `--text-muted` so a glance separates running from sitting. Live re-renders ride the tail of `_renderSessionTabsImmediate()` alongside the phone overview.
|
||||||
|
|
||||||
|
**Home-screen session order** (`CodemanSessionOrder` in constants.js, pure + unit-tested in `test/session-overview-order.test.ts`): BOTH home screens (phone overview and desktop rail) order rows through this ONE comparator, because they list the same sessions and must answer "which of these wants me next?" the same way. Rank is `needs` → `error` → `waiting` → `working` → `idle` → `done`, and ⚠️ **the tiebreak flips direction halfway down**: states a session is still IN sort **oldest-first** (blocked longest / running longest = most urgent), states it has STOPPED in sort **newest-first** (the session that just went quiet is the one you came back for). ⚠️ The running group keys off **`lastSubmitAt`** (the pane's last Enter), never `lastActivityAt`: a working Claude pane repaints about once a second, so its last-activity stamp is always "now" and would rank every running turn as freshly started. A working pane with no submit stamp falls back to last activity, which lands it at the SHORT end of the group rather than falsely leading it. ⚠️ A **0 stamp means "unknown", not "the epoch"**, and it sorts last within its state either way, or a brand-new session would head every oldest-first group. Final tiebreak is the user's tab order (`orderIndex`), so the list is deterministic and cannot shuffle between renders. The tab strip itself is NOT sorted by this; it stays user-ordered and drag-reorderable.
|
||||||
|
|
||||||
**Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. ⚠️ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`), most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. ⚠️ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`.
|
**Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. ⚠️ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`), most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. ⚠️ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`.
|
||||||
|
|
||||||
|
|||||||
@@ -406,6 +406,14 @@ The title is templated into the served HTML on first byte, so it's correct from
|
|||||||
| **110k tokens** | Auto `/compact` | Context summarized, work continues |
|
| **110k tokens** | Auto `/compact` | Context summarized, work continues |
|
||||||
| **140k tokens** | Auto `/clear` | Fresh start with `/init` |
|
| **140k tokens** | Auto `/clear` | Fresh start with `/init` |
|
||||||
|
|
||||||
|
### Tab Alerts
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/images/tab-alerts-glow-20260815.gif" alt="Session tabs: a regular active tab beside a yellow waiting-for-input tab and a red needs-decision tab, both with a breathing glow" width="900">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Every tab tells you its state at a glance. A running session keeps its green status dot. When a session stops and waits for input, its tab turns **yellow**: steady ring, tinted background, yellow dot, with a slow breathing glow on top. When a permission prompt or question is **blocking** the agent, the tab turns **red** with a faster pulse. The base tint never blinks off, so even a split-second glance (or a screenshot) reads the true state; the ring stays visible while the tab is selected, and a page reload re-arms pending alerts from the server, so a blocked session can never hide behind a fresh-looking tab.
|
||||||
|
|
||||||
### Notifications
|
### Notifications
|
||||||
|
|
||||||
Real-time desktop alerts when sessions need attention — `permission_prompt` and `elicitation_dialog` trigger critical red tab blinks, `idle_prompt` triggers yellow blinks. Click any notification to jump directly to the affected session. Hooks auto-configured per case directory.
|
Real-time desktop alerts when sessions need attention — `permission_prompt` and `elicitation_dialog` trigger critical red tab blinks, `idle_prompt` triggers yellow blinks. Click any notification to jump directly to the affected session. Hooks auto-configured per case directory.
|
||||||
@@ -1035,6 +1043,12 @@ See [CLAUDE.md](./CLAUDE.md) for full documentation.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Community
|
||||||
|
|
||||||
|
Questions, setup help, and ideas live in [GitHub Discussions](https://github.com/Ark0N/Codeman/discussions): the [Q&A section](https://github.com/Ark0N/Codeman/discussions/categories/q-a) answers the most common ones (phone access, overnight runs, updating), and the roadmap gets decided in [Ideas](https://github.com/Ark0N/Codeman/discussions/categories/ideas). Bugs go to [issues](https://github.com/Ark0N/Codeman/issues); reports usually get a response within a day, and every release credits its reporters and contributors by name. Want to contribute? [CONTRIBUTING.md](.github/CONTRIBUTING.md) has the map: skins, translations, and docs make great first PRs, and bigger features start life as a Discussion. And if you're proud of your rig, post it in [Show and tell](https://github.com/Ark0N/Codeman/discussions/300).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Codebase Quality
|
## Codebase Quality
|
||||||
|
|
||||||
The codebase went through a comprehensive 7-phase refactoring that eliminated god objects, centralized configuration, and established modular architecture:
|
The codebase went through a comprehensive 7-phase refactoring that eliminated god objects, centralized configuration, and established modular architecture:
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 207 KiB |
@@ -0,0 +1,121 @@
|
|||||||
|
# Warm worker pool: sub-second claude worker spawns
|
||||||
|
|
||||||
|
Design sketch. Status: **proposed**, not started. Opt-in (`workerPoolSize`, default 0 = off); a user who touches nothing sees no change at all.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Problem and numbers
|
||||||
|
|
||||||
|
Measured against prod 1.18.3 on 2026-08-15, AFTER the SKILL.md fast-path hardening
|
||||||
|
(no recon turns), on the identical "spawn two codeman workers" prompt:
|
||||||
|
|
||||||
|
- **Cold orchestrator** (fresh session, skill loaded from disk): **20.2 s** prompt to
|
||||||
|
final report. Breakdown: 3.9 s Skill-load turn, 6.4 s generating the one fused Bash
|
||||||
|
call, **4.4 s spawn call**, 5.5 s summary. Tabs appeared at 10.5 s.
|
||||||
|
- **Warm orchestrator** (skill already in context, no Skill turn): **12.8 s**, spawn
|
||||||
|
call 6.0 s.
|
||||||
|
- Inside the spawn call, session + tmux + case creation is cheap: the workers (and
|
||||||
|
their tabs) appeared 0.2-1.7 s in, both siblings within ~350 ms of each other. The
|
||||||
|
remaining **~4-5 s is claude CLI boot plus the composer-readiness wait**, paid again
|
||||||
|
on every cold spawn. That slice is the pool's entire target.
|
||||||
|
|
||||||
|
The honest framing after the hardening: model turns dominate the skill flow (~16 of
|
||||||
|
20 cold seconds) and no server feature can shrink those. The pool attacks the
|
||||||
|
tool-side floor, and it has two distinct beneficiaries:
|
||||||
|
|
||||||
|
- **Skill/API orchestration**: the spawn call drops from ~4.4-6 s to ~1 s. Cold runs
|
||||||
|
land ~16-17 s, warm ~8 s. Tab appearance barely moves for this consumer (it is
|
||||||
|
model-turn-bound at ~10 s cold / ~4 s warm).
|
||||||
|
- **The UI Run button and direct quick-start callers**: a click today waits the full
|
||||||
|
boot + readiness before the worker can take a prompt; a pooled claim makes the tab
|
||||||
|
appear and the worker READY sub-second. This is the most visible win, and it
|
||||||
|
involves no skill at all.
|
||||||
|
|
||||||
|
Target: hand out an already-ready worker in **under 1 s**.
|
||||||
|
|
||||||
|
## 2. Shape
|
||||||
|
|
||||||
|
A new `src/worker-pool.ts` singleton service, following the `CronService` pattern: it **reuses the existing session layer** (`SessionManager` create + the normal spawn path) and never rebuilds tmux logic.
|
||||||
|
|
||||||
|
A pool member is a real claude `Session`, pre-spawned in a reserved scratch case (`~/codeman-cases/.pool-<n>`, created with the standard scaffold + hooks), already past readiness: composer drawn, hooks installed, preamble file seeded. It sits idle at the composer costing no tokens.
|
||||||
|
|
||||||
|
The claim happens **transparently inside `POST /api/quick-start`**: when a request is pool-eligible (§3) and a healthy member is available, quick-start returns that member instead of cold-spawning. The agent skill, the UI Run button, and every existing caller change **nothing**. Ineligible or pool-empty requests cold-spawn exactly as today, so the pool is only ever a fast path, never a behavior change.
|
||||||
|
|
||||||
|
## 3. Eligibility gate
|
||||||
|
|
||||||
|
Claim only when ALL of these hold; otherwise fall through to a cold spawn:
|
||||||
|
|
||||||
|
- `mode === 'claude'` (external CLIs have different readiness semantics and inject secrets via `tmux setenv` at spawn; out of scope).
|
||||||
|
- No `envOverrides`, no `CLAUDE_CONFIG_DIR`, and `modelOverride`/`effort` unset or equal to what the pool member was spawned with. Env vars flow at spawn time and cannot be applied to a running CLI.
|
||||||
|
- The requested case is **fresh** (does not exist yet). A linked case, an existing directory, a remote-SSH case, or a Docker case means the caller wants a specific workspace; pool members cannot provide one.
|
||||||
|
- Single-user mode, or the requester owns the pool (v1 ships single-user only; §11).
|
||||||
|
|
||||||
|
## 4. What a claim does (~300 ms)
|
||||||
|
|
||||||
|
1. Pop a ready member (in-memory check-and-remove; Node's single thread makes this atomic, so two concurrent quick-starts cannot claim the same member).
|
||||||
|
2. Health-probe it: `isPaneDead` (the existing ~750 ms-cached mux probe) plus one `capturePaneText` asserting a clean composer. A dead, limit-paused, or dirty member is recycled, and the claim tries the next member or falls through to cold spawn.
|
||||||
|
3. Rename the session to the normal `w<n>-<case>` name, set `parentSessionId` via the existing `resolveParentSessionId()`, clear the pool flag, persist state.
|
||||||
|
4. Emit `session_created` **now** (it was suppressed at warm-spawn time, §5). The tab appears here, sub-second after the request.
|
||||||
|
5. Return the **pool case** as `casePath`/`workingDir` and do NOT create a directory under the requested name: an empty dir the worker's CLI does not run in is a trap (files written there are invisible to the worker at cwd), and the agent skill greps the RETURNED `casePath` for Codeman hooks before trusting the worker, so the response must point at the directory that really carries them.
|
||||||
|
6. Kick a background refill (§6).
|
||||||
|
|
||||||
|
**The identity wrinkle, stated honestly:** the session id, `CODEMAN_SESSION_ID` inside the pane, the seeded preamble file, and the CLI's cwd are all fixed at warm-spawn and survive the claim unchanged. So a claimed worker's `workingDir` is the pool dir, not `~/codeman-cases/<requested-name>`; the requested name is a **label**. The API must report the truthful `workingDir`. Transcript projHash, response viewer, subagent windows, and Read My Mind all key off the real path and keep working precisely because we do not lie about it. This is acceptable for the dominant use (ephemeral skill workers that are deleted after answering) and is documented in the skill; a caller that needs the real case as cwd is by definition not pool-eligible.
|
||||||
|
|
||||||
|
**Verified skill compatibility (zero preamble changes).** Checked against the shipped 1.18.3 preamble: `spawn_worker`'s readiness probe (`_composer_up`) is a `wait-output` call with `from=buffer`, which scans output that already scrolled past before blocking, so a pooled member's long-since-drawn composer matches instantly instead of stranding a fresh-stream wait. The trust-dialog fallback never fires (members passed the dialog at warm time), and the hooks grep passes because the pool case carries the standard scaffold. Pooled and cold spawns are indistinguishable to the skill except in speed and the additive `pooled: true`.
|
||||||
|
|
||||||
|
## 5. Hiding pre-claim members
|
||||||
|
|
||||||
|
Pool members must be invisible until claimed or they read as ghost tabs. `Session.isPoolWorker` gates, at minimum:
|
||||||
|
|
||||||
|
- `GET /api/sessions` and `GET /api/sessions/unified` (and therefore the Cmd+K palette and the session-history-index snapshot that feeds `/api/search`).
|
||||||
|
- `session_created` SSE at warm-spawn (deferred to claim time). All other per-session SSE for a hidden member is suppressed at the broadcast call sites it would reach.
|
||||||
|
- Push notifications and the Approvals Inbox (a warm member showing a trust dialog must recycle, not notify).
|
||||||
|
- The phone overview / home rail (both render from the session list, so the list filter covers them).
|
||||||
|
- The lifecycle log records `pool_warm` / `pool_claim` events rather than user-visible session history.
|
||||||
|
|
||||||
|
`maxSessions` (50) **counts** pool members, and the pool refuses to warm within `poolSize + 2` of the cap so it can never starve real session creation.
|
||||||
|
|
||||||
|
## 6. Refill, TTL, drain
|
||||||
|
|
||||||
|
- **Refill** after each claim, debounced, at most one warm spawn in flight (a claim burst falls back to cold spawns rather than forking N CLIs at once; same reasoning as the document-conversion limiter).
|
||||||
|
- **TTL ~30 min**: recycle members older than that so they cannot drift from settings, hooks config, or a self-updated CLI on disk.
|
||||||
|
- **Drain and respawn** on: `claudeModel` change, hooks-config regeneration, self-update, and `workerPoolSize` changes. On server shutdown, kill pool sessions (they are stateless and ours). On boot, kill any leftover `.pool-*` tmux sessions found via `mux-sessions.json` rather than adopting them; adoption buys nothing for stateless members.
|
||||||
|
|
||||||
|
## 7. Failure modes
|
||||||
|
|
||||||
|
| Failure | Handling |
|
||||||
|
| --- | --- |
|
||||||
|
| Member died idle (PTY exit, crash) | Health probe at claim catches it; recycle + try next; PTY-exit breaker applies unchanged |
|
||||||
|
| Member hit a usage limit while idle | `isLimitPaused` members are never handed out; recycle |
|
||||||
|
| Composer dirty (stray keystrokes, dialog) | `capturePaneText` probe refuses it; recycle |
|
||||||
|
| Claim race | Impossible by construction (synchronous in-memory pop) |
|
||||||
|
| Warm spawn itself fails | Log, back off, retry on next refill tick; pool empty just means cold spawns |
|
||||||
|
|
||||||
|
## 8. Cost
|
||||||
|
|
||||||
|
Each warm member is one tmux session + one idle claude process (order 150-300 MB RSS; **measure before defaulting the size above 0**, including whether an idle CLI makes any background requests via its statusline refresh). Zero token cost while idle. Suggested starting size for users who opt in: 2.
|
||||||
|
|
||||||
|
## 9. Settings and API surface
|
||||||
|
|
||||||
|
- `workerPoolSize` (int, 0-4, default 0): **synced** setting in `SettingsUpdateSchema`. The watcher that resizes the pool on `PUT /api/settings` must resolve from `merged`, never the raw body (the partial-PUT gotcha in CLAUDE.md).
|
||||||
|
- One internal status endpoint, `GET /api/worker-pool` (size, members' ages, claims served, fall-through count), for debugging. No new SSE events: the claim emits the existing `session_created`.
|
||||||
|
- No new public API semantics: `/api/quick-start`'s contract is unchanged apart from a `pooled: true` field in the response data, which is additive.
|
||||||
|
|
||||||
|
## 10. Considered and rejected
|
||||||
|
|
||||||
|
- **Renaming the pool case dir to the requested name at claim.** Linux keeps the process cwd working across the rename (inode-based), but claude computed its transcript projHash from the old path string at boot, so transcripts, subagent windows, and the response viewer go blind, the exact failure mode the `CLAUDE_CONFIG_DIR` docs warn about. Truthful label semantics (§4) beat a clever rename.
|
||||||
|
- **A new explicit claim endpoint.** Transparency inside quick-start means the skill, the UI, and every existing script get the speedup with zero changes; a new endpoint means new docs, new drift, and callers that must know the pool exists.
|
||||||
|
- **Pooling external CLI modes.** Readiness there is output stabilization, secrets ride `tmux setenv` at spawn, and codex/pi composer semantics differ per CLI. Claude-only until someone measures a need.
|
||||||
|
- **Returning quick-start at creation instead of readiness (no pool).** Would move tabs earlier on cold spawns too, but `sendwait` immediately after would then race the composer; readiness is what makes immediate tasking safe, and the pool makes the whole question moot for eligible spawns.
|
||||||
|
|
||||||
|
## 11. Phasing
|
||||||
|
|
||||||
|
1. **v1**: single-user, claude-only, fixed-size pool, transparent claim, status endpoint. Everything above.
|
||||||
|
2. **v2**: per-owner pools for multi-user mode (pool members must carry an owner because ownership scoping is structural); possibly model-matched pools (one warm set per configured `claudeModel`).
|
||||||
|
3. **Explicitly out**: warming linked/repo cases (spawning where the work is has no hooks and is the skill's documented costliest mistake; a warm pool must not make it faster to reach).
|
||||||
|
|
||||||
|
## 12. Testing
|
||||||
|
|
||||||
|
- Unit: pool manager logic pure and mock-driven (eligibility gate, TTL, refill debounce, drain triggers), `MockSession` from `test/mocks/`.
|
||||||
|
- Route: `app.inject` on quick-start asserting claim vs cold-spawn per eligibility row in §3, plus the double-claim race (two concurrent injects, one pool member: exactly one `pooled: true`).
|
||||||
|
- Live: re-run the pinned baselines against a warmed beta instance. Before (2026-08-15, prod 1.18.3, post-hardening): cold orchestrator **20.2 s** / warm **12.8 s** end to end, spawn call 4.4-6.0 s. Acceptance: spawn call under 1 s, cold ~16-17 s, warm ~8-9 s, and a UI Run click to a READY worker in under 1 s.
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.18.2",
|
"version": "1.18.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.18.2",
|
"version": "1.18.4",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.18.2",
|
"version": "1.18.4",
|
||||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
+57
-19
@@ -41,7 +41,32 @@ the preamble to a file once and source it afterwards, rather than re-pasting a
|
|||||||
hundred-odd lines at the top of every call (a half-re-pasted preamble used to be the
|
hundred-odd lines at the top of every call (a half-re-pasted preamble used to be the
|
||||||
single most likely way to break a run).
|
single most likely way to break a run).
|
||||||
|
|
||||||
Run this block once per Codeman session:
|
**Codeman seeds the preamble file for you** when it spawns a claude session (server
|
||||||
|
1.18.3+), so the bootstrap is usually nothing at all: these are the two lines every
|
||||||
|
later call opens with, and your first REAL call performs them anyway:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||||
|
[ "${CODEMAN_PREAMBLE:-}" = 1.18.3 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
|
||||||
|
loader, so when §1 is the job, start there: the check rides the spawn call for free,
|
||||||
|
and a standalone "preamble OK" call buys nothing while costing a full model turn
|
||||||
|
(measured live: a lone check plus the deliberation around it added ~6 s to a 28 s
|
||||||
|
two-worker run). §0 is done the moment any job call passes its opening check. Only
|
||||||
|
when a call reports missing or stale, run the full block below once — and run it
|
||||||
|
**verbatim**: paste it as-is, never re-type it, trim it, or "extract the parts you
|
||||||
|
need". A hand-assembled
|
||||||
|
preamble is the documented failure mode of this skill: one live run rebuilt it
|
||||||
|
"minimally" and lost the `X-Codeman-Parent-Session` header (every worker spawned with
|
||||||
|
no lineage arc in the web UI) and the fast-path functions (the spawn fell back to a
|
||||||
|
serial quick-start loop plus pid polls), turning a ten-second job into a fifty-second
|
||||||
|
one. If your harness directs temporary files into a scratchpad directory, that
|
||||||
|
directive covers task scratch, not this file: it is a per-session cache that every
|
||||||
|
later call re-sources by this exact path, so keep the path below. If you must relocate
|
||||||
|
it anyway, copy the block's content byte-for-byte unchanged and source your path in
|
||||||
|
every later call instead.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
test "${CODEMAN_MUX:-}" = 1 || { echo "Not inside a Codeman-managed session; refusing to act."; exit 1; }
|
test "${CODEMAN_MUX:-}" = 1 || { echo "Not inside a Codeman-managed session; refusing to act."; exit 1; }
|
||||||
@@ -50,8 +75,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
|
|||||||
mkdir -p "$(dirname "$PRE")"
|
mkdir -p "$(dirname "$PRE")"
|
||||||
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
||||||
# half-written file self-heals here instead of costing you a round trip to rm it.
|
# half-written file self-heals here instead of costing you a round trip to rm it.
|
||||||
grep -qs '^CODEMAN_PREAMBLE=1.18.2$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
grep -qs '^CODEMAN_PREAMBLE=1.18.3$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||||
# ---- Codeman agent preamble 1.18.2 (written by the SKILL.md §0 bootstrap) ----
|
# ---- Codeman agent preamble 1.18.3 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||||
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||||
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||||
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
||||||
@@ -105,8 +130,10 @@ _composer_up() { # <sid> <timeoutMs> -> "true"/"false". `shift+tab` is the one
|
|||||||
# composer draws, and pid!=null proved startup, never readiness.
|
# composer draws, and pid!=null proved startup, never readiness.
|
||||||
spawn_worker() {
|
spawn_worker() {
|
||||||
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
||||||
|
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
|
||||||
|
# curl (or a body someone rebuilt from this recipe) still carries its lineage.
|
||||||
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" '{caseName:$n,mode:$m}')")
|
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" '{caseName:$n,mode:$m,parentSessionId:$p}')")
|
||||||
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
||||||
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
||||||
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
||||||
@@ -206,18 +233,14 @@ last_text() {
|
|||||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||||
# here would fail that match and rewrite this file on every single bootstrap.
|
# here would fail that match and rewrite this file on every single bootstrap.
|
||||||
CODEMAN_PREAMBLE=1.18.2
|
CODEMAN_PREAMBLE=1.18.3
|
||||||
PREAMBLE
|
PREAMBLE
|
||||||
)
|
)
|
||||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.18.2 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.18.3 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
Every later Bash call that touches the API starts with these two lines instead:
|
Every later Bash call that touches the API starts with the same two loader lines from
|
||||||
|
the top of this section.
|
||||||
```bash
|
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.18.2 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
|
||||||
```
|
|
||||||
|
|
||||||
Why it is built this way, all of it load-bearing:
|
Why it is built this way, all of it load-bearing:
|
||||||
|
|
||||||
@@ -254,13 +277,18 @@ plain-text 401: see §6 and [the symptom gallery](reference/endpoints.md#symptom
|
|||||||
block is the whole thing. Run it, report, and stop reading. §2 onward is for jobs this
|
block is the whole thing. Run it, report, and stop reading. §2 onward is for jobs this
|
||||||
does not cover; you are not being careless by not reading them.**
|
does not cover; you are not being careless by not reading them.**
|
||||||
|
|
||||||
Fill in the case names and the prompts. Everything below is `spawn_workers` /
|
Fill in the case names and the prompts, then run it as your FIRST Bash call: no
|
||||||
`sendwait` / `last_text` / `delete_session` from the §0 preamble, so there is nothing
|
standalone preamble check before it (line one below IS that check), and no
|
||||||
to assemble and no per-call body to hand-build.
|
reconnaissance. `ls ~/codeman-cases` answers nothing this block needs: invented
|
||||||
|
fresh names need no lookup, and `spawn_worker` refuses a name that already exists
|
||||||
|
rather than silently reusing it. Everything below is `spawn_workers` / `sendwait` /
|
||||||
|
`last_text` / `delete_session` from the §0 preamble, so there is nothing to assemble
|
||||||
|
and no per-call body to hand-build.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" # §0
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
||||||
N=(alpha beta) # one FRESH case name per worker
|
[ "${CODEMAN_PREAMBLE:-}" = 1.18.3 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||||
|
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
|
||||||
T=('reply with one line: the absolute path of your working directory'
|
T=('reply with one line: the absolute path of your working directory'
|
||||||
'reply with one line: your model name') # tasks, same order as N
|
'reply with one line: your model name') # tasks, same order as N
|
||||||
|
|
||||||
@@ -287,12 +315,22 @@ done; rm -rf "$D"
|
|||||||
|
|
||||||
Measured against a live 1.18.0 server: two cold workers spawned and ready in **6.3 s**,
|
Measured against a live 1.18.0 server: two cold workers spawned and ready in **6.3 s**,
|
||||||
both turns dispatched and both answers read in **4.0 s** more. If your run takes minutes,
|
both turns dispatched and both answers read in **4.0 s** more. If your run takes minutes,
|
||||||
the time went into deliberation, not the API. The three things that actually cost time:
|
the time went into deliberation, not the API. The four things that actually cost time:
|
||||||
|
|
||||||
- **Spawning serially.** One worker per Bash call is one model turn per worker. `&` plus
|
- **Spawning serially.** One worker per Bash call is one model turn per worker. `&` plus
|
||||||
`wait`, as above, makes N workers cost about what one costs.
|
`wait`, as above, makes N workers cost about what one costs.
|
||||||
|
- **Reconnaissance turns before the spawn.** A standalone preamble check, an
|
||||||
|
`ls ~/codeman-cases`, a `list_sessions` "to see what is there": each is a whole
|
||||||
|
model turn spent learning something this block already handles (line one performs
|
||||||
|
the preamble check, invented names need no listing, and `spawn_worker` refuses
|
||||||
|
collisions). A live two-worker run spent ~12 s of its 28 s total on exactly two
|
||||||
|
such turns; the API work in between was under 10 s.
|
||||||
- **Re-deriving the happy path** from §5.1 + §5.2 + §5.3 + §5.10. That is what the
|
- **Re-deriving the happy path** from §5.1 + §5.2 + §5.3 + §5.10. That is what the
|
||||||
preamble functions exist to end. Compose them; do not rebuild them.
|
preamble functions exist to end. Compose them; do not rebuild them. The tells that
|
||||||
|
you are rebuilding anyway: a `for` loop around `quick-start`, a poll on `.data.pid`,
|
||||||
|
a bespoke `ready()` or `spawn()` of your own. Each is a worse copy of a function
|
||||||
|
already sitting in your preamble; the live run that wrote them spawned serially,
|
||||||
|
polled pid for nothing, and shipped its workers without lineage.
|
||||||
- **Verifying what is already checked for you.** Two verifications specifically are not
|
- **Verifying what is already checked for you.** Two verifications specifically are not
|
||||||
worth a call here, because `spawn_worker` carries them: the hooks check (it refuses a
|
worth a call here, because `spawn_worker` carries them: the hooks check (it refuses a
|
||||||
name that resolved to a hook-less directory with one local grep, so a worker it hands
|
name that resolved to a hook-less directory with one local grep, so a worker it hands
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# ---- Codeman agent preamble 1.18.3 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||||
|
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||||
|
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||||
|
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
||||||
|
# CODEMAN_PASSWORD already (§6 explains why, and what to do when it has not);
|
||||||
|
# the data dir's .env is the documented fallback, the same one `codeman attach`
|
||||||
|
# reads. The data dir is wherever the hook-secret file lives. Values may be
|
||||||
|
# quoted or `export`-prefixed.
|
||||||
|
ENV_FILE="${CODEMAN_HOOK_SECRET_FILE:+${CODEMAN_HOOK_SECRET_FILE%hook-secret}.env}"
|
||||||
|
envval() { sed -n "s/^\(export \)\{0,1\}$1=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; }
|
||||||
|
if [ -z "${CODEMAN_PASSWORD:-}" ] && [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then
|
||||||
|
CODEMAN_USERNAME=$(envval CODEMAN_USERNAME)
|
||||||
|
CODEMAN_PASSWORD=$(envval CODEMAN_PASSWORD)
|
||||||
|
fi
|
||||||
|
AUTH=(); [ -n "${CODEMAN_PASSWORD:-}" ] && AUTH=(-u "${CODEMAN_USERNAME:-admin}:$CODEMAN_PASSWORD")
|
||||||
|
# -k: harmless on http, required on https (self-signed cert).
|
||||||
|
# X-Codeman-Parent-Session: tags workers YOU spawn as your children, so the web UI can
|
||||||
|
# draw the lineage. Set once here and every present and future create call carries it;
|
||||||
|
# it is ignored on every other endpoint. Purely cosmetic (see §5.1) and it can never
|
||||||
|
# fail a spawn, so there is no case where you would want to leave it off.
|
||||||
|
CURL=(curl -sk "${AUTH[@]}" -H "X-Codeman-Parent-Session: $SELF")
|
||||||
|
CID=codeman-agent-1 # FIXED literal, never "agent-$$": see below
|
||||||
|
|
||||||
|
# Fail-CLOSED session delete. The DELETE lives INSIDE the guard on purpose: the older
|
||||||
|
# `is_self "$SID" || curl -X DELETE ...` shape failed OPEN, because an undefined
|
||||||
|
# is_self exits 127 and the `||` branch then ran the delete completely unguarded.
|
||||||
|
# Undefined delete_session is "command not found", which deletes nothing.
|
||||||
|
delete_session() {
|
||||||
|
local id="${1:-}"
|
||||||
|
[ -n "$id" ] || { echo "refusing: empty session id"; return 1; }
|
||||||
|
[ "${#SELF}" -ge 8 ] || { echo "refusing: \$SELF unset or too short to prove this is not me"; return 1; }
|
||||||
|
# ids appear in full AND 8-char form (Docker exports a truncated $SELF; mux names and
|
||||||
|
# UI surfaces carry 8-char ids), so compare by prefix in BOTH directions. Equality or
|
||||||
|
# a one-directional check each miss a real combination, and the miss deletes you.
|
||||||
|
case "$id" in "$SELF"*) echo "refusing: $id is me"; return 1 ;; esac
|
||||||
|
case "$SELF" in "$id"*) echo "refusing: $id is me"; return 1 ;; esac
|
||||||
|
"${CURL[@]}" -X DELETE "$API/api/v1/sessions/$id"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- fast path: the four verbs, already written. §1 composes them. ----
|
||||||
|
_composer_up() { # <sid> <timeoutMs> -> "true"/"false". `shift+tab` is the one token
|
||||||
|
"${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \
|
||||||
|
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
|
||||||
|
--data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
|
||||||
|
}
|
||||||
|
# spawn_worker <caseName> [mode] -> session id on stdout, diagnostics on stderr.
|
||||||
|
# quick-start AND readiness in one call, with a strict contract: NON-EMPTY stdout means
|
||||||
|
# a READY claude worker in a hook-carrying case. Anything less is rc 1 with EMPTY
|
||||||
|
# stdout, and the half-spawned session is deleted here rather than handed back, because
|
||||||
|
# a worker that never drew its composer would eat the task prompt with its trust
|
||||||
|
# dialog. There is deliberately no pid poll: wait-output already blocks until the
|
||||||
|
# composer draws, and pid!=null proved startup, never readiness.
|
||||||
|
spawn_worker() {
|
||||||
|
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
||||||
|
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
|
||||||
|
# curl (or a body someone rebuilt from this recipe) still carries its lineage.
|
||||||
|
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||||
|
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" '{caseName:$n,mode:$m,parentSessionId:$p}')")
|
||||||
|
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
||||||
|
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
||||||
|
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
||||||
|
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # only claude draws a composer
|
||||||
|
# quick-start RESOLVES the name before creating: a linked case or an existing dir
|
||||||
|
# wins over a fresh scratch case, so "created => hooks" is only true after this one
|
||||||
|
# local grep (the same marker the server itself checks for). No marker means sendwait
|
||||||
|
# would false-resolve on flapping idle, possibly inside the user's REAL repo: refuse
|
||||||
|
# rather than run the job there.
|
||||||
|
cp=$(jq -r '.data.casePath // empty' <<<"$q")
|
||||||
|
grep -qs '/api/hook-event' "$cp/.claude/settings.local.json" || {
|
||||||
|
echo "case '$name' resolved to '$cp', which has no Codeman hooks (linked or pre-existing?): pick an unused name, or work §5.1+§5.5 by hand" >&2
|
||||||
|
delete_session "$sid" >/dev/null; return 1; }
|
||||||
|
# Short composer wait FIRST, then the trust-dialog probe: a case still showing the
|
||||||
|
# dialog can never pass the composer wait, so probing early keeps a cold case from
|
||||||
|
# paying the whole long wait before the fallback even runs (§5.2). A warm case
|
||||||
|
# matches in under a second and never reaches the probe.
|
||||||
|
r=$(_composer_up "$sid" 5000)
|
||||||
|
if [ "$r" != true ]; then
|
||||||
|
if "${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait-output" \
|
||||||
|
--data-urlencode 'match=trust' --data-urlencode 'from=buffer' --data-urlencode 'timeout=2000' \
|
||||||
|
| jq -e '.data.wait.matched' >/dev/null; then
|
||||||
|
# Codeman's own auto-accept gives up after 90 s / 3 tries; this is that bounded fallback.
|
||||||
|
"${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
|
||||||
|
-d "$(jq -nc --arg c "$CID-$sid" '{input:"\r",useMux:true,clientId:$c,seq:1}')" >/dev/null
|
||||||
|
fi
|
||||||
|
r=$(_composer_up "$sid" 45000)
|
||||||
|
fi
|
||||||
|
[ "$r" = true ] || { echo "worker $sid never drew a composer; deleted it. Retry by hand via the §5.2 ladder (its billed stage-4 probe included)" >&2
|
||||||
|
delete_session "$sid" >/dev/null; return 1; }
|
||||||
|
printf '%s\n' "$sid"
|
||||||
|
}
|
||||||
|
# spawn_workers <caseName>... -> one "<caseName> <sessionId>" line per worker, in order;
|
||||||
|
# the sessionId column is EMPTY for a spawn that failed (stderr has why). CONCURRENT:
|
||||||
|
# N workers cost about what one costs. Spawning them one Bash call at a time is the
|
||||||
|
# single biggest avoidable delay in this skill. Names must be UNIQUE: two workers in
|
||||||
|
# one case directory co-edit the same tree (§4), so a repeat is an error here, not a race.
|
||||||
|
spawn_workers() {
|
||||||
|
local d n i=0
|
||||||
|
[ "$#" -gt 0 ] || { echo "spawn_workers: no case names given" >&2; return 1; }
|
||||||
|
[ -z "$(printf '%s\n' "$@" | sort | uniq -d)" ] || { echo "spawn_workers: duplicate case names" >&2; return 1; }
|
||||||
|
d=$(mktemp -d "${TMPDIR:-/tmp}/codeman-spawn.XXXXXX") || return 1
|
||||||
|
for n in "$@"; do ( spawn_worker "$n" > "$d/$i" ) & i=$((i+1)); done
|
||||||
|
wait
|
||||||
|
i=0; for n in "$@"; do printf '%s %s\n' "$n" "$(cat "$d/$i" 2>/dev/null)"; i=$((i+1)); done
|
||||||
|
rm -rf "$d"
|
||||||
|
}
|
||||||
|
# sendwait <sid> <prompt> [seq] -> blocks until that worker's turn ENDS (~10 min ceiling
|
||||||
|
# across its two waits). One billed turn. The \r and the per-worker clientId are applied
|
||||||
|
# here, which is why you never hand-build this body. seq defaults to the CURRENT EPOCH
|
||||||
|
# SECOND so that every new prompt is a new frame: the server drops any (clientId,seq)
|
||||||
|
# pair it has already applied, so a fixed default would make every later prompt to that
|
||||||
|
# worker a silent no-op that still "succeeds" and reports the previous turn's state.
|
||||||
|
# Pass seq explicitly for exactly one reason: resending a possibly-delivered frame as a
|
||||||
|
# deliberate duplicate, at the SAME number (§5.3).
|
||||||
|
# Delivery is SELF-HEALING: an Ink repaint occasionally eats the Enter, leaving the
|
||||||
|
# typed prompt stranded on the composer while a long wait runs its whole timeout
|
||||||
|
# (observed live). So the first wait is short; on its timeout a bare \r goes out (the
|
||||||
|
# missing Enter when the prompt is stranded, a no-op when the turn is genuinely
|
||||||
|
# running), then the ORIGINAL frame is resent unchanged, which the server takes as a
|
||||||
|
# tagged duplicate: it re-waits without retyping (§5.3). Trustworthy only for a claude
|
||||||
|
# worker spawn_worker handed back (hooks vetted); hook-less workspaces and other modes
|
||||||
|
# resolve on flapping idle: markers instead (§5.5).
|
||||||
|
sendwait() {
|
||||||
|
local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r
|
||||||
|
body=$(jq -nc --arg p "$p" --arg c "$CID-$sid" --argjson s "$seq" \
|
||||||
|
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:true,waitTimeout:20000}')
|
||||||
|
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
||||||
|
-H 'Content-Type: application/json' --data-binary "$body")
|
||||||
|
if jq -e '.data.delivered and .data.wait.timedOut' <<<"$r" >/dev/null 2>&1; then
|
||||||
|
"${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
|
||||||
|
-d "$(jq -nc --arg c "$CID-$sid" --argjson s "$(date +%s)" \
|
||||||
|
'{input:"\r",useMux:true,clientId:$c,seq:$s}')" >/dev/null
|
||||||
|
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
||||||
|
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=580000' <<<"$body")")
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$r"
|
||||||
|
}
|
||||||
|
# last_text <sid> [prev] -> that worker's last assistant message. Polled, because the
|
||||||
|
# transcript write LAGS the stop signal, and "some text exists" is not "THIS turn's
|
||||||
|
# text exists": right after a SECOND turn on the same worker the endpoint still serves
|
||||||
|
# the previous answer for a beat (observed live). When reading consecutive turns, pass
|
||||||
|
# the previous answer as [prev]: the poll then holds out for text that differs from it,
|
||||||
|
# falling back to whatever it last saw if the budget runs dry, so an honestly repeated
|
||||||
|
# answer still comes back. Non-zero exit means the worker really never wrote one.
|
||||||
|
last_text() {
|
||||||
|
local t="" prev="${2:-}"
|
||||||
|
for _ in $(seq 1 15); do
|
||||||
|
t=$("${CURL[@]}" "$API/api/v1/sessions/$1/last-response" | jq -r '.data.text // empty')
|
||||||
|
[ -n "$t" ] && [ "$t" != "$prev" ] && { printf '%s\n' "$t"; return 0; }
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
[ -n "$t" ] && { printf '%s\n' "$t"; return 0; }
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||||
|
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||||
|
# here would fail that match and rewrite this file on every single bootstrap.
|
||||||
|
CODEMAN_PREAMBLE=1.18.3
|
||||||
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.18.2 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.18.3 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
|
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import { readFile, writeFile, mkdir, lstat, readdir, realpath, rename, unlink, rmdir } from 'node:fs/promises';
|
import { readFile, writeFile, mkdir, lstat, readdir, realpath, rename, unlink, rmdir } from 'node:fs/promises';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
@@ -948,6 +949,49 @@ export async function installAgentSkillInto(skillDir: string): Promise<AgentSkil
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed a claude session's agent preamble file (`$XDG_CACHE_HOME/codeman-agent-<id>.sh`,
|
||||||
|
* default `~/.cache/`) from the packaged `skills/codeman/preamble.sh`, so the agent
|
||||||
|
* skill's §0 bootstrap collapses to a two-line loader instead of a ~150-line block the
|
||||||
|
* model has to type out (measured live: that paste alone cost a spawn run ~47 s of
|
||||||
|
* generation time). The path formula must match the skill's
|
||||||
|
* `${XDG_CACHE_HOME:-$HOME/.cache}` exactly; sessions inherit the server's env, so
|
||||||
|
* reading the server's own XDG_CACHE_HOME keeps the two in agreement (`||` mirrors the
|
||||||
|
* shell's `:-`, treating empty as unset). Callers gate to LOCAL claude sessions (a
|
||||||
|
* remote or in-container HOME is not this filesystem) and treat it as best-effort: the
|
||||||
|
* skill's §0 fallback block self-heals a missing or stale file.
|
||||||
|
*/
|
||||||
|
export async function seedAgentSessionPreamble(sessionId: string): Promise<void> {
|
||||||
|
const content = await readFile(join(agentSkillSourceDir(), 'preamble.sh'), 'utf-8');
|
||||||
|
const cacheDir = process.env.XDG_CACHE_HOME || join(homedir(), '.cache');
|
||||||
|
await mkdir(cacheDir, { recursive: true });
|
||||||
|
await writeFile(join(cacheDir, `codeman-agent-${sessionId}.sh`), content, { mode: 0o600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the USER-LEVEL skill copy (`~/.claude/skills/codeman`) IF one exists and is
|
||||||
|
* Codeman-managed. `codeman skill install` (no `--case`) writes that copy once, and
|
||||||
|
* unlike per-case copies (re-installed on every session create) nothing ever refreshed
|
||||||
|
* it, so it stayed at whatever version installed it. That matters because Claude Code
|
||||||
|
* loads the USER-LEVEL copy over a case's fresh one when both carry the name `codeman`:
|
||||||
|
* observed live 2026-08-14, an Aug 9 user copy (pre fast-path, pre lineage header)
|
||||||
|
* shadowed the current per-case injections, so every agent-driven spawn ran the old
|
||||||
|
* recipes, spawned workers serially, and lost their lineage arcs.
|
||||||
|
*
|
||||||
|
* Refresh-ONLY: an absent copy is not installed (the user never asked for a global
|
||||||
|
* copy), and foreign/symlink copies are refused by installAgentSkillInto itself.
|
||||||
|
*/
|
||||||
|
export async function refreshUserAgentSkill(): Promise<AgentSkillApplyResult | 'absent'> {
|
||||||
|
const skillDir = join(homedir(), '.claude', 'skills', 'codeman');
|
||||||
|
try {
|
||||||
|
const existing = await readFile(join(skillDir, 'SKILL.md'), 'utf-8');
|
||||||
|
if (!existing.includes(AGENT_SKILL_MARKER_PREFIX)) return 'foreign';
|
||||||
|
} catch {
|
||||||
|
return 'absent';
|
||||||
|
}
|
||||||
|
return installAgentSkillInto(skillDir);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a Codeman-managed skill copy from `skillDir`. Same ownership and symlink
|
* Remove a Codeman-managed skill copy from `skillDir`. Same ownership and symlink
|
||||||
* refusals as the install path. Deletes only files the packaged source would have
|
* refusals as the install path. Deletes only files the packaged source would have
|
||||||
|
|||||||
@@ -3992,7 +3992,7 @@ class CodemanApp {
|
|||||||
? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix)
|
? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix)
|
||||||
: (session.workingDir || '');
|
: (session.workingDir || '');
|
||||||
|
|
||||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${loadState ? ' tab-loading' : ''}" data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}" data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
||||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||||
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
||||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||||
|
|||||||
@@ -37,13 +37,21 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
async seedApprovals() {
|
async seedApprovals() {
|
||||||
if (!this.approvals) this.approvals = new Map();
|
if (!this.approvals) this.approvals = new Map();
|
||||||
this.approvals.clear();
|
this.approvals.clear();
|
||||||
if (this.approvalsInboxEnabled()) {
|
// ⚠ Fetch and re-arm the tab-alert state machine REGARDLESS of the inbox
|
||||||
const data = await this._apiJson('/api/approvals');
|
// setting. The server-side approval store runs unconditionally (only the
|
||||||
for (const item of (data && data.approvals) || []) {
|
// inbox SURFACES are opt-in), and the red/yellow tab alert predates the
|
||||||
this.approvals.set(item.id, item);
|
// inbox: gating the seed on the setting meant that with the inbox off, a
|
||||||
// Re-arm the tab alert state machine (idempotent set-add).
|
// reload landed with every alert store empty while a permission dialog sat
|
||||||
this.setPendingHook(item.sessionId, approvalKindToHook(item.kind));
|
// blocking a session (owner report 2026-08-15: rail said NEEDS YOU from
|
||||||
}
|
// the live SSE event, the reloaded-elsewhere tab showed a plain green
|
||||||
|
// dot). Only populating `this.approvals` (bell/drawer/answer strips) stays
|
||||||
|
// behind the setting.
|
||||||
|
const data = await this._apiJson('/api/approvals');
|
||||||
|
const inboxOn = this.approvalsInboxEnabled();
|
||||||
|
for (const item of (data && data.approvals) || []) {
|
||||||
|
if (inboxOn) this.approvals.set(item.id, item);
|
||||||
|
// Re-arm the tab alert state machine (idempotent set-add).
|
||||||
|
this.setPendingHook(item.sessionId, approvalKindToHook(item.kind));
|
||||||
}
|
}
|
||||||
this.renderApprovals();
|
this.renderApprovals();
|
||||||
},
|
},
|
||||||
@@ -68,14 +76,15 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
_onApprovalResolved(info) {
|
_onApprovalResolved(info) {
|
||||||
if (!info || !info.id || !this.approvals) return;
|
if (!info || !info.id) return;
|
||||||
if (this.approvals.delete(info.id)) {
|
// Clear the matching tab alert UNCONDITIONALLY: the inbox resolves on more
|
||||||
// Clear the matching tab alert: the inbox resolves on more signals than
|
// signals than the hook handlers do (superseded, expired, answered from
|
||||||
// the hook handlers do (superseded, expired, answered from another
|
// another device), clearPendingHooks is a no-op when nothing is set, and
|
||||||
// device), and clearPendingHooks is a no-op when nothing is set.
|
// with the inbox setting OFF the item was never stored in `this.approvals`
|
||||||
this.clearPendingHooks(info.sessionId, approvalKindToHook(info.kind));
|
// even though seedApprovals armed the alert — gating the clear on a map hit
|
||||||
this.renderApprovals();
|
// would strand that alert forever.
|
||||||
}
|
this.clearPendingHooks(info.sessionId, approvalKindToHook(info.kind));
|
||||||
|
if (this.approvals?.delete(info.id)) this.renderApprovals();
|
||||||
},
|
},
|
||||||
|
|
||||||
// ─── Actions ─────────────────────────────────────────────────
|
// ─── Actions ─────────────────────────────────────────────────
|
||||||
|
|||||||
+128
-20
@@ -222,23 +222,35 @@ function computeTabScrollLeft(input) {
|
|||||||
// endpoint scrolled outside the strip. `.session-tabs` is `overflow-x: auto`, so a
|
// endpoint scrolled outside the strip. `.session-tabs` is `overflow-x: auto`, so a
|
||||||
// scrolled-out tab still HAS a rect — one lying over the logo or the header
|
// scrolled-out tab still HAS a rect — one lying over the logo or the header
|
||||||
// buttons. Skipping is honest; clamping would point at a tab that isn't there.
|
// buttons. Skipping is honest; clamping would point at a tab that isn't there.
|
||||||
// ⚠ THE DIP IS WHAT MAKES THE ARC AN ARC, and the first shipped numbers were tuned
|
// ⚠ THE DIP IS WHAT MAKES THE ARC AN ARC, and it has now been mis-tuned in BOTH
|
||||||
// against two tabs sitting side by side. A worker the agent skill starts is appended
|
// directions, so treat these numbers as a corridor rather than a dial to crank:
|
||||||
// to the END of the strip, so the real span between a lead and its worker is 800-1500px,
|
// - Too shallow (the first ship, 44px cap): a skill worker is appended to the END of
|
||||||
// not 200, and a 44px cap over 1300px of span is a 33px sag, i.e. a line that reads as
|
// the strip, so a lead-to-worker span is 800-1500px, and a 44px cap over 1300px is
|
||||||
// STRAIGHT and crosses the terminal instead of bracketing under the strip. The dip now
|
// a 33px sag, a line that reads as STRAIGHT across the terminal (#285).
|
||||||
// keeps growing with the span (0.085/px, ~3x steeper against the old cap) so the bracket
|
// - Too deep (the 104px cap that replaced it): in the wrapped-strip case the cap and
|
||||||
// survives the distance the feature is actually used at. The ceiling is what keeps a
|
// the FULL row offset stacked, bowing the bracket ~106px into the terminal text
|
||||||
// full-width pair out of the terminal's fourth line: 104 + the sibling step lands the
|
// (owner screenshot 2026-08-15, "die Linien machen einen grossen Bogen nach unten").
|
||||||
// deepest sag around y=140 on a 1080 screen, the same proportion two adjacent tabs get.
|
// The dip is measured from the STRIP'S BOTTOM EDGE (falling back to the lower tab
|
||||||
|
// bottom when the strip rect is missing or shorter than its tabs), which buys two
|
||||||
|
// things at once: the bow needs no per-row offsets stacked on top, and a same-row
|
||||||
|
// arc between ROW-1 tabs of a wrapped strip clears row 2's labels instead of being
|
||||||
|
// drawn through them (the retune's own first draft had exactly that regression).
|
||||||
const LINEAGE_DIP_BASE_PX = 14;
|
const LINEAGE_DIP_BASE_PX = 14;
|
||||||
const LINEAGE_DIP_PER_PX = 0.085;
|
const LINEAGE_DIP_PER_PX = 0.06;
|
||||||
const LINEAGE_DIP_MIN_PX = 22;
|
const LINEAGE_DIP_MIN_PX = 22;
|
||||||
const LINEAGE_DIP_MAX_PX = 104;
|
const LINEAGE_DIP_MAX_PX = 64;
|
||||||
// Siblings nest by this much. Widened with the stroke: at 2.5px plus its glow, arcs 6px
|
// Siblings nest by this much. Widened with the stroke: at 2.5px plus its glow, arcs 6px
|
||||||
// apart bled into one thick band instead of reading as three separate lines.
|
// apart bled into one thick band instead of reading as three separate lines.
|
||||||
const LINEAGE_SIBLING_STEP_PX = 8;
|
const LINEAGE_SIBLING_STEP_PX = 8;
|
||||||
const LINEAGE_STRIP_TOLERANCE_PX = 4;
|
const LINEAGE_STRIP_TOLERANCE_PX = 4;
|
||||||
|
// Lineage palette, assigned per CHILD in first-seen order and cycled (session-lineage.js).
|
||||||
|
// The empty FIRST entry means "no override": the CSS then falls back to --session-blue,
|
||||||
|
// which every skin block tunes for its own background, so a lone arc keeps the
|
||||||
|
// skin-aware blue that shipped in 1.18.2. The fixed entries are deliberately vivid
|
||||||
|
// (owner call 2026-08-15: matrix green, pinkish, violet, red, turquoise "and so on");
|
||||||
|
// they ride the same double glow as the blue, which is what keeps them legible over
|
||||||
|
// terminal text on every skin.
|
||||||
|
const LINEAGE_COLORS = ['', '#00ff66', '#ff5ea8', '#a78bfa', '#ff5252', '#2dd4bf', '#ffa940'];
|
||||||
|
|
||||||
function computeLineagePath(input) {
|
function computeLineagePath(input) {
|
||||||
const parent = input?.parent;
|
const parent = input?.parent;
|
||||||
@@ -269,18 +281,19 @@ function computeLineagePath(input) {
|
|||||||
const cBottom = cTop + ch;
|
const cBottom = cTop + ch;
|
||||||
const sameRow = Math.abs(pTop + ph / 2 - (cTop + ch / 2)) <= Math.min(ph, ch) / 2;
|
const sameRow = Math.abs(pTop + ph / 2 - (cTop + ch / 2)) <= Math.min(ph, ch) / 2;
|
||||||
|
|
||||||
// Both ends anchor on the tab BOTTOM, and the control points hang below whichever
|
// Both ends anchor on the tab BOTTOM, and the control points hang below the WHOLE
|
||||||
// row is lower, so one formula covers a flat strip and a wrapped one.
|
// strip, so one formula covers a flat strip, a wrapped pair, and a same-row pair
|
||||||
|
// sitting above further rows (see the corridor note above the constants).
|
||||||
const span = Math.abs(cx - px);
|
const span = Math.abs(cx - px);
|
||||||
const rowDrop = Math.abs(cBottom - pBottom);
|
const stripBottom =
|
||||||
// ⚠ A wrapped pair needs the dip measured from the LOWER row, or the bracket would
|
strip && Number(strip.height) > 0 && Number.isFinite(Number(strip.top))
|
||||||
// only reach the row gap again. Adding the row offset also keeps the curve clear of
|
? Number(strip.top) + Number(strip.height)
|
||||||
// the row it crosses instead of grazing its bottom edge.
|
: Number.NEGATIVE_INFINITY;
|
||||||
|
const baseline = Math.max(pBottom, cBottom, stripBottom);
|
||||||
const dip =
|
const dip =
|
||||||
Math.min(LINEAGE_DIP_MAX_PX, Math.max(LINEAGE_DIP_MIN_PX, LINEAGE_DIP_BASE_PX + span * LINEAGE_DIP_PER_PX)) +
|
Math.min(LINEAGE_DIP_MAX_PX, Math.max(LINEAGE_DIP_MIN_PX, LINEAGE_DIP_BASE_PX + span * LINEAGE_DIP_PER_PX)) +
|
||||||
depth * LINEAGE_SIBLING_STEP_PX +
|
depth * LINEAGE_SIBLING_STEP_PX;
|
||||||
rowDrop;
|
const yc = baseline + dip;
|
||||||
const yc = Math.max(pBottom, cBottom) + dip;
|
|
||||||
const d = `M ${r1(px)} ${r1(pBottom)} C ${r1(px)} ${r1(yc)}, ${r1(cx)} ${r1(yc)}, ${r1(cx)} ${r1(cBottom)}`;
|
const d = `M ${r1(px)} ${r1(pBottom)} C ${r1(px)} ${r1(yc)}, ${r1(cx)} ${r1(yc)}, ${r1(cx)} ${r1(cBottom)}`;
|
||||||
return { d, endX: cx, endY: cBottom, sameRow };
|
return { d, endX: cx, endY: cBottom, sameRow };
|
||||||
}
|
}
|
||||||
@@ -424,6 +437,94 @@ function computeSseStale(input) {
|
|||||||
return now - lastMessageAt >= timeoutMs;
|
return now - lastMessageAt >= timeoutMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Home-screen session order: one comparator for both overviews.
|
||||||
|
//
|
||||||
|
// The phone overview (mobile-overview.js) and the desktop tab rail
|
||||||
|
// (home-sessions.js) list the same sessions, so they answer the same question
|
||||||
|
// and must answer it the same way: "which of these wants me next?".
|
||||||
|
//
|
||||||
|
// 1. Anything blocked on a human first (red question, then error, then a
|
||||||
|
// yellow idle prompt), longest-blocked at the top: a session that has been
|
||||||
|
// sitting on a permission dialog for 20 minutes is starving, one that
|
||||||
|
// raised it 5 seconds ago is not.
|
||||||
|
// 2. Then whatever is running, LONGEST-RUNNING first, since that is the turn most
|
||||||
|
// likely to be finished, or stuck, by the time you look.
|
||||||
|
// 3. Then everything quiet, MOST RECENTLY quiet first: when nothing is
|
||||||
|
// running, the session that just finished is the one you came back for,
|
||||||
|
// and the one you abandoned yesterday sinks.
|
||||||
|
//
|
||||||
|
// So the tiebreak flips direction halfway down the list, and that is the point:
|
||||||
|
// for a state something is still doing, longer = more urgent; for a state
|
||||||
|
// something has stopped in, more recent = more relevant.
|
||||||
|
//
|
||||||
|
// Pure: no DOM, no clock (every input is an epoch-ms stamp already on the
|
||||||
|
// session payload), no `this`. Unit-tested in test/session-overview-order.test.ts.
|
||||||
|
const SESSION_ACTIVITY_RANK = {
|
||||||
|
needs: 0,
|
||||||
|
error: 1,
|
||||||
|
waiting: 2,
|
||||||
|
working: 3,
|
||||||
|
idle: 4,
|
||||||
|
done: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** States still in progress, where the OLDEST stamp sorts first. */
|
||||||
|
const SESSION_ACTIVITY_OLDEST_FIRST = ['needs', 'error', 'waiting', 'working'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the row entered the state it is in.
|
||||||
|
*
|
||||||
|
* For everything quiet that is `lastActivityAt`, the last byte the pane printed:
|
||||||
|
* a Claude pane sitting at its composer prints nothing, so the end of the last
|
||||||
|
* turn is exactly when it went quiet.
|
||||||
|
*
|
||||||
|
* A WORKING pane is the opposite: it repaints about once a second, so its
|
||||||
|
* last-activity stamp is always "now" and would rank every running turn as
|
||||||
|
* freshly started. Its real start is the pane's last Enter (`lastSubmitAt`),
|
||||||
|
* persisted server-side and therefore stable across a Codeman restart. A
|
||||||
|
* working pane that has never submitted (spawned with its prompt on the command
|
||||||
|
* line, or an external CLI) falls back to last activity, which puts it at the
|
||||||
|
* short end of the running group rather than falsely at the head of it.
|
||||||
|
*/
|
||||||
|
function sessionActivityAnchor(row) {
|
||||||
|
const activeAt = Number(row && row.lastActivityAt) || 0;
|
||||||
|
if (row && row.state === 'working') return Number(row.lastSubmitAt) || activeAt;
|
||||||
|
return activeAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort comparator for one overview row against another.
|
||||||
|
* @param {{state: string, lastActivityAt?: number, lastSubmitAt?: number, orderIndex?: number}} a
|
||||||
|
* @param {{state: string, lastActivityAt?: number, lastSubmitAt?: number, orderIndex?: number}} b
|
||||||
|
*/
|
||||||
|
function compareSessionActivity(a, b) {
|
||||||
|
const rankA = SESSION_ACTIVITY_RANK[a.state];
|
||||||
|
const rankB = SESSION_ACTIVITY_RANK[b.state];
|
||||||
|
const rank = (rankA === undefined ? 99 : rankA) - (rankB === undefined ? 99 : rankB);
|
||||||
|
if (rank !== 0) return rank;
|
||||||
|
|
||||||
|
const atA = sessionActivityAnchor(a);
|
||||||
|
const atB = sessionActivityAnchor(b);
|
||||||
|
if (atA !== atB) {
|
||||||
|
// A row with no stamp at all gets no opinion: it sorts last either way
|
||||||
|
// rather than claiming to be the oldest (0) thing on the screen.
|
||||||
|
if (!atA) return 1;
|
||||||
|
if (!atB) return -1;
|
||||||
|
return SESSION_ACTIVITY_OLDEST_FIRST.includes(a.state) ? atA - atB : atB - atA;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equal stamps (or two unstamped rows): fall back to the user's tab order so
|
||||||
|
// the list is deterministic and cannot shuffle between renders.
|
||||||
|
const orderA = Number.isFinite(a.orderIndex) ? a.orderIndex : Number.MAX_SAFE_INTEGER;
|
||||||
|
const orderB = Number.isFinite(b.orderIndex) ? b.orderIndex : Number.MAX_SAFE_INTEGER;
|
||||||
|
return orderA - orderB;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copy of `rows`, in overview order. Never sorts in place, so callers keep their array. */
|
||||||
|
function sortSessionsByActivity(rows) {
|
||||||
|
return (Array.isArray(rows) ? rows.slice() : []).sort(compareSessionActivity);
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
||||||
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
||||||
@@ -441,6 +542,7 @@ if (typeof window !== 'undefined') {
|
|||||||
DIP_MIN_PX: LINEAGE_DIP_MIN_PX,
|
DIP_MIN_PX: LINEAGE_DIP_MIN_PX,
|
||||||
DIP_MAX_PX: LINEAGE_DIP_MAX_PX,
|
DIP_MAX_PX: LINEAGE_DIP_MAX_PX,
|
||||||
SIBLING_STEP_PX: LINEAGE_SIBLING_STEP_PX,
|
SIBLING_STEP_PX: LINEAGE_SIBLING_STEP_PX,
|
||||||
|
COLORS: LINEAGE_COLORS,
|
||||||
};
|
};
|
||||||
window.CodemanConnectionLoss = {
|
window.CodemanConnectionLoss = {
|
||||||
compute: computeConnectionLossUi,
|
compute: computeConnectionLossUi,
|
||||||
@@ -450,6 +552,12 @@ if (typeof window !== 'undefined') {
|
|||||||
compute: computeSseStale,
|
compute: computeSseStale,
|
||||||
TIMEOUT_MS: SSE_STALE_TIMEOUT_MS,
|
TIMEOUT_MS: SSE_STALE_TIMEOUT_MS,
|
||||||
};
|
};
|
||||||
|
window.CodemanSessionOrder = {
|
||||||
|
RANK: SESSION_ACTIVITY_RANK,
|
||||||
|
anchor: sessionActivityAnchor,
|
||||||
|
compare: compareSessionActivity,
|
||||||
|
sort: sortSessionsByActivity,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scheduler API — prioritize terminal writes over background UI updates.
|
// Scheduler API — prioritize terminal writes over background UI updates.
|
||||||
|
|||||||
@@ -5,8 +5,13 @@
|
|||||||
* The welcome screen centers ~560px of content in a window that is usually
|
* The welcome screen centers ~560px of content in a window that is usually
|
||||||
* 1400px+, so the two gutters are dead space. The left one now carries the same
|
* 1400px+, so the two gutters are dead space. The left one now carries the same
|
||||||
* list a phone gets on its home screen (mobile-overview.js), turned vertical:
|
* list a phone gets on its home screen (mobile-overview.js), turned vertical:
|
||||||
* one row per live tab, in TAB ORDER (not sorted by state) so it reads as the
|
* one row per live tab.
|
||||||
* tab strip rotated, and so Alt+1..9 still matches what you see.
|
*
|
||||||
|
* Rows are ordered by `CodemanSessionOrder` (constants.js), the same comparator
|
||||||
|
* the phone overview uses: blocked on you first, then running longest-first,
|
||||||
|
* then quiet most-recently-quiet first. The number badge stays the tab-strip
|
||||||
|
* index (Alt+1..9), so it is deliberately NOT sequential down a sorted rail:
|
||||||
|
* it names a shortcut, not a row position.
|
||||||
*
|
*
|
||||||
* DESKTOP ONLY, and only in a wide enough window: the rail is absolutely
|
* DESKTOP ONLY, and only in a wide enough window: the rail is absolutely
|
||||||
* positioned so the centered welcome content never moves, which means it can
|
* positioned so the centered welcome content never moves, which means it can
|
||||||
@@ -16,11 +21,13 @@
|
|||||||
* both scale with the viewport (see the `.home-sessions` block in styles.css) —
|
* both scale with the viewport (see the `.home-sessions` block in styles.css) —
|
||||||
* a fixed 256px card looks abandoned on a 2560px display.
|
* a fixed 256px card looks abandoned on a 2560px display.
|
||||||
*
|
*
|
||||||
* Each row carries when the session was FIRST CREATED and when it was LAST
|
* Each row carries when the session was FIRST CREATED and how long it has been
|
||||||
* ACTIVE, both relative. Those two stamps go stale on their own (a sitting
|
* in the state it is in ("created 3d ago · working 12m"), and that second stamp is
|
||||||
* session emits no event), so a slow clock refreshes them IN PLACE from the
|
* the value the order above is computed from, so the rail explains itself
|
||||||
* epoch-ms values parked on the elements, rather than re-rendering: a re-render
|
* rather than looking arbitrarily shuffled. Both stamps go stale on their own
|
||||||
* would restart every row's blink animation and its working ring.
|
* (a sitting session emits no event), so a slow clock refreshes them IN PLACE
|
||||||
|
* from the epoch-ms values parked on the elements, rather than re-rendering: a
|
||||||
|
* re-render would restart every row's blink animation and its working ring.
|
||||||
*
|
*
|
||||||
* The working state is deliberately identical to the phone's: a pulsing green
|
* The working state is deliberately identical to the phone's: a pulsing green
|
||||||
* dot ringed by the spinner a tab shows while it loads (`tab-load-spin`, reused
|
* dot ringed by the spinner a tab shows while it loads (`tab-load-spin`, reused
|
||||||
@@ -34,6 +41,7 @@
|
|||||||
*
|
*
|
||||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||||
* @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession)
|
* @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession)
|
||||||
|
* @dependency constants.js (CodemanSessionOrder, the shared row comparator)
|
||||||
* @dependency mobile-overview.js (_mobileOverviewState, _mobileOverviewCaseFor, shouldUseMobileOverview)
|
* @dependency mobile-overview.js (_mobileOverviewState, _mobileOverviewCaseFor, shouldUseMobileOverview)
|
||||||
* @dependency ralph-panel.js (formatRelativeTime — the app's one relative-time formatter)
|
* @dependency ralph-panel.js (formatRelativeTime — the app's one relative-time formatter)
|
||||||
* @dependency webview-tabs.js (this.webviews, this.webviewOrder, openWebview)
|
* @dependency webview-tabs.js (this.webviews, this.webviewOrder, openWebview)
|
||||||
@@ -158,11 +166,18 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One row per live session, in the user's tab order. State classification is
|
* One row per live session, in overview order: whatever is blocked on you
|
||||||
* `_mobileOverviewState()` (mobile-overview.js) so both home screens agree on
|
* first, then whatever is running (longest turn first), then the quiet ones
|
||||||
* what counts as needing you; the ORDER differs on purpose — the phone sorts
|
* most-recently-quiet first. The comparator is `CodemanSessionOrder`
|
||||||
* by urgency because it shows one screenful at a time, this column mirrors the
|
* (constants.js), shared with the phone overview, and state classification is
|
||||||
* tab strip so the number badges line up with Alt+1..9.
|
* `_mobileOverviewState()` (mobile-overview.js), so the two home screens can
|
||||||
|
* neither disagree about what "working" means nor about what sorts first.
|
||||||
|
*
|
||||||
|
* `orderIndex` stays the position in the TAB STRIP, because that is what the
|
||||||
|
* number badge means (Alt+1..9). Once the rows are sorted those badges no
|
||||||
|
* longer run 1,2,3 down the rail: the badge answers "which key selects this",
|
||||||
|
* not "how far down the list is it".
|
||||||
|
*
|
||||||
* @returns {Array<object>} row descriptors, ready to render
|
* @returns {Array<object>} row descriptors, ready to render
|
||||||
*/
|
*/
|
||||||
buildHomeSessionRows() {
|
buildHomeSessionRows() {
|
||||||
@@ -173,14 +188,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// invisible here while its tab already exists.
|
// invisible here while its tab already exists.
|
||||||
for (const id of this.sessions?.keys() || []) if (!ids.includes(id)) ids.push(id);
|
for (const id of this.sessions?.keys() || []) if (!ids.includes(id)) ids.push(id);
|
||||||
|
|
||||||
return ids.map((id, index) => {
|
const rows = ids.map((id, orderIndex) => {
|
||||||
const session = this.sessions.get(id);
|
const session = this.sessions.get(id);
|
||||||
const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
|
const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
|
||||||
const state = this._mobileOverviewState(session, this.pendingHooks?.get(id));
|
const state = this._mobileOverviewState(session, this.pendingHooks?.get(id));
|
||||||
const mode = session.mode || 'claude';
|
const mode = session.mode || 'claude';
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
index,
|
orderIndex,
|
||||||
name: this.getSessionName ? this.getSessionName(session) : session.name || id.slice(0, 8),
|
name: this.getSessionName ? this.getSessionName(session) : session.name || id.slice(0, 8),
|
||||||
mode,
|
mode,
|
||||||
modeBadge: HOME_SESSIONS_MODE_BADGE[mode] || '',
|
modeBadge: HOME_SESSIONS_MODE_BADGE[mode] || '',
|
||||||
@@ -192,8 +207,19 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// render time so the clock below can redo it without a re-render.
|
// render time so the clock below can redo it without a re-render.
|
||||||
createdAt: Number(session.createdAt) || 0,
|
createdAt: Number(session.createdAt) || 0,
|
||||||
lastActivityAt: Number(session.lastActivityAt) || 0,
|
lastActivityAt: Number(session.lastActivityAt) || 0,
|
||||||
|
// The running group is ordered by the pane's last Enter, since a
|
||||||
|
// working pane's last-activity stamp is always "now".
|
||||||
|
lastSubmitAt: Number(session.lastSubmitAt) || 0,
|
||||||
|
// "how long has it been like this", resolved by the phone overview's
|
||||||
|
// helper so both home screens label the same stamp with the same word.
|
||||||
|
since: this._mobileOverviewSince(state, session),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Guarded like every other constants.js consumer: a stale cached
|
||||||
|
// constants.js (iOS Safari serves old JS after a deploy) must degrade to
|
||||||
|
// tab order, not TypeError the whole home screen away.
|
||||||
|
return window.CodemanSessionOrder ? window.CodemanSessionOrder.sort(rows) : rows;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -234,32 +260,40 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The "created 2h ago · active 3m ago" footer line. Both stamps keep their raw
|
* The "created 2h ago · working 12m" footer line. Both stamps keep their raw
|
||||||
* epoch-ms on the element (`data-hs-ts`) so `_tickHomeSessionsTimes()` can
|
* epoch-ms on the element (`data-hs-ts`) so `_tickHomeSessionsTimes()` can
|
||||||
* rewrite the text without rebuilding the row.
|
* rewrite the text without rebuilding the row.
|
||||||
|
*
|
||||||
|
* The second stamp is the row's state duration, NOT a plain last-active
|
||||||
|
* stamp: it is the number the rail is sorted by, and a working row that reads
|
||||||
|
* "active just now" (every working pane repaints about once a second) hides
|
||||||
|
* exactly the value that decided its position. `_mobileOverviewSince()` owns
|
||||||
|
* both the word and the anchor, so the phone says the same thing.
|
||||||
*/
|
*/
|
||||||
_buildHomeSessionsMeta(row) {
|
_buildHomeSessionsMeta(row) {
|
||||||
const meta = document.createElement('span');
|
const meta = document.createElement('span');
|
||||||
meta.className = 'home-sessions-row-meta';
|
meta.className = 'home-sessions-row-meta';
|
||||||
// Relative times are generated text, and "created"/"active" here are the
|
// Relative times are generated text, and "created"/"idle" here are the
|
||||||
// same generic words that mean something else on other surfaces.
|
// same generic words that mean something else on other surfaces.
|
||||||
meta.setAttribute('data-i18n-skip', '');
|
meta.setAttribute('data-i18n-skip', '');
|
||||||
|
|
||||||
meta.appendChild(this._buildHomeSessionsStamp('created', row.createdAt, 'home-sessions-meta-created'));
|
meta.appendChild(this._buildHomeSessionsStamp('created', row.createdAt, 'ago', 'home-sessions-meta-created'));
|
||||||
|
|
||||||
const sep = document.createElement('span');
|
if (row.since) {
|
||||||
sep.className = 'home-sessions-meta-sep';
|
const sep = document.createElement('span');
|
||||||
sep.setAttribute('aria-hidden', 'true');
|
sep.className = 'home-sessions-meta-sep';
|
||||||
sep.textContent = '·';
|
sep.setAttribute('aria-hidden', 'true');
|
||||||
meta.appendChild(sep);
|
sep.textContent = '·';
|
||||||
|
meta.appendChild(sep);
|
||||||
|
|
||||||
meta.appendChild(this._buildHomeSessionsStamp('active', row.lastActivityAt, 'home-sessions-meta-active'));
|
meta.appendChild(this._buildHomeSessionsStamp(row.since.key, row.since.at, 'for', 'home-sessions-meta-since'));
|
||||||
|
}
|
||||||
|
|
||||||
return meta;
|
return meta;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** One labelled stamp: a dim key, the relative value, full date in the title. */
|
/** One labelled stamp: a dim key, the value, full date in the title. */
|
||||||
_buildHomeSessionsStamp(key, timestamp, className) {
|
_buildHomeSessionsStamp(key, timestamp, format, className) {
|
||||||
const wrap = document.createElement('span');
|
const wrap = document.createElement('span');
|
||||||
wrap.className = `home-sessions-meta-item ${className}`;
|
wrap.className = `home-sessions-meta-item ${className}`;
|
||||||
|
|
||||||
@@ -270,18 +304,21 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
const value = document.createElement('span');
|
const value = document.createElement('span');
|
||||||
value.dataset.hsTs = String(timestamp || 0);
|
value.dataset.hsTs = String(timestamp || 0);
|
||||||
value.textContent = this._homeSessionsAgo(timestamp);
|
value.dataset.hsFmt = format;
|
||||||
|
value.textContent = this._homeSessionsStampText(timestamp, format);
|
||||||
wrap.appendChild(value);
|
wrap.appendChild(value);
|
||||||
|
|
||||||
if (timestamp)
|
if (timestamp) wrap.title = `${key === 'created' ? 'First created' : key}: ${new Date(timestamp).toLocaleString()}`;
|
||||||
wrap.title = `${key === 'created' ? 'First created' : 'Last active'}: ${new Date(timestamp).toLocaleString()}`;
|
|
||||||
return wrap;
|
return wrap;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Relative label for a stamp. `formatRelativeTime` is the app's one formatter. */
|
/**
|
||||||
_homeSessionsAgo(timestamp) {
|
* 'ago' points at a moment ("3d ago"), 'for' measures a span to now ("12m").
|
||||||
if (!timestamp) return '—';
|
* Both come from the phone overview's formatter, so a duration is written the
|
||||||
return this.formatRelativeTime(timestamp) || '—';
|
* same way on both home screens.
|
||||||
|
*/
|
||||||
|
_homeSessionsStampText(timestamp, format) {
|
||||||
|
return this._mobileOverviewStampText(timestamp, format);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -311,7 +348,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
for (const node of el.querySelectorAll('[data-hs-ts]')) {
|
for (const node of el.querySelectorAll('[data-hs-ts]')) {
|
||||||
const ts = Number(node.dataset.hsTs) || 0;
|
const ts = Number(node.dataset.hsTs) || 0;
|
||||||
const text = this._homeSessionsAgo(ts);
|
const text = this._homeSessionsStampText(ts, node.dataset.hsFmt);
|
||||||
if (node.textContent !== text) node.textContent = text;
|
if (node.textContent !== text) node.textContent = text;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -348,11 +385,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
item.dataset.hsSession = row.id;
|
item.dataset.hsSession = row.id;
|
||||||
item.title = row.dir ? `${row.name} (${row.dir})` : row.name;
|
item.title = row.dir ? `${row.name} (${row.dir})` : row.name;
|
||||||
|
|
||||||
if (row.index < 9) {
|
// The badge is the Alt+N key for this tab, so it keeps the tab-strip index
|
||||||
|
// even though the rows are sorted by activity: it will not read 1,2,3 down
|
||||||
|
// the rail, and must not, or the shortcut it names would be wrong.
|
||||||
|
if (row.orderIndex < 9) {
|
||||||
const number = document.createElement('span');
|
const number = document.createElement('span');
|
||||||
number.className = 'home-sessions-number';
|
number.className = 'home-sessions-number';
|
||||||
number.setAttribute('data-i18n-skip', '');
|
number.setAttribute('data-i18n-skip', '');
|
||||||
number.textContent = String(row.index + 1);
|
number.textContent = String(row.orderIndex + 1);
|
||||||
item.appendChild(number);
|
item.appendChild(number);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1211,6 +1211,13 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="set-row" data-search="pop out detach tab window this session">
|
||||||
|
<div class="set-row-text">
|
||||||
|
<span class="set-row-label">Pop-out button on this tab</span>
|
||||||
|
<span class="set-row-desc">Show the open-in-a-window button on this tab even while the general App Settings toggle is off.</span>
|
||||||
|
</div>
|
||||||
|
<label class="switch switch-sm"><input type="checkbox" id="sessionOptShowTabDetach" onchange="app.onSessionTabDetachToggle(this.checked)"><span class="slider"></span></label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
* errored sessions), then SPACES (cases, expandable to their sessions), then
|
* errored sessions), then SPACES (cases, expandable to their sessions), then
|
||||||
* WORKING and IDLE / DONE.
|
* WORKING and IDLE / DONE.
|
||||||
*
|
*
|
||||||
|
* Rows inside a section are ordered by `CodemanSessionOrder` (constants.js),
|
||||||
|
* the SAME comparator the desktop rail uses: blocked longest-first, then
|
||||||
|
* running longest-first, then quiet most-recently-quiet first.
|
||||||
|
*
|
||||||
* PHONE ONLY. The gate is `shouldUseMobileOverview()` (viewport < 430px, not a
|
* PHONE ONLY. The gate is `shouldUseMobileOverview()` (viewport < 430px, not a
|
||||||
* popped-out solo window, per-device setting on). Tablet and desktop keep the
|
* popped-out solo window, per-device setting on). Tablet and desktop keep the
|
||||||
* welcome overlay untouched. The container ships with the `hidden` attribute and
|
* welcome overlay untouched. The container ships with the `hidden` attribute and
|
||||||
@@ -25,6 +29,7 @@
|
|||||||
* `buildMobileOverviewModel()` is pure and unit-tested (test/mobile-overview.test.ts).
|
* `buildMobileOverviewModel()` is pure and unit-tested (test/mobile-overview.test.ts).
|
||||||
*
|
*
|
||||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||||
|
* @dependency constants.js (CodemanSessionOrder, the shared row comparator)
|
||||||
* @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession, run)
|
* @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession, run)
|
||||||
* @dependency ralph-panel.js (formatRelativeTime, the app's one relative-time formatter)
|
* @dependency ralph-panel.js (formatRelativeTime, the app's one relative-time formatter)
|
||||||
* @dependency mobile-handlers.js (MobileDetection)
|
* @dependency mobile-handlers.js (MobileDetection)
|
||||||
@@ -35,16 +40,6 @@
|
|||||||
/** Viewport width that counts as a phone. Matches the mobile.css phone block. */
|
/** Viewport width that counts as a phone. Matches the mobile.css phone block. */
|
||||||
const MOBILE_OVERVIEW_PHONE_QUERY = '(max-width: 430px)';
|
const MOBILE_OVERVIEW_PHONE_QUERY = '(max-width: 430px)';
|
||||||
|
|
||||||
/** Sort rank per state: the most demanding thing sorts first inside a section. */
|
|
||||||
const MOBILE_OVERVIEW_STATE_RANK = {
|
|
||||||
needs: 0,
|
|
||||||
error: 1,
|
|
||||||
waiting: 2,
|
|
||||||
working: 3,
|
|
||||||
idle: 4,
|
|
||||||
done: 5,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** How many past conversations show before the "Show all" toggle. */
|
/** How many past conversations show before the "Show all" toggle. */
|
||||||
const MOBILE_OVERVIEW_PAST_LIMIT = 8;
|
const MOBILE_OVERVIEW_PAST_LIMIT = 8;
|
||||||
|
|
||||||
@@ -188,18 +183,25 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Epoch ms, straight off the session payload; formatting happens at
|
// Epoch ms, straight off the session payload; formatting happens at
|
||||||
// render time so the clock can redo it without a re-render.
|
// render time so the clock can redo it without a re-render.
|
||||||
createdAt: Number(session.createdAt) || 0,
|
createdAt: Number(session.createdAt) || 0,
|
||||||
|
// Raw stamps for the shared order comparator; `since` above is the same
|
||||||
|
// pair resolved for DISPLAY, and the two must not drift apart.
|
||||||
|
lastActivityAt: Number(session.lastActivityAt) || 0,
|
||||||
|
lastSubmitAt: Number(session.lastSubmitAt) || 0,
|
||||||
since: this._mobileOverviewSince(state, session),
|
since: this._mobileOverviewSince(state, session),
|
||||||
orderIndex: orderIndex === -1 ? Number.MAX_SAFE_INTEGER : orderIndex,
|
orderIndex: orderIndex === -1 ? Number.MAX_SAFE_INTEGER : orderIndex,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const bySeverityThenOrder = (a, b) => {
|
// Order is `CodemanSessionOrder` (constants.js), shared with the desktop
|
||||||
const rank = MOBILE_OVERVIEW_STATE_RANK[a.state] - MOBILE_OVERVIEW_STATE_RANK[b.state];
|
// rail: blocked first (longest-blocked at the top), then running
|
||||||
return rank !== 0 ? rank : a.orderIndex - b.orderIndex;
|
// longest-first, then quiet most-recent-first.
|
||||||
|
// Guarded: a stale cached constants.js (iOS Safari after a deploy) must
|
||||||
|
// degrade to tab order, not TypeError the overview away.
|
||||||
|
const inSection = (states) => {
|
||||||
|
const filtered = rows.filter((r) => states.includes(r.state));
|
||||||
|
return window.CodemanSessionOrder ? window.CodemanSessionOrder.sort(filtered) : filtered;
|
||||||
};
|
};
|
||||||
|
|
||||||
const inSection = (states) => rows.filter((r) => states.includes(r.state)).sort(bySeverityThenOrder);
|
|
||||||
|
|
||||||
// Past = conversations from the unified list that are not currently live.
|
// Past = conversations from the unified list that are not currently live.
|
||||||
// The endpoint already folds a transcript into its owning session (via the
|
// The endpoint already folds a transcript into its owning session (via the
|
||||||
// claudeSessionId alias map), so a plain id check is enough to avoid listing
|
// claudeSessionId alias map), so a plain id check is enough to avoid listing
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
*
|
*
|
||||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||||
* @dependency subagent-windows.js (_updateConnectionLinesImmediate, #connectionLines)
|
* @dependency subagent-windows.js (_updateConnectionLinesImmediate, #connectionLines)
|
||||||
* @dependency constants.js (window.CodemanLineage.computePath)
|
* @dependency constants.js (window.CodemanLineage.computePath + .COLORS)
|
||||||
* @dependency settings-ui.js (loadAppSettingsFromStorage, getDefaultSettings)
|
* @dependency settings-ui.js (loadAppSettingsFromStorage, getDefaultSettings)
|
||||||
* @loadorder 15.6 (after ultracode-windows.js — appended to the same SVG pass)
|
* @loadorder 15.6 (after ultracode-windows.js — appended to the same SVG pass)
|
||||||
*/
|
*/
|
||||||
@@ -90,6 +90,35 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return edges;
|
return edges;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Colour for one child's arc, from CodemanLineage.COLORS, assigned in FIRST-SEEN
|
||||||
|
* order and remembered per child id. First-seen rather than draw-index keeps a
|
||||||
|
* line's colour stable across re-renders, tab reorders and sibling closes (the
|
||||||
|
* SVG is wiped and rebuilt constantly, so an index-based colour would flicker).
|
||||||
|
* An empty string means "no override": the CSS falls back to --session-blue.
|
||||||
|
*/
|
||||||
|
_lineageColorFor(childId) {
|
||||||
|
const palette = (window.CodemanLineage && window.CodemanLineage.COLORS) || [];
|
||||||
|
if (palette.length === 0) return '';
|
||||||
|
if (!this._lineageColorByChild) {
|
||||||
|
this._lineageColorByChild = new Map();
|
||||||
|
this._lineageColorNext = 0;
|
||||||
|
}
|
||||||
|
let idx = this._lineageColorByChild.get(childId);
|
||||||
|
if (idx === undefined) {
|
||||||
|
idx = this._lineageColorNext++ % palette.length;
|
||||||
|
this._lineageColorByChild.set(childId, idx);
|
||||||
|
// Bounded: entries for long-gone sessions are pruned once the map is clearly
|
||||||
|
// stale, so a day-long dashboard cannot grow it without limit.
|
||||||
|
if (this._lineageColorByChild.size > 200 && this.sessions) {
|
||||||
|
for (const key of this._lineageColorByChild.keys()) {
|
||||||
|
if (!this.sessions.has(key)) this._lineageColorByChild.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return palette[idx] || '';
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append the lineage layer to the shared SVG pass.
|
* Append the lineage layer to the shared SVG pass.
|
||||||
*
|
*
|
||||||
@@ -137,6 +166,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// the line itself. `status` is the CHILD's, which is the interesting end.
|
// the line itself. `status` is the CHILD's, which is the interesting end.
|
||||||
const working = edge.status === 'working' ? ' lineage-line--working' : '';
|
const working = edge.status === 'working' ? ' lineage-line--working' : '';
|
||||||
line.setAttribute('class', 'connection-line lineage-line' + working);
|
line.setAttribute('class', 'connection-line lineage-line' + working);
|
||||||
|
// Per-child colour rides a CSS custom property so the stylesheet keeps owning
|
||||||
|
// opacity, glow and dash; an empty colour leaves the --session-blue fallback.
|
||||||
|
const color = this._lineageColorFor(edge.childId);
|
||||||
|
if (color) line.style.setProperty('--lineage-color', color);
|
||||||
// `data-agent-id` is what _applyLineEntrances() queries — see the file header.
|
// `data-agent-id` is what _applyLineEntrances() queries — see the file header.
|
||||||
line.setAttribute('data-agent-id', 'lineage:' + edge.childId);
|
line.setAttribute('data-agent-id', 'lineage:' + edge.childId);
|
||||||
line.setAttribute('data-parent-tab', edge.parentId);
|
line.setAttribute('data-parent-tab', edge.parentId);
|
||||||
@@ -153,6 +186,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
dot.setAttribute('r', '3.5');
|
dot.setAttribute('r', '3.5');
|
||||||
dot.setAttribute('class', 'lineage-line-dot' + working);
|
dot.setAttribute('class', 'lineage-line-dot' + working);
|
||||||
dot.setAttribute('data-child-tab', edge.childId);
|
dot.setAttribute('data-child-tab', edge.childId);
|
||||||
|
if (color) dot.style.setProperty('--lineage-color', color);
|
||||||
svg.appendChild(dot);
|
svg.appendChild(dot);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1283,12 +1283,65 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Session Options Modal
|
// Session Options Modal
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-TAB pop-out button override (Session Options → Session → Identity). The
|
||||||
|
* general `showTabDetachButton` App Setting stays the per-device default for ALL
|
||||||
|
* tabs; this map whitelists single sessions on top of it, so one tab can carry
|
||||||
|
* the ⧉ button while the general toggle stays off. Per-device on purpose, like
|
||||||
|
* the general setting: it is a display choice, so it lives in localStorage and
|
||||||
|
* never touches the server schema. Rendered as the `tab-show-detach` class on
|
||||||
|
* the tab (see _fullRenderSessionTabs), which styles.css exempts from the
|
||||||
|
* global `display: none` gate; the active-tab reveal rules stay shared, so an
|
||||||
|
* overridden tab behaves exactly like a tab under the general toggle.
|
||||||
|
*/
|
||||||
|
_tabDetachOverrides() {
|
||||||
|
if (this._tabDetachOverrideMap === undefined) {
|
||||||
|
try {
|
||||||
|
this._tabDetachOverrideMap = JSON.parse(localStorage.getItem('codeman:tab-detach-overrides') || '{}') || {};
|
||||||
|
} catch (_e) {
|
||||||
|
this._tabDetachOverrideMap = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this._tabDetachOverrideMap;
|
||||||
|
},
|
||||||
|
|
||||||
|
hasTabDetachOverride(sessionId) {
|
||||||
|
return !!this._tabDetachOverrides()[sessionId];
|
||||||
|
},
|
||||||
|
|
||||||
|
onSessionTabDetachToggle(on) {
|
||||||
|
const id = this.editingSessionId;
|
||||||
|
if (!id) return;
|
||||||
|
const map = this._tabDetachOverrides();
|
||||||
|
if (on) map[id] = 1;
|
||||||
|
else delete map[id];
|
||||||
|
// Prune ids whose sessions are gone, so closed sessions cannot grow the map.
|
||||||
|
for (const key of Object.keys(map)) {
|
||||||
|
if (key !== id && this.sessions && !this.sessions.has(key)) delete map[key];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
localStorage.setItem('codeman:tab-detach-overrides', JSON.stringify(map));
|
||||||
|
} catch (_e) {
|
||||||
|
/* storage full/blocked: the in-memory map still applies this page load */
|
||||||
|
}
|
||||||
|
// Apply to the LIVE tab directly: the debounced render may take the
|
||||||
|
// incremental path (same session set), which patches rather than rebuilds,
|
||||||
|
// so the template's class would only land on the next full render. Future
|
||||||
|
// full renders re-emit it from _fullRenderSessionTabs.
|
||||||
|
const tab = document.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
|
||||||
|
if (tab) tab.classList.toggle('tab-show-detach', !!on);
|
||||||
|
},
|
||||||
|
|
||||||
openSessionOptions(sessionId) {
|
openSessionOptions(sessionId) {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
|
||||||
this.editingSessionId = sessionId;
|
this.editingSessionId = sessionId;
|
||||||
|
|
||||||
|
// Per-tab pop-out override state (see _tabDetachOverrides above).
|
||||||
|
const detachToggle = document.getElementById('sessionOptShowTabDetach');
|
||||||
|
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
|
||||||
|
|
||||||
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
|
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
|
||||||
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi';
|
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi';
|
||||||
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
|
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
|
||||||
|
|||||||
+88
-21
@@ -1459,23 +1459,80 @@ html[data-line-anim="packet"] .connection-line.line-enter {
|
|||||||
color: var(--green);
|
color: var(--green);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tab alert animations */
|
/* Tab alerts: a STEADY red/yellow base with a pulse breathing on top.
|
||||||
.session-tab.tab-alert-action {
|
⚠ The original animation swung background AND border to transparent at its
|
||||||
|
0%/100% keyframes, so for roughly half of every cycle an alerted tab was
|
||||||
|
indistinguishable from a normal one: a glance (or a screenshot, owner report
|
||||||
|
2026-08-15) read "no alert" while the home rail showed a steady NEEDS YOU.
|
||||||
|
A pending permission is BLOCKING the agent, so the tab must look blocked at
|
||||||
|
every instant; only the intensity is allowed to move. The status dot joins
|
||||||
|
in (red/yellow, (0,4,0) so it outranks the skin block's (0,3,1) dot rules),
|
||||||
|
mirroring the phone overview's red-row language. */
|
||||||
|
/* The alert paints on ::before, NEVER on the tab element: .session-tab.active
|
||||||
|
forces background/border/box-shadow with !important, and !important beats
|
||||||
|
even a running animation, so an element-level alert vanished the moment the
|
||||||
|
tab was selected. The permission is still blocking while you look at it, so
|
||||||
|
the red ring must survive selection and clear only on resolution (owner call
|
||||||
|
2026-08-15). Same convention as the entrance styles (see the tab-enter block).
|
||||||
|
(0,3,x) via the strip parent on purpose: the non-OG skin block quiets
|
||||||
|
decorative glows (`.tab-glow { box-shadow: none }` lands at (0,2,1)), and an
|
||||||
|
alert halo is signal, not decor, so it must outrank that on every skin.
|
||||||
|
The overlay paints above the tab's inline content (positioned vs flow), which
|
||||||
|
is fine at these alphas and is exactly what keeps it visible over the active
|
||||||
|
tab's opaque-ish background. */
|
||||||
|
.session-tabs .session-tab.tab-alert-action::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -2px;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
/* Explicit: .tab-enter::before (entrance animations) parks ::before at
|
||||||
|
opacity 0 with fill-mode both, and an alerted tab that is also entering
|
||||||
|
would otherwise inherit that and render an invisible alert. Our animation
|
||||||
|
shorthand already displaces theirs at this specificity; the opacity must
|
||||||
|
be pinned the same way. */
|
||||||
|
opacity: 1;
|
||||||
|
border: 2px solid var(--red);
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
box-shadow: 0 0 8px rgba(239, 68, 68, 0.4);
|
||||||
animation: tab-blink-red 2.5s ease-in-out infinite;
|
animation: tab-blink-red 2.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-tab.tab-alert-idle {
|
.session-tab.tab-alert-action .tab-status.idle,
|
||||||
|
.session-tab.tab-alert-action .tab-status.busy,
|
||||||
|
.session-tab.tab-alert-action .tab-status {
|
||||||
|
background: var(--red);
|
||||||
|
box-shadow: 0 0 6px rgba(239, 68, 68, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-tabs .session-tab.tab-alert-idle::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -2px;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 1; /* see the action variant above */
|
||||||
|
border: 2px solid var(--yellow);
|
||||||
|
background: rgba(234, 179, 8, 0.1);
|
||||||
|
box-shadow: 0 0 8px rgba(234, 179, 8, 0.35);
|
||||||
animation: tab-blink-yellow 3.5s ease-in-out infinite;
|
animation: tab-blink-yellow 3.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-tab.tab-alert-idle .tab-status.idle,
|
||||||
|
.session-tab.tab-alert-idle .tab-status.busy,
|
||||||
|
.session-tab.tab-alert-idle .tab-status {
|
||||||
|
background: var(--yellow);
|
||||||
|
box-shadow: 0 0 6px rgba(234, 179, 8, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes tab-blink-red {
|
@keyframes tab-blink-red {
|
||||||
0%, 100% { background: transparent; border-color: transparent; }
|
0%, 100% { background: rgba(239, 68, 68, 0.12); box-shadow: 0 0 8px rgba(239, 68, 68, 0.4); }
|
||||||
50% { background: rgba(239, 68, 68, 0.12); border-color: var(--red); }
|
50% { background: rgba(239, 68, 68, 0.3); box-shadow: 0 0 16px rgba(239, 68, 68, 0.75); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes tab-blink-yellow {
|
@keyframes tab-blink-yellow {
|
||||||
0%, 100% { background: transparent; border-color: transparent; }
|
0%, 100% { background: rgba(234, 179, 8, 0.1); box-shadow: 0 0 8px rgba(234, 179, 8, 0.35); }
|
||||||
50% { background: rgba(234, 179, 8, 0.1); border-color: var(--yellow); }
|
50% { background: rgba(234, 179, 8, 0.24); box-shadow: 0 0 14px rgba(234, 179, 8, 0.65); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
@@ -2081,8 +2138,12 @@ html[data-line-anim="packet"] .connection-line.line-enter {
|
|||||||
/* Pop-out button is opt-in (App Settings → Tab Bar, default off; per-device).
|
/* Pop-out button is opt-in (App Settings → Tab Bar, default off; per-device).
|
||||||
settings-ui.js mirrors the setting as the tabs-show-detach class on <html>.
|
settings-ui.js mirrors the setting as the tabs-show-detach class on <html>.
|
||||||
A tab that is ALREADY detached keeps its icon regardless: it is the
|
A tab that is ALREADY detached keeps its icon regardless: it is the
|
||||||
re-focus affordance for the popped-out window. */
|
re-focus affordance for the popped-out window. A SINGLE tab can also opt in
|
||||||
html:not(.tabs-show-detach) .session-tab:not(.detached) .tab-detach {
|
via Session Options → Session (`tab-show-detach` on the tab, per-device map
|
||||||
|
in session-ui.js) while the general toggle stays off; the active-tab reveal
|
||||||
|
rules above are shared, so the overridden tab behaves identically. Phones are
|
||||||
|
unaffected either way: mobile.css hides .tab-detach with !important. */
|
||||||
|
html:not(.tabs-show-detach) .session-tab:not(.detached):not(.tab-show-detach) .tab-detach {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9329,11 +9390,15 @@ kbd {
|
|||||||
Deliberately quieter and thinner than the subagent lines above so the two
|
Deliberately quieter and thinner than the subagent lines above so the two
|
||||||
layers read as different things in the same SVG.
|
layers read as different things in the same SVG.
|
||||||
|
|
||||||
Colour comes from --session-blue, which EVERY skin block already defines and
|
Colour: every rule reads --lineage-color, which session-lineage.js sets INLINE
|
||||||
already tunes for its own background, so one rule covers all seven (the four
|
per line from the CodemanLineage.COLORS palette (per child, first-seen order,
|
||||||
light skins included). Do not add a per-skin `.lineage-line` override inside the
|
owner call 2026-08-15: several connected tabs must get several colours). The
|
||||||
html:not([data-skin="og"]) block: a bare class rule in there resolves to (0,2,1)
|
FIRST line gets no override, so it falls through to --session-blue, which EVERY
|
||||||
and would outrank this one from a surprising place.
|
skin block already defines and tunes for its own background; a lone arc therefore
|
||||||
|
still renders the skin-aware blue that shipped in 1.18.2. Do not add a per-skin
|
||||||
|
`.lineage-line` override inside the html:not([data-skin="og"]) block: a bare
|
||||||
|
class rule in there resolves to (0,2,1) and would outrank this one from a
|
||||||
|
surprising place.
|
||||||
|
|
||||||
⚠ BLUE, NOT THE VIOLET THIS SHIPPED WITH (owner call, 2026-08-14: "make these
|
⚠ BLUE, NOT THE VIOLET THIS SHIPPED WITH (owner call, 2026-08-14: "make these
|
||||||
lines in blue that they are better visible"). Violet sits close to the terminal's
|
lines in blue that they are better visible"). Violet sits close to the terminal's
|
||||||
@@ -9352,13 +9417,13 @@ kbd {
|
|||||||
(4 4 on a 2.5px line reads as a dotted smudge), and `lineage-flow` marches by
|
(4 4 on a 2.5px line reads as a dotted smudge), and `lineage-flow` marches by
|
||||||
exactly two dash cycles, so it has to move with them. */
|
exactly two dash cycles, so it has to move with them. */
|
||||||
.connection-line.lineage-line {
|
.connection-line.lineage-line {
|
||||||
stroke: var(--session-blue, #2b8fd9);
|
stroke: var(--lineage-color, var(--session-blue, #2b8fd9));
|
||||||
stroke-width: 2.5;
|
stroke-width: 2.5;
|
||||||
stroke-dasharray: 5 5;
|
stroke-dasharray: 5 5;
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
opacity: 0.72;
|
opacity: 0.72;
|
||||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7)) drop-shadow(0 0 5px var(--session-blue, #2b8fd9))
|
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7)) drop-shadow(0 0 5px var(--lineage-color, var(--session-blue, #2b8fd9)))
|
||||||
drop-shadow(0 0 11px var(--session-blue, #2b8fd9));
|
drop-shadow(0 0 11px var(--lineage-color, var(--session-blue, #2b8fd9)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ⚠ OUTSIDE the reduced-motion block below on purpose. A working child is the case
|
/* ⚠ OUTSIDE the reduced-motion block below on purpose. A working child is the case
|
||||||
@@ -9374,9 +9439,10 @@ kbd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lineage-line-dot {
|
.lineage-line-dot {
|
||||||
fill: var(--session-blue, #2b8fd9);
|
fill: var(--lineage-color, var(--session-blue, #2b8fd9));
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
filter: drop-shadow(0 0 4px var(--session-blue, #2b8fd9)) drop-shadow(0 0 9px var(--session-blue, #2b8fd9));
|
filter: drop-shadow(0 0 4px var(--lineage-color, var(--session-blue, #2b8fd9)))
|
||||||
|
drop-shadow(0 0 9px var(--lineage-color, var(--session-blue, #2b8fd9)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The child end marches while that worker is actually working, so the line
|
/* The child end marches while that worker is actually working, so the line
|
||||||
@@ -14836,8 +14902,9 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* The freshest signal on the row: while a session is actually doing something,
|
/* The freshest signal on the row: while a session is actually doing something,
|
||||||
its "active" stamp is the one the eye should land on. */
|
how long it has been doing it is what the eye should land on (and it is what
|
||||||
.home-sessions-row--working .home-sessions-meta-active {
|
the rail is sorted by). */
|
||||||
|
.home-sessions-row--working .home-sessions-meta-since {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
opacity: 0.95;
|
opacity: 0.95;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ import {
|
|||||||
stripCaseEnvKeys,
|
stripCaseEnvKeys,
|
||||||
applyStatusLineConfig,
|
applyStatusLineConfig,
|
||||||
applyAgentSkill,
|
applyAgentSkill,
|
||||||
|
refreshUserAgentSkill,
|
||||||
|
seedAgentSessionPreamble,
|
||||||
refreshStaleCodemanHooks,
|
refreshStaleCodemanHooks,
|
||||||
} from '../../hooks-config.js';
|
} from '../../hooks-config.js';
|
||||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||||
@@ -601,6 +603,13 @@ function abortOnClientHangUp(reply: FastifyReply): AbortController {
|
|||||||
async function injectAgentSkill(casePath: string): Promise<void> {
|
async function injectAgentSkill(casePath: string): Promise<void> {
|
||||||
const skillDir = join(casePath, '.claude', 'skills', 'codeman');
|
const skillDir = join(casePath, '.claude', 'skills', 'codeman');
|
||||||
try {
|
try {
|
||||||
|
// Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`,
|
||||||
|
// written once by `codeman skill install`) over the case copy injected below, so a
|
||||||
|
// stale user copy silently replaces every fresh injection (observed 2026-08-14: an
|
||||||
|
// old copy cost every spawned worker its lineage arc and the fast path). Keep it
|
||||||
|
// current on the same trigger. Refresh-only + marker-guarded; quiet on refusal,
|
||||||
|
// since a foreign user copy is the user's own authored skill, not a config error.
|
||||||
|
await refreshUserAgentSkill();
|
||||||
const result = await applyAgentSkill(casePath, true);
|
const result = await applyAgentSkill(casePath, true);
|
||||||
if (result === 'foreign') {
|
if (result === 'foreign') {
|
||||||
console.warn(
|
console.warn(
|
||||||
@@ -913,6 +922,13 @@ export function registerSessionRoutes(
|
|||||||
ctx.store.incrementSessionsCreated();
|
ctx.store.incrementSessionsCreated();
|
||||||
ctx.persistSessionState(session);
|
ctx.persistSessionState(session);
|
||||||
await ctx.setupSessionListeners(session);
|
await ctx.setupSessionListeners(session);
|
||||||
|
// Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line
|
||||||
|
// loader (see seedAgentSessionPreamble). Local claude sessions only; best-effort.
|
||||||
|
if (mode === 'claude' && !remote && (await ctx.getAgentSkillEnabled())) {
|
||||||
|
await seedAgentSessionPreamble(session.id).catch((err: unknown) =>
|
||||||
|
console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name });
|
getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name });
|
||||||
|
|
||||||
// Use light state for broadcast + response — buffers are fetched on-demand via /terminal.
|
// Use light state for broadcast + response — buffers are fetched on-demand via /terminal.
|
||||||
@@ -3016,6 +3032,13 @@ export function registerSessionRoutes(
|
|||||||
ctx.store.incrementSessionsCreated();
|
ctx.store.incrementSessionsCreated();
|
||||||
ctx.persistSessionState(session);
|
ctx.persistSessionState(session);
|
||||||
await ctx.setupSessionListeners(session);
|
await ctx.setupSessionListeners(session);
|
||||||
|
// Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line
|
||||||
|
// loader (see seedAgentSessionPreamble). Local claude sessions only; best-effort.
|
||||||
|
if (mode === 'claude' && !remote && !docker && (await ctx.getAgentSkillEnabled())) {
|
||||||
|
await seedAgentSessionPreamble(session.id).catch((err: unknown) =>
|
||||||
|
console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
getLifecycleLog().log({
|
getLifecycleLog().log({
|
||||||
event: 'created',
|
event: 'created',
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
|
|||||||
@@ -224,6 +224,11 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
|||||||
// re-capture instead).
|
// re-capture instead).
|
||||||
approvalInbox.resolveForSession(session.id, 'resolved_in_terminal', ['idle']);
|
approvalInbox.resolveForSession(session.id, 'resolved_in_terminal', ['idle']);
|
||||||
deps.broadcast(SseEvent.SessionWorking, { id: session.id });
|
deps.broadcast(SseEvent.SessionWorking, { id: session.id });
|
||||||
|
// Full state ride-along: the home screens sort the running group on
|
||||||
|
// lastSubmitAt, and without this the browser keeps the stamp it loaded
|
||||||
|
// with (a turn started after page load ranks by the PREVIOUS turn's
|
||||||
|
// Enter). Debounced, so working-signal flaps cost one broadcast.
|
||||||
|
deps.broadcastSessionStateDebounced(session.id);
|
||||||
const tracker = deps.getRunSummaryTracker(session.id);
|
const tracker = deps.getRunSummaryTracker(session.id);
|
||||||
if (tracker) {
|
if (tracker) {
|
||||||
tracker.recordWorking();
|
tracker.recordWorking();
|
||||||
|
|||||||
@@ -11,11 +11,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import { mkdtemp, rm, mkdir, writeFile, readFile, symlink, readdir } from 'node:fs/promises';
|
import { mkdtemp, rm, mkdir, writeFile, readFile, symlink, readdir, stat } from 'node:fs/promises';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir, homedir } from 'node:os';
|
||||||
import { applyAgentSkill, installAgentSkillInto, removeAgentSkillFrom } from '../src/hooks-config.js';
|
import {
|
||||||
|
applyAgentSkill,
|
||||||
|
installAgentSkillInto,
|
||||||
|
removeAgentSkillFrom,
|
||||||
|
refreshUserAgentSkill,
|
||||||
|
seedAgentSessionPreamble,
|
||||||
|
} from '../src/hooks-config.js';
|
||||||
|
|
||||||
const MARKER_PREFIX = '<!-- codeman-managed-agent-skill';
|
const MARKER_PREFIX = '<!-- codeman-managed-agent-skill';
|
||||||
|
|
||||||
@@ -120,3 +126,86 @@ describe('removeAgentSkillFrom / applyAgentSkill(disabled)', () => {
|
|||||||
expect(await readFile(join(skillDir(), 'reference', 'my-notes.md'), 'utf-8')).toBe('mine\n');
|
expect(await readFile(join(skillDir(), 'reference', 'my-notes.md'), 'utf-8')).toBe('mine\n');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('preamble single-source (seed + §0 heredoc parity)', () => {
|
||||||
|
const packagedDir = join(process.cwd(), 'skills', 'codeman');
|
||||||
|
|
||||||
|
it("SKILL.md's §0 heredoc is byte-identical to the packaged preamble.sh", async () => {
|
||||||
|
const skillMd = await readFile(join(packagedDir, 'SKILL.md'), 'utf-8');
|
||||||
|
const openTag = "<<'PREAMBLE'\n";
|
||||||
|
const open = skillMd.indexOf(openTag);
|
||||||
|
expect(open).toBeGreaterThan(-1);
|
||||||
|
const start = open + openTag.length;
|
||||||
|
const end = skillMd.indexOf('\nPREAMBLE\n', start);
|
||||||
|
expect(end).toBeGreaterThan(start);
|
||||||
|
// slice(.., end + 1) keeps the final line's own newline.
|
||||||
|
const heredoc = skillMd.slice(start, end + 1);
|
||||||
|
|
||||||
|
// The server seeds preamble.sh while agents that paste §0 write the heredoc; any
|
||||||
|
// byte of drift between the two would make the §0 grep rewrite a seeded file (or
|
||||||
|
// worse, ship different behavior depending on which path wrote it).
|
||||||
|
const preamble = await readFile(join(packagedDir, 'preamble.sh'), 'utf-8');
|
||||||
|
expect(preamble).toBe(heredoc);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seedAgentSessionPreamble writes the stamped preamble to the XDG cache path, 0600', async () => {
|
||||||
|
const prevXdg = process.env.XDG_CACHE_HOME;
|
||||||
|
const cacheDir = join(casePath, 'xdg-cache');
|
||||||
|
process.env.XDG_CACHE_HOME = cacheDir;
|
||||||
|
try {
|
||||||
|
await seedAgentSessionPreamble('seed-test-session');
|
||||||
|
const target = join(cacheDir, 'codeman-agent-seed-test-session.sh');
|
||||||
|
const content = await readFile(target, 'utf-8');
|
||||||
|
expect(content.startsWith('# ---- Codeman agent preamble')).toBe(true);
|
||||||
|
expect(content).toMatch(/\nCODEMAN_PREAMBLE=\d+\.\d+\.\d+\n$/);
|
||||||
|
expect((await stat(target)).mode & 0o777).toBe(0o600);
|
||||||
|
} finally {
|
||||||
|
if (prevXdg === undefined) delete process.env.XDG_CACHE_HOME;
|
||||||
|
else process.env.XDG_CACHE_HOME = prevXdg;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seedAgentSessionPreamble falls back to ~/.cache when XDG_CACHE_HOME is unset', async () => {
|
||||||
|
const prevXdg = process.env.XDG_CACHE_HOME;
|
||||||
|
delete process.env.XDG_CACHE_HOME;
|
||||||
|
try {
|
||||||
|
await seedAgentSessionPreamble('seed-home-session');
|
||||||
|
// setup.ts points HOME at a per-file fixture, so this never touches the real ~.
|
||||||
|
const target = join(homedir(), '.cache', 'codeman-agent-seed-home-session.sh');
|
||||||
|
expect(existsSync(target)).toBe(true);
|
||||||
|
} finally {
|
||||||
|
if (prevXdg !== undefined) process.env.XDG_CACHE_HOME = prevXdg;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refreshUserAgentSkill (the user-level copy must not rot)', () => {
|
||||||
|
const userSkillDir = () => join(homedir(), '.claude', 'skills', 'codeman');
|
||||||
|
|
||||||
|
it('reports absent and installs nothing when there is no user-level copy', async () => {
|
||||||
|
expect(await refreshUserAgentSkill()).toBe('absent');
|
||||||
|
expect(existsSync(userSkillDir())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes a stale Codeman-managed user copy back to the packaged content', async () => {
|
||||||
|
await mkdir(userSkillDir(), { recursive: true });
|
||||||
|
// An old injected version: different content, marker intact. This is the exact
|
||||||
|
// shape that shadowed every fresh per-case injection on 2026-08-14.
|
||||||
|
await writeFile(join(userSkillDir(), 'SKILL.md'), `old skill body\n\n${MARKER_PREFIX}: installed by Codeman -->\n`);
|
||||||
|
|
||||||
|
expect(await refreshUserAgentSkill()).toBe('refreshed');
|
||||||
|
const refreshed = await readFile(join(userSkillDir(), 'SKILL.md'), 'utf-8');
|
||||||
|
expect(refreshed.startsWith('---\nname: codeman')).toBe(true);
|
||||||
|
expect(existsSync(join(userSkillDir(), 'reference', 'endpoints.md'))).toBe(true);
|
||||||
|
|
||||||
|
// And a second run settles to unchanged.
|
||||||
|
expect(await refreshUserAgentSkill()).toBe('unchanged');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a user's own (unmarked) skill alone", async () => {
|
||||||
|
await mkdir(userSkillDir(), { recursive: true });
|
||||||
|
await writeFile(join(userSkillDir(), 'SKILL.md'), 'my own codeman skill\n');
|
||||||
|
expect(await refreshUserAgentSkill()).toBe('foreign');
|
||||||
|
expect(await readFile(join(userSkillDir(), 'SKILL.md'), 'utf-8')).toBe('my own codeman skill\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+48
-19
@@ -2,11 +2,11 @@
|
|||||||
//
|
//
|
||||||
// The desktop home screen's tab column (src/web/public/home-sessions.js) fills
|
// The desktop home screen's tab column (src/web/public/home-sessions.js) fills
|
||||||
// the welcome overlay's left gutter. Two things about it can silently go wrong
|
// the welcome overlay's left gutter. Two things about it can silently go wrong
|
||||||
// and are pinned here: the row ORDER (it mirrors the tab strip, unlike the phone
|
// and are pinned here: the row ORDER (shared with the phone overview via
|
||||||
// overview which sorts by urgency, and the number badges are only correct if it
|
// CodemanSessionOrder, with the number badge still carrying the TAB index so
|
||||||
// does), and the WIDTH GATE, which lives in two places at once — the JS constant
|
// Alt+N keeps working), and the WIDTH GATE, which lives in two places at once —
|
||||||
// and a CSS media query — because the column is absolutely positioned and would
|
// the JS constant and a CSS media query — because the column is absolutely
|
||||||
// overlap the search panel in a narrow window.
|
// positioned and would overlap the search panel in a narrow window.
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import vm from 'node:vm';
|
import vm from 'node:vm';
|
||||||
@@ -35,9 +35,10 @@ function fakeElement(): any {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* home-sessions.js reuses `_mobileOverviewState` / `_mobileOverviewCaseFor` /
|
* home-sessions.js reuses `_mobileOverviewState` / `_mobileOverviewCaseFor` /
|
||||||
* `shouldUseMobileOverview` from mobile-overview.js, so both files run in the
|
* `shouldUseMobileOverview` from mobile-overview.js and the row comparator from
|
||||||
* same context — which is also the point: if that reuse ever breaks, these
|
* constants.js, so all three files run in the same context, which is also the
|
||||||
* tests stop loading rather than quietly testing a divergent copy.
|
* point: if that reuse ever breaks, these tests stop loading rather than
|
||||||
|
* quietly testing a divergent copy.
|
||||||
*/
|
*/
|
||||||
function loadHomeSessionsApp(overrides: Record<string, any> = {}, innerWidth = 1512) {
|
function loadHomeSessionsApp(overrides: Record<string, any> = {}, innerWidth = 1512) {
|
||||||
const CodemanApp = function CodemanApp(this: any) {};
|
const CodemanApp = function CodemanApp(this: any) {};
|
||||||
@@ -52,7 +53,7 @@ function loadHomeSessionsApp(overrides: Record<string, any> = {}, innerWidth = 1
|
|||||||
},
|
},
|
||||||
MobileDetection: { getDeviceType: () => (innerWidth < 430 ? 'mobile' : 'desktop') },
|
MobileDetection: { getDeviceType: () => (innerWidth < 430 ? 'mobile' : 'desktop') },
|
||||||
});
|
});
|
||||||
for (const file of ['mobile-overview.js', 'home-sessions.js']) {
|
for (const file of ['constants.js', 'mobile-overview.js', 'home-sessions.js']) {
|
||||||
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
|
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,9 +77,9 @@ function sessionMap(list: Array<Record<string, any>>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('home sessions column: model', () => {
|
describe('home sessions column: model', () => {
|
||||||
it('lists rows in TAB order, not by urgency, so the number badges match Alt+1..9', () => {
|
it('hoists a session blocked on you, and keeps its badge on the TAB index', () => {
|
||||||
// The phone overview would hoist 'needy' to the top; this surface must not,
|
// The badge names the Alt+N shortcut, so a sorted rail shows 2,1,3 rather
|
||||||
// because its badges are the Alt+N indices.
|
// than renumbering itself 1,2,3 and lying about which key selects what.
|
||||||
const app = loadHomeSessionsApp({
|
const app = loadHomeSessionsApp({
|
||||||
sessions: sessionMap([{ id: 'first' }, { id: 'needy' }, { id: 'third' }]),
|
sessions: sessionMap([{ id: 'first' }, { id: 'needy' }, { id: 'third' }]),
|
||||||
sessionOrder: ['first', 'needy', 'third'],
|
sessionOrder: ['first', 'needy', 'third'],
|
||||||
@@ -87,10 +88,34 @@ describe('home sessions column: model', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const rows = app.buildHomeSessionRows();
|
const rows = app.buildHomeSessionRows();
|
||||||
expect(rows.map((r: any) => r.id)).toEqual(['first', 'needy', 'third']);
|
expect(rows.map((r: any) => r.id)).toEqual(['needy', 'first', 'third']);
|
||||||
expect(rows.map((r: any) => r.index)).toEqual([0, 1, 2]);
|
expect(rows.map((r: any) => r.orderIndex)).toEqual([1, 0, 2]);
|
||||||
expect(rows[1].state).toBe('needs');
|
expect(rows[0].state).toBe('needs');
|
||||||
expect(rows[1].pill).toBe('needs you');
|
expect(rows[0].pill).toBe('needs you');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('orders running sessions longest-turn-first and quiet ones most-recent-first', () => {
|
||||||
|
// The same rule the phone overview follows, and the reason the rail exists:
|
||||||
|
// what is running longest is what is most likely to be done or stuck, and
|
||||||
|
// once nothing is running the session that just stopped is the one you came
|
||||||
|
// back for.
|
||||||
|
const app = loadHomeSessionsApp({
|
||||||
|
sessions: sessionMap([
|
||||||
|
{ id: 'young-turn', status: 'busy', lastSubmitAt: 9_000, lastActivityAt: 10_000 },
|
||||||
|
{ id: 'old-turn', status: 'busy', lastSubmitAt: 1_000, lastActivityAt: 10_000 },
|
||||||
|
{ id: 'stale-idle', status: 'idle', lastActivityAt: 2_000 },
|
||||||
|
{ id: 'fresh-idle', status: 'idle', lastActivityAt: 8_000 },
|
||||||
|
]),
|
||||||
|
sessionOrder: ['young-turn', 'old-turn', 'stale-idle', 'fresh-idle'],
|
||||||
|
cases: CASES,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(app.buildHomeSessionRows().map((r: any) => r.id)).toEqual([
|
||||||
|
'old-turn',
|
||||||
|
'young-turn',
|
||||||
|
'fresh-idle',
|
||||||
|
'stale-idle',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows a session that is not in the order list yet', () => {
|
it('shows a session that is not in the order list yet', () => {
|
||||||
@@ -117,11 +142,13 @@ describe('home sessions column: model', () => {
|
|||||||
cases: CASES,
|
cases: CASES,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Unstamped rows fall back to the tab order inside a state, so this reads
|
||||||
|
// as the state ranking alone: an errored session is blocked on you.
|
||||||
expect(app.buildHomeSessionRows().map((r: any) => [r.state, r.pill])).toEqual([
|
expect(app.buildHomeSessionRows().map((r: any) => [r.state, r.pill])).toEqual([
|
||||||
|
['error', 'error'],
|
||||||
['working', 'working'],
|
['working', 'working'],
|
||||||
['idle', 'idle'],
|
['idle', 'idle'],
|
||||||
['done', 'done'],
|
['done', 'done'],
|
||||||
['error', 'error'],
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -236,8 +263,10 @@ describe('home sessions column: wiring', () => {
|
|||||||
expect(aside).toBeGreaterThan(overlayStart);
|
expect(aside).toBeGreaterThan(overlayStart);
|
||||||
expect(aside).toBeLessThan(content);
|
expect(aside).toBeLessThan(content);
|
||||||
// Load order: the module reuses prototype methods installed by
|
// Load order: the module reuses prototype methods installed by
|
||||||
// mobile-overview.js. Compare the <script> tags, not any mention: both
|
// mobile-overview.js and the comparator installed by constants.js. Compare
|
||||||
// files are named in explanatory comments earlier in the document.
|
// the <script> tags, not any mention: both files are named in explanatory
|
||||||
|
// comments earlier in the document.
|
||||||
expect(html.indexOf('src="home-sessions.js"')).toBeGreaterThan(html.indexOf('src="mobile-overview.js"'));
|
expect(html.indexOf('src="home-sessions.js"')).toBeGreaterThan(html.indexOf('src="mobile-overview.js"'));
|
||||||
|
expect(html.indexOf('src="mobile-overview.js"')).toBeGreaterThan(html.indexOf('src="constants.js"'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,9 +42,12 @@ function loadOverviewApp(overrides: Record<string, any> = {}) {
|
|||||||
},
|
},
|
||||||
MobileDetection: { getDeviceType: () => 'mobile' },
|
MobileDetection: { getDeviceType: () => 'mobile' },
|
||||||
});
|
});
|
||||||
vm.runInContext(readFileSync(resolve(PUBLIC, 'mobile-overview.js'), 'utf8'), context, {
|
// constants.js first: it installs the row comparator (window.CodemanSessionOrder)
|
||||||
filename: 'mobile-overview.js',
|
// that buildMobileOverviewModel() sorts every section with, shared with the
|
||||||
});
|
// desktop rail so the two home screens cannot order the same list differently.
|
||||||
|
for (const file of ['constants.js', 'mobile-overview.js']) {
|
||||||
|
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
|
||||||
|
}
|
||||||
|
|
||||||
const app = new (CodemanApp as any)();
|
const app = new (CodemanApp as any)();
|
||||||
app.getSessionName = (session: any) => session.name || session.workingDir?.split('/').pop() || session.id.slice(0, 8);
|
app.getSessionName = (session: any) => session.name || session.workingDir?.split('/').pop() || session.id.slice(0, 8);
|
||||||
@@ -123,7 +126,7 @@ describe('mobile overview model', () => {
|
|||||||
expect(model.sessionCount).toBe(4);
|
expect(model.sessionCount).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the user tab order as the tiebreak inside a section', () => {
|
it('keeps the user tab order as the tiebreak when nothing is stamped', () => {
|
||||||
const app = loadOverviewApp();
|
const app = loadOverviewApp();
|
||||||
const model = app.buildMobileOverviewModel({
|
const model = app.buildMobileOverviewModel({
|
||||||
sessions: [session({ id: 'first' }), session({ id: 'second' }), session({ id: 'third' })],
|
sessions: [session({ id: 'first' }), session({ id: 'second' }), session({ id: 'third' })],
|
||||||
@@ -134,6 +137,42 @@ describe('mobile overview model', () => {
|
|||||||
expect(model.current.map((r: any) => r.id)).toEqual(['third', 'first', 'second']);
|
expect(model.current.map((r: any) => r.id)).toEqual(['third', 'first', 'second']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sorts running sessions longest-turn-first and quiet ones most-recent-first', () => {
|
||||||
|
// A working pane repaints about once a second, so its last-activity stamp
|
||||||
|
// is always "now": the running group has to key off the pane's last Enter
|
||||||
|
// instead, or every turn ranks as freshly started.
|
||||||
|
const app = loadOverviewApp();
|
||||||
|
const model = app.buildMobileOverviewModel({
|
||||||
|
sessions: [
|
||||||
|
session({ id: 'quiet-old', status: 'idle', lastActivityAt: 2_000 }),
|
||||||
|
session({ id: 'turn-young', status: 'busy', lastSubmitAt: 9_000, lastActivityAt: 10_000 }),
|
||||||
|
session({ id: 'quiet-new', status: 'idle', lastActivityAt: 8_000 }),
|
||||||
|
session({ id: 'turn-old', status: 'busy', lastSubmitAt: 1_000, lastActivityAt: 10_000 }),
|
||||||
|
],
|
||||||
|
cases: CASES,
|
||||||
|
sessionOrder: ['quiet-old', 'turn-young', 'quiet-new', 'turn-old'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.current.map((r: any) => r.id)).toEqual(['turn-old', 'turn-young', 'quiet-new', 'quiet-old']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts the longest-blocked session at the top of NEEDS YOU', () => {
|
||||||
|
const app = loadOverviewApp();
|
||||||
|
const model = app.buildMobileOverviewModel({
|
||||||
|
sessions: [
|
||||||
|
session({ id: 'just-asked', lastActivityAt: 9_000 }),
|
||||||
|
session({ id: 'starving', lastActivityAt: 1_000 }),
|
||||||
|
],
|
||||||
|
cases: CASES,
|
||||||
|
pendingHooks: new Map([
|
||||||
|
['just-asked', new Set(['permission_prompt'])],
|
||||||
|
['starving', new Set(['permission_prompt'])],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.needsYou.map((r: any) => r.id)).toEqual(['starving', 'just-asked']);
|
||||||
|
});
|
||||||
|
|
||||||
it('matches a session started in a subdirectory to its case (longest prefix)', () => {
|
it('matches a session started in a subdirectory to its case (longest prefix)', () => {
|
||||||
const app = loadOverviewApp();
|
const app = loadOverviewApp();
|
||||||
const model = app.buildMobileOverviewModel({
|
const model = app.buildMobileOverviewModel({
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ function loadLineageHelper() {
|
|||||||
DIP_MIN_PX: number;
|
DIP_MIN_PX: number;
|
||||||
DIP_MAX_PX: number;
|
DIP_MAX_PX: number;
|
||||||
SIBLING_STEP_PX: number;
|
SIBLING_STEP_PX: number;
|
||||||
|
COLORS: string[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
).CodemanLineage;
|
).CodemanLineage;
|
||||||
@@ -61,8 +62,9 @@ describe('lineage line geometry', () => {
|
|||||||
const near = helper.computePath({ parent: tab(0), child: tab(140), strip: STRIP })!;
|
const near = helper.computePath({ parent: tab(0), child: tab(140), strip: STRIP })!;
|
||||||
const far = helper.computePath({ parent: tab(0), child: tab(1000), strip: STRIP })!;
|
const far = helper.computePath({ parent: tab(0), child: tab(1000), strip: STRIP })!;
|
||||||
|
|
||||||
const nearDip = controlYs(near.d)[0] - 34;
|
// The dip hangs from the STRIP's bottom edge (40), not the tab bottoms.
|
||||||
const farDip = controlYs(far.d)[0] - 34;
|
const nearDip = controlYs(near.d)[0] - 40;
|
||||||
|
const farDip = controlYs(far.d)[0] - 40;
|
||||||
expect(farDip).toBeGreaterThan(nearDip);
|
expect(farDip).toBeGreaterThan(nearDip);
|
||||||
expect(nearDip).toBeGreaterThanOrEqual(helper.DIP_MIN_PX);
|
expect(nearDip).toBeGreaterThanOrEqual(helper.DIP_MIN_PX);
|
||||||
expect(farDip).toBeLessThanOrEqual(helper.DIP_MAX_PX);
|
expect(farDip).toBeLessThanOrEqual(helper.DIP_MAX_PX);
|
||||||
@@ -80,15 +82,48 @@ describe('lineage line geometry', () => {
|
|||||||
it('keeps bending at strip-wide spans instead of flattening into a straight line', () => {
|
it('keeps bending at strip-wide spans instead of flattening into a straight line', () => {
|
||||||
const helper = loadLineageHelper();
|
const helper = loadLineageHelper();
|
||||||
// A worker the agent skill starts is appended to the END of the strip, so this
|
// A worker the agent skill starts is appended to the END of the strip, so this
|
||||||
// is the span the feature is actually used at. The first shipped clamp (44px)
|
// is the span the feature is actually used at. The corridor has failed in BOTH
|
||||||
// turned it into a flat thread across the terminal.
|
// directions: the first 44px clamp read as a flat thread here (#285), and the
|
||||||
|
// 104px clamp that replaced it bowed deep into the terminal (2026-08-15), so this
|
||||||
|
// pins the cap exactly rather than just a floor.
|
||||||
const wide = helper.computePath({ parent: tab(0), child: tab(1300), strip: { ...STRIP, width: 1500 } })!;
|
const wide = helper.computePath({ parent: tab(0), child: tab(1300), strip: { ...STRIP, width: 1500 } })!;
|
||||||
const near = helper.computePath({ parent: tab(0), child: tab(140), strip: STRIP })!;
|
const near = helper.computePath({ parent: tab(0), child: tab(140), strip: STRIP })!;
|
||||||
|
|
||||||
const wideDip = controlYs(wide.d)[0] - 34;
|
const wideDip = controlYs(wide.d)[0] - 40; // from the strip's bottom edge
|
||||||
const nearDip = controlYs(near.d)[0] - 34;
|
const nearDip = controlYs(near.d)[0] - 40;
|
||||||
expect(wideDip).toBeGreaterThan(nearDip * 2);
|
expect(wideDip).toBeGreaterThan(nearDip * 2);
|
||||||
expect(wideDip).toBeGreaterThanOrEqual(80);
|
expect(wideDip).toBe(helper.DIP_MAX_PX);
|
||||||
|
expect(helper.DIP_MAX_PX).toBe(64);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hangs the dip from the STRIP bottom, so no per-row offset ever stacks on it', () => {
|
||||||
|
const helper = loadLineageHelper();
|
||||||
|
const twoRowStrip = { left: 0, top: 0, width: 1200, height: 84 }; // rows at y 4-34 and 48-78
|
||||||
|
// A wrapped pair (row 1 → row 2) and a same-row pair on ROW 1 of the same strip.
|
||||||
|
const wrapped = helper.computePath({ parent: tab(0), child: tab(400, 48), strip: twoRowStrip })!;
|
||||||
|
const row1Pair = helper.computePath({ parent: tab(0), child: tab(400), strip: twoRowStrip })!;
|
||||||
|
|
||||||
|
// Both brackets clear the ENTIRE strip: the wrapped one does not add the row
|
||||||
|
// offset on top (the 2026-08-15 over-bow), and the row-1 pair does not draw
|
||||||
|
// through row 2's tab labels (the retune's own first-draft regression).
|
||||||
|
for (const geom of [wrapped, row1Pair]) {
|
||||||
|
for (const y of controlYs(geom.d)) {
|
||||||
|
expect(y).toBeGreaterThanOrEqual(84 + helper.DIP_MIN_PX);
|
||||||
|
expect(y).toBeLessThanOrEqual(84 + helper.DIP_MAX_PX + helper.SIBLING_STEP_PX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes a colour palette whose first entry defers to the skin blue', () => {
|
||||||
|
const helper = loadLineageHelper();
|
||||||
|
const colors = helper.COLORS;
|
||||||
|
expect(Array.isArray(colors)).toBe(true);
|
||||||
|
// '' = no override: session-lineage.js sets no inline --lineage-color and the
|
||||||
|
// CSS falls back to the skin-tuned --session-blue, so a lone arc stays blue.
|
||||||
|
expect(colors[0]).toBe('');
|
||||||
|
expect(colors.length).toBeGreaterThanOrEqual(6);
|
||||||
|
expect(new Set(colors).size).toBe(colors.length);
|
||||||
|
for (const c of colors.slice(1)) expect(c).toMatch(/^#[0-9a-f]{6}$/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('brackets a wrapped pair BELOW the lower row rather than inside the row gap', () => {
|
it('brackets a wrapped pair BELOW the lower row rather than inside the row gap', () => {
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
// Port: none (pure comparator — no browser, no server).
|
||||||
|
//
|
||||||
|
// `CodemanSessionOrder` (src/web/public/constants.js) is the single row order
|
||||||
|
// behind both home screens: the phone overview and the desktop tab rail. It is
|
||||||
|
// the one place the two surfaces can disagree about which session you should
|
||||||
|
// look at next, which is why it is pure and pinned here rather than living
|
||||||
|
// inside either renderer.
|
||||||
|
//
|
||||||
|
// The rule it encodes, and the thing worth protecting: the tiebreak FLIPS
|
||||||
|
// direction halfway down the list. For a state a session is still in, older is
|
||||||
|
// more urgent (blocked longest, running longest). For a state it has stopped
|
||||||
|
// in, newer is more relevant (just finished beats abandoned yesterday).
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
id: string;
|
||||||
|
state: string;
|
||||||
|
lastActivityAt?: number;
|
||||||
|
lastSubmitAt?: number;
|
||||||
|
orderIndex?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadOrderHelper() {
|
||||||
|
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||||
|
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||||
|
vm.runInContext(source, context, { filename: 'constants.js' });
|
||||||
|
return (
|
||||||
|
context.window as {
|
||||||
|
CodemanSessionOrder: {
|
||||||
|
RANK: Record<string, number>;
|
||||||
|
anchor: (row: Row) => number;
|
||||||
|
compare: (a: Row, b: Row) => number;
|
||||||
|
sort: (rows: Row[]) => Row[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).CodemanSessionOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = loadOrderHelper();
|
||||||
|
const ids = (rows: Row[]) => order.sort(rows).map((r) => r.id);
|
||||||
|
|
||||||
|
describe('session overview order: state ranking', () => {
|
||||||
|
it('puts everything blocked on a human above everything else', () => {
|
||||||
|
// Red question, then a hard error, then the yellow "waiting for input"
|
||||||
|
// prompt, then work, then whatever has stopped.
|
||||||
|
const rows: Row[] = [
|
||||||
|
{ id: 'done', state: 'done' },
|
||||||
|
{ id: 'idle', state: 'idle' },
|
||||||
|
{ id: 'working', state: 'working' },
|
||||||
|
{ id: 'waiting', state: 'waiting' },
|
||||||
|
{ id: 'error', state: 'error' },
|
||||||
|
{ id: 'needs', state: 'needs' },
|
||||||
|
];
|
||||||
|
expect(ids(rows)).toEqual(['needs', 'error', 'waiting', 'working', 'idle', 'done']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts an unknown state last instead of dropping it or crashing', () => {
|
||||||
|
// A state added to one renderer and not to the rank map must still render,
|
||||||
|
// just at the bottom — a missing row is a worse failure than a misplaced one.
|
||||||
|
const rows: Row[] = [
|
||||||
|
{ id: 'mystery', state: 'quantum' },
|
||||||
|
{ id: 'done', state: 'done' },
|
||||||
|
];
|
||||||
|
expect(ids(rows)).toEqual(['done', 'mystery']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('session overview order: in-progress states sort oldest first', () => {
|
||||||
|
it('ranks the longest-running turn above a turn that just started', () => {
|
||||||
|
const rows: Row[] = [
|
||||||
|
{ id: 'young', state: 'working', lastSubmitAt: 9_000, lastActivityAt: 10_000 },
|
||||||
|
{ id: 'old', state: 'working', lastSubmitAt: 1_000, lastActivityAt: 10_000 },
|
||||||
|
];
|
||||||
|
expect(ids(rows)).toEqual(['old', 'young']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measures a running turn from the last Enter, not the last repaint', () => {
|
||||||
|
// A working pane repaints about once a second, so last-activity is always
|
||||||
|
// "now" and would rank every running turn identically.
|
||||||
|
expect(order.anchor({ id: 'w', state: 'working', lastSubmitAt: 1_000, lastActivityAt: 999_000 })).toBe(1_000);
|
||||||
|
expect(order.anchor({ id: 'i', state: 'idle', lastSubmitAt: 1_000, lastActivityAt: 999_000 })).toBe(999_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to last activity for a working pane that never submitted', () => {
|
||||||
|
// Spawned with its prompt on the command line, or an external CLI whose
|
||||||
|
// Enter never went through Codeman. Its fallback stamp is ~now, so it sits
|
||||||
|
// at the SHORT end of the running group rather than falsely leading it.
|
||||||
|
const rows: Row[] = [
|
||||||
|
{ id: 'no-submit', state: 'working', lastActivityAt: 10_000 },
|
||||||
|
{ id: 'submitted', state: 'working', lastSubmitAt: 1_000, lastActivityAt: 10_000 },
|
||||||
|
];
|
||||||
|
expect(ids(rows)).toEqual(['submitted', 'no-submit']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ranks the longest-blocked session above one that just asked', () => {
|
||||||
|
const rows: Row[] = [
|
||||||
|
{ id: 'just-asked', state: 'needs', lastActivityAt: 9_000 },
|
||||||
|
{ id: 'starving', state: 'needs', lastActivityAt: 1_000 },
|
||||||
|
];
|
||||||
|
expect(ids(rows)).toEqual(['starving', 'just-asked']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('session overview order: stopped states sort newest first', () => {
|
||||||
|
it('puts the session that just went quiet above one idle since yesterday', () => {
|
||||||
|
const rows: Row[] = [
|
||||||
|
{ id: 'yesterday', state: 'idle', lastActivityAt: 1_000 },
|
||||||
|
{ id: 'just-now', state: 'idle', lastActivityAt: 9_000 },
|
||||||
|
{ id: 'this-morning', state: 'idle', lastActivityAt: 5_000 },
|
||||||
|
];
|
||||||
|
expect(ids(rows)).toEqual(['just-now', 'this-morning', 'yesterday']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the same recency rule to finished sessions', () => {
|
||||||
|
const rows: Row[] = [
|
||||||
|
{ id: 'old-exit', state: 'done', lastActivityAt: 1_000 },
|
||||||
|
{ id: 'fresh-exit', state: 'done', lastActivityAt: 9_000 },
|
||||||
|
];
|
||||||
|
expect(ids(rows)).toEqual(['fresh-exit', 'old-exit']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('session overview order: tiebreaks', () => {
|
||||||
|
it('falls back to the tab order when two rows share a stamp', () => {
|
||||||
|
const rows: Row[] = [
|
||||||
|
{ id: 'third', state: 'idle', lastActivityAt: 5_000, orderIndex: 2 },
|
||||||
|
{ id: 'first', state: 'idle', lastActivityAt: 5_000, orderIndex: 0 },
|
||||||
|
];
|
||||||
|
expect(ids(rows)).toEqual(['first', 'third']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts an unstamped row last within its state, never first', () => {
|
||||||
|
// 0 is "we have no stamp", not "the epoch": treating it as a timestamp
|
||||||
|
// would park a brand-new session at the head of the oldest-first groups.
|
||||||
|
expect(
|
||||||
|
ids([
|
||||||
|
{ id: 'none', state: 'idle', orderIndex: 0 },
|
||||||
|
{ id: 'stamped', state: 'idle', lastActivityAt: 1_000, orderIndex: 1 },
|
||||||
|
])
|
||||||
|
).toEqual(['stamped', 'none']);
|
||||||
|
expect(
|
||||||
|
ids([
|
||||||
|
{ id: 'none', state: 'working', orderIndex: 0 },
|
||||||
|
{ id: 'stamped', state: 'working', lastSubmitAt: 1_000, orderIndex: 1 },
|
||||||
|
])
|
||||||
|
).toEqual(['stamped', 'none']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic: two unstamped rows keep tab order in both directions', () => {
|
||||||
|
const a: Row = { id: 'a', state: 'idle', orderIndex: 0 };
|
||||||
|
const b: Row = { id: 'b', state: 'idle', orderIndex: 1 };
|
||||||
|
expect(order.compare(a, b)).toBeLessThan(0);
|
||||||
|
expect(order.compare(b, a)).toBeGreaterThan(0);
|
||||||
|
expect(order.compare(a, a)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies rather than sorting the caller array in place', () => {
|
||||||
|
// Both renderers hand it a filtered slice of a shared row array; mutating
|
||||||
|
// that would reorder the other surface's list as a side effect.
|
||||||
|
const rows: Row[] = [
|
||||||
|
{ id: 'b', state: 'idle', lastActivityAt: 1_000 },
|
||||||
|
{ id: 'a', state: 'idle', lastActivityAt: 9_000 },
|
||||||
|
];
|
||||||
|
order.sort(rows);
|
||||||
|
expect(rows.map((r) => r.id)).toEqual(['b', 'a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives junk input rather than throwing inside a render', () => {
|
||||||
|
expect(order.sort(undefined as unknown as Row[])).toEqual([]);
|
||||||
|
expect(order.anchor({} as Row)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user