Skip to content

feat: pair mode, a linked lead/executor thread pair replacing fan-out delegation #622

Description

@rynfar

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.md and ~/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.md as the product direction; that toolkit stays until Phase 1
replaces 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)

  • Pylon applies provider settings changes at the next turn start by starting a new session
    incarnation resumed from the same conversation (decider.ts providerSettingsChanged path).
    Toggling pair mode rides that path; there is no user-visible restart.
  • Plan mode, proposed plans, and "Implement plan in new thread" exist (interactionMode: "plan",
    OrchestrationProposedPlan, sourceProposedPlan on thread.create and thread.turn.start,
    ChatView.tsx onImplementPlanInNewThread). The pair handoff is the proposed plan.
  • Per-session native fan-out control exists or has a hook per provider:
    Prime setSessionAgentDepth (PrimeAgentDaemonAdapter.ts:7088); Claude canUseTool,
    settings, and extraArgs are already built per session (ClaudeAdapter.ts:5218,5299-5345);
    Codex thread/start accepts a config override map (V2ThreadStartParams.config).
    Antigravity has no per-session control: it is executor-only.
  • Child ids 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.
  • The follow-through reactor (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).
  • Live usage 2026-09-17/18: 33 Antigravity children, 84 child turns, all on
    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

  • No multi-session thread. A thread keeps one session. The pair is two threads.
  • No fan-out under pair mode. One executor per lead.
  • No executor-initiated delegation. Depth stays one.
  • No automatic merge. The lead commits and opens pull requests.
  • No global agent files, skills, or settings that alter a provider's native behavior. Everything
    is session-scoped and exists only while the pair exists.
  • No new decider branch, projector change, contract field on threads, or migration beyond what
    Phase 1 lists explicitly (one checkpoint guard).

4. Identity and state, no contract change

  • The executor thread id is delegated:<leadThreadId>:<first 16 hex of sha256("<leadThreadId>\npair")>.
    Every client and the server compute it from the lead id.
  • Pair mode is on for a lead when that thread exists and is not archived.
  • The executor's provider and model are the executor thread's own modelSelection, changed with
    the existing thread.meta.update.
  • Before the lead thread exists, the composer draft (client-only) holds the pair selection; the
    executor is created on the first send.
  • Turning pair mode off archives the executor. Turning it on again unarchives or creates it.

5. One worktree

  • The executor uses the lead's worktreePath and branch. No separate worktree.
  • Only the lead checkpoints. The checkpoint reactor skips turns whose thread id is a pair
    executor. This is the single guarded change inside the core.
  • A lead rollback first interrupts the executor's running turn, then proceeds.
  • The lead reviews with its own diff and runs checks itself. The executor's report is data.

6. Protocol with gates

  1. Plan. The lead works in plan mode and produces a proposed plan with acceptance checks.
  2. Gate one, plan approval. The user approves, or a project setting lets the lead hand off
    automatically when the plan has at most N steps (default: manual).
  3. Handoff. "Implement with executor" sends the plan and checks to the executor in one prompt.
    Large plans hand off step by step, each step one prompt.
  4. Gate two, executor report. The executor ends its turn with what it changed and which checks
    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.
  5. Gate three, done. The lead commits, opens the PR, reports. The executor stays linked, idle.
  6. Steering is available but exceptional: one steer per executor turn, for a visibly wrong
    direction.

7. Tools, minted only while the pair exists

  • pair_handoff({ text, planId? }): starts the executor's next turn with the brief. Refused while
    the 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, streaming
    MCP 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 handoff
    summary as its first message. Used when context has grown or the executor model changes.

The six fan-out tools and read_delegation_skill are removed from the minted set while a pair
exists. The pair instruction block is appended to the lead's session prompt only while the pair
exists, gated on the pair MCP capability the same way delegation is 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:

  • Claude: deny the Agent tool through the existing per-session hooks.
  • Prime: setSessionAgentDepth(0); restore the harness default afterwards.
  • Codex: per-thread config override disabling collab agents (exact key to be confirmed against
    .repos app-server reference before implementation).
  • Antigravity: unsupported as lead. The composer shows the pair section disabled with the reason.

9. Lifecycle

  • The reactor is scoped to the pair executor and reads state through the toolkit's
    deriveDelegatedThreadState so status and wake agree.
  • The reaper treats the pair as one unit: the executor is not reaped while the lead session is live.
  • Lead archived, settled, or deleted: the executor follows.
  • Executor approvals and input requests surface under the lead's identity: Pair panel row, and a
    lead-attributed notification. PR fix: collapse delegated threads and suppress child notifications #620's suppression gains an exception for pending approval or
    input.
  • The three-turn follow-through budget stays as the runaway guard.
  • A server-wide kill switch (enableAgentDelegation off) stops minting and stops executors.

10. Composer and panels

  • Model picker: a Pair toggle and an executor picker inside the existing picker, shown only when
    the lead provider can hold its subagents (section 8). Default executor from a project setting
    that replaces delegationDefaultModelSelection. High-effort executor options get an inline
    cost note.
  • Plan card: "Implement with executor" beside "Implement in new thread".
  • Pair panel: beside Agents. Executor state, live work log, approvals attributed to the lead,
    open transcript, stop, reset. The executor's stream also renders inline in the lead thread using
    the native agent spawn entry style.
  • Sidebar: executor hidden; PR fix: collapse delegated threads and suppress child notifications #620's collapsed group is the escape hatch.
  • Usage gauge: combined lead plus executor cost per thread.
  • Mobile: Pair section in thread detail, executor picker in the new-task flow, executor hidden
    in the home list.

11. Phases

  • Phase 0, correctness now (plan: 2026-09-18-pair-mode-plan-phase0.md): reactor observation
    fix and consistency test; startup baselining of changed keys; hide observation rows in web and
    mobile work logs; docs and durable fact corrections.
  • Phase 1, server pair core: reserved id, shared worktree and checkpoint guard, four tools,
    per-provider fan-out denial, reactor scoping, reaper unit, lifecycle follow.
  • Phase 2, web: model picker pair section, plan card handoff, Pair panel, usage gauge.
  • Phase 3, mobile: Pair section, new-task executor picker, home list hiding.
  • Phase 4, measure: cost per merged PR and review findings, pair versus solo, three weeks.

Each phase gets its own executable plan written from the code as it stands when the phase starts.

Activity

  1. rynfar commented on Sep 18, 2026

    @rynfar
    CollaboratorAuthor

    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.ts and one baselining rule in DelegationFollowThroughReactor.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-truth from freshly fetched origin/pylon, in an isolated worktree. PR against pylon. Never push to pylon.
    • Do not modify apps/server/src/orchestration/decider.ts, projector.ts, anything under apps/server/src/persistence/Migrations/, packages/contracts/src/orchestration.ts, or apps/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-wide vp check.
    • Product copy says "Pylon".
    • Do not touch packages/shared/src/agentAwareness.ts or apps/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 (the phase IIFE inside observeDelegatedChild)
    • Test: apps/server/src/orchestration/delegationFollowThrough.logic.test.ts:91-97

    Interfaces:

    • Consumes: observeDelegatedChild(shell, pendingRequests?) returning { childThreadId, title, generation, phase, noticeKey, detail? } or null; deriveDelegatedThreadState(shell) from ../mcp/toolkits/delegation/logic.ts returning "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 completed and whose session is stopped (server restart or idle reaper). The reactor observes them as interrupted because the phase computation checks session.status === "stopped" before it looks at the turn. The status tool's deriveDelegatedThreadState reports the same children as completed. 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 (the it.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");
      });

    CommandId is 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.ts

    Expected: 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 whole const 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.ts

    Expected: 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.ts

    Expected: 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 (the baseline computation inside process)
    • Test: apps/server/src/orchestration/DelegationFollowThroughReactor.test.ts

    Interfaces:

    • Consumes: Work = { parentId, liveChildId?, causeId }; startup enqueues causeId: \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 interrupted keys in the live database will differ from the new completed keys 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", ...) in apps/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.ts

    Expected: 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.ts

    Expected: 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 typecheck

    Expected: 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 inside deriveWorkLogEntries)
    • Test: apps/web/src/session-logic.test.ts

    Interfaces:

    • Consumes: deriveWorkLogEntries(activities: ReadonlyArray<OrchestrationThreadActivity>) and the test file's makeActivity(overrides) helper.

    • Produces: delegation.child-state and delegation.follow-through.delivered rows no longer become work-log entries. delegation.follow-through.paused still renders: it is the user-facing pause notice.

    • Step 1: Write the failing test

    Append to apps/web/src/session-logic.test.ts at 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.ts

    Expected: PASS, all tests.

    • Step 5: Web typecheck

    Run: vp run -F @t3tools/web typecheck

    Expected: 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-private deriveWorkLogEntries)
    • Test: apps/mobile/src/lib/threadActivity.test.ts

    Interfaces:

    • Consumes: buildThreadFeed(thread: Pick<OrchestrationThread, "messages" | "activities">) returning ThreadFeedEntry[], and the test file's makeActivity(input) helper (requires id, 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.ts at 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"]);
      });
    });

    EventId and buildThreadFeed are already imported in that test file. ThreadFeedEntry (apps/mobile/src/lib/threadActivity.ts:156) has an "activity-group" variant whose activities are ThreadFeedActivity rows with a summary string.

    • 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.ts

    Expected: PASS, all tests.

    • Step 5: Mobile typecheck

    Run: vp run -F @t3tools/mobile typecheck

    Expected: 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_cancel tool 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 in YYYY-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.ts are 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.tsx

    Expected: 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.md

    Write /tmp/pr-body.md first 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 (each vp test run and 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.

  2. rynfar commented on Sep 18, 2026

    @rynfar
    CollaboratorAuthor

    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 key pair-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 in 64000318e3.

    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.

  3. rynfar commented on Sep 18, 2026

    @rynfar
    CollaboratorAuthor

    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 key pair, so there is no contract field, event, decider branch, projector change, or migration. Every tool requires the existing delegation MCP capability, which keeps enableAgentDelegation as 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/ai Tool/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 (commits 97705aa1b0, 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 against pylon. Never push to pylon.
    • 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" in apps/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 under apps/server/src/persistence/Migrations/, packages/contracts/src/orchestration.ts, packages/contracts/src/settings.ts, or apps/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. No console.log. No sleeps in tests.
    • Exported names and signatures in pair/logic.ts and the exported PairToolkitHandlersLive in pair/handlers.ts must 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-wide vp 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 or null.
    • Produces (used by Task 2): PAIR_DELEGATION_KEY, pairExecutorThreadId, isPairExecutorThreadId, derivePairExecutorState, pairAwaitCapSeconds, pairMessageId, pairSteerMessageId, pairExecutorTitle.

    Rules, each pinned by a test:

    1. pairExecutorThreadId(lead, sha256Hex) is delegatedThreadId(lead, PAIR_DELEGATION_KEY, sha256Hex).
    2. isPairExecutorThreadId(id, sha256Hex): get the parent with delegatedParentThreadId(id); return false when it is null; otherwise return pairExecutorThreadId(parent, sha256Hex) === id.
    3. derivePairExecutorState(shell):
      • shell.archivedAt !== null gives "archived".
      • shell.session === null && shell.latestTurn === null gives "idle" (an executor that never ran; the fan-out derivation would call this queued).
      • Otherwise map deriveDelegatedThreadState(shell): "queued" and "running" give "running"; "completed", "interrupted", "error", "archived" pass through.
    4. pairAwaitCapSeconds(driver): 150 for "codex" (Pylon sets Codex's MCP tool_timeout_sec to 180 in CodexAdapter.ts), 45 for everything else including undefined. Add a comment saying why, and that Prime cancels tool calls at 60 seconds.
    5. pairMessageId(executorId, messageKey) is MessageId.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.
    6. pairExecutorTitle(leadTitle) is `Executor · ${leadTitle}` cut to 200 characters.

    Remove the notImplemented helper and the STUB paragraph from the module comment. Change the type-only imports to value imports where you now call MessageId.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 stub make; keep export 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 and PairToolkit from ./tools.ts.
    • Services, exactly the dependencies array in pair/tools.ts plus Crypto: McpInvocationContext, OrchestrationEngineService, ProjectionSnapshotQuery, ProviderRegistry, ServerSettingsService, Crypto.Crypto. Do not require GitWorkflowService, VcsStatusBroadcaster, ThreadDeletionReactor, or ServerConfig: 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 to PairFailedError), mapDispatch, the per-lead Semaphore gate (withLeadGate), nowIso, and findExecutor (active shell first, then getArchivedShellSnapshot). Compute the executor id with crypto.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.threadId is the lead.

    pair_start({ providerInstanceId?, model? })

    1. If isDelegatedThreadId(scope.threadId) fail with PairDepthExceededError.
    2. Inside the lead gate: findExecutor. If found and archivedAt !== null fail with PairArchivedError. If found, return { created: false, state: derivePairExecutorState(shell), providerInstanceId, model, runtimeMode, worktreePath, branch } from the executor shell and dispatch nothing.
    3. Load the lead shell (PairLeadNotFoundError if missing) and its project shell (same error).
    4. Resolve settings with resolveProjectSettings(settings, project.id).settings. Target: resolveDelegationTarget({ defaultSelection: projectSettings.delegationDefaultModelSelection, requestedInstanceId, requestedModel }); not ok gives PairDefaultMissingError.
    5. Validate the provider snapshot exactly like delegate_thread does (enabled, isProviderAvailable, not unauthenticated), else PairProviderUnavailableError. resolveDelegatedModel, else PairModelUnavailableError.
    6. Mode: resolveDelegatedRuntimeMode(lead.runtimeMode, undefined, projectSettings.delegationChildRuntimeMode). It cannot fail with undefined requested; treat a not-ok result as PairFailedError. If getServerProviderSupportedRuntimeModes(snapshot) lacks the mode fail with PairRuntimeModeUnsupportedError.
    7. 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 OrchestrationCommandPreviouslyRejectedError to PairFailedError (there is no key for the agent to change). No thread.meta.update, no thread.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? })

    1. isValidDelegationKey(messageKey) else PairKeyInvalidError, before any lookup.
    2. Inside the lead gate: findExecutor; none gives PairNotActiveError; archived gives PairArchivedError.
    3. state = derivePairExecutorState(shell).
    4. If state === "running":
      • without steer === true: PairExecutorBusyError.
      • with steer: require shell.session?.activeTurnId to be non-null, else PairExecutorBusyError (nothing admitted to steer into). messageId = pairSteerMessageId(executorId, activeTurnId). If getTurnStartMessage({ threadId: executorId, messageId }) is Some, fail with PairSteerLimitError. steered = true.
    5. Otherwise messageId = pairMessageId(executorId, messageKey), steered = false. If getTurnStartMessage is Some, return the accepted result without dispatching.
    6. Dispatch thread.turn.start with commandId: CommandId.make(\server:mcp-pair-turn:${executorId}:${messageKey}`), message: { messageId, role: "user", text, attachments: [] }, the executor shell's modelSelectionandruntimeMode, interactionMode: "default", sourceEpoch: shell.sourceEpoch ?? 0, createdAt`. A turn start on a running thread is a steer: the decider chooses that, not this code.
    7. Map OrchestrationCommandPreviouslyRejectedError to PairMessageKeyConsumedError({ messageKey }) and OrchestrationCommandInvariantError to PairTurnRejectedError({ detail }).
    8. Return { threadId, accepted: true, messageId, steered }.

    pair_await({ maxSeconds?, maxChars? })

    1. findExecutor (no gate: reads only); none gives PairNotActiveError. An archived executor is not an error here: it returns state: "archived" with no message.
    2. The wait budget is Math.min(maxSeconds ?? 0, pairAwaitCapSeconds(driver)) where driver is the driver of the provider snapshot whose instanceId === scope.providerInstanceId, or undefined when none matches.
    3. Poll once per second with Effect.sleep and Clock.currentTimeMillis, the same wall-clock loop as delegated_thread_status, until derivePairExecutorState(shell) !== "running", or hasPendingApprovals, or hasPendingUserInput, or the deadline. Check those conditions before the first sleep so a settled executor returns with waitedSeconds: 0. If the executor disappears mid-wait fail with PairNotActiveError.
    4. Result: state, waitedSeconds (rounded like the fan-out tool), hasPendingApprovals, hasPendingUserInput, lastError: shell.session?.lastError ?? null. When state is "running" or "archived", or the detail query returns None: assistantMessage: null, filesChanged: [], turnCount: 0. Otherwise read getThreadDetailById(executorId) and fill assistantMessage with selectAssistantMessage plus truncateText(text, maxChars ?? 4000), filesChanged: aggregateFilesChanged(detail.checkpoints), turnCount: detail.checkpoints.length.

    pair_stop()
    Inside the lead gate: findExecutor; none gives PairNotActiveError. If state !== "running" return { threadId, interrupted: false, state } and dispatch nothing. Otherwise dispatch thread.turn.interrupt with commandId: 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, with pair 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 (the requireKey helper)
    • Test (lead-owned, read only): "refuses the key reserved for the pair executor on every tool" in apps/server/src/mcp/toolkits/delegation/handlers.test.ts

    DelegationKeyReservedError already exists in delegation/tools.ts and is already in DelegationToolError.

    • 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 DelegationKeyReservedError from ./tools.ts and PAIR_DELEGATION_KEY from ../pair/logic.ts. Change requireKey so that, only for field === "delegationKey", a value equal to PAIR_DELEGATION_KEY fails with new DelegationKeyReservedError({ delegationKey: value }). Keep the existing invalid-key check first. messageKey is not affected. All five tools already call requireKey("delegationKey", ...) before any lookup; confirm that for lookupChild and delegate_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 (beside DelegationToolkitRegistrationLive, 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). In presentation.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.ts import PairToolkitHandlersLive from ./toolkits/pair/handlers.ts and PairToolkit from ./toolkits/pair/tools.ts, then:

    export const PairToolkitRegistrationLive = McpServer.toolkit(PairToolkit).pipe(
      Layer.provide(PairToolkitHandlersLive),
    );

    and add PairToolkitRegistrationLive to the Layer.mergeAll(...) in layer, after DelegationToolkitRegistrationLive. In presentation.ts add to T3_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 typecheck and vp 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.ts

    Expected blob hashes, in that order: a90c991f4ad7960b972309b91adcf523738c871f, b23635bd20d9a71c02dc011786a3690922bab9e9, 66ed7b6b873176bd95754ecfeeb7e46c65c38a86. If vp fmt reformatted one of them, restore it with git 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 isPairExecutorThreadId as 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.md sections: 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 with Part 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.

  4. rynfar commented on Sep 18, 2026

    @rynfar
    CollaboratorAuthor

    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_agent off, 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 executor

    How 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:

    1. 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.
    2. 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.
  5. rynfar commented on Sep 18, 2026

    @rynfar
    CollaboratorAuthor

    Working state (for continuity across context compaction)

    Merged to pylon

    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/delegatedThreads sync pairExecutorThreadId; @t3tools/client-runtime/state/pair). Verified in browser. Merge when green (user gave standing approval to land this work).
    • feat/pair-control-wiring (stacked on feat/pair-control, which is stacked on pre-rebase client foundations): PairControl.tsx done (10 tests). Executor is implementing pairControl.logic.ts (8 lead tests, hashes: logic test 519c3a7a…, PairControl test 450c8879…), usePairControl.ts, and one render site in ChatComposer.tsx after <ProviderModelPicker> (~line 5853). ChatView.tsx must not change.
      NEXT: verify hashes+diff, rebase onto pylon after 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

    1. Land feat: show delegation wake messages as a notice, and add client pair state #634; finish + verify + PR the pair control wiring.
    2. 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.
    3. Mobile: Pair section in thread detail, executor picker in new-task flow, hide executors in home list (withoutPairExecutors).
    4. Follow-ups: pair_await should 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.
    5. Measure cost per merged PR, pair vs solo, before widening the default.
  6. rynfar commented on Sep 18, 2026

    @rynfar
    CollaboratorAuthor

    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=false and multi_agent_v2=false do not remove them. Reports seeing neither the protocol nor t3-code tools 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

    1. 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 for delegated_thread_status. Plan: when a tool returns an actionable observation to the parent in-turn, write the delegation.child-state activity for that noticeKey with baseline: true, so the reactor's existing rule skips it.
    2. Codex lead. Find out why the developer instructions and t3-code tools are invisible to the model, and whether any config removes collaboration.*. Until then Codex is executor-only.
    3. Pairing needs the Pylon delegation setting. The control says so, but it is a second step; decide whether the switch alone should be enough.
    4. Earlier list still stands: pair_await latest-turn files only, Prime depth 0 while paired, reset via a fresh-session command, orphaned executors, Pair panel, mobile.
  7. rynfar commented on Sep 18, 2026

    @rynfar
    CollaboratorAuthor

    Status: three more findings from live runs

    Codex lead, what is now known

    • The pair tools are reachable: asked directly, the Codex lead found mcp__t3_code__pair_await through its tool registry and called it correctly. MCP tools are deferred there, not missing.
    • Pylon sends its instructions only inside turn/start collaborationMode.settings.developer_instructions. Codex 0.153.4 lists collaboration_modes as a removed feature, and the lead behaves as if it never saw them (no pair protocol, polls, uses its own agents). thread/start and thread/resume accept developerInstructions, which Pylon does not send.
    • Experiment on branch codex-developer-instructions (local, unmerged): sending Pylon's runtime instructions as developerInstructions on start and resume. With it, the same Codex lead used pair_handoff and pair_await unprompted and no collaboration tools, but the thread history already contained an explicit pair_await call, 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/prompt fails 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.
  8. rynfar commented on Sep 18, 2026

    @rynfar
    CollaboratorAuthor

    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_await waits 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 wake
    Codex 0.153.4 (GPT-6-Astra) Claude Same, zero collaboration.* calls, on a build with #640 and #642

    Root 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

    1. Decide whether Pair should need the Pylon delegation setting, or whether the switch alone is consent.
    2. Mobile: Pair section, executor picker, hide executors in the home list.
    3. Pair panel beside Agents; pair_await reporting only the latest turn's files; Prime depth 0 while paired; a fresh-session reset; orphaned executors from abandoned drafts.
    4. Separate from pair mode: a Prime Agent thread whose session/prompt fails stays "running".
    5. Measure cost per merged PR, pair against solo.
  9. rynfar commented on Sep 18, 2026

    @rynfar
    CollaboratorAuthor

    Status: Prime verified, pairing decoupled from the delegation setting, open gaps as a checklist

    Open pair gaps

    • pair_await reports 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_await with maxSeconds: 0; nothing counts repeated reads
    • delegated_thread_status still 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/resume applies 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
  10. rynfar commented on Sep 18, 2026

    @rynfar
    CollaboratorAuthor

    Status: three more merged, one found in real use

    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.

  11. rynfar commented on Sep 18, 2026

    @rynfar
    CollaboratorAuthor

    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

    Verified, and how

    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

  12. rynfar commented on Sep 21, 2026

    @rynfar
    CollaboratorAuthor

    Closing as not planned per user request.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions