paseo-follow-up 1.2.0-beta.1 → 1.2.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/OVERVIEW.md CHANGED
@@ -22,6 +22,10 @@ Requires Paseo 0.14+, Node 22+ and an authenticated `gh` on the daemon host. Mer
22
22
 
23
23
  Auto-send starts on for a workspace tracked for the first time (earlier tracked workspaces keep their setting). With it off, each repair batch waits for your click; with it on, Follow-up sends batches once the main agent is idle, after fresh permission and Session Guard checks. Follow-up never merges, turns on merge when ready, requests reviewers or archives on its own, and Merge always re-checks the stack and asks again. Done needs the main agent's declaration, settled PRs and tasks, and no open questions. Answers are sent only when you click Send; an answer the agent can't take yet waits and goes out on the next check, and one Paseo didn't confirm waits for you to Resend. An optional recovery agent can rebuild missed PRs and tasks from a workspace's history; it runs only when you start it and never edits code. Deployment checks and reviewer shortcuts exist only for specifically configured repositories; elsewhere deployment stays unknown.
24
24
 
25
+ ### What agents can do for you
26
+
27
+ Every root agent Paseo launches (tracked or not) gets Follow-up's action tools: `list_prs`, `add_reviewers`, `merge`, `enable_merge_when_ready`, `disable_merge_when_ready`, `send_repairs`, `set_auto_send`, `clear_hold`, `retry_batch`, `track`, `inbox_list`, `sign_off`, `snooze` and `set_finish_flags`. Each runs the server path its button runs, with the same guards (tracked workspace, hold, PR eligibility, merge blockers), across every tracked workspace, so an orchestrating agent can act "on all" when you ask it to. Tools that act on a list, and merge, return a dry run and run only with `confirm: true` and that plan's id; merge is marked destructive and the list tools read-only. Inbox tools act on other agents only: an agent can't sign itself off. Recovery agents, contributors and subagents never get them, and the daemon re-checks the caller on each call. Each agent action is kept in the inbox's delivery ledger as `by: "agent:<id>"`; it never counts as you writing to an agent. A sign-off is still a sign-off made on your behalf, so it clears the flag. There is no tool to untrack or archive a workspace, or to put one on hold, because Follow-up has no button for those either.
28
+
25
29
  ## Data and access
26
30
 
27
31
  This is a trusted, unsandboxed plugin. It reads workspace metadata, saved agent history and configuration, GitHub status and existing CLI authentication, and keeps its state in `plugin-data/follow-through` on the daemon host. It registers scoped MCP tools and reversible provider hooks for tracked sessions and, while the Needs-you inbox is on, the `needs_you` tool and its Stop-hook nudge for root agents (see below). To find the spec link it reads completed Claude Artifact publish results in the workspace's root sessions, never page content, chat links or the repository; a link you set wins over the agent's, which wins over detection. Repair prompts go to your configured agent and provider. It has no Slack identity and no background channel scanning; replies to stakeholders need your authorization and a sent-message receipt from the agent.
package/README.md CHANGED
@@ -101,7 +101,7 @@ The content comes from a new `needs_you` tool that every root agent gets while t
101
101
  Install the beta:
102
102
 
103
103
  ```sh
104
- paseo plugin update follow-through --version 1.2.0-beta.1
104
+ paseo plugin update follow-through --version 1.2.0-beta.2
105
105
  # or, for a fresh install:
106
106
  paseo plugin install npm:paseo-follow-up@beta
107
107
  ```
@@ -117,18 +117,32 @@ Betas publish to npm's `beta` dist-tag, so `paseo plugin update` without `--vers
117
117
  ## How it works
118
118
 
119
119
  - The workspace's main agent owns its PRs, tasks, questions, the spec link and the Done call. Other agents in the workspace can register PRs and check status.
120
- - **Auto-send** is on for every workspace tracked for the first time (1.2.0-beta.1), so review threads and red CI go to the agent without you. Workspaces tracked before keep their setting. Turn it off per workspace and each repair batch waits for your click; with it on, Follow-up sends batches itself once the agent is idle, after fresh permission and Session Guard checks.
120
+ - **Auto-send** is on for every workspace tracked for the first time (1.2.0-beta.2), so review threads and red CI go to the agent without you. Workspaces tracked before keep their setting. Turn it off per workspace and each repair batch waits for your click; with it on, Follow-up sends batches itself once the agent is idle, after fresh permission and Session Guard checks.
121
121
  - Follow-up never merges, turns on merge when ready, requests reviewers or archives on its own. **Merge** re-checks the stack, lists any PRs underneath that would merge first, and asks again.
122
122
  - A merged PR is not the end: tasks such as production checks and open questions keep the workspace open until the main agent declares Done and everything has settled.
123
123
  - Answers go out only when you click Send. Follow-up never answers for you.
124
124
 
125
+ ### Agents can do what you can click
126
+
127
+ Tell an agent "the PRs look good, assign reviewers on all" and it can do it. Every root agent Paseo launches gets Follow-up's action tools, which run the same server code as the buttons, so the inbox, briefs and PR state change exactly as if you clicked:
128
+
129
+ | Tool | Does what this button does |
130
+ | --- | --- |
131
+ | `list_prs` | Reads tracked PRs across all workspaces (draft, checks, threads, reviewers, merge blockers). Filters: `needs_reviewers`, `ready_to_merge`, `workspace`. |
132
+ | `add_reviewers` | **Add reviewers**, on one PR or many. Repository defaults, or the logins you name. |
133
+ | `merge`, `enable_merge_when_ready`, `disable_merge_when_ready` | **Merge** and the PR sheet's merge-when-ready switch, with the same plan and blockers. |
134
+ | `send_repairs`, `set_auto_send`, `clear_hold`, `retry_batch`, `track` | **Send**, the **Auto-send** switch, **Resume**, **Retry**, **Track this workspace**. |
135
+ | `inbox_list`, `sign_off`, `snooze`, `set_finish_flags` | The Needs-you list, **Sign off**, **Later** and **Don't flag finishes**, for other agents only. |
136
+
137
+ Tools that act on a list, and merge, return a dry run first and act only when called again with `confirm: true` and that plan's id. Their descriptions tell the agent to use them only when you asked. An agent can't sign itself off. Recovery agents, contributors and subagents don't get these tools, and the daemon checks the caller again on every call.
138
+
125
139
  ## Send to Paseo and Graphite
126
140
 
127
141
  With [Send to Paseo](https://github.com/tomgrin10/send-to-paseo) on the same daemon, its composer picks the agent that registered the PR. With Send to Paseo 2.2+, Follow-up can also toggle Graphite's **Merge when ready** through your existing pairing, while Graphite is open and signed in. Each change is confirmed in Chrome, which can remember the permission per repository. See the [integration and security contract](https://github.com/tomgrin10/send-to-paseo/blob/main/docs/follow-up-integration.md).
128
142
 
129
143
  ## Data and access
130
144
 
131
- Paseo plugins run trusted, unsandboxed code on the daemon host. Follow-up reads workspace metadata, agent history and GitHub PR status with your existing credentials, and keeps its state in `$PASEO_HOME/plugin-data/follow-through`. It registers scoped MCP tools and reversible provider hooks only for tracked sessions. GitHub and Graphite writes happen only after an explicit click and a fresh eligibility check.
145
+ Paseo plugins run trusted, unsandboxed code on the daemon host. Follow-up reads workspace metadata, agent history and GitHub PR status with your existing credentials, and keeps its state in `$PASEO_HOME/plugin-data/follow-through`. It registers scoped MCP tools and reversible provider hooks only for tracked sessions, plus the action tools for root agents (above). GitHub and Graphite writes happen only after an explicit click, or a root agent's confirmed plan, and a fresh eligibility check.
132
146
 
133
147
  ## Development
134
148
 
package/client/pill.tsx CHANGED
@@ -5,7 +5,7 @@ import { useRpc, type PluginButtonContentProps, type PluginButtonIconProps, type
5
5
  import { Icon } from "@getpaseo/plugin/client/ui";
6
6
  import { browserStatusRpc, getRpc } from "../shared/contracts";
7
7
  import { needsYou, type NeedItem } from "../shared/attention";
8
- import { HEADLINE_ICON, HEADLINE_TONE, pillState, todoLabel, type PillState } from "../shared/headline";
8
+ import { briefQuestionIds, HEADLINE_ICON, HEADLINE_TONE, pillState, todoLabel, withoutBriefQuestions, type PillState } from "../shared/headline";
9
9
  import { snoozeUntil, type InboxRow } from "../shared/inbox";
10
10
  import { isOpen as questionOpen } from "../shared/questions";
11
11
  import type { Workspace } from "../shared/model";
@@ -88,7 +88,7 @@ export function contributeFollowUpPill(client: PluginClientContext, deps: Deps)
88
88
  const undo = state.mode === "undo";
89
89
  const label = undo ? state.label : state.headline.label;
90
90
  // The tooltip (and accessible name) says whose pill it is, then the ask.
91
- const title = undo ? "Follow-up: signed off. Click to undo." : `Follow-up: ${state.headline.line}${state.todo && state.mode === "flag" ? ` · ${todoLabel(target.items)} in this workspace` : ""}`;
91
+ const title = undo ? "Follow-up: signed off. Click to undo." : `Follow-up: ${state.headline.line}${state.todo && state.mode === "flag" ? ` · ${todoLabel(withoutBriefQuestions(target.items, target.row))} in this workspace` : ""}`;
92
92
  const key = `${state.mode}|${label}|${title}`;
93
93
  const button = { title, label, behavior: undo ? { kind: "action" as const, onPress() { void inbox.undo([agentId]); } } : { kind: "popover" as const, Content } };
94
94
  const entry = pills.get(agentId);
@@ -145,8 +145,10 @@ export function PillPopover(props: PluginButtonContentProps & { deps: Deps }) {
145
145
  const row = target.row;
146
146
  const flagged = state?.mode === "flag";
147
147
  const running = !!row && ["running", "initializing"].includes(row.status);
148
- const asking = !!w?.questions.some(questionOpen);
149
- const items = w ? needsYou(w, now) : [];
148
+ // The flagged brief's own decision is its ask above; don't repeat it as Q1 in the stepper or the count.
149
+ const asked = briefQuestionIds(row);
150
+ const asking = !!w?.questions.some(q => questionOpen(q) && !asked.has(q.id));
151
+ const items = w ? withoutBriefQuestions(needsYou(w, now), row) : [];
150
152
  const done = (work: () => void) => { props.close(); work(); };
151
153
  const headline = state && state.mode !== "undo" ? state.headline : null;
152
154
  const tone = headline ? headlineColor(HEADLINE_TONE[headline.key], c) : c.foregroundMuted;
@@ -157,7 +159,7 @@ export function PillPopover(props: PluginButtonContentProps & { deps: Deps }) {
157
159
  <View style={{ flexDirection: "row", alignItems: "center", gap: 7 }}>
158
160
  <Icon name={HEADLINE_ICON[headline.key]} size={14} color={tone} />
159
161
  <Text style={{ color: tone, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>{headline.label}</Text>
160
- {flagged && state.todo > 0 && <Text style={t.small}>· {todoLabel(items.length ? items : target.items)} in this workspace</Text>}
162
+ {flagged && state.todo > 0 && <Text style={t.small}>· {todoLabel(items.length ? items : withoutBriefQuestions(target.items, row))} in this workspace</Text>}
161
163
  </View>
162
164
  <Text numberOfLines={2} style={{ color: c.foreground, fontSize: 14, lineHeight: 20, fontWeight: "600" }}>{headline.line}</Text>
163
165
  </View> : <Text style={t.meta}>Nothing needs you here right now.</Text>}
package/index.server.ts CHANGED
@@ -16,6 +16,7 @@ import { Github } from "./server/github";
16
16
  import { Graphite } from "./server/graphite";
17
17
  import { BrowserBridge } from "./server/browser-bridge";
18
18
  import { PrActions } from "./server/pr-actions";
19
+ import { AgentActions, AGENT_TOOLS, controlWorkspace, type AgentTool } from "./server/agent-actions";
19
20
  import { PaseoGateway, paseoHome, clientEntry, closeConnections, sessionGuardOwnsInterruption } from "./server/daemon";
20
21
  import { mcpSource } from "./server/mcp-source";
21
22
  import { excludeStack } from "./server/exclusions";
@@ -332,24 +333,9 @@ export default function contribute(server: PluginServerContext) {
332
333
  throw error;
333
334
  }
334
335
  });
335
- server.handle(rpc.controlRpc, async ({ workspaceId, enable_auto_sends, clearHold, retryBatchId }) => {
336
+ server.handle(rpc.controlRpc, async input => {
336
337
  await requireReady();
337
- return store.mutate(state => {
338
- const w = findWorkspace(state, workspaceId);
339
- if (!w.enable_tracking || w.archived) throw new Error("Activate this workspace before changing follow-up controls.");
340
- if (enable_auto_sends !== undefined) w.enable_auto_sends = enable_auto_sends;
341
- if (clearHold) w.hold = null;
342
- if (retryBatchId) {
343
- const b = w.batches.find(b => b.id === retryBatchId);
344
- if (!b || !["blocked", "uncertain"].includes(b.state)) throw new Error("This batch cannot be retried.");
345
- if (b.attempts >= 3) throw new Error("Three delivery attempts were made. Resolve this batch with the main agent.");
346
- b.state = "cancelled";
347
- w.batches.push({ ...b, ...(recoveryBatch(b) ? { enable_manual_recovery: true } : {}), id: randomUUID(), messageId: randomUUID(), state: "pending", attempts: b.attempts + 1, createdAt: new Date().toISOString(), sentAt: null, settledAt: null, result: "", activityAt: null, acknowledgmentError: null });
348
- }
349
- if (clearHold || retryBatchId && !recoveryBatch(w.batches.find(b => b.id === retryBatchId)!)) changed(w);
350
- else w.updatedAt = new Date().toISOString();
351
- return w;
352
- });
338
+ return store.mutate(state => controlWorkspace(state, input));
353
339
  });
354
340
  server.handle(rpc.refreshRpc, async () => { await requireReady(); void engine.tick(); return { ok: true }; });
355
341
  server.handle(rpc.refineTasksRpc, async ({ workspaceId, tasks }) => {
@@ -389,6 +375,21 @@ export default function contribute(server: PluginServerContext) {
389
375
  server.handle(rpc.checkPrActionRpc, async ({ workspaceId, actionId }) => { await requireReady(); return actions.check(workspaceId, actionId); });
390
376
  server.handle(rpc.requestReviewersRpc, async ({ workspaceId, prKey }) => { await requireReady(); const result = await actions.requestReviewers(workspaceId, prKey); void engine.tick(); return result; });
391
377
 
378
+ const agentActions = new AgentActions({ store, prActions: actions, engine, inbox });
379
+ server.handle(rpc.agentActRpc, async ({ agentId, tool, args }, { paseo }) => {
380
+ await requireReady(); seen(paseo);
381
+ if (!(AGENT_TOOLS as readonly string[]).includes(tool)) throw new Error(`Unknown Follow-up tool ${tool}.`);
382
+ if (!/^[\w-]+$/.test(agentId)) throw new Error("Unsupported agent identity.");
383
+ // Root agents only, the same rule as needs_you: never recovery, contributors or subagents.
384
+ const agent = (await paseo.agents.ref(agentId).refresh())?.agent;
385
+ if (!agent?.workspaceId || agent.archivedAt) throw new Error("Cannot locate the caller's workspace.");
386
+ if (agent.labels["follow-through.recovery"]) throw new Error("Recovery sessions can't act for the developer. Acknowledge your batch instead.");
387
+ if ((await store.snapshot()).workspaces.some(w => w.enable_tracking && !w.archived && w.contributorAgentIds?.includes(agentId))) throw new Error("Contributor sessions can't act for the developer. Report to the main agent.");
388
+ if (!await isWorkspaceRoot(agent, async id => (await paseo.agents.ref(id).refresh())?.agent)) throw new Error("Follow-up's action tools are for root agents. Ask your parent agent.");
389
+ const result = await agentActions.run(agentId, tool as AgentTool, args);
390
+ void engine.tick();
391
+ return result;
392
+ });
392
393
  server.handle(rpc.inboxListRpc, async () => { await requireReady(); return { ...await inbox.list(), error: inboxError }; });
393
394
  server.handle(rpc.inboxActRpc, async input => { await requireReady(); return inbox.act(input); });
394
395
  server.handle(rpc.needsYouRpc, async ({ agentId, ...input }, { paseo }) => {
@@ -479,12 +480,13 @@ export default function contribute(server: PluginServerContext) {
479
480
  });
480
481
  }
481
482
  if (!w) {
482
- if (!brief || !root) return request;
483
- return withSessionMcp({ ...request, env: { ...request.env, FOLLOW_THROUGH_BRIEF: brief } }, directory, clientEntry(), paseoHome, false, process.env, brief);
483
+ // Root agents get the action tools (agent parity) and, while the inbox is on, needs_you.
484
+ if (!root) return request;
485
+ return withSessionMcp({ ...request, env: { ...request.env, ...(brief ? { FOLLOW_THROUGH_BRIEF: brief } : {}) } }, directory, clientEntry(), paseoHome, false, process.env, brief, true);
484
486
  }
485
487
  const recovery = w.mainAgentId !== request.agentId && !w.contributorAgentIds?.includes(request.agentId);
486
488
  const main = !recovery && w.mainAgentId === request.agentId ? brief : null;
487
- return withSessionMcp({ ...request, env: { ...request.env, FOLLOW_THROUGH_CLI: bridge, FOLLOW_THROUGH_CLIENT_ENTRY: clientEntry(), ...(recovery ? { FOLLOW_THROUGH_RECOVERY: "1" } : {}), ...(main ? { FOLLOW_THROUGH_BRIEF: main } : {}) } }, directory, clientEntry(), paseoHome, recovery, process.env, main);
489
+ return withSessionMcp({ ...request, env: { ...request.env, FOLLOW_THROUGH_CLI: bridge, FOLLOW_THROUGH_CLIENT_ENTRY: clientEntry(), ...(recovery ? { FOLLOW_THROUGH_RECOVERY: "1" } : {}), ...(main ? { FOLLOW_THROUGH_BRIEF: main } : {}) } }, directory, clientEntry(), paseoHome, recovery, process.env, main, root && !recovery);
488
490
  }),
489
491
  server.on("agent.turn_ended", async (event, { paseo, signal }) => {
490
492
  await ready;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "paseo-follow-up",
3
3
  "private": false,
4
- "version": "1.2.0-beta.1",
4
+ "version": "1.2.0-beta.2",
5
5
  "description": "Follow every workspace PR stack in Paseo, answer agent questions, batch repairs for the main agent, and see what needs you.",
6
6
  "type": "module",
7
7
  "files": [
@@ -0,0 +1,270 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { z } from "zod";
3
+ import type { ActionPlan } from "../shared/pr-actions";
4
+ import { mergeBlockers } from "../shared/pr-actions";
5
+ import { recoveryBatch } from "../shared/follow-up";
6
+ import { flagHeadline } from "../shared/headline";
7
+ import type { InboxRow } from "../shared/inbox";
8
+ import type { Pr, State, Workspace } from "../shared/model";
9
+ import { defaultReviewers, noReviewers } from "../shared/reviewers";
10
+ import { findWorkspace } from "./engine";
11
+ import type { InboxAction } from "./inbox";
12
+
13
+ /**
14
+ * Agent parity: every action the developer can click in Follow-up has a tool that runs the same server path.
15
+ *
16
+ * Each tool calls the method the button's RPC calls (PrActions, Engine, Inbox.act, controlWorkspace), so the
17
+ * inbox, briefs and state change exactly as if the developer clicked. Tools that act on a list, and merge,
18
+ * return a dry-run plan first; `confirm: true` with that plan's id runs it. Every agent write is recorded in
19
+ * the inbox ledger as `by: "agent:<id>"`, which never counts as the developer writing to the agent.
20
+ */
21
+ export const AGENT_TOOLS = ["list_prs", "add_reviewers", "merge", "enable_merge_when_ready", "disable_merge_when_ready", "send_repairs", "set_auto_send", "clear_hold", "retry_batch", "track", "inbox_list", "sign_off", "snooze", "set_finish_flags"] as const;
22
+ export type AgentTool = typeof AGENT_TOOLS[number];
23
+ /** Tools that only read. */
24
+ export const READ_ONLY_TOOLS: readonly AgentTool[] = ["list_prs", "inbox_list"];
25
+ /** Tools that can't be undone from Follow-up. */
26
+ export const DESTRUCTIVE_TOOLS: readonly AgentTool[] = ["merge"];
27
+ /** Tools that plan first and run only with `confirm: true` and the plan's id. */
28
+ export const PLANNED_TOOLS: readonly AgentTool[] = ["add_reviewers", "merge", "enable_merge_when_ready", "disable_merge_when_ready", "sign_off", "snooze", "set_finish_flags"];
29
+
30
+ /** The developer's control panel switches (Auto-send, Resume, Retry). The button and the agent tool share it. */
31
+ export function controlWorkspace(state: State, input: { workspaceId: string; enable_auto_sends?: boolean; clearHold?: boolean; retryBatchId?: string }, now = Date.now()): Workspace {
32
+ const w = findWorkspace(state, input.workspaceId);
33
+ if (!w.enable_tracking || w.archived) throw new Error("Activate this workspace before changing follow-up controls.");
34
+ if (input.enable_auto_sends !== undefined) w.enable_auto_sends = input.enable_auto_sends;
35
+ if (input.clearHold) w.hold = null;
36
+ if (input.retryBatchId) {
37
+ const b = w.batches.find(b => b.id === input.retryBatchId);
38
+ if (!b || !["blocked", "uncertain"].includes(b.state)) throw new Error("This batch cannot be retried.");
39
+ if (b.attempts >= 3) throw new Error("Three delivery attempts were made. Resolve this batch with the main agent.");
40
+ b.state = "cancelled";
41
+ w.batches.push({ ...b, ...(recoveryBatch(b) ? { enable_manual_recovery: true } : {}), id: randomUUID(), messageId: randomUUID(), state: "pending", attempts: b.attempts + 1, createdAt: new Date(now).toISOString(), sentAt: null, settledAt: null, result: "", activityAt: null, acknowledgmentError: null });
42
+ }
43
+ const at = new Date(now).toISOString();
44
+ if (input.clearHold || input.retryBatchId && !recoveryBatch(w.batches.find(b => b.id === input.retryBatchId)!)) { w.completion = null; w.updatedAt = at; }
45
+ else w.updatedAt = at;
46
+ return w;
47
+ }
48
+
49
+ type Result = { state: "submitted" | "failed" | "uncertain"; result: string };
50
+ export type AgentActionDeps = {
51
+ store: { snapshot(): Promise<State>; mutate<T>(fn: (state: State) => T): Promise<T> };
52
+ prActions: {
53
+ prepare(workspaceId: string, key: string, kind: "merge" | "enable_mwr" | "disable_mwr"): Promise<ActionPlan>;
54
+ confirm(planId: string, scope: "target" | "downstack"): Promise<Result>;
55
+ requestReviewers(workspaceId: string, key: string, logins?: string[]): Promise<Result>;
56
+ };
57
+ engine: { sendPending(workspaceId: string, batchId: string): Promise<Workspace>; activate(workspaceId: string): Promise<Workspace> };
58
+ inbox: {
59
+ list(): Promise<{ enabled: boolean; rows: InboxRow[] }>;
60
+ act(input: InboxAction): Promise<{ results: { agentId: string; ok: boolean; cameBack: boolean }[] }>;
61
+ noteAction(by: string, agentId: string, action: string): Promise<void>;
62
+ };
63
+ reviewersFor?: (repo: string) => { name: string; login: string }[];
64
+ now?: () => number;
65
+ };
66
+
67
+ const login = z.string().trim().regex(/^[\w.-]+(\/[\w.-]+)?$/, "Use GitHub logins (or org/team).");
68
+ const prKey = z.string().trim().regex(/^[\w.-]+\/[\w.-]+#\d+$/, "Use owner/repo#number keys from list_prs.");
69
+ const id = z.string().trim().min(1).max(200);
70
+ const confirm = { planId: id.optional(), confirm: z.boolean().optional() };
71
+ const agentIds = z.array(id).min(1).max(100);
72
+ export const TOOL_INPUT: Record<AgentTool, z.ZodType> = {
73
+ list_prs: z.strictObject({ workspace: id.optional(), needs_reviewers: z.boolean().optional(), ready_to_merge: z.boolean().optional(), include_closed: z.boolean().optional() }),
74
+ add_reviewers: z.strictObject({ prs: z.array(prKey).min(1).max(50).optional(), reviewers: z.array(login).min(1).max(15).optional(), ...confirm }),
75
+ merge: z.strictObject({ pr: prKey.optional(), scope: z.enum(["target", "downstack"]).optional(), ...confirm }),
76
+ enable_merge_when_ready: z.strictObject({ pr: prKey.optional(), scope: z.enum(["target", "downstack"]).optional(), ...confirm }),
77
+ disable_merge_when_ready: z.strictObject({ pr: prKey.optional(), scope: z.enum(["target", "downstack"]).optional(), ...confirm }),
78
+ send_repairs: z.strictObject({ workspace: id, batchId: id.optional() }),
79
+ set_auto_send: z.strictObject({ workspace: id, value: z.boolean() }),
80
+ clear_hold: z.strictObject({ workspace: id }),
81
+ retry_batch: z.strictObject({ workspace: id, batchId: id }),
82
+ track: z.strictObject({ workspace: id }),
83
+ inbox_list: z.strictObject({ flagged_only: z.boolean().optional() }),
84
+ sign_off: z.strictObject({ agentIds: agentIds.optional(), ...confirm }),
85
+ snooze: z.strictObject({ agentIds: agentIds.optional(), until: z.string().trim().min(1).max(200).optional(), ...confirm }),
86
+ set_finish_flags: z.strictObject({ agentIds: agentIds.optional(), value: z.boolean().optional(), ...confirm }),
87
+ };
88
+
89
+ /** One PR as an agent sees it: enough to decide "add reviewers on all" or "merge what's ready". */
90
+ export function prRow(w: Workspace, p: Pr) {
91
+ const s = p.snapshot;
92
+ const blockers = mergeBlockers(p);
93
+ return {
94
+ key: p.key, workspaceId: w.id, workspace: w.title, stack: p.stack, parent: p.parent, title: s?.title ?? null, url: s?.url ?? null,
95
+ state: p.error ? "unknown" : s?.state ?? "unknown", draft: !!s?.draft, error: p.error,
96
+ checks: !s ? "unknown" : s.failedChecks.length ? "failed" : s.checksPending ? "pending" : "passed", failedChecks: s?.failedChecks ?? [],
97
+ openThreads: s?.threads.length ?? 0, reviewDecision: s?.reviewDecision ?? null,
98
+ reviewers: s?.reviewers ? { requested: s.reviewers.requested, teams: s.reviewers.teams, reviewed: s.reviewers.reviewed } : null,
99
+ needsReviewers: noReviewers(p) && !s!.draft, mergeable: blockers.length === 0, mergeBlockers: blockers,
100
+ };
101
+ }
102
+
103
+ type Plan = { id: string; tool: AgentTool; caller: string; expiresAt: number; run: () => Promise<unknown> };
104
+
105
+ export class AgentActions {
106
+ private plans = new Map<string, Plan>();
107
+ private reviewersFor: (repo: string) => { name: string; login: string }[];
108
+ private now: () => number;
109
+ constructor(private d: AgentActionDeps) { this.reviewersFor = d.reviewersFor ?? defaultReviewers; this.now = d.now ?? Date.now; }
110
+
111
+ /** Runs one tool for a verified root agent. The caller's identity is checked by the RPC before this. */
112
+ async run(caller: string, tool: AgentTool, raw: unknown): Promise<unknown> {
113
+ const parsed = TOOL_INPUT[tool].safeParse(raw ?? {});
114
+ if (!parsed.success) throw new Error(`${tool}: ${parsed.error.issues.map(i => `${i.path.join(".") || "arguments"} ${i.message}`).join("; ")}`);
115
+ const args = parsed.data as Record<string, any>;
116
+ if (PLANNED_TOOLS.includes(tool) && args.confirm) return this.confirm(caller, tool, args.planId);
117
+ switch (tool) {
118
+ case "list_prs": return this.listPrs(args);
119
+ case "inbox_list": return this.inboxList(caller, args);
120
+ case "add_reviewers": return this.planReviewers(caller, args);
121
+ case "merge": return this.planPr(caller, tool, "merge", args);
122
+ case "enable_merge_when_ready": return this.planPr(caller, tool, "enable_mwr", args);
123
+ case "disable_merge_when_ready": return this.planPr(caller, tool, "disable_mwr", args);
124
+ case "sign_off": case "snooze": case "set_finish_flags": return this.planInbox(caller, tool, args);
125
+ case "send_repairs": return this.sendRepairs(caller, args as { workspace: string; batchId?: string });
126
+ case "set_auto_send": return this.control(caller, tool, { workspaceId: args.workspace, enable_auto_sends: args.value });
127
+ case "clear_hold": return this.control(caller, tool, { workspaceId: args.workspace, clearHold: true });
128
+ case "retry_batch": return this.control(caller, tool, { workspaceId: args.workspace, retryBatchId: args.batchId });
129
+ case "track": {
130
+ const w = await this.d.engine.activate(args.workspace);
131
+ await this.note(caller, w.mainAgentId, tool);
132
+ return { workspaceId: w.id, title: w.title, enable_tracking: w.enable_tracking, enable_auto_sends: w.enable_auto_sends };
133
+ }
134
+ }
135
+ }
136
+
137
+ private async note(caller: string, target: string | null | undefined, action: string) {
138
+ if (target) await this.d.inbox.noteAction(`agent:${caller}`, target, action).catch(() => undefined);
139
+ }
140
+ private tracked(state: State): Workspace[] { return state.workspaces.filter(w => w.enable_tracking && !w.archived); }
141
+ private findPr(state: State, key: string): { w: Workspace; p: Pr } {
142
+ for (const w of this.tracked(state)) { const p = w.prs.find(p => p.key.toLowerCase() === key.toLowerCase()); if (p) return { w, p }; }
143
+ throw new Error(`${key} isn't a PR in a tracked workspace. Use a key from list_prs.`);
144
+ }
145
+ private plan(caller: string, tool: AgentTool, run: () => Promise<unknown>, ttl = 5 * 60_000): string {
146
+ for (const [key, p] of this.plans) if (p.expiresAt <= this.now()) this.plans.delete(key);
147
+ const planId = randomUUID();
148
+ this.plans.set(planId, { id: planId, tool, caller, expiresAt: this.now() + ttl, run });
149
+ return planId;
150
+ }
151
+ private async confirm(caller: string, tool: AgentTool, planId: string | undefined) {
152
+ if (!planId) throw new Error(`${tool}: confirm needs the planId from the dry run. Call ${tool} without confirm first.`);
153
+ const plan = this.plans.get(planId);
154
+ if (!plan || plan.expiresAt <= this.now()) throw new Error("This plan expired or was already used. Run the dry run again.");
155
+ if (plan.tool !== tool || plan.caller !== caller) throw new Error("This plan belongs to a different tool or agent. Run the dry run again.");
156
+ this.plans.delete(planId);
157
+ return plan.run();
158
+ }
159
+
160
+ private async listPrs(args: { workspace?: string; needs_reviewers?: boolean; ready_to_merge?: boolean; include_closed?: boolean }) {
161
+ const state = await this.d.store.snapshot();
162
+ const workspaces = this.tracked(state).filter(w => !args.workspace || w.id === args.workspace || w.title === args.workspace);
163
+ if (args.workspace && !workspaces.length) throw new Error(`No tracked workspace matches ${args.workspace}.`);
164
+ const prs = workspaces.flatMap(w => w.prs.map(p => prRow(w, p)))
165
+ .filter(r => args.include_closed || !["MERGED", "CLOSED"].includes(r.state))
166
+ .filter(r => !args.needs_reviewers || r.needsReviewers)
167
+ .filter(r => !args.ready_to_merge || r.mergeable);
168
+ return { prs, workspaces: workspaces.map(w => ({ id: w.id, title: w.title, mainAgentId: w.mainAgentId, enable_auto_sends: w.enable_auto_sends, hold: w.hold, pendingBatches: w.batches.filter(b => b.state === "pending" && !recoveryBatch(b)).map(b => ({ id: b.id, tasks: b.tasks.length })), retryableBatches: w.batches.filter(b => ["blocked", "uncertain"].includes(b.state)).map(b => ({ id: b.id, state: b.state, result: b.result })) })) };
169
+ }
170
+
171
+ /** Dry run: who would be asked on each PR, and what stops it. The same checks the Add reviewers button makes. */
172
+ private async planReviewers(caller: string, args: { prs?: string[]; reviewers?: string[] }) {
173
+ if (!args.prs?.length) throw new Error("add_reviewers: give prs (owner/repo#number keys from list_prs).");
174
+ const state = await this.d.store.snapshot();
175
+ const rows = args.prs.map(key => {
176
+ try {
177
+ const { w, p } = this.findPr(state, key);
178
+ const s = p.snapshot;
179
+ const author = s?.reviewers?.author?.toLowerCase();
180
+ const chosen = args.reviewers ?? this.reviewersFor(p.repo).map(r => r.login);
181
+ const reviewers = chosen.filter(l => l.toLowerCase() !== author);
182
+ const blockers: string[] = [];
183
+ if (p.error || !s?.complete || !s.reviewers?.complete) blockers.push("Reviewer status is unavailable. Refresh first.");
184
+ else if (s.state !== "OPEN" || s.draft) blockers.push("Not an open PR that is ready for review.");
185
+ else if (!noReviewers(p)) blockers.push("Reviewers already assigned.");
186
+ if (!chosen.length) blockers.push("No default reviewers for this repository. Pass reviewers.");
187
+ else if (!reviewers.length) blockers.push("Only the PR author was given.");
188
+ return { pr: p.key, workspaceId: w.id, title: s?.title ?? null, reviewers, source: args.reviewers ? "given" : "defaults", blockers };
189
+ } catch (error) { return { pr: key, workspaceId: null, title: null, reviewers: [] as string[], source: args.reviewers ? "given" : "defaults", blockers: [error instanceof Error ? error.message : String(error)] }; }
190
+ });
191
+ const ready = rows.filter(r => !r.blockers.length);
192
+ const planId = ready.length ? this.plan(caller, "add_reviewers", async () => {
193
+ const results = [];
194
+ for (const r of ready) {
195
+ try {
196
+ const result = await this.d.prActions.requestReviewers(r.workspaceId!, r.pr, args.reviewers ? r.reviewers : undefined);
197
+ await this.note(caller, findWorkspace(await this.d.store.snapshot(), r.workspaceId!).mainAgentId, "add_reviewers");
198
+ results.push({ pr: r.pr, ...result });
199
+ } catch (error) { results.push({ pr: r.pr, state: "failed" as const, result: error instanceof Error ? error.message : String(error) }); }
200
+ }
201
+ return { results };
202
+ }) : null;
203
+ return { dryRun: true, planId, rows, next: planId ? `Show the developer this plan if they haven't approved it; then call add_reviewers with confirm=true and planId=${planId}. Blocked rows are skipped.` : "Nothing to request." };
204
+ }
205
+
206
+ /** Merge and merge-when-ready: Follow-up's own action plan (plan, then confirm), with its blockers. */
207
+ private async planPr(caller: string, tool: AgentTool, kind: "merge" | "enable_mwr" | "disable_mwr", args: { pr?: string; scope?: "target" | "downstack" }) {
208
+ if (!args.pr) throw new Error(`${tool}: give pr (an owner/repo#number key from list_prs).`);
209
+ const { w, p } = this.findPr(await this.d.store.snapshot(), args.pr);
210
+ const plan = await this.d.prActions.prepare(w.id, p.key, kind);
211
+ const scope = args.scope ?? (plan.prs.length > 1 ? "downstack" : "target");
212
+ const blocked = plan.blockers.length > 0 || (kind === "merge" ? (scope === "target" ? plan.prs.slice(-1) : plan.prs) : plan.prs.slice(-1)).some(p => p.blockers.length > 0);
213
+ const planId = blocked ? null : this.plan(caller, tool, async () => {
214
+ const result = await this.d.prActions.confirm(plan.id, scope);
215
+ await this.note(caller, w.mainAgentId, tool);
216
+ return { pr: plan.target, scope, ...result };
217
+ }, Math.max(1_000, Date.parse(plan.expiresAt) - this.now()));
218
+ return { dryRun: true, planId, kind, target: plan.target, scope, expiresAt: plan.expiresAt, blockers: plan.blockers, prs: plan.prs.map(p => ({ key: p.key, title: p.title, blockers: p.blockers })),
219
+ next: planId ? `Run only if the developer asked for it: call ${tool} with confirm=true and planId=${planId} within a minute.` : "Blocked. Resolve the blockers first." };
220
+ }
221
+
222
+ private async sendRepairs(caller: string, args: { workspace: string; batchId?: string }) {
223
+ const w = findWorkspace(await this.d.store.snapshot(), args.workspace);
224
+ const batchId = args.batchId ?? w.batches.find(b => b.state === "pending" && !recoveryBatch(b))?.id;
225
+ if (!batchId) throw new Error("No repairs are waiting to send in this workspace.");
226
+ const after = await this.d.engine.sendPending(w.id, batchId);
227
+ await this.note(caller, w.mainAgentId, "send_repairs");
228
+ const b = after.batches.find(b => b.id === batchId);
229
+ return { workspaceId: w.id, batch: b ? { id: b.id, state: b.state, tasks: b.tasks.length, result: b.result } : null };
230
+ }
231
+
232
+ private async control(caller: string, tool: AgentTool, input: Parameters<typeof controlWorkspace>[1]) {
233
+ const w = await this.d.store.mutate(state => controlWorkspace(state, input, this.now()));
234
+ await this.note(caller, w.mainAgentId, tool);
235
+ return { workspaceId: w.id, enable_auto_sends: w.enable_auto_sends, hold: w.hold, batches: w.batches.filter(b => !["completed", "cancelled"].includes(b.state)).map(b => ({ id: b.id, state: b.state })) };
236
+ }
237
+
238
+ private async inboxList(caller: string, args: { flagged_only?: boolean }) {
239
+ const { enabled, rows } = await this.d.inbox.list();
240
+ const now = this.now();
241
+ return { enabled, you: caller, rows: rows.filter(r => !args.flagged_only || r.needs && !r.snooze).map(r => {
242
+ const head = flagHeadline(r, now);
243
+ return { agentId: r.agentId, title: r.title, workspace: r.workspaceTitle, workspaceId: r.workspaceId, status: r.status, flagged: !!head, headline: head?.label ?? null, ask: head?.line ?? null,
244
+ snoozedUntil: r.snooze ? (r.snooze as { until?: string }).until ?? "next-event" : null, enable_finish_flags: r.enable_finish_flags, self: r.agentId === caller };
245
+ }) };
246
+ }
247
+
248
+ /** Sign off, snooze and finish flags, for other agents only. Seqs pin the flag that was shown, like the UI. */
249
+ private async planInbox(caller: string, tool: "sign_off" | "snooze" | "set_finish_flags", args: { agentIds?: string[]; until?: string; value?: boolean }) {
250
+ if (!args.agentIds?.length) throw new Error(`${tool}: give agentIds (from inbox_list).`);
251
+ if (args.agentIds.includes(caller)) throw new Error(tool === "sign_off" ? "An agent can't sign itself off. Only the developer clears your flag." : `${tool} is for other agents' rows, not your own.`);
252
+ if (tool === "snooze" && !args.until) throw new Error("snooze: give until (\"next-event\", \"1h\", \"tomorrow\" or an ISO time).");
253
+ const { rows } = await this.d.inbox.list();
254
+ const plan = args.agentIds.map(agentId => {
255
+ const r = rows.find(r => r.agentId === agentId);
256
+ const blockers = !r ? ["Not in the inbox."] : tool === "sign_off" && !r.needs ? ["Nothing to sign off."] : tool === "snooze" && !r.needs ? ["Not flagged."] : [];
257
+ return { agentId, title: r?.title ?? null, workspace: r?.workspaceTitle ?? null, headline: r ? flagHeadline(r, this.now())?.label ?? null : null, seq: (r as { seq?: number } | undefined)?.seq, blockers };
258
+ });
259
+ const ready = plan.filter(p => !p.blockers.length);
260
+ const planId = ready.length ? this.plan(caller, tool, async () => {
261
+ const ids = ready.map(p => p.agentId);
262
+ const input: InboxAction = tool === "sign_off" ? { action: "sign_off", agentIds: ids, seqs: Object.fromEntries(ready.filter(p => p.seq !== undefined).map(p => [p.agentId, p.seq!])) }
263
+ : tool === "snooze" ? { action: "snooze", agentIds: ids, until: args.until! } : { action: "set_finish_flags", agentIds: ids, value: args.value ?? true };
264
+ const { results } = await this.d.inbox.act(input);
265
+ for (const r of results) if (r.ok) await this.note(caller, r.agentId, tool);
266
+ return { results };
267
+ }) : null;
268
+ return { dryRun: true, planId, rows: plan.map(({ seq: _seq, ...p }) => p), next: planId ? `Only on the developer's request: call ${tool} with confirm=true and planId=${planId}.` : "Nothing to do." };
269
+ }
270
+ }
@@ -9,7 +9,7 @@ import { join } from "node:path";
9
9
  */
10
10
  export type Sender = "agent" | "follow-up" | "defer" | "session" | "schedule" | "tom";
11
11
  export type Message = { text: string; messageId?: string | null; clientMessageId?: string | null };
12
- export type Ledger = { agentId: string; messageId: string | null; at: string; by?: "follow-up" | "developer" }[];
12
+ export type Ledger = { agentId: string; messageId: string | null; at: string; by?: string; action?: string }[];
13
13
  export type Sources = {
14
14
  /** Follow-up's own delivery ledger, written before each send. */
15
15
  ledger: () => Promise<Ledger>;
@@ -27,7 +27,7 @@ export async function classify(message: Message | null, agentId: string, at: num
27
27
  // Follow-up's own sends are automation, except a brief option or move the developer clicked: that is their message.
28
28
  const own = await (async () => {
29
29
  try {
30
- const ledger = (await sources.ledger()).filter(l => l.agentId === agentId);
30
+ const ledger = (await sources.ledger()).filter(l => l.agentId === agentId && !l.action);
31
31
  const ids = [message?.messageId, message?.clientMessageId].filter((id): id is string => !!id);
32
32
  const exact = ledger.find(l => l.messageId && ids.includes(l.messageId));
33
33
  if (exact) return exact.by === "developer" ? "tom" as const : "follow-up" as const;
package/server/inbox.ts CHANGED
@@ -327,6 +327,12 @@ export class Inbox {
327
327
  await this.store.mutate(state => { state.ledger = [...state.ledger.filter(l => this.now() - Date.parse(l.at) < 3_600_000), { agentId, messageId, at, ...(by ? { by } : {}) }].slice(-200); });
328
328
  }
329
329
 
330
+ /** An agent's Follow-up action (agent parity), kept beside the deliveries as `by: "agent:<id>"`. Not a message. */
331
+ async noteAction(by: string, agentId: string, action: string): Promise<void> {
332
+ const at = this.iso();
333
+ await this.store.mutate(state => { state.ledger = [...state.ledger.filter(l => this.now() - Date.parse(l.at) < 3_600_000), { agentId, messageId: null, at, by, action }].slice(-200); });
334
+ }
335
+
330
336
  /**
331
337
  * The developer clicked an option or a move on the brief. Validated against the stored brief (a stale card is refused),
332
338
  * logged in the ledger as the developer's (so the classifier clears the flag, #11), then sent, then collapsed.
@@ -569,7 +575,7 @@ export class Inbox {
569
575
  resumesAt: this.waiting.get(agent.id) ?? null, lastActivityAt: agent.updatedAt,
570
576
  needs: e?.needs ?? null, snooze: e?.snooze ?? null, signedOffAt: e?.signedOffAt ?? null,
571
577
  undoable: !!e?.undo && Date.parse(e.undo.expires) > now, seq: e?.seq ?? 0, muteTurn: e?.muteTurn ?? false, enable_finish_flags: e?.enable_finish_flags ?? true,
572
- brief: e?.brief ? { kind: e.brief.kind, source: e.brief.source, state: e.brief.state, ask: e.brief.ask } : null,
578
+ brief: e?.brief ? { kind: e.brief.kind, source: e.brief.source, state: e.brief.state, ask: e.brief.ask, questionIds: e.brief.questionIds } : null,
573
579
  });
574
580
  const rows: InboxRow[] = [];
575
581
  for (const { agent, delegatedFrom } of roots.values()) {
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/build.mjs.
2
- export const mcpSource = "#!/usr/bin/env node\nimport { createInterface } from \"node:readline\";\nimport { createRequire } from \"node:module\";\nimport { pathToFileURL } from \"node:url\";\nimport { readFile } from \"node:fs/promises\";\nimport { existsSync, realpathSync } from \"node:fs\";\nimport { join, delimiter } from \"node:path\";\nimport { homedir } from \"node:os\";\n\nconst string = { type: \"string\" };\nconst boolean = { type: \"boolean\" };\nconst integer = { type: \"integer\", minimum: 1 };\nconst array = { type: \"array\", items: string };\nconst communication = { type: \"object\", properties: { threadUrl: string, recipient: string, purpose: { type: \"string\", enum: [\"progress\", \"verified\"] }, authorization: string, replyUrl: { type: [\"string\", \"null\"] } }, required: [\"threadUrl\", \"recipient\"], additionalProperties: false };\nconst trigger = { type: [\"object\", \"null\"], properties: { event: { type: \"string\", enum: [\"now\", \"merge\", \"deploy\"] }, delayDays: { type: \"integer\", minimum: 0, maximum: 3650 } }, required: [\"event\"], additionalProperties: false };\nconst tools = [\n [\"workspace_status\", \"Read the tracked workspace's durable goal, PRs, tasks, work queue and optional recovery history. Read after compaction. Returns current work by default; detail=full includes complete evidence/history. Normal follow-through does not require recovery.\", \"follow.agent-status\", { detail: { type: \"string\", enum: [\"summary\", \"full\"] } }, []],\n [\"sync_workspace\", \"Silently confirm the sidebar matches your work before finishing, after registering owned PRs and substantive tasks. Call even when no changes are needed. Keep the summary in this tool; never announce sync or recap sidebar/plugin status to the developer. Does not declare Done, run recovery, or start repairs. Tracked root agents only.\", \"follow.sync\", { summary: { ...string, minLength: 1, maxLength: 1000 } }, [\"summary\"]],\n [\"enroll_workspace\", \"Refine this developer-activated workspace's goal. Ownership is initialized by Track this workspace or the project’s default-tracking setting. This tool cannot activate another workspace. Never ask the developer to enter this. Keep the goal concise; new scope reopens completion.\", \"follow.enroll\", { goal: string }, [\"goal\"]],\n [\"register_pr\", \"Register a PR produced by this workspace. Call after every submission. Register parents first; repeat to update its stack or parent after restacking. Supply a disposition for a deliberately closed PR.\", \"follow.register-pr\", { repo: string, number: integer, stack: string, parentNumber: { type: [\"integer\", \"null\"], minimum: 1 }, disposition: string }, [\"repo\", \"number\", \"stack\"]],\n [\"exclude_stack\", \"Correct an unrelated stack registration. Preserve its PRs and requirements in an exclusion audit; future registration is blocked. reason must cite the scope/ownership evidence. obligationIds may include related tasks without PR dependencies. Keep external dependencies separate from owned work.\", \"follow.exclude-stack\", { stack: string, reason: string, obligationIds: array }, [\"stack\", \"reason\"]],\n [\"update_obligation\", \"Record concrete follow-through: verification, migrations, decisions or stakeholder replies. Never add approve/merge/deploy checklist tasks. afterPrs uses owner/repo#number keys. Tasks are manual unless explicitly scheduled with trigger={event:\\\"now\\\"|\\\"merge\\\"|\\\"deploy\\\",delayDays:0}. Only set a trigger from the developer’s requested timing; never infer timing from a title or enable_follow_up alone. Merge/deploy triggers require owned afterPrs; delayDays=14 waits fourteen days after the last dependency event. Now requires no PR dependencies or delay. Deployment timing starts at the first verified fully serving observation and remains conservative when history is unknown. Preserve existing trigger fields on updates; trigger=null makes a task manual. Auto controls delivery, not timing or production permissions. For Slack replies, communication records the original threadUrl, recipient, purpose (progress or verified), explicit developer authorization and sent replyUrl. Empty authorization never queues a reply; Auto does not authorize external posts. Use a verify-and-reply task after deployment for the final update. Read the thread first; mark blocked when posting is unavailable or uncertain, never treat a draft as sent. Done requires replyUrl and evidence; preserve fields when updating.\", \"follow.obligation\", { id: string, title: string, status: { type: \"string\", enum: [\"pending\", \"waiting\", \"working\", \"blocked\", \"done\", \"waived\"] }, afterPrs: array, dependencyStage: { type: \"string\", enum: [\"merged\", \"deployed\"] }, evidence: string, enable_follow_up: boolean, communication, trigger }, [\"title\", \"status\"]],\n [\"acknowledge_batch\", \"Record a follow-up result. Complete only after verifying all tasks; blocked surfaces the reason to the developer. Always acknowledge before ending a repair turn. Keep the result within 2000 characters.\", \"follow.acknowledge\", { batchId: string, outcome: { type: \"string\", enum: [\"completed\", \"blocked\"] }, result: { ...string, maxLength: 2000 } }, [\"batchId\", \"outcome\", \"result\"]],\n [\"ask_question\", \"Ask the developer a decision you can't make yourself. Give a short title, the question, lettered options A, B, C… with what each means for them, and recommend the option you'd pick. Set big only when building ahead would send something off this computer, set something people or programs outside the repo rely on, or most remaining work would build on it. Link a PR (owner/repo#number) or task id when it is about one. The reply says the question mode and exactly what to do until the answer arrives.\", \"follow.ask-question\", { title: { ...string, maxLength: 120 }, question: { ...string, maxLength: 2000 }, options: { type: \"array\", maxItems: 26, items: { type: \"object\", properties: { letter: { type: \"string\", pattern: \"^[A-Z]$\" }, label: { ...string, maxLength: 200 }, consequence: { ...string, maxLength: 600 } }, required: [\"letter\", \"label\"], additionalProperties: false } }, recommend: string, big: string, background: string, links: { type: \"array\", maxItems: 10, items: { type: \"object\", properties: { url: string, path: string, label: string }, additionalProperties: false } }, pr: string, task: string }, [\"title\", \"question\"]],\n [\"record_answer\", \"Record the developer's answer when they answered a question in chat instead of in Follow-up. choice is an option letter, or their short answer when there were no options; words is what they said.\", \"follow.record-answer\", { id: string, choice: string, words: string }, [\"id\", \"choice\"]],\n [\"withdraw_question\", \"Withdraw a question that no longer matters, with a short reason.\", \"follow.withdraw-question\", { id: string, reason: string }, [\"id\", \"reason\"]],\n [\"set_spec\", \"Register the link to this workspace's spec: the Claude artifact (or doc) you keep summarizing the goal, decisions and status. Call it once after the first publish, with the URL from the publish result, and again only if the link changes. Follow-up shows it in the workspace header.\", \"follow.set-spec\", { url: { ...string, maxLength: 2000 }, title: { ...string, maxLength: 120 } }, [\"url\"]],\n [\"needs_you\", \"Call once at the end of a turn, just before your final reply: what the developer has to do next. ask is one line (≤120). kind: decision (give decisions with 2–5 options, a consequence each and recommend=<option index>), action (something only they can do: merge, approve, log in, run or send), review (a PR to review and merge; link it with pr), or done (\\\"Nothing needed. <what you finished>.\\\": nothing for the developer, won't flag you). context ≤400 chars: only what they need to decide. Never blocks and never changes what you should do next.\", \"follow.needs-you\", { ask: { ...string, minLength: 1, maxLength: 120 }, kind: { type: \"string\", enum: [\"decision\", \"action\", \"review\", \"done\"] }, decisions: { type: \"array\", maxItems: 3, items: { type: \"object\", properties: { question: { ...string, minLength: 1, maxLength: 200 }, options: { type: \"array\", minItems: 2, maxItems: 5, items: { type: \"object\", properties: { label: { ...string, minLength: 1, maxLength: 60 }, consequence: { ...string, maxLength: 160 } }, required: [\"label\"], additionalProperties: false } }, recommend: { type: \"integer\", minimum: 0, maximum: 4 } }, required: [\"question\"], additionalProperties: false } }, context: { ...string, maxLength: 400 }, links: { type: \"array\", maxItems: 4, items: { type: \"object\", properties: { label: string, url: string, path: string, pr: string }, required: [\"label\"], additionalProperties: false } } }, [\"ask\", \"kind\"]],\n [\"declare_done\", \"Declare that the workspace goal is fulfilled. Live PR status and remaining obligations are checked; returned blockers must be resolved first. Done labels the workspace but never archives it.\", \"follow.done\", { summary: string }, [\"summary\"]],\n];\n\n// The brief's tool: every root agent while the Needs-you inbox is on; never contributors, recovery or subagents.\n// Kept identical to NEEDS_YOU_INSTRUCTION in shared/brief.ts (a test checks it).\nconst needsYouInstruction = \"When you end a turn, call `needs_you` once, just before your final reply, with what the developer has to do next: a decision (give 2–3 options, what each means for them, and the one you recommend), an action only they can take (merge, approve, log in, run or send something), or kind=done (\\\"Nothing needed\\\" plus what you finished, in one line), which means there is nothing for the developer and won't flag you in their inbox. Keep `ask` to one line and `context` to what they need to decide. Don't repeat your report there; still write your normal reply. Don't call it while a permission prompt is waiting or while you're still working.\";\nfunction toolList(recovery = process.env.FOLLOW_THROUGH_RECOVERY === \"1\", contributor = false, brief = false, briefOnly = false) {\n if (briefOnly) return brief ? tools.filter(t => t[0] === \"needs_you\") : [];\n return tools.filter(t => t[0] === \"needs_you\" ? brief && !recovery && !contributor : contributor ? [\"workspace_status\", \"register_pr\", \"sync_workspace\"].includes(t[0]) : !recovery || ![\"declare_done\", \"sync_workspace\", \"ask_question\", \"record_answer\", \"withdraw_question\", \"set_spec\"].includes(t[0]));\n}\n/** Whether this session may call needs_you: launched as a root agent (FOLLOW_THROUGH_BRIEF) and the inbox is on. */\nasync function briefEnabled() {\n if (!process.env.FOLLOW_THROUGH_BRIEF || process.env.FOLLOW_THROUGH_RECOVERY === \"1\") return false;\n try {\n const directory = join(process.env.PASEO_HOME ?? join(homedir(), \".paseo\"), \"plugin-data/follow-through\");\n const lease = JSON.parse(await readFile(join(directory, \"hooks-enabled.json\"), \"utf8\"));\n if (!(lease.expiresAt > Date.now())) return false;\n const brief = JSON.parse(await readFile(join(directory, \"brief.json\"), \"utf8\"));\n return brief?.enable_inbox === true;\n } catch { return false; }\n}\nconst schemas = list => list.map(([name, description, , properties, required]) => ({ name, description, inputSchema: { type: \"object\", properties, required, additionalProperties: false }, annotations: { readOnlyHint: name === \"workspace_status\", destructiveHint: false } }));\nasync function scope(resolveRecovery = false) {\n const agentId = process.env.PASEO_AGENT_ID;\n if (!agentId) return null;\n try {\n const home = process.env.PASEO_HOME ?? join(homedir(), \".paseo\");\n const directory = join(home, \"plugin-data/follow-through\");\n const lease = JSON.parse(await readFile(join(directory, \"hooks-enabled.json\"), \"utf8\"));\n if (!(lease.expiresAt > Date.now())) return null;\n const state = JSON.parse(await readFile(join(directory, \"state.json\"), \"utf8\"));\n for (const w of state.workspaces) {\n if (!w.enable_tracking || w.archived) continue;\n if (w.mainAgentId === agentId) return process.env.FOLLOW_THROUGH_RECOVERY === \"1\" ? \"recovery\" : \"main\";\n if (w.batches.some(b => b.recipientAgentId === agentId && !b.settledAt && [\"sending\", \"sent\", \"blocked\", \"uncertain\"].includes(b.state) && b.tasks.every(t => t.kind === \"inventory\"))) return \"recovery\";\n if (w.contributorAgentIds?.includes(agentId)) return \"contributor\";\n }\n if (resolveRecovery && process.env.FOLLOW_THROUGH_RECOVERY === \"1\") {\n const w = await call(\"follow.agent-status\", {});\n if (w.enable_tracking && !w.archived && w.mainAgentId !== agentId && w.batches.some(b => b.recipientAgentId === agentId && !b.settledAt && [\"sending\", \"sent\", \"blocked\", \"uncertain\"].includes(b.state) && b.tasks.every(t => t.kind === \"inventory\"))) return \"recovery\";\n }\n } catch {}\n return null;\n}\nfunction validate(value, schema, path = \"arguments\") {\n const types = Array.isArray(schema.type) ? schema.type : [schema.type];\n const type = value === null ? \"null\" : Array.isArray(value) ? \"array\" : typeof value;\n if (!types.includes(type) && !(type === \"number\" && types.includes(\"integer\") && Number.isInteger(value))) throw new Error(`${path}: expected ${types.join(\" or \")}.`);\n if (schema.enum && !schema.enum.includes(value)) throw new Error(`${path}: choose ${schema.enum.join(\", \")}.`);\n if (type === \"object\") {\n const extra = Object.keys(value).filter(k => !Object.hasOwn(schema.properties ?? {}, k));\n if (extra.length && schema.additionalProperties === false) throw new Error(`Unknown argument ${path}.${extra.join(\", \")}. ${extra.includes(\"dependsOnPrs\") ? \"Use afterPrs with owner/repo#number keys.\" : \"Check this tool's input schema with list.\"}`);\n for (const key of schema.required ?? []) if (!(key in value)) throw new Error(`${path}.${key} is required. Check this tool's input schema with list.`);\n for (const [key, nested] of Object.entries(value)) if (schema.properties?.[key]) validate(nested, schema.properties[key], `${path}.${key}`);\n }\n if (type === \"array\") for (let i = 0; i < value.length; i++) validate(value[i], schema.items, `${path}[${i}]`);\n if (type === \"number\" && (schema.minimum !== undefined && value < schema.minimum || schema.maximum !== undefined && value > schema.maximum)) throw new Error(`${path}: outside the allowed range.`);\n if (type === \"string\" && (schema.minLength !== undefined && value.length < schema.minLength || schema.maxLength !== undefined && value.length > schema.maxLength)) throw new Error(`${path}: outside the allowed length.`);\n}\nfunction currentWork(w) {\n return { id: w.id, title: w.title, goal: w.goal, mainAgentId: w.mainAgentId, enable_tracking: w.enable_tracking, enable_auto_sends: w.enable_auto_sends, hold: w.hold, archived: w.archived, runtimeBusy: w.runtimeBusy, runtimeReason: w.runtimeReason, backgroundBusy: w.backgroundBusy, completion: w.completion,\n prs: w.prs.map(p => ({ key: p.key, stack: p.stack, parent: p.parent, disposition: p.disposition, error: p.error, ...(p.snapshot ? { title: p.snapshot.title, state: p.snapshot.state, head: p.snapshot.head, draft: p.snapshot.draft, reviewDecision: p.snapshot.reviewDecision, conflict: p.snapshot.conflict, openThreads: p.snapshot.threads.length, failedChecks: p.snapshot.failedChecks, otherFailedChecks: p.snapshot.otherFailedChecks ?? [], deployment: p.snapshot.deployment } : {}) })),\n obligations: w.obligations.filter(o => ![\"done\", \"waived\"].includes(o.status)), completedObligations: w.obligations.filter(o => [\"done\", \"waived\"].includes(o.status)).length,\n openQuestions: (w.questions ?? []).filter(q => !q.answer && !q.closed).map(q => ({ id: q.id, title: q.title, question: q.question, options: q.options, recommend: q.recommend, big: q.big, mode: q.mode, askedBy: q.askedBy, taskId: q.taskId })),\n spec: w.spec?.url ? { url: w.spec.url, title: w.spec.title, source: w.spec.source } : null,\n recentAnswers: (w.questions ?? []).filter(q => q.answer).slice(-5).map(q => ({ id: q.id, title: q.title, choice: q.answer.choice, words: q.answer.words, delivery: q.answer.delivery.state })),\n batches: w.batches.filter(b => ![\"completed\", \"cancelled\"].includes(b.state)).map(b => ({ id: b.id, state: b.state, tasks: b.tasks, result: b.result, recipientAgentId: b.recipientAgentId, contextPath: b.contextPath })),\n excludedStacks: w.excludedStacks.map(s => ({ name: s.name, reason: s.reason, prs: s.prs.map(p => p.key) })), detail: \"summary\" };\n}\nfunction compact(tool, input, result) {\n if (tool === \"workspace_status\") return currentWork(result);\n if (tool === \"needs_you\") return { ok: result.ok, briefId: result.briefId };\n if (tool === \"declare_done\") return { workspaceId: result.workspace.id, completion: result.workspace.completion, blockers: result.blockers };\n if (tool === \"ask_question\") return { workspaceId: result.workspaceId, question: { id: result.question.id, title: result.question.title, recommend: result.question.recommend, big: result.question.big }, mode: result.mode, instruction: result.instruction };\n if (tool === \"record_answer\" || tool === \"withdraw_question\") return { workspaceId: result.workspaceId, question: { id: result.question.id, answer: result.question.answer, closed: result.question.closed } };\n if (tool === \"set_spec\") return { workspaceId: result.workspaceId, spec: result.spec && { url: result.spec.url, title: result.spec.title, source: result.spec.source }, applied: result.applied, message: result.message };\n if (!result?.prs) return result;\n const workspaceId = result.id;\n if (tool === \"enroll_workspace\") return { workspaceId, goal: result.goal };\n if (tool === \"register_pr\") { const p = result.prs.find(p => p.key === `${input.repo}#${input.number}`); return { workspaceId, pr: { key: p.key, stack: p.stack, parent: p.parent, disposition: p.disposition } }; }\n if (tool === \"update_obligation\") return { workspaceId, obligation: result.obligations.find(o => o.id === result.updatedObligationId) };\n if (tool === \"acknowledge_batch\") { const b = result.batches.find(b => b.id === input.batchId); return { workspaceId, batch: { id: b.id, state: b.state, result: b.result } }; }\n if (tool === \"exclude_stack\") { const s = result.excludedStacks.find(s => s.name === input.stack); return { workspaceId, excludedStack: { name: s.name, reason: s.reason, prs: s.prs.map(p => p.key) } }; }\n return result;\n}\nasync function loadClient() {\n const candidates = [];\n if (process.env.FOLLOW_THROUGH_CLIENT_ENTRY) candidates.push(() => pathToFileURL(process.env.FOLLOW_THROUGH_CLIENT_ENTRY).href);\n const specifier = \"@getpaseo/client/internal/daemon-client\";\n if (process.resourcesPath) candidates.push(() => pathToFileURL(createRequire(join(process.resourcesPath, \"app.asar/node_modules/@getpaseo/cli/dist/index.js\")).resolve(specifier)).href);\n for (const directory of (process.env.PATH ?? \"\").split(delimiter)) {\n const cli = join(directory, process.platform === \"win32\" ? \"paseo.cmd\" : \"paseo\");\n if (existsSync(cli)) candidates.push(() => pathToFileURL(createRequire(realpathSync(cli)).resolve(specifier)).href);\n }\n candidates.push(() => pathToFileURL(createRequire(import.meta.url).resolve(specifier)).href);\n for (const resolve of candidates) try { return await import(resolve()); } catch {}\n throw new Error(\"Cannot load the installed Paseo client. Reload Follow-through to refresh its runtime connection.\");\n}\nconst batchInstructions = \"Follow-through batches contain a header with the batchId and ordered tasks. Read workspace_status and refresh live conditions; skip resolved tasks. Process PRs bottom to top, one fully before the next, using the repository's /fix-pr and safe reparent workflow. Validate feedback, verify changes, submit with Graphite --publish, reply/resolve addressed threads and preserve uncommitted work. Post-deploy tasks require verified serving revisions and existing production permissions. Stakeholder update requests are follow-through tasks: retain the original thread, recipient and developer authorization in communication. Use current evidence, distinguish fixed/deployed/verified, and read existing replies before posting. Record the sent replyUrl; a draft or uncertain send stays blocked and must be checked before retrying. Auto authorizes agent delivery only. Acknowledge the header's batchId with acknowledge_batch before finishing (completed with the result, or blocked with the reason), before declare_done for completion batches. Declare Done only after the whole goal and post-merge work are verified. Leave merging and archiving to the developer.\";\n\nasync function call(method, input) {\n const agentId = process.env.PASEO_AGENT_ID;\n if (!agentId) throw new Error(\"This bridge must run inside a Paseo agent (PASEO_AGENT_ID is missing).\");\n const { DaemonClient } = await loadClient();\n const home = process.env.PASEO_HOME ?? join(homedir(), \".paseo\");\n const config = JSON.parse(await readFile(join(home, \"config.json\"), \"utf8\"));\n let password = process.env.PASEO_PASSWORD;\n if (!password && process.env.PASEO_PASSWORD_FILE) password = (await readFile(process.env.PASEO_PASSWORD_FILE, \"utf8\")).trim();\n if (!password && home === join(homedir(), \".paseo\")) password = await readFile(join(homedir(), \"paseo-hub/secrets/daemon-password\"), \"utf8\").then(s => s.trim()).catch(() => undefined);\n const client = new DaemonClient({ url: process.env.PASEO_DAEMON_URL ?? `ws://${config.daemon?.listen ?? \"127.0.0.1:6767\"}/ws`, clientId: \"follow-through-mcp\", clientType: \"cli\", reconnect: { enabled: false }, connectTimeoutMs: 10000, suppressSendErrors: true, ...(password ? { password } : {}) });\n try { await client.connect(); return await client.invokePluginRpc(\"follow-through\", method, { ...input, agentId }); }\n finally { await client.close().catch(() => undefined); }\n}\n\nconst command = process.argv[2];\nif (command === \"list\" || command === \"--help\" || command === \"help\") {\n process.stdout.write(command === \"list\" ? JSON.stringify({ tools: schemas(toolList(undefined, false, true)) }, null, 2) + \"\\n\" : \"Usage: node mcp.mjs list | call <tool> '<json>' [--compact]\\nNative MCP returns compact receipts; workspace_status({detail:full}) requests complete history. CLI calls retain full replies unless --compact is supplied.\\n\");\n} else if (command === \"call\") {\n try {\n const tool = toolList(undefined, false, await briefEnabled()).find(t => t[0] === process.argv[3]);\n if (!tool) throw new Error(\"Unknown tool. Run list for names and input schemas.\");\n const input = JSON.parse(process.argv[4] ?? \"{}\");\n validate(input, schemas([tool])[0].inputSchema);\n if (process.argv.slice(5).some(arg => arg !== \"--compact\")) throw new Error(\"Unknown CLI option. Use --compact or omit it for a full reply.\");\n const result = await call(tool[2], input);\n process.stdout.write(JSON.stringify(process.argv.includes(\"--compact\") && input.detail !== \"full\" ? compact(tool[0], input, result) : result, null, 2) + \"\\n\");\n } catch (error) { console.error(error.message); process.exitCode = 1; }\n} else if (command) {\n console.error(\"Unknown command. Use list, --help, or call <tool> '<json>'.\"); process.exitCode = 1;\n} else {\n const lines = createInterface({ input: process.stdin });\n let tail = Promise.resolve(), initialized = false, lastScope = null;\n const timer = process.env.PASEO_AGENT_ID ? setInterval(() => {\n void (async () => { const current = `${await scope()}:${await briefEnabled()}`; if (initialized && current !== lastScope) process.stdout.write(JSON.stringify({ jsonrpc: \"2.0\", method: \"notifications/tools/list_changed\" }) + \"\\n\"); lastScope = current; })().catch(() => {});\n }, 2000) : null;\n timer?.unref(); lines.on(\"close\", () => { if (timer) clearInterval(timer); });\n lines.on(\"line\", line => {\n tail = tail.then(async () => {\n let request;\n try { request = JSON.parse(line); } catch { return; }\n if (request.id === undefined) return;\n try {\n let result;\n const role = await scope(request.method === \"initialize\" || request.method === \"tools/list\");\n const brief = await briefEnabled();\n const available = role ? toolList(role === \"recovery\", role === \"contributor\", brief) : toolList(false, false, brief, true);\n if (request.method === \"initialize\") {\n initialized = true; lastScope = `${role}:${brief}`;\n result = { protocolVersion: request.params?.protocolVersion ?? \"2024-11-05\", capabilities: { tools: { listChanged: true } }, serverInfo: { name: \"paseo-follow-through\", version: \"0.3.5\" }, instructions: (role ? (role === \"contributor\" ? \"Register PRs this session submits, including from secondary git worktrees. Read workspace_status and call sync_workspace before finishing. Goal, task, scope and completion changes remain with the designated main agent.\" : role === \"recovery\" ? \"Recover tracking metadata from evidence, acknowledge your assigned batch, and leave completion to the main agent.\" : \"Keep workspace follow-through updated after PR submission and scope changes. Read workspace_status after compaction. Track all post-merge work. When a decision needs the developer, ask it with ask_question (lettered options and a recommendation) and follow the question mode in its reply. If you keep a spec artifact for this workspace, register its link once with set_spec. \" + batchInstructions) + \" Perform bookkeeping silently: no sidebar-update announcements, plugin-status commentary or final sidebar recap. Put receipts and summaries in tools. Report actual work outcomes or decisions/blockers; discuss sidebar status only when explicitly asked.\" : brief ? \"\" : \"Follow-through tools are available to tracked workspace root agents and active recovery agents; contributor sessions receive PR registration and status tools.\") + (brief && role !== \"contributor\" && role !== \"recovery\" ? (role ? \" \" : \"\") + needsYouInstruction : \"\") };\n } else if (request.method === \"ping\") result = {};\n else if (request.method === \"tools/list\") result = { tools: schemas(available) };\n else if (request.method === \"tools/call\") {\n const tool = available.find(t => t[0] === request.params?.name);\n try {\n if (!tool) throw new Error(\"This tool is unavailable for the current agent. Tracking must be active; recovery authority ends after acknowledgment.\");\n const input = request.params.arguments ?? {}; validate(input, schemas([tool])[0].inputSchema);\n const value = await call(tool[2], input);\n result = { content: [{ type: \"text\", text: JSON.stringify(input.detail === \"full\" ? value : compact(tool[0], input, value)) }] };\n } catch (error) { result = { isError: true, content: [{ type: \"text\", text: error.message }] }; }\n } else { process.stdout.write(JSON.stringify({ jsonrpc: \"2.0\", id: request.id, error: { code: -32601, message: \"Method not found\" } }) + \"\\n\"); return; }\n process.stdout.write(JSON.stringify({ jsonrpc: \"2.0\", id: request.id, result }) + \"\\n\");\n } catch (error) { process.stdout.write(JSON.stringify({ jsonrpc: \"2.0\", id: request.id, error: { code: -32603, message: error.message } }) + \"\\n\"); }\n });\n });\n}\n";
2
+ export const mcpSource = "#!/usr/bin/env node\nimport { createInterface } from \"node:readline\";\nimport { createRequire } from \"node:module\";\nimport { pathToFileURL } from \"node:url\";\nimport { readFile } from \"node:fs/promises\";\nimport { existsSync, realpathSync } from \"node:fs\";\nimport { join, delimiter } from \"node:path\";\nimport { homedir } from \"node:os\";\n\nconst string = { type: \"string\" };\nconst boolean = { type: \"boolean\" };\nconst integer = { type: \"integer\", minimum: 1 };\nconst array = { type: \"array\", items: string };\nconst communication = { type: \"object\", properties: { threadUrl: string, recipient: string, purpose: { type: \"string\", enum: [\"progress\", \"verified\"] }, authorization: string, replyUrl: { type: [\"string\", \"null\"] } }, required: [\"threadUrl\", \"recipient\"], additionalProperties: false };\nconst trigger = { type: [\"object\", \"null\"], properties: { event: { type: \"string\", enum: [\"now\", \"merge\", \"deploy\"] }, delayDays: { type: \"integer\", minimum: 0, maximum: 3650 } }, required: [\"event\"], additionalProperties: false };\nconst tools = [\n [\"workspace_status\", \"Read the tracked workspace's durable goal, PRs, tasks, work queue and optional recovery history. Read after compaction. Returns current work by default; detail=full includes complete evidence/history. Normal follow-through does not require recovery.\", \"follow.agent-status\", { detail: { type: \"string\", enum: [\"summary\", \"full\"] } }, []],\n [\"sync_workspace\", \"Silently confirm the sidebar matches your work before finishing, after registering owned PRs and substantive tasks. Call even when no changes are needed. Keep the summary in this tool; never announce sync or recap sidebar/plugin status to the developer. Does not declare Done, run recovery, or start repairs. Tracked root agents only.\", \"follow.sync\", { summary: { ...string, minLength: 1, maxLength: 1000 } }, [\"summary\"]],\n [\"enroll_workspace\", \"Refine this developer-activated workspace's goal. Ownership is initialized by Track this workspace or the project’s default-tracking setting. This tool cannot activate another workspace. Never ask the developer to enter this. Keep the goal concise; new scope reopens completion.\", \"follow.enroll\", { goal: string }, [\"goal\"]],\n [\"register_pr\", \"Register a PR produced by this workspace. Call after every submission. Register parents first; repeat to update its stack or parent after restacking. Supply a disposition for a deliberately closed PR.\", \"follow.register-pr\", { repo: string, number: integer, stack: string, parentNumber: { type: [\"integer\", \"null\"], minimum: 1 }, disposition: string }, [\"repo\", \"number\", \"stack\"]],\n [\"exclude_stack\", \"Correct an unrelated stack registration. Preserve its PRs and requirements in an exclusion audit; future registration is blocked. reason must cite the scope/ownership evidence. obligationIds may include related tasks without PR dependencies. Keep external dependencies separate from owned work.\", \"follow.exclude-stack\", { stack: string, reason: string, obligationIds: array }, [\"stack\", \"reason\"]],\n [\"update_obligation\", \"Record concrete follow-through: verification, migrations, decisions or stakeholder replies. Never add approve/merge/deploy checklist tasks. afterPrs uses owner/repo#number keys. Tasks are manual unless explicitly scheduled with trigger={event:\\\"now\\\"|\\\"merge\\\"|\\\"deploy\\\",delayDays:0}. Only set a trigger from the developer’s requested timing; never infer timing from a title or enable_follow_up alone. Merge/deploy triggers require owned afterPrs; delayDays=14 waits fourteen days after the last dependency event. Now requires no PR dependencies or delay. Deployment timing starts at the first verified fully serving observation and remains conservative when history is unknown. Preserve existing trigger fields on updates; trigger=null makes a task manual. Auto controls delivery, not timing or production permissions. For Slack replies, communication records the original threadUrl, recipient, purpose (progress or verified), explicit developer authorization and sent replyUrl. Empty authorization never queues a reply; Auto does not authorize external posts. Use a verify-and-reply task after deployment for the final update. Read the thread first; mark blocked when posting is unavailable or uncertain, never treat a draft as sent. Done requires replyUrl and evidence; preserve fields when updating.\", \"follow.obligation\", { id: string, title: string, status: { type: \"string\", enum: [\"pending\", \"waiting\", \"working\", \"blocked\", \"done\", \"waived\"] }, afterPrs: array, dependencyStage: { type: \"string\", enum: [\"merged\", \"deployed\"] }, evidence: string, enable_follow_up: boolean, communication, trigger }, [\"title\", \"status\"]],\n [\"acknowledge_batch\", \"Record a follow-up result. Complete only after verifying all tasks; blocked surfaces the reason to the developer. Always acknowledge before ending a repair turn. Keep the result within 2000 characters.\", \"follow.acknowledge\", { batchId: string, outcome: { type: \"string\", enum: [\"completed\", \"blocked\"] }, result: { ...string, maxLength: 2000 } }, [\"batchId\", \"outcome\", \"result\"]],\n [\"ask_question\", \"Ask the developer a decision you can't make yourself. Give a short title, the question, lettered options A, B, C… with what each means for them, and recommend the option you'd pick. Set big only when building ahead would send something off this computer, set something people or programs outside the repo rely on, or most remaining work would build on it. Link a PR (owner/repo#number) or task id when it is about one. The reply says the question mode and exactly what to do until the answer arrives.\", \"follow.ask-question\", { title: { ...string, maxLength: 120 }, question: { ...string, maxLength: 2000 }, options: { type: \"array\", maxItems: 26, items: { type: \"object\", properties: { letter: { type: \"string\", pattern: \"^[A-Z]$\" }, label: { ...string, maxLength: 200 }, consequence: { ...string, maxLength: 600 } }, required: [\"letter\", \"label\"], additionalProperties: false } }, recommend: string, big: string, background: string, links: { type: \"array\", maxItems: 10, items: { type: \"object\", properties: { url: string, path: string, label: string }, additionalProperties: false } }, pr: string, task: string }, [\"title\", \"question\"]],\n [\"record_answer\", \"Record the developer's answer when they answered a question in chat instead of in Follow-up. choice is an option letter, or their short answer when there were no options; words is what they said.\", \"follow.record-answer\", { id: string, choice: string, words: string }, [\"id\", \"choice\"]],\n [\"withdraw_question\", \"Withdraw a question that no longer matters, with a short reason.\", \"follow.withdraw-question\", { id: string, reason: string }, [\"id\", \"reason\"]],\n [\"set_spec\", \"Register the link to this workspace's spec: the Claude artifact (or doc) you keep summarizing the goal, decisions and status. Call it once after the first publish, with the URL from the publish result, and again only if the link changes. Follow-up shows it in the workspace header.\", \"follow.set-spec\", { url: { ...string, maxLength: 2000 }, title: { ...string, maxLength: 120 } }, [\"url\"]],\n [\"needs_you\", \"Call once at the end of a turn, just before your final reply: what the developer has to do next. ask is one line (≤120). kind: decision (give decisions with 2–5 options, a consequence each and recommend=<option index>), action (something only they can do: merge, approve, log in, run or send), review (a PR to review and merge; link it with pr), or done (\\\"Nothing needed. <what you finished>.\\\": nothing for the developer, won't flag you). context ≤400 chars: only what they need to decide. Never blocks and never changes what you should do next.\", \"follow.needs-you\", { ask: { ...string, minLength: 1, maxLength: 120 }, kind: { type: \"string\", enum: [\"decision\", \"action\", \"review\", \"done\"] }, decisions: { type: \"array\", maxItems: 3, items: { type: \"object\", properties: { question: { ...string, minLength: 1, maxLength: 200 }, options: { type: \"array\", minItems: 2, maxItems: 5, items: { type: \"object\", properties: { label: { ...string, minLength: 1, maxLength: 60 }, consequence: { ...string, maxLength: 160 } }, required: [\"label\"], additionalProperties: false } }, recommend: { type: \"integer\", minimum: 0, maximum: 4 } }, required: [\"question\"], additionalProperties: false } }, context: { ...string, maxLength: 400 }, links: { type: \"array\", maxItems: 4, items: { type: \"object\", properties: { label: string, url: string, path: string, pr: string }, required: [\"label\"], additionalProperties: false } } }, [\"ask\", \"kind\"]],\n [\"declare_done\", \"Declare that the workspace goal is fulfilled. Live PR status and remaining obligations are checked; returned blockers must be resolved first. Done labels the workspace but never archives it.\", \"follow.done\", { summary: string }, [\"summary\"]],\n];\n\n// Agent parity: every action the developer can click in Follow-up, through the same server path (follow.agent-act).\n// Planned tools return a dry run; confirm=true with its planId runs it. Use them only when the developer asked.\nconst planned = { planId: string, confirm: boolean };\nconst prKey = { ...string, description: \"owner/repo#number, from list_prs\" };\nconst ask = \" Only when the developer asked for it. Returns a dry-run plan first; run it with confirm=true and the planId.\";\nconst actionTools = [\n [\"list_prs\", \"List tracked PRs across all workspaces with state, draft, checks, open threads, requested reviewers and merge blockers. Filters: needs_reviewers, ready_to_merge, workspace (id or title). This is how you act \\\"on all\\\".\", \"follow.agent-act\", { workspace: string, needs_reviewers: boolean, ready_to_merge: boolean, include_closed: boolean }, []],\n [\"add_reviewers\", \"Request reviewers on one or many PRs, the same as the Add reviewers button. Without reviewers, uses the repository's default reviewers; otherwise the GitHub logins you give.\" + ask, \"follow.agent-act\", { prs: { type: \"array\", items: prKey }, reviewers: array, ...planned }, []],\n [\"merge\", \"Merge a PR (and its downstack with scope=downstack) through Graphite, using Follow-up's merge plan and blockers. Can't be undone.\" + ask, \"follow.agent-act\", { pr: prKey, scope: { type: \"string\", enum: [\"target\", \"downstack\"] }, ...planned }, []],\n [\"enable_merge_when_ready\", \"Turn on Graphite merge-when-ready for a PR, as the PR sheet does.\" + ask, \"follow.agent-act\", { pr: prKey, scope: { type: \"string\", enum: [\"target\", \"downstack\"] }, ...planned }, []],\n [\"disable_merge_when_ready\", \"Turn off Graphite merge-when-ready for a PR.\" + ask, \"follow.agent-act\", { pr: prKey, scope: { type: \"string\", enum: [\"target\", \"downstack\"] }, ...planned }, []],\n [\"send_repairs\", \"Send a workspace's waiting repairs (review threads, failed checks) to its main agent, like the Send button. Only when the developer asked. batchId defaults to the waiting batch.\", \"follow.agent-act\", { workspace: string, batchId: string }, [\"workspace\"]],\n [\"set_auto_send\", \"Turn Auto-send on or off for a workspace, like its switch. Only when the developer asked.\", \"follow.agent-act\", { workspace: string, value: boolean }, [\"workspace\", \"value\"]],\n [\"clear_hold\", \"Resume Follow-up on a held workspace, like the Resume button. Only when the developer asked.\", \"follow.agent-act\", { workspace: string }, [\"workspace\"]],\n [\"retry_batch\", \"Queue a blocked or uncertain repair batch again, like the Retry button. Only when the developer asked.\", \"follow.agent-act\", { workspace: string, batchId: string }, [\"workspace\", \"batchId\"]],\n [\"track\", \"Start tracking a workspace, like Track this workspace. Only when the developer asked.\", \"follow.agent-act\", { workspace: string }, [\"workspace\"]],\n [\"inbox_list\", \"Read the developer's Needs-you inbox: each root agent, whether it is flagged and why, snoozes and finish flags. Your own row has self=true.\", \"follow.agent-act\", { flagged_only: boolean }, []],\n [\"sign_off\", \"Sign off other agents' Needs-you flags on the developer's behalf, like Sign off. Never your own.\" + ask, \"follow.agent-act\", { agentIds: array, ...planned }, []],\n [\"snooze\", \"Snooze other agents' flags (until: next-event, 1h, tomorrow or an ISO time), like Later.\" + ask, \"follow.agent-act\", { agentIds: array, until: string, ...planned }, []],\n [\"set_finish_flags\", \"Turn finish flags on or off for other agents, like Don't flag finishes.\" + ask, \"follow.agent-act\", { agentIds: array, value: boolean, ...planned }, []],\n];\nconst actionNames = new Set(actionTools.map(t => t[0]));\nconst readOnlyTools = new Set([\"workspace_status\", \"list_prs\", \"inbox_list\"]);\nconst destructiveTools = new Set([\"merge\"]);\nconst actionInstruction = \"Follow-up's action tools let you do what the developer can click, across every tracked workspace: list_prs and inbox_list to read; add_reviewers, merge, enable/disable_merge_when_ready, send_repairs, set_auto_send, clear_hold, retry_batch, track, and sign_off/snooze/set_finish_flags for other agents. Use them only when the developer asks (\\\"the PRs look good, assign reviewers on all\\\": list_prs needs_reviewers=true, add_reviewers with those keys, then confirm the plan). Planned tools return a dry run; run it with confirm=true and its planId. Never sign yourself off.\";\n\n// The brief's tool: every root agent while the Needs-you inbox is on; never contributors, recovery or subagents.\n// Kept identical to NEEDS_YOU_INSTRUCTION in shared/brief.ts (a test checks it).\nconst needsYouInstruction = \"When you end a turn, call `needs_you` once, just before your final reply, with what the developer has to do next: a decision (give 2–3 options, what each means for them, and the one you recommend), an action only they can take (merge, approve, log in, run or send something), or kind=done (\\\"Nothing needed\\\" plus what you finished, in one line), which means there is nothing for the developer and won't flag you in their inbox. Keep `ask` to one line and `context` to what they need to decide. Don't repeat your report there; still write your normal reply. Don't call it while a permission prompt is waiting or while you're still working.\";\nfunction toolList(recovery = process.env.FOLLOW_THROUGH_RECOVERY === \"1\", contributor = false, brief = false, briefOnly = false, actions = false) {\n // Action tools: root agents (tracked main or untracked) while Follow-up runs; never recovery or contributors.\n const acting = actions && !recovery && !contributor ? actionTools : [];\n if (briefOnly) return [...(brief ? tools.filter(t => t[0] === \"needs_you\") : []), ...acting];\n return [...tools.filter(t => t[0] === \"needs_you\" ? brief && !recovery && !contributor : contributor ? [\"workspace_status\", \"register_pr\", \"sync_workspace\"].includes(t[0]) : !recovery || ![\"declare_done\", \"sync_workspace\", \"ask_question\", \"record_answer\", \"withdraw_question\", \"set_spec\"].includes(t[0])), ...acting];\n}\n/** Whether this session may call needs_you: launched as a root agent (FOLLOW_THROUGH_BRIEF) and the inbox is on. */\nasync function briefEnabled() {\n if (!process.env.FOLLOW_THROUGH_BRIEF || process.env.FOLLOW_THROUGH_RECOVERY === \"1\") return false;\n try {\n const directory = join(process.env.PASEO_HOME ?? join(homedir(), \".paseo\"), \"plugin-data/follow-through\");\n const lease = JSON.parse(await readFile(join(directory, \"hooks-enabled.json\"), \"utf8\"));\n if (!(lease.expiresAt > Date.now())) return false;\n const brief = JSON.parse(await readFile(join(directory, \"brief.json\"), \"utf8\"));\n return brief?.enable_inbox === true;\n } catch { return false; }\n}\nconst schemas = list => list.map(([name, description, , properties, required]) => ({ name, description, inputSchema: { type: \"object\", properties, required, additionalProperties: false }, annotations: { readOnlyHint: readOnlyTools.has(name), destructiveHint: destructiveTools.has(name) } }));\n/** Action tools: a session Paseo launched as a root agent (FOLLOW_THROUGH_ROOT) while Follow-up runs (fresh hook lease). */\nasync function actionsEnabled() {\n if (!process.env.PASEO_AGENT_ID || process.env.FOLLOW_THROUGH_ROOT !== \"1\" || process.env.FOLLOW_THROUGH_RECOVERY === \"1\") return false;\n try {\n const lease = JSON.parse(await readFile(join(process.env.PASEO_HOME ?? join(homedir(), \".paseo\"), \"plugin-data/follow-through/hooks-enabled.json\"), \"utf8\"));\n return lease.expiresAt > Date.now();\n } catch { return false; }\n}\n/** The bridge input for a tool: action tools travel as { tool, args } through follow.agent-act. */\nconst callInput = (tool, input) => actionNames.has(tool[0]) ? { tool: tool[0], args: input } : input;\nasync function scope(resolveRecovery = false) {\n const agentId = process.env.PASEO_AGENT_ID;\n if (!agentId) return null;\n try {\n const home = process.env.PASEO_HOME ?? join(homedir(), \".paseo\");\n const directory = join(home, \"plugin-data/follow-through\");\n const lease = JSON.parse(await readFile(join(directory, \"hooks-enabled.json\"), \"utf8\"));\n if (!(lease.expiresAt > Date.now())) return null;\n const state = JSON.parse(await readFile(join(directory, \"state.json\"), \"utf8\"));\n for (const w of state.workspaces) {\n if (!w.enable_tracking || w.archived) continue;\n if (w.mainAgentId === agentId) return process.env.FOLLOW_THROUGH_RECOVERY === \"1\" ? \"recovery\" : \"main\";\n if (w.batches.some(b => b.recipientAgentId === agentId && !b.settledAt && [\"sending\", \"sent\", \"blocked\", \"uncertain\"].includes(b.state) && b.tasks.every(t => t.kind === \"inventory\"))) return \"recovery\";\n if (w.contributorAgentIds?.includes(agentId)) return \"contributor\";\n }\n if (resolveRecovery && process.env.FOLLOW_THROUGH_RECOVERY === \"1\") {\n const w = await call(\"follow.agent-status\", {});\n if (w.enable_tracking && !w.archived && w.mainAgentId !== agentId && w.batches.some(b => b.recipientAgentId === agentId && !b.settledAt && [\"sending\", \"sent\", \"blocked\", \"uncertain\"].includes(b.state) && b.tasks.every(t => t.kind === \"inventory\"))) return \"recovery\";\n }\n } catch {}\n return null;\n}\nfunction validate(value, schema, path = \"arguments\") {\n const types = Array.isArray(schema.type) ? schema.type : [schema.type];\n const type = value === null ? \"null\" : Array.isArray(value) ? \"array\" : typeof value;\n if (!types.includes(type) && !(type === \"number\" && types.includes(\"integer\") && Number.isInteger(value))) throw new Error(`${path}: expected ${types.join(\" or \")}.`);\n if (schema.enum && !schema.enum.includes(value)) throw new Error(`${path}: choose ${schema.enum.join(\", \")}.`);\n if (type === \"object\") {\n const extra = Object.keys(value).filter(k => !Object.hasOwn(schema.properties ?? {}, k));\n if (extra.length && schema.additionalProperties === false) throw new Error(`Unknown argument ${path}.${extra.join(\", \")}. ${extra.includes(\"dependsOnPrs\") ? \"Use afterPrs with owner/repo#number keys.\" : \"Check this tool's input schema with list.\"}`);\n for (const key of schema.required ?? []) if (!(key in value)) throw new Error(`${path}.${key} is required. Check this tool's input schema with list.`);\n for (const [key, nested] of Object.entries(value)) if (schema.properties?.[key]) validate(nested, schema.properties[key], `${path}.${key}`);\n }\n if (type === \"array\") for (let i = 0; i < value.length; i++) validate(value[i], schema.items, `${path}[${i}]`);\n if (type === \"number\" && (schema.minimum !== undefined && value < schema.minimum || schema.maximum !== undefined && value > schema.maximum)) throw new Error(`${path}: outside the allowed range.`);\n if (type === \"string\" && (schema.minLength !== undefined && value.length < schema.minLength || schema.maxLength !== undefined && value.length > schema.maxLength)) throw new Error(`${path}: outside the allowed length.`);\n}\nfunction currentWork(w) {\n return { id: w.id, title: w.title, goal: w.goal, mainAgentId: w.mainAgentId, enable_tracking: w.enable_tracking, enable_auto_sends: w.enable_auto_sends, hold: w.hold, archived: w.archived, runtimeBusy: w.runtimeBusy, runtimeReason: w.runtimeReason, backgroundBusy: w.backgroundBusy, completion: w.completion,\n prs: w.prs.map(p => ({ key: p.key, stack: p.stack, parent: p.parent, disposition: p.disposition, error: p.error, ...(p.snapshot ? { title: p.snapshot.title, state: p.snapshot.state, head: p.snapshot.head, draft: p.snapshot.draft, reviewDecision: p.snapshot.reviewDecision, conflict: p.snapshot.conflict, openThreads: p.snapshot.threads.length, failedChecks: p.snapshot.failedChecks, otherFailedChecks: p.snapshot.otherFailedChecks ?? [], deployment: p.snapshot.deployment } : {}) })),\n obligations: w.obligations.filter(o => ![\"done\", \"waived\"].includes(o.status)), completedObligations: w.obligations.filter(o => [\"done\", \"waived\"].includes(o.status)).length,\n openQuestions: (w.questions ?? []).filter(q => !q.answer && !q.closed).map(q => ({ id: q.id, title: q.title, question: q.question, options: q.options, recommend: q.recommend, big: q.big, mode: q.mode, askedBy: q.askedBy, taskId: q.taskId })),\n spec: w.spec?.url ? { url: w.spec.url, title: w.spec.title, source: w.spec.source } : null,\n recentAnswers: (w.questions ?? []).filter(q => q.answer).slice(-5).map(q => ({ id: q.id, title: q.title, choice: q.answer.choice, words: q.answer.words, delivery: q.answer.delivery.state })),\n batches: w.batches.filter(b => ![\"completed\", \"cancelled\"].includes(b.state)).map(b => ({ id: b.id, state: b.state, tasks: b.tasks, result: b.result, recipientAgentId: b.recipientAgentId, contextPath: b.contextPath })),\n excludedStacks: w.excludedStacks.map(s => ({ name: s.name, reason: s.reason, prs: s.prs.map(p => p.key) })), detail: \"summary\" };\n}\nfunction compact(tool, input, result) {\n if (actionNames.has(tool)) return result;\n if (tool === \"workspace_status\") return currentWork(result);\n if (tool === \"needs_you\") return { ok: result.ok, briefId: result.briefId };\n if (tool === \"declare_done\") return { workspaceId: result.workspace.id, completion: result.workspace.completion, blockers: result.blockers };\n if (tool === \"ask_question\") return { workspaceId: result.workspaceId, question: { id: result.question.id, title: result.question.title, recommend: result.question.recommend, big: result.question.big }, mode: result.mode, instruction: result.instruction };\n if (tool === \"record_answer\" || tool === \"withdraw_question\") return { workspaceId: result.workspaceId, question: { id: result.question.id, answer: result.question.answer, closed: result.question.closed } };\n if (tool === \"set_spec\") return { workspaceId: result.workspaceId, spec: result.spec && { url: result.spec.url, title: result.spec.title, source: result.spec.source }, applied: result.applied, message: result.message };\n if (!result?.prs) return result;\n const workspaceId = result.id;\n if (tool === \"enroll_workspace\") return { workspaceId, goal: result.goal };\n if (tool === \"register_pr\") { const p = result.prs.find(p => p.key === `${input.repo}#${input.number}`); return { workspaceId, pr: { key: p.key, stack: p.stack, parent: p.parent, disposition: p.disposition } }; }\n if (tool === \"update_obligation\") return { workspaceId, obligation: result.obligations.find(o => o.id === result.updatedObligationId) };\n if (tool === \"acknowledge_batch\") { const b = result.batches.find(b => b.id === input.batchId); return { workspaceId, batch: { id: b.id, state: b.state, result: b.result } }; }\n if (tool === \"exclude_stack\") { const s = result.excludedStacks.find(s => s.name === input.stack); return { workspaceId, excludedStack: { name: s.name, reason: s.reason, prs: s.prs.map(p => p.key) } }; }\n return result;\n}\nasync function loadClient() {\n const candidates = [];\n if (process.env.FOLLOW_THROUGH_CLIENT_ENTRY) candidates.push(() => pathToFileURL(process.env.FOLLOW_THROUGH_CLIENT_ENTRY).href);\n const specifier = \"@getpaseo/client/internal/daemon-client\";\n if (process.resourcesPath) candidates.push(() => pathToFileURL(createRequire(join(process.resourcesPath, \"app.asar/node_modules/@getpaseo/cli/dist/index.js\")).resolve(specifier)).href);\n for (const directory of (process.env.PATH ?? \"\").split(delimiter)) {\n const cli = join(directory, process.platform === \"win32\" ? \"paseo.cmd\" : \"paseo\");\n if (existsSync(cli)) candidates.push(() => pathToFileURL(createRequire(realpathSync(cli)).resolve(specifier)).href);\n }\n candidates.push(() => pathToFileURL(createRequire(import.meta.url).resolve(specifier)).href);\n for (const resolve of candidates) try { return await import(resolve()); } catch {}\n throw new Error(\"Cannot load the installed Paseo client. Reload Follow-through to refresh its runtime connection.\");\n}\nconst batchInstructions = \"Follow-through batches contain a header with the batchId and ordered tasks. Read workspace_status and refresh live conditions; skip resolved tasks. Process PRs bottom to top, one fully before the next, using the repository's /fix-pr and safe reparent workflow. Validate feedback, verify changes, submit with Graphite --publish, reply/resolve addressed threads and preserve uncommitted work. Post-deploy tasks require verified serving revisions and existing production permissions. Stakeholder update requests are follow-through tasks: retain the original thread, recipient and developer authorization in communication. Use current evidence, distinguish fixed/deployed/verified, and read existing replies before posting. Record the sent replyUrl; a draft or uncertain send stays blocked and must be checked before retrying. Auto authorizes agent delivery only. Acknowledge the header's batchId with acknowledge_batch before finishing (completed with the result, or blocked with the reason), before declare_done for completion batches. Declare Done only after the whole goal and post-merge work are verified. Leave merging and archiving to the developer.\";\n\nasync function call(method, input) {\n const agentId = process.env.PASEO_AGENT_ID;\n if (!agentId) throw new Error(\"This bridge must run inside a Paseo agent (PASEO_AGENT_ID is missing).\");\n const { DaemonClient } = await loadClient();\n const home = process.env.PASEO_HOME ?? join(homedir(), \".paseo\");\n const config = JSON.parse(await readFile(join(home, \"config.json\"), \"utf8\"));\n let password = process.env.PASEO_PASSWORD;\n if (!password && process.env.PASEO_PASSWORD_FILE) password = (await readFile(process.env.PASEO_PASSWORD_FILE, \"utf8\")).trim();\n if (!password && home === join(homedir(), \".paseo\")) password = await readFile(join(homedir(), \"paseo-hub/secrets/daemon-password\"), \"utf8\").then(s => s.trim()).catch(() => undefined);\n const client = new DaemonClient({ url: process.env.PASEO_DAEMON_URL ?? `ws://${config.daemon?.listen ?? \"127.0.0.1:6767\"}/ws`, clientId: \"follow-through-mcp\", clientType: \"cli\", reconnect: { enabled: false }, connectTimeoutMs: 10000, suppressSendErrors: true, ...(password ? { password } : {}) });\n try { await client.connect(); return await client.invokePluginRpc(\"follow-through\", method, { ...input, agentId }); }\n finally { await client.close().catch(() => undefined); }\n}\n\nconst command = process.argv[2];\nif (command === \"list\" || command === \"--help\" || command === \"help\") {\n process.stdout.write(command === \"list\" ? JSON.stringify({ tools: schemas(toolList(undefined, false, true, false, true)) }, null, 2) + \"\\n\" : \"Usage: node mcp.mjs list | call <tool> '<json>' [--compact]\\nNative MCP returns compact receipts; workspace_status({detail:full}) requests complete history. CLI calls retain full replies unless --compact is supplied.\\n\");\n} else if (command === \"call\") {\n try {\n const tool = toolList(undefined, false, await briefEnabled(), false, await actionsEnabled()).find(t => t[0] === process.argv[3]);\n if (!tool) throw new Error(\"Unknown tool. Run list for names and input schemas.\");\n const input = JSON.parse(process.argv[4] ?? \"{}\");\n validate(input, schemas([tool])[0].inputSchema);\n if (process.argv.slice(5).some(arg => arg !== \"--compact\")) throw new Error(\"Unknown CLI option. Use --compact or omit it for a full reply.\");\n const result = await call(tool[2], callInput(tool, input));\n process.stdout.write(JSON.stringify(process.argv.includes(\"--compact\") && input.detail !== \"full\" ? compact(tool[0], input, result) : result, null, 2) + \"\\n\");\n } catch (error) { console.error(error.message); process.exitCode = 1; }\n} else if (command) {\n console.error(\"Unknown command. Use list, --help, or call <tool> '<json>'.\"); process.exitCode = 1;\n} else {\n const lines = createInterface({ input: process.stdin });\n let tail = Promise.resolve(), initialized = false, lastScope = null;\n const timer = process.env.PASEO_AGENT_ID ? setInterval(() => {\n void (async () => { const current = `${await scope()}:${await briefEnabled()}:${await actionsEnabled()}`; if (initialized && current !== lastScope) process.stdout.write(JSON.stringify({ jsonrpc: \"2.0\", method: \"notifications/tools/list_changed\" }) + \"\\n\"); lastScope = current; })().catch(() => {});\n }, 2000) : null;\n timer?.unref(); lines.on(\"close\", () => { if (timer) clearInterval(timer); });\n lines.on(\"line\", line => {\n tail = tail.then(async () => {\n let request;\n try { request = JSON.parse(line); } catch { return; }\n if (request.id === undefined) return;\n try {\n let result;\n const role = await scope(request.method === \"initialize\" || request.method === \"tools/list\");\n const brief = await briefEnabled();\n const acting = await actionsEnabled();\n const available = role ? toolList(role === \"recovery\", role === \"contributor\", brief, false, acting) : toolList(false, false, brief, true, acting);\n if (request.method === \"initialize\") {\n initialized = true; lastScope = `${role}:${brief}:${acting}`;\n result = { protocolVersion: request.params?.protocolVersion ?? \"2024-11-05\", capabilities: { tools: { listChanged: true } }, serverInfo: { name: \"paseo-follow-through\", version: \"0.3.5\" }, instructions: (role ? (role === \"contributor\" ? \"Register PRs this session submits, including from secondary git worktrees. Read workspace_status and call sync_workspace before finishing. Goal, task, scope and completion changes remain with the designated main agent.\" : role === \"recovery\" ? \"Recover tracking metadata from evidence, acknowledge your assigned batch, and leave completion to the main agent.\" : \"Keep workspace follow-through updated after PR submission and scope changes. Read workspace_status after compaction. Track all post-merge work. When a decision needs the developer, ask it with ask_question (lettered options and a recommendation) and follow the question mode in its reply. If you keep a spec artifact for this workspace, register its link once with set_spec. \" + batchInstructions) + \" Perform bookkeeping silently: no sidebar-update announcements, plugin-status commentary or final sidebar recap. Put receipts and summaries in tools. Report actual work outcomes or decisions/blockers; discuss sidebar status only when explicitly asked.\" : brief ? \"\" : \"Follow-through tools are available to tracked workspace root agents and active recovery agents; contributor sessions receive PR registration and status tools.\") + (brief && role !== \"contributor\" && role !== \"recovery\" ? (role ? \" \" : \"\") + needsYouInstruction : \"\") + (acting && role !== \"contributor\" && role !== \"recovery\" ? \" \" + actionInstruction : \"\") };\n } else if (request.method === \"ping\") result = {};\n else if (request.method === \"tools/list\") result = { tools: schemas(available) };\n else if (request.method === \"tools/call\") {\n const tool = available.find(t => t[0] === request.params?.name);\n try {\n if (!tool) throw new Error(\"This tool is unavailable for the current agent. Tracking must be active; recovery authority ends after acknowledgment.\");\n const input = request.params.arguments ?? {}; validate(input, schemas([tool])[0].inputSchema);\n const value = await call(tool[2], callInput(tool, input));\n result = { content: [{ type: \"text\", text: JSON.stringify(input.detail === \"full\" ? value : compact(tool[0], input, value)) }] };\n } catch (error) { result = { isError: true, content: [{ type: \"text\", text: error.message }] }; }\n } else { process.stdout.write(JSON.stringify({ jsonrpc: \"2.0\", id: request.id, error: { code: -32601, message: \"Method not found\" } }) + \"\\n\"); return; }\n process.stdout.write(JSON.stringify({ jsonrpc: \"2.0\", id: request.id, result }) + \"\\n\");\n } catch (error) { process.stdout.write(JSON.stringify({ jsonrpc: \"2.0\", id: request.id, error: { code: -32603, message: error.message } }) + \"\\n\"); }\n });\n });\n}\n";
package/server/mcp.mjs CHANGED
@@ -29,12 +29,40 @@ const tools = [
29
29
  ["declare_done", "Declare that the workspace goal is fulfilled. Live PR status and remaining obligations are checked; returned blockers must be resolved first. Done labels the workspace but never archives it.", "follow.done", { summary: string }, ["summary"]],
30
30
  ];
31
31
 
32
+ // Agent parity: every action the developer can click in Follow-up, through the same server path (follow.agent-act).
33
+ // Planned tools return a dry run; confirm=true with its planId runs it. Use them only when the developer asked.
34
+ const planned = { planId: string, confirm: boolean };
35
+ const prKey = { ...string, description: "owner/repo#number, from list_prs" };
36
+ const ask = " Only when the developer asked for it. Returns a dry-run plan first; run it with confirm=true and the planId.";
37
+ const actionTools = [
38
+ ["list_prs", "List tracked PRs across all workspaces with state, draft, checks, open threads, requested reviewers and merge blockers. Filters: needs_reviewers, ready_to_merge, workspace (id or title). This is how you act \"on all\".", "follow.agent-act", { workspace: string, needs_reviewers: boolean, ready_to_merge: boolean, include_closed: boolean }, []],
39
+ ["add_reviewers", "Request reviewers on one or many PRs, the same as the Add reviewers button. Without reviewers, uses the repository's default reviewers; otherwise the GitHub logins you give." + ask, "follow.agent-act", { prs: { type: "array", items: prKey }, reviewers: array, ...planned }, []],
40
+ ["merge", "Merge a PR (and its downstack with scope=downstack) through Graphite, using Follow-up's merge plan and blockers. Can't be undone." + ask, "follow.agent-act", { pr: prKey, scope: { type: "string", enum: ["target", "downstack"] }, ...planned }, []],
41
+ ["enable_merge_when_ready", "Turn on Graphite merge-when-ready for a PR, as the PR sheet does." + ask, "follow.agent-act", { pr: prKey, scope: { type: "string", enum: ["target", "downstack"] }, ...planned }, []],
42
+ ["disable_merge_when_ready", "Turn off Graphite merge-when-ready for a PR." + ask, "follow.agent-act", { pr: prKey, scope: { type: "string", enum: ["target", "downstack"] }, ...planned }, []],
43
+ ["send_repairs", "Send a workspace's waiting repairs (review threads, failed checks) to its main agent, like the Send button. Only when the developer asked. batchId defaults to the waiting batch.", "follow.agent-act", { workspace: string, batchId: string }, ["workspace"]],
44
+ ["set_auto_send", "Turn Auto-send on or off for a workspace, like its switch. Only when the developer asked.", "follow.agent-act", { workspace: string, value: boolean }, ["workspace", "value"]],
45
+ ["clear_hold", "Resume Follow-up on a held workspace, like the Resume button. Only when the developer asked.", "follow.agent-act", { workspace: string }, ["workspace"]],
46
+ ["retry_batch", "Queue a blocked or uncertain repair batch again, like the Retry button. Only when the developer asked.", "follow.agent-act", { workspace: string, batchId: string }, ["workspace", "batchId"]],
47
+ ["track", "Start tracking a workspace, like Track this workspace. Only when the developer asked.", "follow.agent-act", { workspace: string }, ["workspace"]],
48
+ ["inbox_list", "Read the developer's Needs-you inbox: each root agent, whether it is flagged and why, snoozes and finish flags. Your own row has self=true.", "follow.agent-act", { flagged_only: boolean }, []],
49
+ ["sign_off", "Sign off other agents' Needs-you flags on the developer's behalf, like Sign off. Never your own." + ask, "follow.agent-act", { agentIds: array, ...planned }, []],
50
+ ["snooze", "Snooze other agents' flags (until: next-event, 1h, tomorrow or an ISO time), like Later." + ask, "follow.agent-act", { agentIds: array, until: string, ...planned }, []],
51
+ ["set_finish_flags", "Turn finish flags on or off for other agents, like Don't flag finishes." + ask, "follow.agent-act", { agentIds: array, value: boolean, ...planned }, []],
52
+ ];
53
+ const actionNames = new Set(actionTools.map(t => t[0]));
54
+ const readOnlyTools = new Set(["workspace_status", "list_prs", "inbox_list"]);
55
+ const destructiveTools = new Set(["merge"]);
56
+ const actionInstruction = "Follow-up's action tools let you do what the developer can click, across every tracked workspace: list_prs and inbox_list to read; add_reviewers, merge, enable/disable_merge_when_ready, send_repairs, set_auto_send, clear_hold, retry_batch, track, and sign_off/snooze/set_finish_flags for other agents. Use them only when the developer asks (\"the PRs look good, assign reviewers on all\": list_prs needs_reviewers=true, add_reviewers with those keys, then confirm the plan). Planned tools return a dry run; run it with confirm=true and its planId. Never sign yourself off.";
57
+
32
58
  // The brief's tool: every root agent while the Needs-you inbox is on; never contributors, recovery or subagents.
33
59
  // Kept identical to NEEDS_YOU_INSTRUCTION in shared/brief.ts (a test checks it).
34
60
  const needsYouInstruction = "When you end a turn, call `needs_you` once, just before your final reply, with what the developer has to do next: a decision (give 2–3 options, what each means for them, and the one you recommend), an action only they can take (merge, approve, log in, run or send something), or kind=done (\"Nothing needed\" plus what you finished, in one line), which means there is nothing for the developer and won't flag you in their inbox. Keep `ask` to one line and `context` to what they need to decide. Don't repeat your report there; still write your normal reply. Don't call it while a permission prompt is waiting or while you're still working.";
35
- function toolList(recovery = process.env.FOLLOW_THROUGH_RECOVERY === "1", contributor = false, brief = false, briefOnly = false) {
36
- if (briefOnly) return brief ? tools.filter(t => t[0] === "needs_you") : [];
37
- return tools.filter(t => t[0] === "needs_you" ? brief && !recovery && !contributor : contributor ? ["workspace_status", "register_pr", "sync_workspace"].includes(t[0]) : !recovery || !["declare_done", "sync_workspace", "ask_question", "record_answer", "withdraw_question", "set_spec"].includes(t[0]));
61
+ function toolList(recovery = process.env.FOLLOW_THROUGH_RECOVERY === "1", contributor = false, brief = false, briefOnly = false, actions = false) {
62
+ // Action tools: root agents (tracked main or untracked) while Follow-up runs; never recovery or contributors.
63
+ const acting = actions && !recovery && !contributor ? actionTools : [];
64
+ if (briefOnly) return [...(brief ? tools.filter(t => t[0] === "needs_you") : []), ...acting];
65
+ return [...tools.filter(t => t[0] === "needs_you" ? brief && !recovery && !contributor : contributor ? ["workspace_status", "register_pr", "sync_workspace"].includes(t[0]) : !recovery || !["declare_done", "sync_workspace", "ask_question", "record_answer", "withdraw_question", "set_spec"].includes(t[0])), ...acting];
38
66
  }
39
67
  /** Whether this session may call needs_you: launched as a root agent (FOLLOW_THROUGH_BRIEF) and the inbox is on. */
40
68
  async function briefEnabled() {
@@ -47,7 +75,17 @@ async function briefEnabled() {
47
75
  return brief?.enable_inbox === true;
48
76
  } catch { return false; }
49
77
  }
50
- const schemas = list => list.map(([name, description, , properties, required]) => ({ name, description, inputSchema: { type: "object", properties, required, additionalProperties: false }, annotations: { readOnlyHint: name === "workspace_status", destructiveHint: false } }));
78
+ const schemas = list => list.map(([name, description, , properties, required]) => ({ name, description, inputSchema: { type: "object", properties, required, additionalProperties: false }, annotations: { readOnlyHint: readOnlyTools.has(name), destructiveHint: destructiveTools.has(name) } }));
79
+ /** Action tools: a session Paseo launched as a root agent (FOLLOW_THROUGH_ROOT) while Follow-up runs (fresh hook lease). */
80
+ async function actionsEnabled() {
81
+ if (!process.env.PASEO_AGENT_ID || process.env.FOLLOW_THROUGH_ROOT !== "1" || process.env.FOLLOW_THROUGH_RECOVERY === "1") return false;
82
+ try {
83
+ const lease = JSON.parse(await readFile(join(process.env.PASEO_HOME ?? join(homedir(), ".paseo"), "plugin-data/follow-through/hooks-enabled.json"), "utf8"));
84
+ return lease.expiresAt > Date.now();
85
+ } catch { return false; }
86
+ }
87
+ /** The bridge input for a tool: action tools travel as { tool, args } through follow.agent-act. */
88
+ const callInput = (tool, input) => actionNames.has(tool[0]) ? { tool: tool[0], args: input } : input;
51
89
  async function scope(resolveRecovery = false) {
52
90
  const agentId = process.env.PASEO_AGENT_ID;
53
91
  if (!agentId) return null;
@@ -96,6 +134,7 @@ function currentWork(w) {
96
134
  excludedStacks: w.excludedStacks.map(s => ({ name: s.name, reason: s.reason, prs: s.prs.map(p => p.key) })), detail: "summary" };
97
135
  }
98
136
  function compact(tool, input, result) {
137
+ if (actionNames.has(tool)) return result;
99
138
  if (tool === "workspace_status") return currentWork(result);
100
139
  if (tool === "needs_you") return { ok: result.ok, briefId: result.briefId };
101
140
  if (tool === "declare_done") return { workspaceId: result.workspace.id, completion: result.workspace.completion, blockers: result.blockers };
@@ -142,15 +181,15 @@ async function call(method, input) {
142
181
 
143
182
  const command = process.argv[2];
144
183
  if (command === "list" || command === "--help" || command === "help") {
145
- process.stdout.write(command === "list" ? JSON.stringify({ tools: schemas(toolList(undefined, false, true)) }, null, 2) + "\n" : "Usage: node mcp.mjs list | call <tool> '<json>' [--compact]\nNative MCP returns compact receipts; workspace_status({detail:full}) requests complete history. CLI calls retain full replies unless --compact is supplied.\n");
184
+ process.stdout.write(command === "list" ? JSON.stringify({ tools: schemas(toolList(undefined, false, true, false, true)) }, null, 2) + "\n" : "Usage: node mcp.mjs list | call <tool> '<json>' [--compact]\nNative MCP returns compact receipts; workspace_status({detail:full}) requests complete history. CLI calls retain full replies unless --compact is supplied.\n");
146
185
  } else if (command === "call") {
147
186
  try {
148
- const tool = toolList(undefined, false, await briefEnabled()).find(t => t[0] === process.argv[3]);
187
+ const tool = toolList(undefined, false, await briefEnabled(), false, await actionsEnabled()).find(t => t[0] === process.argv[3]);
149
188
  if (!tool) throw new Error("Unknown tool. Run list for names and input schemas.");
150
189
  const input = JSON.parse(process.argv[4] ?? "{}");
151
190
  validate(input, schemas([tool])[0].inputSchema);
152
191
  if (process.argv.slice(5).some(arg => arg !== "--compact")) throw new Error("Unknown CLI option. Use --compact or omit it for a full reply.");
153
- const result = await call(tool[2], input);
192
+ const result = await call(tool[2], callInput(tool, input));
154
193
  process.stdout.write(JSON.stringify(process.argv.includes("--compact") && input.detail !== "full" ? compact(tool[0], input, result) : result, null, 2) + "\n");
155
194
  } catch (error) { console.error(error.message); process.exitCode = 1; }
156
195
  } else if (command) {
@@ -159,7 +198,7 @@ if (command === "list" || command === "--help" || command === "help") {
159
198
  const lines = createInterface({ input: process.stdin });
160
199
  let tail = Promise.resolve(), initialized = false, lastScope = null;
161
200
  const timer = process.env.PASEO_AGENT_ID ? setInterval(() => {
162
- void (async () => { const current = `${await scope()}:${await briefEnabled()}`; if (initialized && current !== lastScope) process.stdout.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/tools/list_changed" }) + "\n"); lastScope = current; })().catch(() => {});
201
+ void (async () => { const current = `${await scope()}:${await briefEnabled()}:${await actionsEnabled()}`; if (initialized && current !== lastScope) process.stdout.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/tools/list_changed" }) + "\n"); lastScope = current; })().catch(() => {});
163
202
  }, 2000) : null;
164
203
  timer?.unref(); lines.on("close", () => { if (timer) clearInterval(timer); });
165
204
  lines.on("line", line => {
@@ -171,10 +210,11 @@ if (command === "list" || command === "--help" || command === "help") {
171
210
  let result;
172
211
  const role = await scope(request.method === "initialize" || request.method === "tools/list");
173
212
  const brief = await briefEnabled();
174
- const available = role ? toolList(role === "recovery", role === "contributor", brief) : toolList(false, false, brief, true);
213
+ const acting = await actionsEnabled();
214
+ const available = role ? toolList(role === "recovery", role === "contributor", brief, false, acting) : toolList(false, false, brief, true, acting);
175
215
  if (request.method === "initialize") {
176
- initialized = true; lastScope = `${role}:${brief}`;
177
- result = { protocolVersion: request.params?.protocolVersion ?? "2024-11-05", capabilities: { tools: { listChanged: true } }, serverInfo: { name: "paseo-follow-through", version: "0.3.5" }, instructions: (role ? (role === "contributor" ? "Register PRs this session submits, including from secondary git worktrees. Read workspace_status and call sync_workspace before finishing. Goal, task, scope and completion changes remain with the designated main agent." : role === "recovery" ? "Recover tracking metadata from evidence, acknowledge your assigned batch, and leave completion to the main agent." : "Keep workspace follow-through updated after PR submission and scope changes. Read workspace_status after compaction. Track all post-merge work. When a decision needs the developer, ask it with ask_question (lettered options and a recommendation) and follow the question mode in its reply. If you keep a spec artifact for this workspace, register its link once with set_spec. " + batchInstructions) + " Perform bookkeeping silently: no sidebar-update announcements, plugin-status commentary or final sidebar recap. Put receipts and summaries in tools. Report actual work outcomes or decisions/blockers; discuss sidebar status only when explicitly asked." : brief ? "" : "Follow-through tools are available to tracked workspace root agents and active recovery agents; contributor sessions receive PR registration and status tools.") + (brief && role !== "contributor" && role !== "recovery" ? (role ? " " : "") + needsYouInstruction : "") };
216
+ initialized = true; lastScope = `${role}:${brief}:${acting}`;
217
+ result = { protocolVersion: request.params?.protocolVersion ?? "2024-11-05", capabilities: { tools: { listChanged: true } }, serverInfo: { name: "paseo-follow-through", version: "0.3.5" }, instructions: (role ? (role === "contributor" ? "Register PRs this session submits, including from secondary git worktrees. Read workspace_status and call sync_workspace before finishing. Goal, task, scope and completion changes remain with the designated main agent." : role === "recovery" ? "Recover tracking metadata from evidence, acknowledge your assigned batch, and leave completion to the main agent." : "Keep workspace follow-through updated after PR submission and scope changes. Read workspace_status after compaction. Track all post-merge work. When a decision needs the developer, ask it with ask_question (lettered options and a recommendation) and follow the question mode in its reply. If you keep a spec artifact for this workspace, register its link once with set_spec. " + batchInstructions) + " Perform bookkeeping silently: no sidebar-update announcements, plugin-status commentary or final sidebar recap. Put receipts and summaries in tools. Report actual work outcomes or decisions/blockers; discuss sidebar status only when explicitly asked." : brief ? "" : "Follow-through tools are available to tracked workspace root agents and active recovery agents; contributor sessions receive PR registration and status tools.") + (brief && role !== "contributor" && role !== "recovery" ? (role ? " " : "") + needsYouInstruction : "") + (acting && role !== "contributor" && role !== "recovery" ? " " + actionInstruction : "") };
178
218
  } else if (request.method === "ping") result = {};
179
219
  else if (request.method === "tools/list") result = { tools: schemas(available) };
180
220
  else if (request.method === "tools/call") {
@@ -182,7 +222,7 @@ if (command === "list" || command === "--help" || command === "help") {
182
222
  try {
183
223
  if (!tool) throw new Error("This tool is unavailable for the current agent. Tracking must be active; recovery authority ends after acknowledgment.");
184
224
  const input = request.params.arguments ?? {}; validate(input, schemas([tool])[0].inputSchema);
185
- const value = await call(tool[2], input);
225
+ const value = await call(tool[2], callInput(tool, input));
186
226
  result = { content: [{ type: "text", text: JSON.stringify(input.detail === "full" ? value : compact(tool[0], input, value)) }] };
187
227
  } catch (error) { result = { isError: true, content: [{ type: "text", text: error.message }] }; }
188
228
  } else { process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, error: { code: -32601, message: "Method not found" } }) + "\n"); return; }
@@ -83,22 +83,23 @@ export class PrActions {
83
83
  void work.finally(() => this.running.delete(work)).catch(() => undefined);
84
84
  return work;
85
85
  }
86
- requestReviewers(workspaceId: string, key: string) {
87
- const requestKey = JSON.stringify([workspaceId, key]);
86
+ /** The Add reviewers button: the repository's defaults. An agent may name logins instead (agent parity). */
87
+ requestReviewers(workspaceId: string, key: string, logins?: string[]) {
88
+ const requestKey = JSON.stringify([workspaceId, key, logins ?? null]);
88
89
  const pending = this.reviewerRequests.get(requestKey);
89
90
  if (pending) return pending;
90
- const work = this.executeReviewers(workspaceId, key);
91
+ const work = this.executeReviewers(workspaceId, key, logins);
91
92
  this.reviewerRequests.set(requestKey, work); this.running.add(work);
92
93
  void work.finally(() => { this.reviewerRequests.delete(requestKey); this.running.delete(work); }).catch(() => undefined);
93
94
  return work;
94
95
  }
95
- private async executeReviewers(workspaceId: string, key: string) {
96
+ private async executeReviewers(workspaceId: string, key: string, logins?: string[]) {
96
97
  const w = await this.fresh(workspaceId);
97
98
  const pr = w.prs.find(p => p.key === key);
98
99
  if (!pr) throw new Error("This PR is outside the workspace's inventory.");
99
100
  if (pr.error || !pr.snapshot?.complete || !pr.snapshot.reviewers?.complete) throw new Error("Reviewer status is unavailable. Refresh before requesting reviews.");
100
101
  if (pr.snapshot.state !== "OPEN" || pr.snapshot.draft) throw new Error("Request reviews on an open PR that is ready for review.");
101
- const defaults = this.reviewersFor(pr.repo);
102
+ const defaults = logins?.length ? logins.map(login => ({ name: login, login })) : this.reviewersFor(pr.repo);
102
103
  if (!defaults.length || !this.github.requestReviewers) throw new Error("Default reviewers are not configured for this repository.");
103
104
  if (!noReviewers(pr)) return { state: "submitted" as const, result: "Reviewers already assigned." };
104
105
  const reviewers = defaults.filter(r => r.login.toLowerCase() !== pr.snapshot!.reviewers!.author?.toLowerCase());
@@ -2,7 +2,7 @@ import type { PluginSessionOpenRequest } from "@getpaseo/plugin/server";
2
2
  import { mcpDefinition } from "./agent-mcp";
3
3
 
4
4
  /** The bridge and caller identity belong to this launch, never global CLI config. */
5
- export function withSessionMcp(request: PluginSessionOpenRequest, directory: string, clientEntry: string, home: string, recovery: boolean, env: NodeJS.ProcessEnv = process.env, brief: string | null = null): PluginSessionOpenRequest {
5
+ export function withSessionMcp(request: PluginSessionOpenRequest, directory: string, clientEntry: string, home: string, recovery: boolean, env: NodeJS.ProcessEnv = process.env, brief: string | null = null, root = false): PluginSessionOpenRequest {
6
6
  if (request.purpose !== "interactive" || !["claude", "codex"].includes(request.provider) || request.labels["plugin-tools/enabled"] === "false") return request;
7
7
  const definition = mcpDefinition(directory, clientEntry, home);
8
8
  return { ...request, mcpServers: { ...request.mcpServers, "follow-through": { ...definition, env: {
@@ -10,6 +10,8 @@ export function withSessionMcp(request: PluginSessionOpenRequest, directory: str
10
10
  ...(recovery ? { FOLLOW_THROUGH_RECOVERY: "1" } : {}),
11
11
  // The provider name, for a root agent that may call needs_you (the Stop-hook nudge is Claude Code only).
12
12
  ...(brief && !recovery ? { FOLLOW_THROUGH_BRIEF: brief } : {}),
13
+ // A root agent (tracked or not) gets Follow-up's action tools; the daemon re-checks the caller on every call.
14
+ ...(root && !recovery ? { FOLLOW_THROUGH_ROOT: "1" } : {}),
13
15
  ...(env.PASEO_DAEMON_URL ? { PASEO_DAEMON_URL: env.PASEO_DAEMON_URL } : {}),
14
16
  ...(env.PASEO_PASSWORD ? { PASEO_PASSWORD: env.PASEO_PASSWORD } : {}),
15
17
  ...(env.PASEO_PASSWORD_FILE ? { PASEO_PASSWORD_FILE: env.PASEO_PASSWORD_FILE } : {}),
@@ -89,3 +89,6 @@ export const needsYouRpc = defineRpc({ name: "follow.needs-you", input: z.strict
89
89
  export const briefActionRpc = defineRpc({ name: "follow.brief-action", input: z.strictObject({
90
90
  agentId: idSchema, briefId: z.string().max(300), decisionId: z.string().max(20).optional(), choice: z.string().regex(/^[A-Z]$/).optional(), move: z.enum(["retry"]).optional(),
91
91
  }), output: z.object({ brief: briefSchema }) });
92
+
93
+ // Agent parity: one RPC carries every agent tool; the server runs the same path as the developer's button.
94
+ export const agentActRpc = defineRpc({ name: "follow.agent-act", input: z.strictObject({ agentId: idSchema, tool: z.string().max(60), args: z.record(z.string(), z.unknown()).default({}) }), output: z.unknown() });
@@ -110,6 +110,16 @@ export function workspaceHeadline(items: NeedItem[]): Headline | null {
110
110
  }
111
111
  }
112
112
 
113
+ /** Question ids the agent's open brief already asks. The popover shows that decision once, as the brief's ask. */
114
+ export function briefQuestionIds(row: Pick<InboxRow, "brief"> | null): Set<string> {
115
+ return new Set(row?.brief?.state === "open" ? row.brief.questionIds ?? [] : []);
116
+ }
117
+ /** The workspace's items minus the questions the open brief already asks (decision 3: no repeat in the popover). */
118
+ export function withoutBriefQuestions(items: NeedItem[], row: Pick<InboxRow, "brief"> | null): NeedItem[] {
119
+ const asked = briefQuestionIds(row);
120
+ return asked.size ? items.filter(i => !(i.kind === "question" && asked.has(i.questionId))) : items;
121
+ }
122
+
113
123
  /** "3 to do": the workspace count shown inside the popover. Never on the pill, where it read like a count of agents. */
114
124
  export const todoLabel = (items: NeedItem[]) => items.length ? `${items.length} to do` : "";
115
125
 
@@ -132,7 +142,8 @@ export function pillState(input: {
132
142
  recent: { agentIds: string[]; at: number } | null;
133
143
  now: number;
134
144
  }): PillState {
135
- const { row, items, recent, now } = input;
145
+ const { row, recent, now } = input;
146
+ const items = withoutBriefQuestions(input.items, row);
136
147
  const flag = row ? flagHeadline(row, now, items) : null;
137
148
  if (flag) return { mode: "flag", headline: flag, todo: items.length };
138
149
  if (row && recent && recent.agentIds.includes(row.agentId) && now - recent.at < UNDO_MS) return { mode: "undo", label: "Signed off · Undo" };
package/shared/inbox.ts CHANGED
@@ -56,7 +56,10 @@ export const inboxStateSchema = z.object({
56
56
  /** Follow-up's own deliveries, written before sending, so its messages never count as the developer's. */
57
57
  ledger: z.array(z.object({ agentId: z.string(), messageId: z.string().nullable(), at: isoSchema,
58
58
  /** "developer": a brief option or move the developer clicked. It counts as their message (design §6, F6). */
59
- by: z.enum(["follow-up", "developer"]).optional() })).default([]),
59
+ /** "agent:<id>": a Follow-up action another agent took through its tools (agent parity). Never the developer's. */
60
+ by: z.union([z.enum(["follow-up", "developer"]), z.string().regex(/^agent:[\w-]+$/)]).optional(),
61
+ /** Set on an agent action's record (no message was sent), so the classifier never matches it to a message. */
62
+ action: z.string().max(60).optional() })).default([]),
60
63
  /** Last time the worker was alive; anything that finished after it was missed. */
61
64
  lastSeen: isoSchema.nullable().default(null),
62
65
  /** Set once Follow-up's existing PR states were recorded as already seen. */
@@ -295,7 +298,7 @@ export const inboxRowSchema = z.object({
295
298
  muteTurn: z.boolean(),
296
299
  enable_finish_flags: z.boolean(),
297
300
  /** The open or collapsed brief's one-liner, so every surface says the same thing (§4). */
298
- brief: z.object({ kind: z.enum(BRIEF_KINDS), source: z.enum(BRIEF_SOURCES), state: z.enum(BRIEF_STATES), ask: z.string() }).nullable().optional(),
301
+ brief: z.object({ kind: z.enum(BRIEF_KINDS), source: z.enum(BRIEF_SOURCES), state: z.enum(BRIEF_STATES), ask: z.string(), questionIds: z.array(z.string()).optional() }).nullable().optional(),
299
302
  });
300
303
  export type InboxRow = z.infer<typeof inboxRowSchema>;
301
304