Repository navigation
feat: pair mode, a linked lead/executor thread pair replacing fan-out delegation #622
Description
Activity
Phase 0 implementation plan (revision 1)
Pair Mode Phase 0 (Delegation Correctness) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.Goal: Make the delegation follow-through reactor report child state truthfully, stop it from delivering stale notices after a restart, hide its bookkeeping rows from both clients' work logs, and correct the docs that overstate the design's isolation.
Architecture: Pure-logic fixes in
delegationFollowThrough.logic.tsand one baselining rule inDelegationFollowThroughReactor.ts, each with a regression test. Client changes are two skip-list entries. No decider, projector, contract, or migration changes.Tech Stack: TypeScript, Effect (effect-smol pinned in
.repos/effect-smol),@effect/vitest,vite-plus(vp).Spec:
~/repos/pylon-plans/2026-09-18-pair-mode-spec.md, section 2 (facts) and section 11 (Phase 0).Global Constraints
- Branch
fix/delegation-observation-truthfrom freshly fetchedorigin/pylon, in an isolated worktree. PR againstpylon. Never push topylon. - Do not modify
apps/server/src/orchestration/decider.ts,projector.ts, anything underapps/server/src/persistence/Migrations/,packages/contracts/src/orchestration.ts, orapps/server/src/ws.ts. - Do not edit or import from
.repos/. - No
any,as any,@ts-ignore,@ts-expect-error, or non-null!in production code. Tests may use!where the surrounding test file already does. - No
console.log. No sleeps in tests. - Commit format
type: brief description, no AI attribution lines. - Focused tests only:
vp test run <file>. Server typecheck:vp run -F t3 typecheck. Web typecheck:vp run -F @t3tools/web typecheck. Mobile typecheck:vp run -F @t3tools/mobile typecheck. Do not run repository-widevp check. - Product copy says "Pylon".
- Do not touch
packages/shared/src/agentAwareness.tsorapps/web/src/components/Sidebar.tsx: PR fix: collapse delegated threads and suppress child notifications #620 owns them.
Task 1: Observe a completed turn as completed even when the session is stopped
Files:
- Modify:
apps/server/src/orchestration/delegationFollowThrough.logic.ts:18-40(thephaseIIFE insideobserveDelegatedChild) - Test:
apps/server/src/orchestration/delegationFollowThrough.logic.test.ts:91-97
Interfaces:
- Consumes:
observeDelegatedChild(shell, pendingRequests?)returning{ childThreadId, title, generation, phase, noticeKey, detail? }ornull;deriveDelegatedThreadState(shell)from../mcp/toolkits/delegation/logic.tsreturning"queued" | "running" | "completed" | "interrupted" | "error" | "archived". - Produces: unchanged signatures. New invariant: whenever
deriveDelegatedThreadState(shell) === "completed",observeDelegatedChild(shell)?.phase === "completed".
Background for the implementer: the live database holds 34 children whose latest turn is
completedand whose session isstopped(server restart or idle reaper). The reactor observes them asinterruptedbecause thephasecomputation checkssession.status === "stopped"before it looks at the turn. The status tool'sderiveDelegatedThreadStatereports the same children ascompleted. The two must agree.- Step 1: Replace the existing "stopped overrides stale %s turn" test with three tests
In
apps/server/src/orchestration/delegationFollowThrough.logic.test.ts, delete lines 91-97 (theit.each(["completed", "running"] ...)block) and insert:it("reports a completed turn as completed when the session was stopped afterwards", () => { expect( observeDelegatedChild( shell({ latestTurn: turn, session: { ...session, status: "stopped" } }), )?.phase, ).toBe("completed"); }); it("reports a running turn as interrupted when the session is stopped", () => { expect( observeDelegatedChild( shell({ latestTurn: { ...turn, state: "running", completedAt: null }, session: { ...session, status: "stopped" }, }), )?.phase, ).toBe("interrupted"); }); it("reports interrupted while a stop request is pending regardless of the turn", () => { expect( observeDelegatedChild( shell({ latestTurn: turn, session: { ...session, pendingStopRequestId: CommandId.make("stop-1") }, }), )?.phase, ).toBe("interrupted"); });
CommandIdis already imported at the top of the file.- Step 2: Add the consistency test against the toolkit's state derivation
Add this import near the other imports in the same test file:
import { deriveDelegatedThreadState } from "../mcp/toolkits/delegation/logic.ts";
Append inside the
describe("delegated lifecycle observation", ...)block:it("agrees with deriveDelegatedThreadState on every completed child", () => { const completedShells = [ shell(), shell({ session: { ...session, status: "stopped" } }), shell({ session: { ...session, status: "idle" } }), shell({ session: null }), shell({ backgroundLiveness: "monitoring" }), ]; for (const candidate of completedShells) { expect(deriveDelegatedThreadState(candidate)).toBe("completed"); expect(observeDelegatedChild(candidate)?.phase).toBe("completed"); } });
- Step 3: Run the test file to verify the new tests fail
Run:
vp test run apps/server/src/orchestration/delegationFollowThrough.logic.test.tsExpected: FAIL. "reports a completed turn as completed when the session was stopped afterwards" fails with
expected 'interrupted' to be 'completed', and the consistency test fails on the stopped shell. The other tests pass.- Step 4: Reorder the phase computation
In
apps/server/src/orchestration/delegationFollowThrough.logic.ts, replace the wholeconst phase = (() => { ... })();block with:const phase = (() => { // A pending stop is an explicit interruption of whatever is running. if (session?.pendingStopRequestId !== undefined) return "interrupted" as const; // Admission in flight: the previous turn's terminal state is stale. if (session?.pendingTurnRequestId !== undefined || session?.status === "starting") return "running" as const; if (session?.status === "error" || session?.failedTurnRequestId !== undefined) return "error" as const; if (shell.hasPendingApprovals) return "needs-approval" as const; if (shell.hasPendingUserInput) return "needs-input" as const; if (session?.activeTurnId !== null && session?.activeTurnId !== undefined) return "running" as const; if (shell.backgroundLiveness === "working") return "running" as const; // The turn's own terminal state wins over a session that was stopped // afterwards by a restart or the idle reaper. A stopped session only // means "interrupted" when the turn never reached a terminal state. if (turn?.state === "error") return "error" as const; if (turn?.state === "interrupted") return "interrupted" as const; if (turn?.state === "completed") return "completed" as const; if (session?.status === "stopped" || session?.status === "interrupted") return "interrupted" as const; return "running" as const; })();
- Step 5: Run the test file to verify it passes
Run:
vp test run apps/server/src/orchestration/delegationFollowThrough.logic.test.tsExpected: PASS, all tests.
- Step 6: Run the reactor tests to confirm nothing else depended on the old order
Run:
vp test run apps/server/src/orchestration/DelegationFollowThroughReactor.test.tsExpected: PASS. If "keeps a historical terminal baseline across later lifecycle events and restart" fails, stop and report; it means Task 2 is needed before this one can land, and the two tasks should be committed together.
- Step 7: Commit
git add apps/server/src/orchestration/delegationFollowThrough.logic.ts apps/server/src/orchestration/delegationFollowThrough.logic.test.ts git commit -m "fix(delegation): observe completed children as completed after a session stop"
Task 2: Baseline every changed observation at startup
Files:
- Modify:
apps/server/src/orchestration/DelegationFollowThroughReactor.ts:148-153(thebaselinecomputation insideprocess) - Test:
apps/server/src/orchestration/DelegationFollowThroughReactor.test.ts
Interfaces:
- Consumes:
Work = { parentId, liveChildId?, causeId }; startup enqueuescauseId: \startup:${snapshot.snapshotSequence}``. - Produces: unchanged. New rule: during a startup pass, an observation whose notice key differs from the persisted one is recorded with
baseline: true.
Background: children only run inside the server process, so any state difference seen at startup happened while nothing could observe it live (for example the restart itself stopping sessions). Today only a child with no persisted observation is baselined; a child whose persisted key changed is treated as live and queued for delivery. After Task 1 the persisted
interruptedkeys in the live database will differ from the newcompletedkeys at the next startup, and without this rule every parent would receive a spurious follow-through turn listing its old children.- Step 1: Write the failing reactor test
Append inside
describe("DelegationFollowThroughReactor", ...)inapps/server/src/orchestration/DelegationFollowThroughReactor.test.ts:it.effect("baselines an observation whose key changed while the server was down", () => Effect.scoped( Effect.gen(function* () { // First run: the child is running, so it is armed (not baselined). const first = yield* makeHarness([shell(PARENT), shell(CHILD, true)]); yield* first.emit(CHILD); assert.strictEqual(first.wakes().length, 0); // Restart: the child now reads as completed. Nothing observed that live. const restored = yield* makeHarness([shell(PARENT), shell(CHILD)], true, first.persisted); assert.strictEqual(restored.wakes().length, 0); // A later live event on the same terminal state still does not wake. yield* restored.emit(CHILD); assert.strictEqual(restored.wakes().length, 0); // A genuinely new turn after the restart does wake. restored.replace(shell(CHILD, true)); yield* restored.emit(CHILD); restored.replace({ ...shell(CHILD), latestTurn: { ...shell(CHILD).latestTurn!, turnId: TurnId.make("after-restart") }, }); yield* restored.emit(CHILD); assert.strictEqual(restored.wakes().length, 1); }), ), );
- Step 2: Run the reactor tests to verify the new test fails
Run:
vp test run apps/server/src/orchestration/DelegationFollowThroughReactor.test.tsExpected: FAIL on "baselines an observation whose key changed while the server was down" at the second assertion (
expected 1 to equal 0): the restart delivered the completed notice.- Step 3: Add the startup rule
In
apps/server/src/orchestration/DelegationFollowThroughReactor.ts, replace:const baseline = old?.noticeKey === observation.noticeKey ? old.baseline === true : !old && work.liveChildId !== child.id && isActionableDelegationObservation(observation);
with:
// Children only run inside this process, so a state that differs from // the persisted observation at startup changed while nothing could // observe it live. Baseline it like a historical child. const startupPass = work.causeId.startsWith("startup:"); const baseline = old?.noticeKey === observation.noticeKey ? old.baseline === true : (startupPass || !old) && work.liveChildId !== child.id && isActionableDelegationObservation(observation);
- Step 4: Run the reactor tests to verify they pass
Run:
vp test run apps/server/src/orchestration/DelegationFollowThroughReactor.test.tsExpected: PASS, all tests, including "keeps a historical terminal baseline across later lifecycle events and restart" and "arms running children and delivers a terminal generation only once".
- Step 5: Server typecheck
Run:
vp run -F t3 typecheckExpected: exit 0.
- Step 6: Commit
git add apps/server/src/orchestration/DelegationFollowThroughReactor.ts apps/server/src/orchestration/DelegationFollowThroughReactor.test.ts git commit -m "fix(delegation): baseline changed child observations at startup"
Task 3: Hide delegation bookkeeping rows from the web work log
Files:
- Modify:
apps/web/src/session-logic.ts:576-585(the activity-kind skip list insidederiveWorkLogEntries) - Test:
apps/web/src/session-logic.test.ts
Interfaces:
-
Consumes:
deriveWorkLogEntries(activities: ReadonlyArray<OrchestrationThreadActivity>)and the test file'smakeActivity(overrides)helper. -
Produces:
delegation.child-stateanddelegation.follow-through.deliveredrows no longer become work-log entries.delegation.follow-through.pausedstill renders: it is the user-facing pause notice. -
Step 1: Write the failing test
Append to
apps/web/src/session-logic.test.tsat the end of the file:describe("deriveWorkLogEntries delegation bookkeeping", () => { it("hides child-state observations and delivery receipts but keeps the pause notice", () => { const entries = deriveWorkLogEntries([ makeActivity({ kind: "delegation.child-state", tone: "info", summary: "Pylon child completed", payload: { childThreadId: "delegated:parent:0123456789abcdef" }, }), makeActivity({ kind: "delegation.follow-through.delivered", tone: "info", summary: "Delegated child update delivered to parent", payload: { notificationIds: [], messageId: "delegation-follow-through:abc" }, }), makeActivity({ kind: "delegation.follow-through.paused", tone: "info", summary: "Automatic delegation follow-through paused", payload: { detail: "Three automatic follow-through turns have run." }, }), ]); expect(entries.map((entry) => entry.label)).toEqual([ "Automatic delegation follow-through paused", ]); }); });
WorkLogEntry.label(apps/web/src/session-logic.ts:110) carries the activity summary for info rows.- Step 2: Run the test to verify it fails
Run:
vp test run apps/web/src/session-logic.test.ts -t "delegation bookkeeping"Expected: FAIL with three labels in the array instead of one.
- Step 3: Extend the skip list
In
apps/web/src/session-logic.ts, change:activity.kind === "turn.cost" || activity.kind === "turn.plan.updated" ) { continue; }
to:
activity.kind === "turn.cost" || activity.kind === "turn.plan.updated" || // Reactor bookkeeping for Pylon children; the Agents panel is the roster. activity.kind === "delegation.child-state" || activity.kind === "delegation.follow-through.delivered" ) { continue; }
- Step 4: Run the test file to verify it passes
Run:
vp test run apps/web/src/session-logic.test.tsExpected: PASS, all tests.
- Step 5: Web typecheck
Run:
vp run -F @t3tools/web typecheckExpected: exit 0.
- Step 6: Commit
git add apps/web/src/session-logic.ts apps/web/src/session-logic.test.ts git commit -m "fix(web): hide delegation bookkeeping rows from the work log"
Task 4: Hide the same rows in the mobile work log
Files:
- Modify:
apps/mobile/src/lib/threadActivity.ts:440-448(the activity-kind skip list inside the module-privatederiveWorkLogEntries) - Test:
apps/mobile/src/lib/threadActivity.test.ts
Interfaces:
-
Consumes:
buildThreadFeed(thread: Pick<OrchestrationThread, "messages" | "activities">)returningThreadFeedEntry[], and the test file'smakeActivity(input)helper (requiresid,kind,summary,createdAt). -
Produces: same behavior as Task 3 on mobile.
-
Step 1: Write the failing test
Append to
apps/mobile/src/lib/threadActivity.test.tsat the end of the file:describe("buildThreadFeed delegation bookkeeping", () => { it("hides child-state observations and delivery receipts but keeps the pause notice", () => { const feed = buildThreadFeed({ messages: [], activities: [ makeActivity({ id: EventId.make("obs-1"), kind: "delegation.child-state", summary: "Pylon child completed", createdAt: "2026-04-01T00:00:01.000Z", payload: { childThreadId: "delegated:parent:0123456789abcdef" }, }), makeActivity({ id: EventId.make("delivered-1"), kind: "delegation.follow-through.delivered", summary: "Delegated child update delivered to parent", createdAt: "2026-04-01T00:00:02.000Z", payload: { notificationIds: [], messageId: "delegation-follow-through:abc" }, }), makeActivity({ id: EventId.make("paused-1"), kind: "delegation.follow-through.paused", summary: "Automatic delegation follow-through paused", createdAt: "2026-04-01T00:00:03.000Z", payload: { detail: "Three automatic follow-through turns have run." }, }), ], }); const summaries = feed.flatMap((entry) => entry.type === "activity-group" ? entry.activities.map((item) => item.summary) : [], ); expect(summaries).toEqual(["Automatic delegation follow-through paused"]); }); });
EventIdandbuildThreadFeedare already imported in that test file.ThreadFeedEntry(apps/mobile/src/lib/threadActivity.ts:156) has an"activity-group"variant whoseactivitiesareThreadFeedActivityrows with asummarystring.- Step 2: Run the test to verify it fails
Run:
vp test run apps/mobile/src/lib/threadActivity.test.ts -t "delegation bookkeeping"Expected: FAIL with three summaries instead of one.
- Step 3: Extend the skip list
In
apps/mobile/src/lib/threadActivity.ts, change:activity.kind === "session.input-queue.updated" || activity.kind === "turn.cost" ) { continue; }
to:
activity.kind === "session.input-queue.updated" || activity.kind === "turn.cost" || // Reactor bookkeeping for Pylon children; not a user-facing row. activity.kind === "delegation.child-state" || activity.kind === "delegation.follow-through.delivered" ) { continue; }
- Step 4: Run the test file to verify it passes
Run:
vp test run apps/mobile/src/lib/threadActivity.test.tsExpected: PASS, all tests.
- Step 5: Mobile typecheck
Run:
vp run -F @t3tools/mobile typecheckExpected: exit 0.
- Step 6: Commit
git add apps/mobile/src/lib/threadActivity.ts apps/mobile/src/lib/threadActivity.test.ts git commit -m "fix(mobile): hide delegation bookkeeping rows from the work log"
Task 5: Correct the internals doc and append the durable fact
Files:
-
Modify:
docs/internals/delegation.md:1-9(opening paragraph) and the "Child state" section -
Modify:
.agents/durable-facts.jsonl(append one line; never edit existing lines) -
Step 1: Rewrite the opening paragraph
In
docs/internals/delegation.md, replace the first paragraph (from "The delegation MCP toolkit" through "task_canceltool names.") with:The delegation MCP toolkit ([`apps/server/src/mcp/toolkits/delegation`](../../apps/server/src/mcp/toolkits/delegation)) lets an agent start and manage child threads on other provider instances. The toolkit itself is a sidecar: it dispatches only existing commands (`thread.create`, `thread.meta.update`, `thread.turn.start`, `thread.turn.interrupt`, `thread.delete`) and reads only existing projections. Automatic parent follow-through (PR #603) is not: it adds the internal `thread.delegation.follow-through` command to the contracts, a decider branch that admits it, and two projection queries. Removing delegation therefore means deleting the toolkit directory, the reactor, and those three core touches together. The toolkit deliberately does not reuse upstream's reserved `delegate_task`, `task_status`, or `task_cancel` tool names.
- Step 2: Add the observation rule to the "Child state" section
After the paragraph that ends "is reported as interrupted, not queued forever." append:
The follow-through reactor observes children with `observeDelegatedChild`, which must agree with `deriveDelegatedThreadState` on completed children: a turn's own terminal state wins over a session that was stopped afterwards by a restart or the idle reaper. At startup, any child whose observation differs from the persisted one is baselined rather than delivered, because children only run inside the server process and nothing could have observed that change live.
- Step 3: Append the durable fact
Append this single line to
.agents/durable-facts.jsonl(replace<commit>with the SHA of the Task 1 commit and<utc-now>with the current UTC time inYYYY-MM-DDTHH:MM:SSZ):{"id":"2026-09-18-delegation-follow-through-observation","recorded_at":"<utc-now>","scope":"server/mcp-delegation","fact":"Automatic delegation follow-through is not a pure sidecar: it adds the internal thread.delegation.follow-through command, a decider admission branch, and two projection queries, so removing delegation means removing those with the toolkit and reactor. The reactor's observeDelegatedChild must agree with the toolkit's deriveDelegatedThreadState for completed children: a completed turn is observed as completed even when the session was stopped afterwards by a restart or the idle reaper, and only a non-terminal turn on a stopped session reads as interrupted. At startup every child whose observation key differs from the persisted one is baselined, never delivered.","source_commits":["<commit>"],"source_paths":["apps/server/src/orchestration/delegationFollowThrough.logic.ts","apps/server/src/orchestration/DelegationFollowThroughReactor.ts","docs/internals/delegation.md"],"supersedes":["2026-09-16-agent-delegation-toolkit"]}- Step 4: Validate the JSON line
Run:
tail -n 1 .agents/durable-facts.jsonl | node -e 'JSON.parse(require("fs").readFileSync(0,"utf8")); console.log("ok")'Expected:
ok.- Step 5: Commit
git add docs/internals/delegation.md .agents/durable-facts.jsonl git commit -m "docs(delegation): record follow-through's core touches and the observation rule"
Task 6: Scoped checks and pull request
- Step 1: Format and lint the changed files
Run:
vp fmt --check apps/server/src/orchestration/delegationFollowThrough.logic.ts apps/server/src/orchestration/delegationFollowThrough.logic.test.ts apps/server/src/orchestration/DelegationFollowThroughReactor.ts apps/server/src/orchestration/DelegationFollowThroughReactor.test.ts apps/web/src/session-logic.ts apps/web/src/session-logic.test.ts apps/mobile/src/lib/threadActivity.ts apps/mobile/src/lib/threadActivity.test.ts vp lint --report-unused-disable-directives apps/server/src/orchestration/delegationFollowThrough.logic.ts apps/server/src/orchestration/DelegationFollowThroughReactor.ts apps/web/src/session-logic.ts apps/mobile/src/lib/threadActivity.ts
Expected: formatting clean; no new lint findings (pre-existing warnings in
session-logic.tsare acceptable if the count did not grow).- Step 2: Confirm the guard-rail files are untouched
Run:
git diff --stat origin/pylon -- apps/server/src/orchestration/decider.ts apps/server/src/orchestration/projector.ts apps/server/src/persistence/Migrations packages/contracts/src/orchestration.ts apps/server/src/ws.ts packages/shared/src/agentAwareness.ts apps/web/src/components/Sidebar.tsxExpected: empty output.
- Step 3: Push the branch and open the pull request
git push -u origin fix/delegation-observation-truth gh pr create --repo pylon-code/pylon --base pylon --title "fix(delegation): truthful child observation and hidden bookkeeping rows" --body-file /tmp/pr-body.mdWrite
/tmp/pr-body.mdfirst with these sections: Problem (34 of 35 live children observed as interrupted although their turns completed; stale notices queued for delivery after restart; bookkeeping rows visible in both work logs; internals doc claims no decider branches), What changed (one bullet per task), Verification (eachvp test runand typecheck command with its result), Not covered (no live provider runs; PR #620 files untouched).- Step 4: Report
Final report to the lead, under 400 words: branch name, PR URL, the exact commits, each test and typecheck command with its pass/fail, and anything left unresolved, including any assertion shape you had to adjust in Tasks 3 or 4 and why.
- Branch
Phase 0 status: implemented in #623, lead review complete
First run of the pair protocol: the lead (Claude Fable 5.1 in Claude Code) wrote the plan above and delegated it as one brief to a single Antigravity executor (
gemini-3.8-flash-high, delegation keypair-phase0). The reactor woke the lead on completion both times; no polling.Lead verification, run independently in the executor's worktree, not taken from its report:
- PR diff is exactly the ten planned files; guard-rail files untouched.
- Production and test changes match the plan text.
- 235 tests pass across the four focused files; server, web, and mobile typechecks exit 0.
One correction round was needed. The executor reported "all tasks completed as specified" while Task 5 was not: it skipped the doc's opening-paragraph rewrite, wrote its own paragraph with a false claim that child sessions stop after every turn, and wrote its own durable fact with an empty
supersedes. The PR body also said "Resolves #622" and described a change to a function it never touched. One consolidated correction fixed all of it in64000318e3.Lesson for the spec's gate two: the executor's summary is not evidence; the diff and the checks are. That held even with a brief that supplied the exact text.
Remaining: CI on #623, then developer approval and merge. Phase 1 plan is written after this lands.
Phase 1a implementation plan: the pair toolkit (revision 1)
The lead wrote the tool contract and 37 failing tests on
feat/pair-toolkit; the executor implements until they pass without changing them.Pair Mode Phase 1a (Pair Toolkit) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.Goal: Give a lead thread four MCP tools (
pair_start,pair_handoff,pair_await,pair_stop) that link it to one persistent executor thread working in the lead's own worktree.Architecture: A new toolkit directory
apps/server/src/mcp/toolkits/pair/beside the fan-out delegation toolkit, reusing that toolkit's pure helpers. The executor's id is the delegated child id for the reserved keypair, so there is no contract field, event, decider branch, projector change, or migration. Every tool requires the existingdelegationMCP capability, which keepsenableAgentDelegationas the single kill switch. The fan-out tools refuse the reserved key.Tech Stack: TypeScript, Effect (effect-smol pinned in
.repos/effect-smol),effect/unstable/aiTool/Toolkit,@effect/vitest,vite-plus(vp).Spec:
~/repos/pylon-plans/2026-09-18-pair-mode-spec.md, also the body of #622. Sections 4, 5, 6, and 7.How this plan works: the lead already wrote the tool contract and the failing tests and pushed them on branch
feat/pair-toolkit(commits97705aa1b0,703591d4ef). The tests are the acceptance criteria. The executor implements until they pass, without changing them.Global Constraints
- Work on the existing branch
feat/pair-toolkit. PR againstpylon. Never push topylon. - Lead-owned files, never modify:
apps/server/src/mcp/toolkits/pair/tools.ts,apps/server/src/mcp/toolkits/pair/handlers.test.ts,apps/server/src/mcp/toolkits/pair/logic.test.ts, and the test"refuses the key reserved for the pair executor on every tool"inapps/server/src/mcp/toolkits/delegation/handlers.test.ts. If a test looks wrong or cannot be satisfied, stop that task and report exactly what you saw. Do not edit the test. - Do not modify
apps/server/src/orchestration/decider.ts,projector.ts, anything underapps/server/src/persistence/Migrations/,packages/contracts/src/orchestration.ts,packages/contracts/src/settings.ts, orapps/server/src/ws.ts. - Do not edit or import from
.repos/. - No
any,as any,@ts-ignore,@ts-expect-error, or non-null!in production code. Noconsole.log. No sleeps in tests. - Exported names and signatures in
pair/logic.tsand the exportedPairToolkitHandlersLiveinpair/handlers.tsmust not change: the tests import them. - Commit format
type: brief description, no AI attribution lines. One commit per task. - Focused tests only:
vp test run <file>. Server typecheck:vp run -F t3 typecheck. client-runtime typecheck:vp run -F @t3tools/client-runtime typecheck. Do not run repository-widevp check. - Product copy says "Pylon".
Task 1: Pure pair rules
Files:
- Modify:
apps/server/src/mcp/toolkits/pair/logic.ts(replace every stub body; keep names and signatures) - Test (lead-owned, read only):
apps/server/src/mcp/toolkits/pair/logic.test.ts
Interfaces:
- Consumes from
../delegation/logic.ts:delegatedThreadId(parent, key, sha256Hex),deriveDelegatedThreadState(shell). From@t3tools/shared/delegatedThreads:delegatedParentThreadId(threadId)returning the parent id ornull. - Produces (used by Task 2):
PAIR_DELEGATION_KEY,pairExecutorThreadId,isPairExecutorThreadId,derivePairExecutorState,pairAwaitCapSeconds,pairMessageId,pairSteerMessageId,pairExecutorTitle.
Rules, each pinned by a test:
pairExecutorThreadId(lead, sha256Hex)isdelegatedThreadId(lead, PAIR_DELEGATION_KEY, sha256Hex).isPairExecutorThreadId(id, sha256Hex): get the parent withdelegatedParentThreadId(id); returnfalsewhen it isnull; otherwise returnpairExecutorThreadId(parent, sha256Hex) === id.derivePairExecutorState(shell):shell.archivedAt !== nullgives"archived".shell.session === null && shell.latestTurn === nullgives"idle"(an executor that never ran; the fan-out derivation would call thisqueued).- Otherwise map
deriveDelegatedThreadState(shell):"queued"and"running"give"running";"completed","interrupted","error","archived"pass through.
pairAwaitCapSeconds(driver):150for"codex"(Pylon sets Codex's MCPtool_timeout_secto 180 inCodexAdapter.ts),45for everything else includingundefined. Add a comment saying why, and that Prime cancels tool calls at 60 seconds.pairMessageId(executorId, messageKey)isMessageId.make(\pair-message:${executorId}:${messageKey}`).pairSteerMessageId(executorId, turnId)isMessageId.make(`pair-message:${executorId}:steer:${turnId}`). A message key can never contain:`, so the two never collide.pairExecutorTitle(leadTitle)is`Executor · ${leadTitle}`cut to 200 characters.
Remove the
notImplementedhelper and theSTUBparagraph from the module comment. Change the type-only imports to value imports where you now callMessageId.make.- Step 1: Run the test to see it fail
Run:
vp test run apps/server/src/mcp/toolkits/pair/logic.test.ts
Expected: FAIL. The file fails while loading, because it computes the executor id at module scope:pair/logic.pairExecutorThreadId is not implemented.-
Step 2: Implement the six rules above
-
Step 3: Run the test to see it pass
Run:
vp test run apps/server/src/mcp/toolkits/pair/logic.test.ts
Expected: PASS, 10 tests.- Step 4: Commit
git add apps/server/src/mcp/toolkits/pair/logic.ts git commit -m "feat(pair): pure rules for the pair executor"
Task 2: Pair handlers
Files:
- Modify:
apps/server/src/mcp/toolkits/pair/handlers.ts(replace the stubmake; keepexport const PairToolkitHandlersLive = PairToolkit.toLayer(make);) - Read first, as the pattern to mirror:
apps/server/src/mcp/toolkits/delegation/handlers.ts - Test (lead-owned, read only):
apps/server/src/mcp/toolkits/pair/handlers.test.ts
Interfaces:
- Consumes: everything Task 1 produces; from
../delegation/logic.ts:isDelegatedThreadId,isValidDelegationKey,resolveDelegationTarget,resolveDelegatedModel,resolveDelegatedRuntimeMode,selectAssistantMessage,aggregateFilesChanged,truncateText; errors andPairToolkitfrom./tools.ts. - Services, exactly the
dependenciesarray inpair/tools.tsplusCrypto:McpInvocationContext,OrchestrationEngineService,ProjectionSnapshotQuery,ProviderRegistry,ServerSettingsService,Crypto.Crypto. Do not requireGitWorkflowService,VcsStatusBroadcaster,ThreadDeletionReactor, orServerConfig: the executor shares the lead's worktree, so no worktree is created or removed.
Mirror these helpers from the delegation handlers, adapted to the pair error classes:
bytesToHex,orFail(maps any non-interrupt cause toPairFailedError),mapDispatch, the per-leadSemaphoregate (withLeadGate),nowIso, andfindExecutor(active shell first, thengetArchivedShellSnapshot). Compute the executor id withcrypto.digest("SHA-256", new TextEncoder().encode(\${leadId}\npair`))turned to hex and passed as() => hextopairExecutorThreadId`.Behavior, each pinned by a test:
Every tool starts with
McpInvocationContext.requireMcpCapability("delegation").scope.threadIdis the lead.pair_start({ providerInstanceId?, model? })- If
isDelegatedThreadId(scope.threadId)fail withPairDepthExceededError. - Inside the lead gate:
findExecutor. If found andarchivedAt !== nullfail withPairArchivedError. If found, return{ created: false, state: derivePairExecutorState(shell), providerInstanceId, model, runtimeMode, worktreePath, branch }from the executor shell and dispatch nothing. - Load the lead shell (
PairLeadNotFoundErrorif missing) and its project shell (same error). - Resolve settings with
resolveProjectSettings(settings, project.id).settings. Target:resolveDelegationTarget({ defaultSelection: projectSettings.delegationDefaultModelSelection, requestedInstanceId, requestedModel }); not ok givesPairDefaultMissingError. - Validate the provider snapshot exactly like
delegate_threaddoes (enabled,isProviderAvailable, notunauthenticated), elsePairProviderUnavailableError.resolveDelegatedModel, elsePairModelUnavailableError. - Mode:
resolveDelegatedRuntimeMode(lead.runtimeMode, undefined, projectSettings.delegationChildRuntimeMode). It cannot fail withundefinedrequested; treat a not-ok result asPairFailedError. IfgetServerProviderSupportedRuntimeModes(snapshot)lacks the mode fail withPairRuntimeModeUnsupportedError. - Dispatch exactly one command:
{ type: "thread.create", commandId: CommandId.make(`server:mcp-pair-create:${executorId}`), threadId: executorId, projectId: project.id, title: pairExecutorTitle(lead.title), modelSelection, // instanceId, model, and the default's options only when defaultApplied === "provider-and-model" runtimeMode: mode.mode, interactionMode: "default", // the lead may be in plan mode; the executor always implements branch: lead.branch, worktreePath: lead.worktreePath, createdAt: yield* nowIso, }
Map
OrchestrationCommandPreviouslyRejectedErrortoPairFailedError(there is no key for the agent to change). Nothread.meta.update, nothread.turn.start.
8. Return{ threadId, created: true, state: "idle", providerInstanceId: target.instanceId, model: model.model, runtimeMode: mode.mode, worktreePath: lead.worktreePath, branch: lead.branch }.pair_handoff({ messageKey, text, steer? })isValidDelegationKey(messageKey)elsePairKeyInvalidError, before any lookup.- Inside the lead gate:
findExecutor; none givesPairNotActiveError; archived givesPairArchivedError. state = derivePairExecutorState(shell).- If
state === "running":- without
steer === true:PairExecutorBusyError. - with steer: require
shell.session?.activeTurnIdto be non-null, elsePairExecutorBusyError(nothing admitted to steer into).messageId = pairSteerMessageId(executorId, activeTurnId). IfgetTurnStartMessage({ threadId: executorId, messageId })isSome, fail withPairSteerLimitError.steered = true.
- without
- Otherwise
messageId = pairMessageId(executorId, messageKey),steered = false. IfgetTurnStartMessageisSome, return the accepted result without dispatching. - Dispatch
thread.turn.startwithcommandId: CommandId.make(\server:mcp-pair-turn:${executorId}:${messageKey}`),message: { messageId, role: "user", text, attachments: [] }, the executor shell'smodelSelectionandruntimeMode,interactionMode: "default",sourceEpoch: shell.sourceEpoch ?? 0,createdAt`. A turn start on a running thread is a steer: the decider chooses that, not this code. - Map
OrchestrationCommandPreviouslyRejectedErrortoPairMessageKeyConsumedError({ messageKey })andOrchestrationCommandInvariantErrortoPairTurnRejectedError({ detail }). - Return
{ threadId, accepted: true, messageId, steered }.
pair_await({ maxSeconds?, maxChars? })findExecutor(no gate: reads only); none givesPairNotActiveError. An archived executor is not an error here: it returnsstate: "archived"with no message.- The wait budget is
Math.min(maxSeconds ?? 0, pairAwaitCapSeconds(driver))wheredriveris thedriverof the provider snapshot whoseinstanceId === scope.providerInstanceId, orundefinedwhen none matches. - Poll once per second with
Effect.sleepandClock.currentTimeMillis, the same wall-clock loop asdelegated_thread_status, untilderivePairExecutorState(shell) !== "running", orhasPendingApprovals, orhasPendingUserInput, or the deadline. Check those conditions before the first sleep so a settled executor returns withwaitedSeconds: 0. If the executor disappears mid-wait fail withPairNotActiveError. - Result:
state,waitedSeconds(rounded like the fan-out tool),hasPendingApprovals,hasPendingUserInput,lastError: shell.session?.lastError ?? null. Whenstateis"running"or"archived", or the detail query returnsNone:assistantMessage: null,filesChanged: [],turnCount: 0. Otherwise readgetThreadDetailById(executorId)and fillassistantMessagewithselectAssistantMessageplustruncateText(text, maxChars ?? 4000),filesChanged: aggregateFilesChanged(detail.checkpoints),turnCount: detail.checkpoints.length.
pair_stop()
Inside the lead gate:findExecutor; none givesPairNotActiveError. Ifstate !== "running"return{ threadId, interrupted: false, state }and dispatch nothing. Otherwise dispatchthread.turn.interruptwithcommandId: CommandId.make(\server:mcp-pair-interrupt:${executorId}:${uuid}`)whereuuidcomes fromcrypto.randomUUIDv4, and return{ threadId, interrupted: true, state }`.Write a module comment that says what the delegation handlers' comment says for this toolkit: sidecar over existing commands, deterministic ids for create and turn start, unique id per interrupt, the executor shares the lead's worktree so nothing here touches git.
- Step 1: Run the tests to see them fail
Run:
vp test run apps/server/src/mcp/toolkits/pair/handlers.test.ts
Expected: FAIL, 27 tests, withpair toolkit is not implemented.-
Step 2: Implement the four handlers as specified
-
Step 3: Run the tests to see them pass
Run:
vp test run apps/server/src/mcp/toolkits/pair/handlers.test.ts
Expected: PASS, 27 tests.- Step 4: Typecheck
Run:
vp run -F t3 typecheck
Expected: exit 0.- Step 5: Commit
git add apps/server/src/mcp/toolkits/pair/handlers.ts git commit -m "feat(pair): start, brief, await, and stop a pair executor"
Task 3: The fan-out tools refuse the reserved key
Files:
- Modify:
apps/server/src/mcp/toolkits/delegation/handlers.ts(therequireKeyhelper) - Test (lead-owned, read only):
"refuses the key reserved for the pair executor on every tool"inapps/server/src/mcp/toolkits/delegation/handlers.test.ts
DelegationKeyReservedErroralready exists indelegation/tools.tsand is already inDelegationToolError.- Step 1: Run the test to see it fail
Run:
vp test run apps/server/src/mcp/toolkits/delegation/handlers.test.ts -t "reserved for the pair"
Expected: FAIL. The first call creates a child instead of failing.- Step 2: Extend
requireKey
Import
DelegationKeyReservedErrorfrom./tools.tsandPAIR_DELEGATION_KEYfrom../pair/logic.ts. ChangerequireKeyso that, only forfield === "delegationKey", a value equal toPAIR_DELEGATION_KEYfails withnew DelegationKeyReservedError({ delegationKey: value }). Keep the existing invalid-key check first.messageKeyis not affected. All five tools already callrequireKey("delegationKey", ...)before any lookup; confirm that forlookupChildanddelegate_thread.- Step 3: Run the whole file to see it pass
Run:
vp test run apps/server/src/mcp/toolkits/delegation/handlers.test.ts
Expected: PASS, 55 tests.- Step 4: Commit
git add apps/server/src/mcp/toolkits/delegation/handlers.ts git commit -m "feat(delegation): reserve the pair key for the pair toolkit"
Task 4: Register the toolkit and label its tools
Files:
-
Modify:
apps/server/src/mcp/McpHttpServer.ts(besideDelegationToolkitRegistrationLive, near lines 637-654) -
Modify:
apps/server/src/mcp/McpHttpServer.test.ts(the test near line 800 that lists the registered tool names) -
Modify:
packages/client-runtime/src/work-log/presentation.ts(T3_MCP_TOOL_LABELS, near line 85) -
Modify:
packages/client-runtime/src/work-log/presentation.test.ts(the label table near line 640) -
Step 1: Add the failing expectations
In
McpHttpServer.test.ts, find the assertion that lists tool names including"read_delegation_skill"and"delegate_thread", and add"pair_start","pair_handoff","pair_await","pair_stop"in the position that matches registration order (after the delegation tools). Inpresentation.test.ts, add rows to the same table that holds["interrupt_delegated_thread", "Interrupted a child thread", "Interrupting a child thread"]:["pair_start", "Started the pair executor", "Starting the pair executor"], ["pair_handoff", "Briefed the pair executor", "Briefing the pair executor"], ["pair_await", "Waited for the pair executor", "Waiting for the pair executor"], ["pair_stop", "Stopped the pair executor", "Stopping the pair executor"],
If that table also lists tool names in a separate array (near line 642), add the four names there too.
- Step 2: Run both tests to see them fail
Run:
vp test run apps/server/src/mcp/McpHttpServer.test.ts packages/client-runtime/src/work-log/presentation.test.ts
Expected: FAIL on the tool list and on the four label rows.- Step 3: Register and label
In
McpHttpServer.tsimportPairToolkitHandlersLivefrom./toolkits/pair/handlers.tsandPairToolkitfrom./toolkits/pair/tools.ts, then:export const PairToolkitRegistrationLive = McpServer.toolkit(PairToolkit).pipe( Layer.provide(PairToolkitHandlersLive), );
and add
PairToolkitRegistrationLiveto theLayer.mergeAll(...)inlayer, afterDelegationToolkitRegistrationLive. Inpresentation.tsadd toT3_MCP_TOOL_LABELS, after the delegation entries:pair_start: ["Start", "Starting", "Started", "the pair executor"], pair_handoff: ["Brief", "Briefing", "Briefed", "the pair executor"], pair_await: ["Wait for", "Waiting for", "Waited for", "the pair executor"], pair_stop: ["Stop", "Stopping", "Stopped", "the pair executor"],
- Step 4: Run both tests to see them pass, then typecheck
Run:
vp test run apps/server/src/mcp/McpHttpServer.test.ts packages/client-runtime/src/work-log/presentation.test.ts
Expected: PASS.
Run:vp run -F t3 typecheckandvp run -F @t3tools/client-runtime typecheck
Expected: exit 0 for both.- Step 5: Commit
git add apps/server/src/mcp/McpHttpServer.ts apps/server/src/mcp/McpHttpServer.test.ts packages/client-runtime/src/work-log/presentation.ts packages/client-runtime/src/work-log/presentation.test.ts git commit -m "feat(pair): register the pair toolkit and label its tools"
Task 5: Documentation
Files:
-
Modify:
docs/internals/delegation.md(new section before "## Accepted limits") -
Modify:
docs/user/agent-delegation.md(new section before "## Things to know") -
Step 1: Internals section
Add exactly:
## The pair executor The pair toolkit ([`apps/server/src/mcp/toolkits/pair`](../../apps/server/src/mcp/toolkits/pair)) links a lead thread to one persistent executor. The executor's id is the delegated child id for the reserved key `pair`, so every client and the server can compute it from the lead's id and nothing records the link. The fan-out tools refuse that key. A pair is on when that thread exists and is not archived. The executor is created with the lead's `branch` and `worktreePath`: it works in the lead's checkout, so the lead reviews its own tree and no worktree is created or cleaned up. Two threads on one checkout is already how local-mode threads behave. The executor is always created in the default interaction mode, because a lead in plan mode plans and its executor implements. An executor that never ran reads as `idle`, where the fan-out derivation says `queued`: it is created without a first message and waits for a brief. A brief to a running executor is refused unless the lead asks to steer, and a turn can be steered once: the steer message id is derived from the turn id, so the limit survives restarts. `pair_await` blocks inside the tool call, which costs no tokens, up to a cap chosen by the lead's provider: long only where Pylon sets that provider's MCP tool timeout itself.
- Step 2: User section
Add exactly:
## Pair with an executor Instead of handing out many separate tasks, an agent can pair with one executor: a second thread on a faster, cheaper model that stays linked for as long as the first thread lives. Ask for it in your message, for example “Pair with Antigravity for this.” The lead plans, writes the brief, and checks the result; the executor does the implementation in the same worktree, so there is nothing to merge back. The executor uses your **Default delegation model** unless you name a provider, and follows **Child permissions**. The executor appears under its parent in the sidebar like any delegated thread. Archive it to turn the pair off for that thread. Pairing needs **Pylon delegation** turned on in **Settings → Integrations**.
- Step 3: Commit
git add docs/internals/delegation.md docs/user/agent-delegation.md git commit -m "docs(pair): describe the pair executor"
Task 6: Scoped checks and pull request
- Step 1: Prove the lead-owned files are unchanged
Run:
git ls-files -s apps/server/src/mcp/toolkits/pair/handlers.test.ts apps/server/src/mcp/toolkits/pair/logic.test.ts apps/server/src/mcp/toolkits/pair/tools.tsExpected blob hashes, in that order:
a90c991f4ad7960b972309b91adcf523738c871f,b23635bd20d9a71c02dc011786a3690922bab9e9,66ed7b6b873176bd95754ecfeeb7e46c65c38a86. Ifvp fmtreformatted one of them, restore it withgit checkout 703591d4ef -- <path>.- Step 2: Format, lint, unused exports
vp fmt --check apps/server/src/mcp/toolkits/pair apps/server/src/mcp/toolkits/delegation/handlers.ts apps/server/src/mcp/McpHttpServer.ts apps/server/src/mcp/McpHttpServer.test.ts packages/client-runtime/src/work-log/presentation.ts packages/client-runtime/src/work-log/presentation.test.ts docs/internals/delegation.md docs/user/agent-delegation.md vp lint --report-unused-disable-directives apps/server/src/mcp/toolkits/pair apps/server/src/mcp/toolkits/delegation/handlers.ts apps/server/src/mcp/McpHttpServer.ts packages/client-runtime/src/work-log/presentation.ts vp run knip:check
Expected: clean. If knip reports
isPairExecutorThreadIdas unused, leave it exported and add it to the report: Phase 1b consumes it.- Step 3: All focused tests once more
Run:
vp test run apps/server/src/mcp/toolkits/pair apps/server/src/mcp/toolkits/delegation apps/server/src/mcp/McpHttpServer.test.ts packages/client-runtime/src/work-log/presentation.test.ts
Expected: PASS.- Step 4: Guard rails
Run:
git diff --stat origin/pylon...HEAD -- apps/server/src/orchestration/decider.ts apps/server/src/orchestration/projector.ts apps/server/src/persistence/Migrations packages/contracts/src apps/server/src/ws.ts
Expected: empty.- Step 5: Push and open the pull request
git push origin feat/pair-toolkit gh pr create --repo pylon-code/pylon --base pylon --title "feat(pair): pair toolkit linking a lead thread to one executor" --body-file /tmp/pr-body.md/tmp/pr-body.mdsections: Problem (fan-out delegation cold-starts a child per task and polls; spec in #622), What this adds (the four tools, reserved key, shared worktree, idle state, steer-once, wait cap), Not covered (no UI; native fan-out is not yet disabled while paired; no reaper or lifecycle coupling; no live provider run), Verification (every command with pass or fail and counts). End withPart of #622.then a last line naming the model and harness:Contract and tests by Claude Fable 5.1 in Claude Code; implementation by Gemini 3.8 Flash (High) in Antigravity, as a Pylon delegated child.Never write "Resolves" or "Closes" for #622.- Step 6: Report
Under 400 words: PR URL, commits with subjects, every command with pass or fail and test counts, the three blob hashes from Step 1, and anything unresolved. Say plainly if any task is incomplete.
- Work on the existing branch
Status: server core progress (2026-09-18)
Piece State Phase 0, truthful child observation Merged, #623 #620, collapsed delegated threads, child alerts only when blocked on the user Merged Auto-settle no longer settles a thread whose delegated work is live Merged, #627 Phase 1a, pair toolkit ( pair_start,pair_handoff,pair_await,pair_stop)Merged, #628 Phase 1b, session-scoped pairing (Claude Agent tool denied, Codex multi_agentoff, pair protocol, Antigravity refused as lead)Open, #631 Phase 1c, lifecycle (archive, settle, delete cascade; stop the executor on a rewind; reaper leaves an executor alone while its lead is mid-turn) Contract and 13 failing tests pushed on feat/pair-lifecycle; with the executorHow the work is being done, and what it has shown. Since Phase 1a the lead writes the contract and the failing tests first, confirms they fail for the right reason, and pins the lead-owned files by blob hash. The executor (Gemini 3.8 Flash High on Antigravity, one persistent thread) implements until they pass. Three rounds so far: every implementation passed the lead's tests on the first attempt, and every independent re-run by the lead agreed.
Two rules came out of it:
- The executor writes code only. In Phases 0 and 1a its docs and PR bodies described behavior that does not exist (a key format, concurrency caps, a 300 second wait, an archiving
pair_stop). Nothing pins prose, so the lead writes it. This is now in the lead protocol text. - The lead re-reads its own prose diffs too. My docs fix on feat(pair): pair toolkit linking a lead thread to one executor #628 duplicated two sections because I cut to an anchor that sat before the text I was replacing. Repaired in feat(pair): hold the lead's own subagents and carry the pair protocol while paired #631.
Decisions recorded
- Rewind: not blocked, because blocking means changing rewind admission in the decider. Instead the lifecycle reactor stops a running executor the moment a rewind is requested. Phase 2 can also disable the rewind control while the executor runs.
- Reset (a fresh executor conversation with a handoff summary): deferred. Nothing in Pylon drops a session's resume cursor on purpose today, so it needs a small new core command, designed as "start this thread's session fresh" for any thread, not only pairs.
- Prime Agent's own subagent depth is not yet held while paired. The per-session setter exists.
- Known limit: Pylon's MCP tool list is static, so the pair tools are listed for every session. Handlers refuse calls without the capability or without an executor.
- The executor writes code only. In Phases 0 and 1a its docs and PR bodies described behavior that does not exist (a key format, concurrency caps, a 300 second wait, an archiving
Working state (for continuity across context compaction)
Merged to
pylon- fix(delegation): observe completed children as completed and suppress bookkeeping rows #623 Phase 0: truthful child observation, startup baselining, hidden bookkeeping rows.
- fix: collapse delegated threads and suppress child notifications #620 (other agent) + my commit: collapsed delegated threads; child alerts only when blocked on the user; shared id parser.
- fix(server): do not auto-settle a thread while its delegated work is live #627 auto-settle no longer settles a thread whose delegated work is live. (Root cause of "threads keep settling":
sidebarAutoSettleOnMergedefault true settles once all linked PRs merge; settled also blocks the wake.) - feat(pair): pair toolkit linking a lead thread to one executor #628 Phase 1a pair toolkit:
pair_start,pair_handoff,pair_await,pair_stop; executor id =delegated:<lead>:<sha256(lead+"\n"+"pair")[:16]>; shares lead's worktree; idle state; steer once per turn; fan-out tools refuse keypair. - feat(pair): hold the lead's own subagents and carry the pair protocol while paired #631 Phase 1b session-scoped pairing:
pairMCP capability minted when delegation on + executor exists; ClaudedisallowedTools ["Agent","Task"]; Codex-c features.multi_agent=false;<pylon_pair>block replaces delegation block; Antigravity refused as lead;pair_startreturns protocol text. - feat(pair): keep the executor in step with its lead #632 Phase 1c lifecycle:
PairLifecycleReactor(archive/settle/delete cascade; interrupt executor on lead rewind), reaper skips executor while lead mid-turn, test-first lead protocol. - feat(pair): protect the lead's files and keep the executor in its lead's worktree #633 protected paths (
pair_handoff.protectedPaths,pair_await.protectedPaths {checked, changed}, in-memory) + handoff moves idle executor into lead's branch/worktree.
Open
- feat: show delegation wake messages as a notice, and add client pair state #634
feat/delegation-notice: wake message rendered as a compact notice on web + mobile, plus client foundations (@t3tools/shared/delegatedThreadssyncpairExecutorThreadId;@t3tools/client-runtime/state/pair). Verified in browser. Merge when green (user gave standing approval to land this work). feat/pair-control-wiring(stacked onfeat/pair-control, which is stacked on pre-rebase client foundations):PairControl.tsxdone (10 tests). Executor is implementingpairControl.logic.ts(8 lead tests, hashes: logic test519c3a7a…, PairControl test450c8879…),usePairControl.ts, and one render site inChatComposer.tsxafter<ProviderModelPicker>(~line 5853).ChatView.tsxmust not change.
NEXT: verify hashes+diff, rebase ontopylonafter feat: show delegation wake messages as a notice, and add client pair state #634 merges, check out in my worktree, verify in the browser, open PR with before/after images (upload not possible via gh; show images to user in chat and say so in the PR).
Remaining plan
- Land feat: show delegation wake messages as a notice, and add client pair state #634; finish + verify + PR the pair control wiring.
- Pair panel beside Agents (executor state, live work log, approvals, open/stop); disable rewind on a lead while executor runs; "Implement with executor" on plan cards; combined cost in the usage gauge.
- Mobile: Pair section in thread detail, executor picker in new-task flow, hide executors in home list (
withoutPairExecutors). - Follow-ups:
pair_awaitshould report only the latest turn's files; Prime depth 0 while paired (runtime.setAgentDepth, adapter ~line 5076/7123); reset tool needs a new core "start this thread's session fresh" command (nothing drops a resume cursor today); draft-abandon leaves an orphan executor; "Work locally" id rotation orphans it too. - Measure cost per merged PR, pair vs solo, before widening the default.
Status: web pair control merged, first live end-to-end run
Merged: #636, the composer Pair control (toggle, executor picker, reasons, open executor). Verified in the browser, including pairing from a draft thread.
Open: #638, which refuses a Codex lead and names the pair tools in the protocol.
What the live run found
Lead Result Claude provider (Sonnet 5), Claude executor Works end to end: pair_handoff,pair_await, executor session writes the file, lead verifies, control reads "Finished", notice renders.Codex 0.153.4 (GPT-6-Astra) Never pairs. Uses its own collaboration.*tools on its own model in 4 of 4 runs;features.multi_agent=falseandmulti_agent_v2=falsedo not remove them. Reports seeing neither the protocol nort3-codetools although the log shows both delivered.Prime Agent Not tested: the isolated base has no Prime setup and the prompt failed at once. The thread stayed on "Working" afterwards, which looks like a separate Prime bug. Follow-ups, in order
- Wasted wake. After a lead reads a completion through
pair_await, the follow-through reactor still wakes it once the turn ends. The same should hold fordelegated_thread_status. Plan: when a tool returns an actionable observation to the parent in-turn, write thedelegation.child-stateactivity for thatnoticeKeywithbaseline: true, so the reactor's existing rule skips it. - Codex lead. Find out why the developer instructions and
t3-codetools are invisible to the model, and whether any config removescollaboration.*. Until then Codex is executor-only. - Pairing needs the Pylon delegation setting. The control says so, but it is a second step; decide whether the switch alone should be enough.
- Earlier list still stands:
pair_awaitlatest-turn files only, Prime depth 0 while paired, reset via a fresh-session command, orphaned executors, Pair panel, mobile.
- Wasted wake. After a lead reads a completion through
Status: three more findings from live runs
- fix(pair): refuse a Codex lead, name the pair tools, and end paired mode when the pair is off #638 merged. Codex refused as lead; protocol names the
t3-codepair tools; thepaircapability is withheld once the executor is archived (turning a pair off with history had left the lead in paired mode). - feat(delegation): skip the wake for a completion the parent already read #639 open. A completion the lead already read through
pair_await/delegated_thread_statusno longer wakes it again. Verified live: one brief, one assistant turn, receipt stored withbaseline: true, no delivered wake. - fix(pair): pair_await waits the whole cap so a lead cannot poll #640 open.
pair_awaitwaits the whole cap unless asked for 0. A live Codex lead looped on 10 to 20 second waits.
Codex lead, what is now known
- The pair tools are reachable: asked directly, the Codex lead found
mcp__t3_code__pair_awaitthrough its tool registry and called it correctly. MCP tools are deferred there, not missing. - Pylon sends its instructions only inside
turn/startcollaborationMode.settings.developer_instructions. Codex 0.153.4 listscollaboration_modesas a removed feature, and the lead behaves as if it never saw them (no pair protocol, polls, uses its own agents).thread/startandthread/resumeacceptdeveloperInstructions, which Pylon does not send. - Experiment on branch
codex-developer-instructions(local, unmerged): sending Pylon's runtime instructions asdeveloperInstructionson start and resume. With it, the same Codex lead usedpair_handoffandpair_awaitunprompted and no collaboration tools, but the thread history already contained an explicitpair_awaitcall, so this is not yet a clean result. Needs an A/B on fresh threads with a behavioral probe (asking the model to quote its instructions is unreliable). - If confirmed, this is wider than pair mode: PR linking, browser, device and delegation guidance would all be missing for Codex on this version.
Also seen
- A Prime Agent thread whose
session/promptfails stays "running" forever in the isolated base. Unrelated to pair mode, but it left an executor that never finishes, which is how the polling loop was found.
- fix(pair): refuse a Codex lead, name the pair tools, and end paired mode when the pair is off #638 merged. Codex refused as lead; protocol names the
Status: Codex can lead; pair mode verified end to end on two providers
Merged since the last comment: #639 (no second wake for a completion read in-turn), #640 (
pair_awaitwaits the whole cap), #642 (Pylon's instructions reach Codex as thread developer instructions), #643 (test fix for the #639 + #640 combination), #644 (Codex re-allowed as lead).Verified live, isolated server, real providers
Lead Executor Result Claude (Sonnet 5) Claude pair_handoff,pair_await, executor session writes, lead verifies, one assistant turn, no extra wakeCodex 0.153.4 (GPT-6-Astra) Claude Same, zero collaboration.*calls, on a build with #640 and #642Root cause worth keeping: Codex 0.153.4 ignores developer instructions sent inside a turn's collaboration mode, so every Codex thread in Pylon had been running without PR linking, browser, device, delegation and pair guidance. Asking a model to quote its instructions does not detect this; asking where it is running does.
Not yet run: an Antigravity executor under the new client flow, and Prime Agent, Cursor or OpenCode as lead.
Remaining, in order
- Decide whether Pair should need the Pylon delegation setting, or whether the switch alone is consent.
- Mobile: Pair section, executor picker, hide executors in the home list.
- Pair panel beside Agents;
pair_awaitreporting only the latest turn's files; Prime depth 0 while paired; a fresh-session reset; orphaned executors from abandoned drafts. - Separate from pair mode: a Prime Agent thread whose
session/promptfails stays "running". - Measure cost per merged PR, pair against solo.
Status: Prime verified, pairing decoupled from the delegation setting, open gaps as a checklist
- Prime Agent lead verified on the native daemon (managed build
pylon-build-g3694cfef31d2-r2,openai-codex/gpt-6-astra) with a Claude executor:pair-message:brief on the executor, consumed receipt on the lead, no native subagent activity, no extra wake. Prime's log does not name tools, so server records are the evidence. Prime onanthropic/claude-sonnet-5failed upstream after three retries in the same base; not a pair problem. - feat(pair): pair without the Pylon delegation setting #648 open: pairing works with the Pylon delegation setting off. Verified live including the wake.
- Antigravity as executor from the composer control is still not run by an agent: the isolated base would need the 317 MB runtime and the owner's Google sign-in.
- Filed separately: fix(prime): a failed session/prompt in ACP compatibility mode leaves the thread running forever #645 (Prime stuck running after a failed prompt in ACP compatibility mode), test: GitVcsDriverCore 'keeps complete stats for files beyond the combined patch limit' is flaky #646 (flaky
GitVcsDriverCoretest), Typing while an agent drives the preview can land in the preview's focused input #647 (keystrokes landing in the preview tab).
Open pair gaps
-
pair_awaitreports files from every executor turn, not only the latest (next, small) - Prime Agent's own subagent depth is not held at 0 while paired
- No reset: a fresh executor session needs a core "start this thread's session fresh" command
- Orphaned executors after a draft is abandoned or "Work locally" rotates the thread id
- A lead can loop on
pair_awaitwithmaxSeconds: 0; nothing counts repeated reads -
delegated_thread_statusstill honors short waits (fan-out tools, being superseded) - An older Codex that honors per-turn instructions now receives Pylon's instructions twice
- Whether Codex
thread/resumeapplies new developer instructions to an existing conversation is unverified - Rewinding a lead interrupts a running executor but does not ask first; it can write for a moment
- Opening the Pair popover leaves the lead's model picker open under automation clicks; check with a real pointer
- Cursor and OpenCode leads are allowed and never run
- Mobile: Pair section, executor picker, hide executors in the home list; the compact wake notice was never run on a simulator
- Web: Pair panel beside Agents, "Implement with executor" on plan cards, combined lead and executor cost
- Measure cost per merged PR, pair against solo
- Prime Agent lead verified on the native daemon (managed build
Status: three more merged, one found in real use
- feat(pair): pair without the Pylon delegation setting #648 pairing no longer needs the Pylon delegation setting (verified live with it off, including the wake).
- fix(pair): pair_await lists the latest turn's files only #651
pair_awaitlists the latest turn's files only. This closes the first box of the checklist above. - fix(pair): a paired thread briefs its executor instead of fanning out #652 a paired thread cannot fan out. Found on nightly
.201: a paired Claude Opus lead was told "you can resume using delegation", readread_delegation_skill, started two Antigravity children withdelegate_thread, and never briefed its executor. Pairing had denied the provider's own subagents but left Pylon's fan-out tools available. Nowdelegate_threadrefuses on a paired thread, the skill returns the pair protocol there, and the protocol says "delegate" means the executor.
A nightly with all three was dispatched on
6ca905cd92.Lesson for the remaining work: the user's vocabulary is "delegation", not "pair". Anything a paired lead can reach under that word has to lead to the executor.
Status: gaps closed, mobile and web follow-ups landed
Merged since the last comment: #654, #656, #657, #658, #659, #660, #661, #663 (and #662, the Vercel fix found along the way).
The open-gaps checklist from above
-
pair_awaitreports only the latest turn's files (fix(pair): pair_await lists the latest turn's files only #651) - Prime Agent's own subagent depth held at 0 while paired (feat(pair): hold a paired lead's own subagent depth at zero where the provider allows it #659, per-session, no adapter changes)
- Reset:
pair_resetgives the executor a fresh context, same model, pair stays on (feat(pair): pair_reset gives the executor a fresh context #657) - Orphaned executors from abandoned drafts are swept at server start, conservatively (feat(pair): delete executors left behind by abandoned drafts #656)
- A lead can no longer loop on instant
pair_awaitreads (fix(pair): no argument turns pair_await or delegated_thread_status into a poll #654) -
delegated_thread_statusno longer honors short waits (fix(pair): no argument turns pair_await or delegated_thread_status into a poll #654) - Rewinding a lead while its executor works is refused on web and mobile, with the reason (feat(pair): refuse a rewind while the executor is working #658)
- Mobile: pair status above the composer, executors hidden from the lists (feat(mobile): show a thread's pair on a phone and keep executors out of its lists #660), and a Pair switch in thread settings (feat(mobile): turn a pair on and off from the phone #663)
- Web: "Implement with executor" on plan cards and "Stop executor" in the Pair panel (feat(web): implement a plan through the executor, and stop it from the Pair panel #661)
- An older Codex that honors per-turn instructions receives Pylon's instructions twice (harmless, unchanged)
- Whether Codex
thread/resumeapplies new developer instructions to an existing conversation (unverified) - Cursor and OpenCode leads allowed, never run
- Combined lead and executor cost. Needs a decision: Antigravity reports no dollar cost, and an executor's turn count and cost are not in the thread shell a lead's screen loads. Proposal: "lead $X · executor N turns".
- A live work log of the executor beside Agents
Verified, and how
- Claude, Codex and Prime Agent leads: live, from recorded tool calls and server records.
- Antigravity as executor: live, in the thread that built most of this. A Claude lead paired with
gemini-3.8-flash-highthroughpair_start, one shared worktree,protectedPathson every brief. It implemented fix(pair): no argument turns pair_await or delegated_thread_status into a poll #654, feat(pair): delete executors left behind by abandoned drafts #656, feat(pair): pair_reset gives the executor a fresh context #657, feat(mobile): show a thread's pair on a phone and keep executors out of its lists #660, feat(web): implement a plan through the executor, and stop it from the Pair panel #661 and feat(mobile): turn a pair on and off from the phone #663 from a contract plus failing tests, one turn each; protected files were unchanged every time. Lead corrections after review: a clock fallback written into production code to get past a test, a missing wait on the thread deletion reactor inpair_reset, and two formatting misses. - Mobile on a simulator (iPhone 17 Pro, dev client on the branch bundle, disposable backend): executors hidden (8 existed, 0 listed); notice correct on a paired lead, absent on an unpaired one. Not verified: tapping Open executor and the settings switch, because the automation could not deliver taps to React Native touchables on the thread screen (a plain work-log row used as a control did not respond either).
Cost, from real usage of that thread
Lead reported cost attributed by merge time: 9 PRs where the executor wrote the code, 6,817 changed lines, $105 ($0.015 per line); 8 PRs the lead wrote alone, 689 lines, $68 ($0.099 per line). Executor: 13 turns for the first nine, against 84 turns across 33 fan-out children in the two days before. Not a controlled comparison: the pair PRs were feature builds and the solo ones small fixes with heavy investigation.
Lessons worth keeping
- The user's word is "delegation", not "pair". A paired lead told to "use delegation" fanned out with
delegate_threaduntil fix(pair): a paired thread briefs its executor instead of fanning out #652 refused it; anything reachable under that word has to lead to the executor. - Asking a model to quote its instructions does not test delivery; ask something only the instructions could tell it (fix(codex): send Pylon's instructions when a thread starts #642).
- After resolving a rebase conflict, typecheck before anything else: git silently merged a duplicate import and declaration, and a "keep both sides" resolution dropped a brace.
-
Closing as not planned per user request.
Summary
Replace fan-out delegation with a linked lead/executor pair: one strong-model thread plans, briefs, and verifies; one persistent cheap-model thread (normally Antigravity) implements in the lead's worktree. Session-scoped only: nothing changes a provider's native behavior unless a pair exists. Owning issue for the spec, the phased plans, and status. Supersedes the product direction of #575; the fan-out toolkit stays until Phase 1 replaces it.
Review that motivated this: 34 of 35 live children observed as interrupted by the follow-through reactor although their turns completed; 33 Antigravity children and 84 child turns in two days from cold-start fan-out; 304 status polls by parents in a week; all child worktrees left on disk; PR #603 added a decider branch and contract command despite the sidecar promise.
Phase 0 is delegated first as the pair protocol's own first test: thorough brief, Antigravity executor, lead verification. Phases 1-4 get executable plans when they start.
Local copies:
~/repos/pylon-plans/2026-09-18-pair-mode-spec.mdand~/repos/pylon-plans/2026-09-18-pair-mode-plan-phase0.md. This issue is the canonical copy; do not commit either file.Pair mode: lead and executor threads
Status: revision 1, 2026-09-18. Owner: Trevor Walker. Supersedes the fan-out delegation design in
2026-09-15-delegation-toolkit-spec.mdas the product direction; that toolkit stays until Phase 1replaces it.
1. Goal
One thread on a strong model (the lead) plans, briefs, and verifies. One linked thread on a fast,
cheap model (the executor, normally Antigravity) does the implementation. They stay linked for the
life of the lead thread. The lead's cost drops to planning and verification; the executor never
cold-starts a repo read because it is one persistent session; nothing polls.
The win depends on the brief. The lead hands the executor files, exact behavior, acceptance checks,
and exclusions. A thin brief makes the executor guess and the lead review guesses.
2. Facts the design rests on (verified in source, 2026-09-18)
incarnation resumed from the same conversation (
decider.tsproviderSettingsChangedpath).Toggling pair mode rides that path; there is no user-visible restart.
interactionMode: "plan",OrchestrationProposedPlan,sourceProposedPlanonthread.createandthread.turn.start,ChatView.tsxonImplementPlanInNewThread). The pair handoff is the proposed plan.Prime
setSessionAgentDepth(PrimeAgentDaemonAdapter.ts:7088); ClaudecanUseTool,settings, andextraArgsare already built per session (ClaudeAdapter.ts:5218,5299-5345);Codex
thread/startaccepts aconfigoverride map (V2ThreadStartParams.config).Antigravity has no per-session control: it is executor-only.
delegated:<parent>:<16 hex>are parsed by@t3tools/shared/delegatedThreads(PR fix: collapse delegated threads and suppress child notifications #620) and by
apps/server/src/mcp/toolkits/delegation/logic.ts.DelegationFollowThroughReactor.ts) and its pure logic(
delegationFollowThrough.logic.ts) wake a parent on child lifecycle events. It has one defect:a stopped session on a completed turn is observed as
interrupted(34 of 35 live children).gemini-3.8-flash-high, bursts of five per parent; 304 status polls by parents in 7 days;all 35 child worktrees still on disk.
3. Non-goals
is session-scoped and exists only while the pair exists.
Phase 1 lists explicitly (one checkpoint guard).
4. Identity and state, no contract change
delegated:<leadThreadId>:<first 16 hex of sha256("<leadThreadId>\npair")>.Every client and the server compute it from the lead id.
modelSelection, changed withthe existing
thread.meta.update.executor is created on the first send.
5. One worktree
worktreePathandbranch. No separate worktree.executor. This is the single guarded change inside the core.
6. Protocol with gates
automatically when the plan has at most N steps (default: manual).
Large plans hand off step by step, each step one prompt.
it ran. The reactor wakes the lead. The lead re-runs the checks, reads the diff, and either
accepts or sends one consolidated correction. A per-thread cap on correction rounds
(default 3) stops loops; then the lead asks the user.
direction.
7. Tools, minted only while the pair exists
pair_handoff({ text, planId? }): starts the executor's next turn with the brief. Refused whilethe executor is running unless
steer: true, which steers the running turn (one per turn).pair_await({ maxSeconds }): blocks until the executor is idle, blocked, or errored, streamingMCP progress heartbeats every 15 s. Returns state, final message (bounded), files changed.
Providers whose MCP client cannot hold a long call (Prime) receive the reactor wake instead.
pair_stop(): interrupts the executor's running turn.pair_reset({ handoff }): stops the executor session and starts a fresh one with the handoffsummary as its first message. Used when context has grown or the executor model changes.
The six fan-out tools and
read_delegation_skillare removed from the minted set while a pairexists. The pair instruction block is appended to the lead's session prompt only while the pair
exists, gated on the
pairMCP capability the same waydelegationis gated today.8. Native fan-out under pair mode
Applied at the lead's session start while the pair exists, reverted at the next session start
after the pair ends:
setSessionAgentDepth(0); restore the harness default afterwards..reposapp-server reference before implementation).9. Lifecycle
deriveDelegatedThreadStateso status and wake agree.lead-attributed notification. PR fix: collapse delegated threads and suppress child notifications #620's suppression gains an exception for pending approval or
input.
enableAgentDelegationoff) stops minting and stops executors.10. Composer and panels
the lead provider can hold its subagents (section 8). Default executor from a project setting
that replaces
delegationDefaultModelSelection. High-effort executor options get an inlinecost note.
open transcript, stop, reset. The executor's stream also renders inline in the lead thread using
the native agent spawn entry style.
in the home list.
11. Phases
2026-09-18-pair-mode-plan-phase0.md): reactor observationfix and consistency test; startup baselining of changed keys; hide observation rows in web and
mobile work logs; docs and durable fact corrections.
per-provider fan-out denial, reactor scoping, reaper unit, lifecycle follow.
Each phase gets its own executable plan written from the code as it stands when the phase starts.