chore: version packages

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-12 02:30:08 +02:00
parent cf3183abf7
commit f39beb3326
29 changed files with 3191 additions and 441 deletions
+25
View File
@@ -407,6 +407,31 @@ count against the same 16, not 16 of each. An abandoned request no longer holds
slot, because the routes release the waiter when the client disconnects, but a
client that opens many concurrent waits against one session will still hit the cap.
## Session lineage (`parentSessionId`)
A create request may name the session that spawned it, which the web UI draws as a
line between the two tabs. Accepted on `POST /api/v1/sessions` and
`POST /api/v1/quick-start`, either way:
```bash
# as a body field
-d '{"caseName":"worker-1","mode":"claude","parentSessionId":"'"$CODEMAN_SESSION_ID"'"}'
# or as a header, which is what an agent driving many spawns should use: set it once
# on the curl invocation and every spawn call carries it
-H "X-Codeman-Parent-Session: $CODEMAN_SESSION_ID"
```
The body field wins if both are present. The value is resolved against live sessions
(exact id, or a unique prefix of at least 8 characters) and must belong to the same
owner as the session being created.
**It cannot fail your spawn.** An unknown, stale, foreign or malformed value is
silently dropped and the session is created without lineage — never a `400`. It is
also pure decoration: it confers no permission, and a child is unaffected by its
parent exiting. It appears on session state as `parentSessionId` (absent when
unresolved) and survives a server restart.
## Approvals Inbox
Cross-session queue of prompts waiting on a human (permission dialogs,
+12
View File
@@ -76,6 +76,18 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
**Unified session list** (COD-160/#139): `GET /api/sessions/unified?limit=&q=` merges live sessions, persisted state, lifecycle-log history, and Claude transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). Transcript rows are keyed by conversation UUID and folded into their owning session via a `claudeSessionId → Codeman id` alias map (resumed//clear-respawned sessions must not appear twice); lifecycle name/mode resolution is first-seen-wins (the log returns entries NEWEST-first). No terminal buffers in the response (unlike `/api/sessions`). Consumed by the Cmd+K Session Manager (#146). Session Manager polish (COD-162/#157, 1.6.0): **pinning** via `POST /api/sessions/:id/pin` (`session:pinned` SSE; killing a pinned session demotes it to a lightweight stopped record that stays visible/resumable, and cleanup skips pinned records); **cross-device tab order** via `PUT /api/session-order` (`session:orderChanged` SSE, persisted in `state.json`; pure `normalizeSessionOrder`/`mergeSessionOrder` in `src/session-order.ts`: pushing device wins, server-only ids fall to the end, never dropped); resume from the manager keeps the original session name (COD-143); `firstPrompt` is backfilled for sessions whose id != transcript UUID and the most recent prompt (`lastPrompt`) is shown + searched (COD-140/145).
### Session lineage lines (tab → tab it spawned)
**The relationship did not exist before this** (1.17.0): `SessionState` had no `parentSessionId`, `quick-start` recorded only the multi-user *human* owner, and an agent's spawn call is plain `curl` from a tmux pane, so nothing in the request identifies the caller (`SO_PEERCRED` needs a unix socket; the API is TCP). The caller therefore supplies it — every managed pane already gets `CODEMAN_SESSION_ID` from `session-cli-builder.ts`. Two equivalent inputs, body wins: a `parentSessionId` field on `POST /api/sessions` / `POST /api/quick-start`, or the `X-Codeman-Parent-Session` header, which exists so the agent skill can set it ONCE on its shared curl invocation and have every present and future spawn recipe carry it.
**Resolved, not trusted** (`resolveParentSessionId()`, route-helpers.ts): exact id first, then a UNIQUE prefix of ≥8 chars (ids reach agents truncated — mux names and a Docker export's `$CODEMAN_SESSION_ID` both carry 8), and an ambiguous prefix resolves to NOTHING rather than to a guess. The parent must be a live session the caller can already see (`canAccessOwned`) AND carry the same owner as the session being created, so a multi-user caller cannot staple their session under someone else's tab. ⚠️ **Everything unresolvable is DROPPED, never a 400**: a stale id from a cached skill preamble must cost a decorative line, not a worker. ⚠️ It is decoration at every layer — never an ownership, permission or lifecycle signal; a child outlives its parent, and the Session ctor refuses a self-parent (reachable only via recovery, where both values come off disk). It rides `toState()` into `session_created` / `session_updated`, so there is **no new SSE event**, and `server.ts`'s recovery path restores it so lineage survives a restart.
**Rendering is an additional LAYER, not a second pass** (`session-lineage.js`, loadorder 15.6): `_updateConnectionLinesImmediate()` (subagent-windows.js) calls `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode's two layers, so all of them share ONE batched read→write reflow and the same `tab:<id>` rect cache. Geometry is pure and unit-tested in `computeLineagePath()` (constants.js): both endpoints live in one horizontal strip, so the subagent shape (tab-bottom → window-top) has nothing to aim at, and same-row pairs get a shallow U-bridge HANGING BELOW the strip (dip scales with distance, plus a per-sibling step so several children of one parent nest instead of overprinting), while a wrapped strip (`tabs-two-rows`/`tabs-auto-wrap`) falls back to the vertical bezier.
⚠️ **Desktop only, for a z-index reason**: the overlay is `z-index: 999` and the desktop header is 100, so arcs paint OVER it — which is exactly what lets them touch tab bottoms. Under 1024px mobile.css makes the header `position: fixed; z-index: 1200` and would bury them, and the phone strip is a scroller where both endpoints are rarely on screen at once. Raising the SVG to ~1250 (above the fixed header, below modals at 1300) is the phase-2 option, and needs a real check against the mobile overview and the drawer.
⚠️ **`data-agent-id="lineage:<childId>"` is load-bearing**, not a label: `_applyLineEntrances()` queries paths by that attribute, so tagging them this way is the whole reason the arcs get the draw-in animation AND its negative-`animation-delay` resume across `svg.innerHTML = ''` with zero new animation code. ⚠️ `.session-tabs` is `overflow-x: auto`, so a tab scrolled out of the strip still HAS a rect — one lying over the logo or the header buttons; edges with an endpoint outside the strip are SKIPPED (clamping would point at a tab that is not there), and a passive `scroll` listener re-anchors the rest, since a scroll moves both endpoints without firing any render. The incremental tab render also redraws when `_lineageEdgeCount > 0`: a badge appearing widens a tab and shifts every tab after it. Setting: `sessionLineageLines`, per-device (in `displayKeys`, absent from the `.strict()` `SettingsUpdateSchema`), desktop default ON. Tests: `test/session-lineage-lines.test.ts` (geometry), `test/routes/session-routes-parent-lineage.test.ts` (resolution + reject paths).
### Full-scrollback replay
**Full-scrollback replay** (COD-164/#148, reworked for #205): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -<lines>` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). The first load OF EACH SESSION per page load requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other tab one frame of history); later switches keep the cheap `?tail=` visible-frame path. On top of that, scrolling up while already at the TOP of the buffer re-pulls `full=1` on demand (`_maybeRefetchFullHistory`, 4s per-session cooldown, in-flight + tab-switch guards, viewport position held across the replay). The re-pull exists because xterm's buffer is only a WINDOW onto tmux's history and two things shrink it: tmux coalesces bursty output into pane REPAINTS that overwrite rows instead of emitting linefeeds (measured: a 60-line burst added 1 row of browser scrollback and destroyed 34), and a tab switch replays only the visible frame. tmux's own history is intact throughout — the browser just has to ask for it again. On-demand rather than automatic because at a 100k history limit the capture can be megabytes. ⚠️ **The re-pull must never DOWNGRADE the buffer** (#205 round 2): the same reasoning that makes it a win for a shell pane makes it destructive for a repaint-mode CLI pane, where tmux keeps no history of its own (`history_size≈0` measured for a Claude pane) and the capture is roughly ONE frame while xterm may hold hundreds of rows of replayed frames — `_resetTerminalForReplay()` + rewrite then deletes history mid-scroll ("goes back a bit, repeats blocks, gets worse the further up I go"; measured A/B on a live pane: 341 rows → 42 with the guard off). `_replayWouldShrinkBuffer()` (terminal-ui.js) estimates the capture's rendered rows — escape sequences stripped, `capture-pane -J` re-wrapping accounted for — and the pull is skipped when that is more than one screen short of `buffer.active.length`. The one-screen tolerance matters: both sides are estimates (the buffer length counts trailing blank rows), so only a clear downgrade is refused. A refused session joins `_fullHistoryRepullUseless`, raising its cooldown from 4s to 60s so a hollow pane stops re-fetching megabytes on every scroll-up. Tests: `test/tmux-capture-full-history.test.ts`, `test/tmux-scrollback-eol.test.ts`, `test/terminal-scroll-routing.test.ts`.
+218
View File
@@ -0,0 +1,218 @@
# Session lineage lines (spawn lines between tabs)
**Goal:** when a session spawns another session (the `codeman` agent skill starting a
worker, or anything else that says who it is), draw the same kind of glowing connection
line the subagent windows already use, but **tab → tab**, so a glance at the strip shows
which tab spawned which.
Status: PLAN. Nothing implemented yet.
---
## 1. The blocking fact: no parent relationship exists today
There is no spawn-parent link between sessions anywhere in the codebase:
- `SessionState` (`src/types/session.ts:388`) has no `parentSessionId` / `spawnedBy` /
`createdBy`.
- `POST /api/quick-start` and `POST /api/sessions` record only `owner = ownerFor(req)`,
which is the multi-user **human**, not the calling session.
- The only parent links that do exist are `TeamConfig.leadSessionId` (agent teams) and
`subagent-parents.json` (a frontend **window-layout** store for subagent windows).
Neither says "session A spawned session B".
- Nothing in the HTTP request identifies the caller: an agent's spawn call is plain
`curl` from inside a tmux pane, so there is no socket-level identity to recover
(`SO_PEERCRED` needs a unix socket; the API is TCP).
So the caller has to **tell** us. It already knows its own id: every managed pane gets
`CODEMAN_SESSION_ID` exported by `session-cli-builder.ts` (and the skill's §0 preamble
already binds it to `$SELF`).
## 2. Wire format
Two ways in, because they serve different callers. Body wins when both are present.
| Where | Shape | Who uses it |
| --- | --- | --- |
| body field | `"parentSessionId": "<uuid>"` | anything hand-writing one create call |
| request header | `X-Codeman-Parent-Session: <uuid>` | the skill: added **once** to the `CURL` array in the §0 preamble, so every present and future create call carries it with no per-recipe edit |
Rules, all of them deliberate:
- **Advisory decoration only.** It never grants access, never scopes anything, never
affects lifecycle. A child is not killed when its parent dies; the line just stops
being drawn once the parent tab is gone.
- **Never fails a spawn.** An unknown / stale / foreign parent id is silently dropped
(field ends up `undefined`), not a `400`. A cosmetic field must not be able to break
worker creation.
- **Resolved, not trusted.** The id must match a live session the caller can already
see (`canAccessOwned`), and the resolved parent's `owner` must equal the new
session's `owner`. Otherwise a user could staple their session under another user's
tab in multi-user mode.
- Exact id match first; a `>= 8`-char **unique** prefix match as a fallback (ids appear
truncated in mux names and UI surfaces; ambiguous prefixes resolve to nothing).
## 3. Server changes
| File | Change |
| --- | --- |
| `src/types/session.ts` | `SessionState.parentSessionId?: string` with a doc comment saying it is UI decoration and never a permission signal |
| `src/session.ts` | constructor option `parentSessionId` → `_parentSessionId`, public getter, emitted from `toState()` (~line 1170) |
| `src/web/schemas.ts` | `parentSessionId: z.string().max(100).optional()` on `CreateSessionSchema` (272) and `QuickStartSchema` (680). Neither is `.strict()`, so this is additive |
| `src/web/route-helpers.ts` | new `resolveParentSessionId(ctx, req, bodyValue, owner)` implementing §2's rules; returns `string \| undefined`, never throws |
| `src/web/routes/session-routes.ts` | pass it into the three `new Session({...})` sites: `POST /api/sessions` (846), `POST /api/run` (2522), `POST /api/quick-start` (2896) |
| `src/web/server.ts` | recovery path (~2617): `parentSessionId: savedState?.parentSessionId` so the link survives a restart |
**No new SSE event.** `session_created` / `session_updated` broadcast
`getSessionStateWithRespawn(session)`, which is `toState()`-derived, so the field rides
along to the browser for free — and the frontend already does
`this.sessions.set(data.id, data)`, so `session.parentSessionId` is simply there.
Optional follow-up: surface it on `/api/sessions/unified` rows so the Session Manager
and the home rails can show "spawned by w3-claudeman".
## 4. Frontend rendering
### 4.1 Where the code goes
`_updateConnectionLinesImmediate()` (`subagent-windows.js:242`) is a strict
**batched read → batched write** pass, and it already has an extension point:
ultracode appends its own layer via `_appendUltracodeConnectionLines(svg, rects)` at
the end, sharing the `rects` cache so no layer forces a second reflow.
Lineage lines follow that exactly: a new module `src/web/public/session-lineage.js`
(load order 15.6, after `ultracode-windows.js`) exporting
`_appendLineageConnectionLines(svg, rects)` onto `CodemanApp.prototype`, called from the
same tail. **The core function keeps ownership of the read/write split**; the new layer
only reads through the shared `rects` map and only appends paths.
The path math itself lives in `constants.js` as a pure
`computeLineagePath(parentRect, childRect, stripRect, depth)` — same treatment as
`computeTabScrollLeft`, so the geometry is unit-testable without a browser.
### 4.2 Geometry
Both endpoints are tabs in one horizontal strip, so the subagent shape (tab-bottom →
window-top) does not apply. Two cases:
- **Same row** (the normal case): a shallow **U-bridge hanging below the strip**.
`y0 = max(parent.bottom, child.bottom)`, dip
`d = clamp(14 + |x2 - x1| * 0.06, 16, 44) + depth * 6`, path
`M x1 y0 C x1 y0+d, x2 y0+d, x2 y0`. `depth` is the child's index among its
siblings, so several children of one parent **nest** instead of overprinting.
- **Different rows** (`tabs-two-rows` / `tabs-auto-wrap` on desktop): the existing
vertical bezier from parent-bottom-center to child-top-center.
A small `<circle r="3">` at the child end marks direction (an SVG `marker` would need a
`<defs>` block and fights `stroke-dasharray`).
Each path gets `class="connection-line lineage-line"`, `data-parent-tab`,
`data-child-tab`, and `data-agent-id="lineage:<childId>"` — that last one is what makes
the existing entrance machinery (`markConnectionLineEntering` / `_applyLineEntrances`,
keyed on `data-agent-id`) work on these lines with **zero** new animation code,
including the negative-`animation-delay` resume across the `svg.innerHTML = ''` rebuild.
### 4.3 Clipping
`.session-tabs` is `overflow-x: auto`, so a tab scrolled out of the strip still has a
rect — one that lies outside the strip box and would draw an arc across the logo or the
header buttons. **Skip any edge whose parent or child center falls outside
`stripRect` (4px tolerance).** Skipping is honest; clamping would draw a line to a tab
that is not there.
### 4.4 Redraw triggers
`updateConnectionLines()` already coalesces through `scheduleBackground`, so extra
callers are cheap. Needed:
- `_fullRenderSessionTabs()` — already calls it (app.js:3912). Free.
- `_renderSessionTabsImmediate()` — does **not**. A badge appearing widens a tab and
moves every tab after it, which slides the arcs off their anchors. Add the call,
guarded on `this._lineageEdgeCount > 0` so nobody pays for it without the feature.
- **strip `scroll`** (passive listener on `#sessionTabs`) — the arcs must track the
scroller. This is new; no existing line layer needed it.
- window `resize` — piggyback the throttled handler in `terminal-ui.js:930`.
- `_onSessionCreated` — `markConnectionLineEntering('lineage:' + data.id)` so a new
child draws in **if** the user has a line-entrance theme on (all entrance styles are
`legacy`/off by default, so this is a no-op for an untouched install).
### 4.5 Styling
`.connection-line.lineage-line`: violet stroke from a `--lineage-line` token,
`stroke-width: 2`, `dasharray 4 4`, `opacity: .55`, softer glow than the subagent lines
so the two layers read as different things. Trap to respect: the skin block nests under
`html:not([data-skin="og"])`, so a bare `.lineage-line` rule inside it would outrank the
base rule at higher specificity. **Define the color as a token per skin, keep exactly
one `.lineage-line` rule.** Light skins get a darker stroke.
Optional signal worth having: `.lineage-line--working` (a slow `stroke-dashoffset`
march) only while the **child** session is working, wrapped in
`prefers-reduced-motion: no-preference`. Static otherwise — a permanently marching line
per tab pair is noise and battery.
### 4.6 Desktop only, and why
The SVG overlay is `z-index: 999`. On desktop the header is `z-index: 100`, so arcs
paint **over** the header and can touch tab bottoms. Under 1024px `mobile.css` makes the
header `position: fixed; z-index: 1200`, which would **bury** the arcs — and the phone
strip is a scroller where both endpoints are rarely on screen together anyway. So the
layer returns early unless `MobileDetection.getDeviceType() === 'desktop'`.
Raising the SVG to ~1250 (above the fixed header, below modals at 1300) is a possible
phase 2, but it needs a real check against the mobile overview and the drawer.
### 4.7 Setting
`sessionLineageLines`, **per-device** — so it goes in the `displayKeys` set in
`settings-ui.js` and must **not** be added to `SettingsUpdateSchema` (`.strict()`;
sending an undeclared key fails the whole PUT). Rendered as a switch in
App Settings → Appearance, beside the entrance-animation pickers.
**Default: ON for desktop** (phones never render it). This is the one deliberate
departure from the "new visual surfaces ship OFF" convention — the feature is the
request, and a user with 12 unrelated tabs has a one-click off switch. Flag for the
owner if the convention should win instead.
## 5. Optional extras (call them separately, none are required)
1. **Order children after their parent** in `sessionOrder` on create, so arcs stay short
and the strip reads as a tree. Real cost: it renumbers the Alt+N badges and moves
tabs under the user's cursor, so it should be its own toggle, default OFF.
2. **Lineage hover focus**: hovering a tab dims unrelated arcs and brightens its own
subtree.
3. **"Spawned by" in the Session Manager / home rails**, once `parentSessionId` is on
the unified rows.
4. **Inherited tab tint**: children pick up a faded version of the parent's tab color.
## 6. Tests
- `test/session-lineage.test.ts` (route-level, `app.inject`): round-trips through
`POST /api/sessions` + `POST /api/quick-start`, header path, body-wins-over-header,
unknown id dropped without failing the spawn, cross-owner parent dropped in
multi-user, field present in `GET /api/sessions` and persisted state.
- `test/session-lineage-lines.test.ts` (jsdom, pure): `computeLineagePath` — same-row U,
wrapped-row bezier, sibling nesting depth, off-strip skip, degenerate zero-width rects.
- Browser check (not in `test:ci`): spawn two workers with a parent, assert two
`path.lineage-line` elements anchored to the right tabs, then scroll the strip and
assert they moved.
- Existing guards that must stay green: `test/mobile-header-buttons-policy.test.ts`
(nothing new on phones), `test/app-settings-structure.test.ts` (the new switch pairs
with its rail section).
## 7. Skill side (owned by the release session, not this plan)
One line in the `codeman` skill's §0 preamble covers every spawn recipe:
```bash
CURL=(curl -sk "${AUTH[@]}" -H "X-Codeman-Parent-Session: $SELF")
```
plus a `CODEMAN_PREAMBLE` version bump so stale cached preambles fail loudly instead of
silently spawning unparented workers. Recipes that build a create payload by hand can
alternatively send `"parentSessionId":"'"$SELF"'"`.
## 8. Docs to update when it lands
`CLAUDE.md` (a Key Patterns bullet), `docs/architecture-invariants.md` (new anchor: the
resolve-don't-trust rule, the desktop-only z-index reason, the shared `rects` pass),
`docs/api-reference.md` (the new field + header on the create endpoints).