Skip to content

Child sessions: parent-as-coordinator across sessions, with strict archive #351

Description

@germanescobar

Problem

Today every Controller session is independent. An agent that wants to coordinate work — e.g. "hand off this issue to codex, then spin up a claude session to review the PR, then ask codex to address the feedback" — has no first-class way to express that. The primitives it would need (start a new session, send a message to a sibling session, watch the sibling until it finishes) either don't exist or aren't surfaced clearly.

The plumbing is mostly there: controller sessions start (issue #190) creates a new session headlessly, and controller sessions wake <sessionId> (issue #339) enqueues a follow-up on any session by id. What's missing is (a) a way to name the cross-session message primitive so the agent reaches for it naturally, (b) a persistent parent → child link so the sidebar can render the relationship, (c) a way for the parent to watch a child without polling the agent, and (d) a strict archive that refuses to run while the session has unfinished business (live agent, queued messages, persistent monitors, or live children) — both because today's archive silently leaves the agent process running, and because archiving a parent whose children are still grinding is the orphan scenario we want to prevent.

This issue files the whole coordinator surface as one PR.

Concrete motivation — the "coordinator" pattern

"Ok, hand off this issue to codex as the agent, then create a new conversation to review the pr using claude as the agent, and ask the codex conversation to address the feedback."

In the parent session the agent types:

  1. controller sessions start --agent codex --message "Fix issue #1234" → returns { sessionId: <codexChild>, url }.
  2. controller sessions start --agent claude --message "Review the PR from session <codexChild>" → returns { sessionId: <claudeChild>, url }.
  3. controller sessions monitor start <thisParent> --description "Watch claude review" --command "controller sessions tail-events <claudeChild> --follow --until-line 'review complete|review failed'" --on-line 'review complete|review failed'

When claude emits "review complete", the parent's monitor matches --on-line, the regex fires, and a [/monitor: Watch claude review] <matched line> user message is enqueued on the parent. The parent's next turn sees it and acts:

  1. controller sessions send <codexChild> --message "Claude's review came back. Here's the feedback: …" --from <thisParent>

The codex child's user-turn is recorded as [/from: <parentTitle>] Claude's review came back. … so the user can see the parent injected the message. The child treats it like any other queued follow-up.

The parent then ends its turn. Its work is done — the orchestrator carries the coordination forward.

Design

1. parentId on SessionState

Add an optional parentId?: string field to SessionState (server/lib/sessions.ts). Persisted in the session file. Set at session creation time; never mutated after.

2. POST /api/projects/:id/sessions accepts parentId

The headless session-start endpoint from #190 already returns { sessionId, url }. Add an optional parentId to the body; if present, the new session's SessionState.parentId is set to it before the run starts.

CLI:

controller sessions start [<project>] --worktree <id> --message <text> \
    [--provider|--agent codex|claude|anita] [--model <model>] [--mode default|plan] [--skill <name>] \
    [--parent <sessionId>]

3. New verb: controller sessions send <targetSessionId> --message <text> [--from <parentSessionId>]

send is an alias for wake that takes an explicit target and an optional sender. Reuses the existing POST /api/sessions/:sessionId/wake route; the CLI just resolves the target session id directly instead of using the self-resolve path.

The queued message records fromSessionId so the server can render [/from: <parentTitle>] <message> in the recipient's transcript. That's visible to the user (good audit trail) and to the recipient agent (good context).

The recipient treats it like any other queued follow-up: appends a user_message event with the [/from: …] marker, then drains via advanceSessionQueue (#339) if the agent isn't running. If the agent is running, the message is queued and replays when the current turn finishes — same shape as a user-typed composer message.

4. controller sessions children <parentSessionId>

Walks SessionState.parentId == parent.id for every session in the project and prints a summary list. New server route: GET /api/projects/:id/sessions/:sid/children returning Session[] filtered by parentId. Cheap because session files are small and there's no transcript work.

5. Sidebar: children group + orphan badge

The sidebar (client/) groups child sessions under their parent as a collapsible subtree. Click → navigate to the child. When a parent is archived, children that survive (because the user explicitly chose not to stop them) get a small "orphaned" badge in the sidebar. The badge is purely cosmetic in v1; we don't inject any message into the child's turn.

6. Strict archive

Today archiveSession (server/routes/sessions.ts) sets status = "archived", clears the queue, stops persistent monitors, and removes the row from the sidebar — but it does not kill the live agent process. That's a latent bug independent of child sessions.

Refactor: archive is blocked when any of the following hold for the target session (recursively for children):

  • A live agent process (getSessionRuntime(id).active is true).
  • A non-empty message queue (listQueue(id).length > 0, including deferred runAt items).
  • Any live persistent monitor.
  • Any child session that itself fails this same test.

The route returns 409 Conflict with a structured payload:

{
  "error": "Cannot archive: session has unfinished business",
  "blockers": {
    "runningAgents": ["sess-abc"],
    "queuedMessages": 2,
    "activeMonitors": 1,
    "runningChildren": [{ "id": "sess-def", "title": "Review PR" }]
  }
}

Stop is not blocked when children are running — that's an emergency brake and the user may need it. Stop-then-archive is the explicit sequence. Archive is the gate.

The button in the UI is disabled with a tooltip listing the blockers, so the 409 is the fallback rather than the surprise.

7. Monitor --on-line <regex> → enqueue wake

Existing monitor primitive (server/lib/monitors.ts, from #339) appends each stdout line to the session event log as a monitor_event. Add --on-line <regex> to controller sessions monitor start. Every line matching the regex is also enqueued as a [/monitor: <description>] <matched line> user message on the session's own queue. Composability win: the parent's monitor can watch a child's transcript via controller sessions tail-events <childId> --follow --until-line <regex> and feed wake-ups back into itself.

New server flag: POST /api/projects/:id/sessions/:sid/monitors accepts onLine?: string. The monitors loop runs the regex against each line; on match, calls enqueueMessage(sessionId, { text, visibleText: "…", provider, model, … }) using the session's stored provider/model so the wake-up runs on the same agent that started the monitor.

Cross-cutting

  • Agent preamble (server/lib/agent-preamble.ts) gets a new section documenting start --parent, send, children, and monitor start --on-line, with a worked example for the coordinator pattern above.
  • controller:// link format (shared/conversation-links.ts) already supports cross-session links; the [/from: …] marker is parsed by the markdown linkifier so the parent title becomes a clickable link to the parent session. No new link format needed.
  • Tests: server-side tests under server/lib/__tests__/ for the strict-archive rule (one per blocker type, one for the recursive-children case, one for the success path); CLI parsing tests under cli/__tests__/ for the new verbs and flags; an integration test that spawns a parent, a child, sends a message from parent to child, and asserts the child's transcript contains [/from: …].

Non-goals

  • Auto-archiving children when a parent is archived. Strong contract but limits the "background fire-and-forget" pattern. The strict-archive rule forces the user to be explicit; that's enough.
  • Injecting a "your parent was archived" notice into orphaned children's next turn. v1 is sidebar badge only; tune wording later based on what people actually do.
  • Cross-provider transcript sharing (parent reads child's full transcript inline). The parent can controller sessions events <childId> --tail N if it wants a snippet, but we don't auto-attach.
  • Sandboxing the monitor's command. Already called out in Same-session scheduled follow-ups, goal evaluator, and Monitor primitive (single PR, sub-issue of #219) #339 as high-trust; keep that for a separate issue.
  • Capping parent-child depth. Allow arbitrary depth — it's free and "coordinator of coordinators" is a real pattern.

Why this fits the current architecture

Decisions

  • send to self is allowed. A typo'd controller sessions send <self> resolves to a normal wake <self> rather than 500-ing. Document this in controller sessions send --help.
  • Future-dated queue items count as blockers. Strict archive refuses when listQueue(id) is non-empty, regardless of whether each item's runAt is in the future or past. A queued message is unfinished business no matter when it's scheduled to fire.
  • Single 409 for every blocker category. Different blocker shapes use different keys in the blockers payload (see §6); the HTTP status is always 409 Conflict. The UI renders one error path with a per-key breakdown.
  • children is direct-only in v1. Grandchildren are reachable transitively via children <childId>. The sidebar can collapse to a flat subtree either way; if anyone asks for recursive listing, it's a one-liner on top.

Acceptance criteria

  • SessionState.parentId?: string is persisted and round-trips through saveSession / getSession.
  • POST /api/projects/:id/sessions accepts parentId; the new session's file records it; the sidebar picks it up via the existing session-added event.
  • controller sessions start --parent <sessionId> works; without --parent behavior is unchanged.
  • controller sessions send <targetSessionId> --message "…" --from <parentSessionId> enqueues the message on the target; the persisted event is [/from: <parentTitle>] <message>; the markdown linkifier turns the parent title into a controller:// link.
  • controller sessions children <parentSessionId> lists direct children with id, title, status, provider (no recursion in v1 — see Decisions).
  • controller sessions send <self> resolves to a normal wake <self> rather than failing (see Decisions).
  • Sidebar renders children grouped under their parent as a collapsible subtree.
  • POST /api/projects/:id/sessions/:sid/archive returns 409 with a structured blockers payload when any of {live agent, queued messages, active monitors, live children} holds; the UI disables the Archive button with a tooltip listing the blockers.
  • controller sessions monitor start … --on-line <regex> enqueues a [/monitor: <description>] <matched line> user message on the session for every stdout line that matches.
  • Agent preamble documents start --parent, send, children, and monitor start --on-line with a worked coordinator example.
  • New server routes + strict-archive rule get tests under server/lib/__tests__/; new CLI verbs get unit tests under cli/__tests__/.

Related

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions