Follow-up to #285. Violet sits close to the terminal's own dim foreground, so the arcs lost contrast exactly where they cross text, which is most of their length. Blue reads at a glance on the dark skins and on the light ones. Colour still comes from a token every skin block already defines and tunes for its own background (--session-blue instead of --session-purple), so it stays one rule for all seven skins with no per-skin override, and the two blues are not even the same: --session-blue is per palette while the subagent rule hardcodes #3b82f6. Hue no longer separates this layer from the subagent lines, so the separation now rests entirely on shape (a lineage arc hangs under the strip and never reaches a window), weight and dash pattern. Noted in the rule. CSS only: no geometry, no markup, no settings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
13 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. One case, a U-bridge hanging below the strip that touches both tabs on their bottom edge:
y0 = max(parent.bottom, child.bottom)
d = clamp(14 + |x2 - x1| * 0.085, 22, 104) + depth * 8 + |child.bottom - parent.bottom|
path: M x1 parent.bottom C x1 y0+d, x2 y0+d, x2 child.bottom
depth is the child's index among its siblings, so several children of one parent
nest instead of overprinting.
Superseded (2026-08-14): the two shapes this section used to specify. The dip was
clamp(14 + span * 0.06, 16, 44) + depth * 6, and a wrapped strip (tabs-two-rows/tabs-auto-wrap) got its own parent-bottom → child-top bezier. Both were tuned against two tabs side by side and failed at the distances the feature is used at:
- a skill worker is appended to the end of the strip, so the real span is 800-1500px, where a 44px cap is a 33px sag, i.e. a line that reads as straight and crosses the terminal instead of bracketing under the strip;
- and when the strip wraps, parent-bottom (34) to child-top (48) leaves 14px to bend in, so the arc was a flat line hidden in the row gap, with siblings drawn on top of each other. Reported as "they connect already, but the lines are straight and not easy visible".
Anchoring both ends at the tab bottoms and hanging the control points below the lower row gives the wrapped case the same bracket as the flat one, and removes the branch. Pinned by
test/session-lineage-lines.test.ts.
A small <circle r="3.5"> at the child end marks direction (it breathes to 4.5 while that worker is busy) (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: blue stroke from the per-skin --session-blue token
(violet until 2026-08-14, changed because it lost contrast against the terminal's own
dim foreground the moment the arc crossed text),
stroke-width: 2.5, dasharray 5 5, opacity: .72 (.95 while the child works),
softer than the subagent lines so the two layers still read as different things now that
hue no longer separates them (shape does most of that work: a lineage arc hangs under the
strip and never reaches a window), but the contrast against the terminal comes from a
second, wider glow rather than more weight, because the first
cut (2px / 4 4 / .55 / one 5px glow) disappeared into terminal text on a real 1080p
desktop. lineage-flow marches by two dash cycles, so it moves with the dash array
(5 5 → -20). 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).