12 KiB
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 noparentSessionId/spawnedBy/createdBy.POST /api/quick-startandPOST /api/sessionsrecord onlyowner = ownerFor(req), which is the multi-user human, not the calling session.- The only parent links that do exist are
TeamConfig.leadSessionId(agent teams) andsubagent-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
curlfrom inside a tmux pane, so there is no socket-level identity to recover (SO_PEERCREDneeds 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 a400. 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'sownermust equal the new session'sowner. 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), dipd = clamp(14 + |x2 - x1| * 0.06, 16, 44) + depth * 6, pathM x1 y0 C x1 y0+d, x2 y0+d, x2 y0.depthis the child's index among its siblings, so several children of one parent nest instead of overprinting. - Different rows (
tabs-two-rows/tabs-auto-wrapon 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 onthis._lineageEdgeCount > 0so 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 interminal-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 arelegacy/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)
- Order children after their parent in
sessionOrderon 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. - Lineage hover focus: hovering a tab dims unrelated arcs and brightens its own subtree.
- "Spawned by" in the Session Manager / home rails, once
parentSessionIdis on the unified rows. - 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 throughPOST /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 inGET /api/sessionsand 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 twopath.lineage-lineelements 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:
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).