You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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."
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:
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.
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:
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.
SessionState already lives under Controller's ownership (the persistence layer strips Controller-only fields before letting the provider read it). parentId joins the same party as focusPinnedAt and goal.
getSessionRuntime(id) already tracks live agent state, listQueue(id) already reports pending messages, and listMonitors(id) already reports active monitors. The strict-archive rule is a small conjunction over existing predicates.
Arbitrary parent → worktree navigation isn't required. A parent's children all live in the same project; the children endpoint walks the project store, not all of ~/Library/Application Support/Controller.
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__/.
Touches: server/lib/sessions.ts (one new field), server/routes/sessions.ts (one new field on POST body, strict-archive gate, children listing, monitor --on-line), server/lib/monitors.ts (--on-line regex match), server/lib/agent-preamble.ts (docs section), cli/controller (three new verbs), client/ (sidebar children group + disabled-archive tooltip). Roughly 300–400 LOC of new code, mostly thin glue.
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, andcontroller 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 strictarchivethat 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
In the parent session the agent types:
controller sessions start --agent codex --message "Fix issue #1234"→ returns{ sessionId: <codexChild>, url }.controller sessions start --agent claude --message "Review the PR from session <codexChild>"→ returns{ sessionId: <claudeChild>, url }.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: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.
parentIdonSessionStateAdd an optional
parentId?: stringfield toSessionState(server/lib/sessions.ts). Persisted in the session file. Set at session creation time; never mutated after.2.
POST /api/projects/:id/sessionsacceptsparentIdThe headless session-start endpoint from #190 already returns
{ sessionId, url }. Add an optionalparentIdto the body; if present, the new session'sSessionState.parentIdis set to it before the run starts.CLI:
3. New verb:
controller sessions send <targetSessionId> --message <text> [--from <parentSessionId>]sendis an alias forwakethat takes an explicit target and an optional sender. Reuses the existingPOST /api/sessions/:sessionId/wakeroute; the CLI just resolves the target session id directly instead of using the self-resolve path.The queued message records
fromSessionIdso 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_messageevent with the[/from: …]marker, then drains viaadvanceSessionQueue(#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.idfor every session in the project and prints a summary list. New server route:GET /api/projects/:id/sessions/:sid/childrenreturningSession[]filtered byparentId. 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
archiveToday
archiveSession(server/routes/sessions.ts) setsstatus = "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:
archiveis blocked when any of the following hold for the target session (recursively for children):getSessionRuntime(id).activeis true).listQueue(id).length > 0, including deferredrunAtitems).The route returns
409 Conflictwith 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 wakeExisting monitor primitive (
server/lib/monitors.ts, from #339) appends each stdout line to the session event log as amonitor_event. Add--on-line <regex>tocontroller 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 viacontroller sessions tail-events <childId> --follow --until-line <regex>and feed wake-ups back into itself.New server flag:
POST /api/projects/:id/sessions/:sid/monitorsacceptsonLine?: string. The monitors loop runs the regex against each line; on match, callsenqueueMessage(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
server/lib/agent-preamble.ts) gets a new section documentingstart --parent,send,children, andmonitor 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.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 undercli/__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
controller sessions events <childId> --tail Nif it wants a snippet, but we don't auto-attach.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.Why this fits the current architecture
{ sessionId, url }. AddingparentIdis a one-line change to the request body and a one-line change to the persistence call.sendCLI verb just resolves the target directly;fromSessionIdis a marker on theQueuedMessagethat the persistence layer already supports adding.--on-lineis a small post-processing step in the same loop.advanceSessionQueuealready drains the queue onrun.completedandrun.failed(Same-session scheduled follow-ups, goal evaluator, and Monitor primitive (single PR, sub-issue of #219) #339's one-line fix), so asend-injected message runs even if the recipient's previous turn failed.SessionStatealready lives under Controller's ownership (the persistence layer strips Controller-only fields before letting the provider read it).parentIdjoins the same party asfocusPinnedAtandgoal.getSessionRuntime(id)already tracks live agent state,listQueue(id)already reports pending messages, andlistMonitors(id)already reports active monitors. The strict-archive rule is a small conjunction over existing predicates.~/Library/Application Support/Controller.Decisions
sendto self is allowed. A typo'dcontroller sessions send <self>resolves to a normalwake <self>rather than 500-ing. Document this incontroller sessions send --help.listQueue(id)is non-empty, regardless of whether each item'srunAtis in the future or past. A queued message is unfinished business no matter when it's scheduled to fire.blockerspayload (see §6); the HTTP status is always409 Conflict. The UI renders one error path with a per-key breakdown.childrenis direct-only in v1. Grandchildren are reachable transitively viachildren <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?: stringis persisted and round-trips throughsaveSession/getSession.POST /api/projects/:id/sessionsacceptsparentId; 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--parentbehavior 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 acontroller://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 normalwake <self>rather than failing (see Decisions).POST /api/projects/:id/sessions/:sid/archivereturns409with a structuredblockerspayload 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.start --parent,send,children, andmonitor start --on-linewith a worked coordinator example.server/lib/__tests__/; new CLI verbs get unit tests undercli/__tests__/.Related
wake, monitors, scheduler tick).server/lib/sessions.ts(one new field),server/routes/sessions.ts(one new field on POST body, strict-archive gate, children listing, monitor--on-line),server/lib/monitors.ts(--on-lineregex match),server/lib/agent-preamble.ts(docs section),cli/controller(three new verbs),client/(sidebar children group + disabled-archive tooltip). Roughly 300–400 LOC of new code, mostly thin glue.