paseo-follow-up 0.1.2 → 0.3.0

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  Follow-up keeps PR stacks, remaining tasks and explicit agent completion attached to a Paseo workspace. It groups workspaces by whether you or an agent can act next and retains settled PR history.
4
4
 
5
- Requires Paseo 0.11+, Node 22+ and authenticated gh on the daemon host. Graphite CLI authentication is used for readiness and explicit Merge. The optional Send to Paseo browser companion enables Graphite's Merge when ready setting; it is not needed for tracking, recovery or follow-up.
5
+ Requires Paseo 0.14+, Node 22+ and authenticated gh on the daemon host. Graphite CLI authentication is used for readiness and explicit Merge. The optional Send to Paseo browser companion enables Graphite's Merge when ready setting after separate machine/repository consent in Chrome; it is not needed for tracking, recovery or follow-up.
6
6
 
7
7
  Open the sidebar or Command-K, then Track this workspace. Project settings can enroll existing and new workspaces with original main-agent ownership; Auto always starts off. The retained development default enables canonical Git repositories named hero-poc, while other projects require opt-in. Disable that project default to stop further enrollment without losing records.
8
8
 
@@ -12,4 +12,4 @@ Repair batches go to the original main agent after a click, or after explicit pe
12
12
 
13
13
  This trusted unsandboxed plugin reads selected workspace metadata, saved agent history/configuration, GitHub status and existing CLI authentication. State and private recovery evidence remain in plugin-data/follow-through. Scoped MCP registration and reversible provider hooks support tracked main agents; Codex's trust review remains in force. Prompts and repair context are sent to your configured agent/provider. Site-specific reviewer and Cloud Run adapters only apply to the configured repository; unsupported deployments remain unknown.
14
14
 
15
- Merge, Merge when ready and reviewer requests require explicit developer controls. Auto and agent tools cannot authorize them. Follow-up has no Slack posting identity or background channel scanner. Stakeholder replies require the developer's authorization, original destination and a verified sent-message receipt from the agent.
15
+ Merge, Merge when ready and reviewer requests require explicit developer controls. Optional direct browser grants trust that machine to change the PR toggle for the allowed repository, including later pushes; read-only grants require confirmation in Chrome. Auto and agent tools cannot authorize them. Follow-up has no Slack posting identity or background channel scanner. Stakeholder replies require the developer's authorization, original destination and a verified sent-message receipt from the agent.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/paseo-follow-up?style=for-the-badge&color=cb3837)](https://www.npmjs.com/package/paseo-follow-up)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/paseo-follow-up?style=for-the-badge&color=cb3837)](https://www.npmjs.com/package/paseo-follow-up)
5
- [![Paseo](https://img.shields.io/badge/Paseo-%E2%89%A5%200.11.0-8A63D2?style=for-the-badge)](https://paseo.sh)
5
+ [![Paseo](https://img.shields.io/badge/Paseo-%E2%89%A5%200.14.0-8A63D2?style=for-the-badge)](https://paseo.sh)
6
6
  [![License](https://img.shields.io/github/license/tomgrin10/paseo-follow-up?style=for-the-badge&color=2563eb)](LICENSE)
7
7
 
8
8
  Keep long-lived Paseo workspaces moving through review, deployment, and completion. Follow-up retains PR stacks and remaining tasks, batches repairs for the existing main agent, and records the agent's explicit Done declaration.
@@ -13,7 +13,7 @@ Screenshots use synthetic workspaces and PRs.
13
13
 
14
14
  ## Requirements
15
15
 
16
- - Paseo 0.11.0 or newer and Node 22 or newer.
16
+ - Paseo 0.14.0 or newer and Node 22 or newer.
17
17
  - Authenticated GitHub CLI (`gh`) on the daemon host.
18
18
  - Saved Graphite CLI authentication for Graphite readiness and explicit Merge controls.
19
19
  - Optional Send to Paseo browser companion for Graphite's actual Merge when ready switch. Core tracking and follow-up work without it.
@@ -40,6 +40,22 @@ Tasks default to manual. Explicit Now, After merge or After deploy triggers can
40
40
 
41
41
  ![Follow-up overview with sample workspaces](docs/screenshots/follow-up-overview.png)
42
42
 
43
+ ## Send to Paseo and Graphite
44
+
45
+ With Send to Paseo installed on the same daemon, its composer can return to the agent that
46
+ registered a PR here. New registrations record that agent; older records retain the workspace’s
47
+ main agent. You can still choose a fresh agent in Send to Paseo. Tracking and follow-up remain
48
+ usable without the companion.
49
+
50
+ To control Graphite’s **Merge when ready** here, install the Send to Paseo plugin and Chrome
51
+ extension, pair them, and keep Graphite open in Chrome. In extension settings → **Graphite browser
52
+ access**, find this machine and allow an exact repository. Check **Allow Follow Up to change Merge
53
+ when ready directly** to use this plugin’s toggle without another Chrome prompt. Leave it unchecked
54
+ to confirm each change in Chrome. Revoke access in those settings at any time.
55
+
56
+ This is a PR setting, including later pushes. Send pairing alone grants no Graphite access.
57
+ See the [integration and security contract](https://github.com/tomgrin10/send-to-paseo/blob/main/docs/follow-up-integration.md).
58
+
43
59
  ## Data and permissions
44
60
 
45
61
  This is a trusted, unsandboxed plugin. It reads selected workspace metadata, agent history/configuration and GitHub PR status using your existing host credentials. Local state and private recovery evidence live under `$PASEO_HOME/plugin-data/follow-through`; credentials and provider configuration are not published or transferred.
@@ -63,6 +79,16 @@ Integration checks use a separate Paseo home, synthetic agents and fixtures. Nev
63
79
 
64
80
  Release tags (`vX.Y.Z`) run `.github/workflows/publish-npm.yml`. GitHub Actions verifies the plugin and packed installation, publishes through npm Trusted Publishing, verifies registry integrity, then creates the GitHub release. No npm token is stored in repository secrets.
65
81
 
82
+ ## Paseo 0.14 agent integration
83
+
84
+ The Follow-up MCP attaches when a tracked main or active recovery agent opens a Claude or Codex session. Normal startup no longer writes global Claude/Codex MCP settings. Existing global entries are preserved. Turn-ended discovery now fetches recent history through the SDK because Paseo 0.13+ sends an empty timeline in lifecycle events.
85
+
86
+ Requires Paseo **0.14.0 or newer**. Agent tools are scoped to interactive Claude
87
+ and Codex sessions; history-only opens receive none. They become available on
88
+ the next idle session open after install or reload. The agent label
89
+ `plugin-tools/enabled: "false"` opts a session out. Existing plugin data and
90
+ settings are retained.
91
+
66
92
  ## Other plugins
67
93
 
68
94
  Also available from [Tom Gringauz](https://github.com/tomgrin10):
package/client/panel.tsx CHANGED
@@ -55,7 +55,7 @@ export function WorkspaceView({ theme, layout, workspace, navigation, loadError
55
55
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}><Text style={{ color: theme.colors.foreground, fontSize: 18, fontWeight: "600", flex: 1 }}>Follow-up</Text><Pressable accessibilityRole="switch" aria-checked={workspace.enable_auto_sends} accessibilityLabel="Auto" accessibilityHint="Allow automatic sends in this workspace" accessibilityState={{ checked: workspace.enable_auto_sends, disabled: change.isPending }} disabled={change.isPending} onPress={() => change.mutate({ workspaceId: workspace.id, enable_auto_sends: !workspace.enable_auto_sends })} style={{ minHeight: compact ? 44 : 36, paddingHorizontal: 10, borderRadius: 6, borderWidth: 1, borderColor: workspace.enable_auto_sends ? theme.colors.accent : theme.colors.border, backgroundColor: workspace.enable_auto_sends ? theme.colors.accent : theme.colors.surface0, flexDirection: "row", alignItems: "center", gap: 5, opacity: change.isPending ? 0.5 : 1 }}><Icon name={workspace.enable_auto_sends ? "Check" : "Circle"} size={13} color={workspace.enable_auto_sends ? theme.colors.accentForeground : theme.colors.foregroundMuted} /><Text style={{ fontSize: 13, color: workspace.enable_auto_sends ? theme.colors.accentForeground : theme.colors.foreground }}>Auto</Text></Pressable><Button title={update.isPending ? "Refreshing…" : "Refresh"} icon="RefreshCw" theme={theme} compact={compact} disabled={update.isPending} onPress={() => update.mutate()} /></View>
56
56
  <View style={{ marginTop: 8, gap: 6 }}><StatusLabel workspace={workspace} theme={theme} />{(state.status === "needs-you" || state.status === "done") && <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>{state.reason}</Text>}{workspace.hold && <Button title="Resume follow-up" theme={theme} compact={compact} onPress={() => change.mutate({ workspaceId: workspace.id, clearHold: true })} disabled={change.isPending} />}</View>
57
57
  {error && <Text accessibilityRole="alert" style={{ color: theme.colors.statusDanger, fontSize: 12, marginTop: 8 }}>{error}</Text>}
58
- <Section title="Pull requests" count={workspace.prs.length} theme={theme} compact={compact}>{workspace.prs.length === 0 && <Text style={labelStyle}>No PRs recorded.</Text>}{workspace.prActions.filter(a => ["sending", "uncertain"].includes(a.state)).map(a => <View key={a.id} style={{ gap: 7 }}><Text style={{ ...labelStyle, color: theme.colors.statusWarning, lineHeight: 18 }}>{a.result}</Text>{a.state === "uncertain" && <Button title={inspectAction.isPending ? "Checking…" : "Check state"} theme={theme} compact={compact} disabled={inspectAction.isPending} onPress={() => inspectAction.mutate(a.id)} />}</View>)}{inspectAction.error && <Text accessibilityRole="alert" style={{ ...labelStyle, color: theme.colors.statusDanger }}>{inspectAction.error.message}</Text>}{activeStacks.map(stack => <StackGroup browserReady={browser.data?.ready ?? false} key={stack.name} name={stack.name} prs={stack.prs} workspace={workspace} theme={theme} onOpen={open} compact={compact} />)}{settledStacks.length > 0 && <Section title="Settled" count={settledStacks.reduce((count, stack) => count + stack.prs.length, 0)} theme={theme} compact={compact} initiallyExpanded={false}>{settledStacks.map(stack => <StackGroup browserReady={browser.data?.ready ?? false} key={stack.name} name={stack.name} prs={stack.prs} workspace={workspace} theme={theme} onOpen={open} compact={compact} />)}</Section>}{activeStacks.length > 0 && browser.data && !browser.data.ready && <Section title="Optional merge when ready" theme={theme} compact={compact} initiallyExpanded={false}><Text style={[labelStyle, { lineHeight: 18 }]}>Connect Chrome for Graphite’s When ready toggle.</Text><Text style={[labelStyle, { lineHeight: 18 }]}>Requires the paired Send to Paseo extension and an open Graphite tab.</Text><Button title="Connect Graphite" theme={theme} compact={compact} onPress={() => open("https://app.graphite.com")} /></Section>}</Section>
58
+ <Section title="Pull requests" count={workspace.prs.length} theme={theme} compact={compact}>{workspace.prs.length === 0 && <Text style={labelStyle}>No PRs recorded.</Text>}{workspace.prActions.filter(a => ["sending", "uncertain"].includes(a.state)).map(a => <View key={a.id} style={{ gap: 7 }}><Text style={{ ...labelStyle, color: theme.colors.statusWarning, lineHeight: 18 }}>{a.result}</Text>{a.state === "uncertain" && <Button title={inspectAction.isPending ? "Checking…" : "Check state"} theme={theme} compact={compact} disabled={inspectAction.isPending} onPress={() => inspectAction.mutate(a.id)} />}</View>)}{inspectAction.error && <Text accessibilityRole="alert" style={{ ...labelStyle, color: theme.colors.statusDanger }}>{inspectAction.error.message}</Text>}{activeStacks.map(stack => <StackGroup browserReady={browser.data?.ready ?? false} key={stack.name} name={stack.name} prs={stack.prs} workspace={workspace} theme={theme} onOpen={open} compact={compact} />)}{settledStacks.length > 0 && <Section title="Settled" count={settledStacks.reduce((count, stack) => count + stack.prs.length, 0)} theme={theme} compact={compact} initiallyExpanded={false}>{settledStacks.map(stack => <StackGroup browserReady={browser.data?.ready ?? false} key={stack.name} name={stack.name} prs={stack.prs} workspace={workspace} theme={theme} onOpen={open} compact={compact} />)}</Section>}{activeStacks.length > 0 && browser.data && !browser.data.ready && <Section title="Optional merge when ready" theme={theme} compact={compact} initiallyExpanded={false}><Text style={[labelStyle, { lineHeight: 18 }]}>Connect Chrome for Graphite’s When ready toggle.</Text><Text style={[labelStyle, { lineHeight: 18 }]}>Open Graphite in Chrome, then allow this machine and repository in Send to Paseo extension settings. Each toggle also needs confirmation in Chrome.</Text><Button title="Connect Graphite" theme={theme} compact={compact} onPress={() => open("https://app.graphite.com")} /></Section>}</Section>
59
59
  <Section title="Tasks" count={outstanding.length} theme={theme} compact={compact}>
60
60
  {!outstanding.length && <Text style={labelStyle}>No follow-through tasks.</Text>}
61
61
  {outstanding.map(o => <FollowTask key={o.id} obligation={o} workspace={workspace} theme={theme} compact={compact} />)}
@@ -3,7 +3,7 @@ import { Pressable, Text, View } from "react-native";
3
3
  import { useRpc } from "@getpaseo/plugin/client";
4
4
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
5
5
  import { confirmPrActionRpc, preparePrActionRpc, prControlStatusRpc, requestReviewersRpc } from "../shared/contracts";
6
- import type { ActionPlan, PrActionKind } from "../shared/pr-actions";
6
+ import { prActionBlocks, type ActionPlan, type PrActionKind } from "../shared/pr-actions";
7
7
  import type { Pr, Workspace } from "../shared/model";
8
8
  import { Button, type Theme } from "./components";
9
9
  import { defaultReviewers, noReviewers } from "../shared/reviewers";
@@ -21,7 +21,7 @@ export function PrControls({ pr, workspace, theme, compact, browserReady }: { br
21
21
  if (result.kind !== "merge" && result.prs.length === 1 && !result.blockers.length && !result.prs[0].blockers.length) submit.mutate({ planId: result.id, scope: "target" });
22
22
  else setPlan(result);
23
23
  } });
24
- const pendingAction = workspace.prActions.some(a => ["sending", "uncertain"].includes(a.state));
24
+ const pendingAction = workspace.prActions.some(a => prActionBlocks(a, [pr.key]));
25
25
  const busy = checking.isPending || submit.isPending;
26
26
  const actual = browserReady ? status.data?.pr : null;
27
27
  const known = !!actual && !status.error && !status.data?.error;
package/index.server.ts CHANGED
@@ -4,6 +4,7 @@ import { mkdir, writeFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import * as rpc from "./shared/contracts";
7
+ import { prOwner } from "./server/pr-owner";
7
8
  import { type Workspace } from "./shared/model";
8
9
  import { Store } from "./server/store";
9
10
  import { Engine, agentWorkspace, findWorkspace, interruptionHold } from "./server/engine";
@@ -18,7 +19,7 @@ import { applyHistory, applyDiscovery, extractHistory } from "./server/discovery
18
19
  import { lifecycleOnly, recoveryBatch } from "./shared/follow-up";
19
20
  import { Deployments } from "./server/deployments";
20
21
  import { AgentHooks } from "./server/agent-hooks";
21
- import { AgentMcp } from "./server/agent-mcp";
22
+ import { withSessionMcp } from "./server/session-mcp";
22
23
  import { updateObligation } from "./server/obligations";
23
24
 
24
25
  export default function contribute(server: PluginServerContext) {
@@ -27,7 +28,6 @@ export default function contribute(server: PluginServerContext) {
27
28
  const bridge = join(directory, "mcp.mjs");
28
29
  const store = new Store(directory);
29
30
  const agentHooks = new AgentHooks(directory, clientEntry());
30
- const agentMcp = new AgentMcp(directory, clientEntry(), paseoHome);
31
31
  const gateway = new PaseoGateway();
32
32
  const graphite = new Graphite();
33
33
  const github = new Github(undefined, graphite, new Deployments());
@@ -48,8 +48,6 @@ export default function contribute(server: PluginServerContext) {
48
48
  await engine.recover();
49
49
  hooksStarted = true;
50
50
  await agentHooks.start().catch(() => { hookError = "Automatic sidebar instructions could not be registered for every provider. Check Claude/Codex hook settings; tracking is still available."; });
51
- const mcp = await agentMcp.start();
52
- if ([mcp.claude, mcp.codex].some(status => ["unavailable", "name-conflict"].includes(status))) hookError = "Some agent MCP connections could not be registered. Existing CLI access remains available.";
53
51
  hookTimer = setInterval(() => void agentHooks.heartbeat().catch(() => {}), 30_000);
54
52
  })().catch(() => { loadError = "Follow-through could not start. Check its saved state and worker lock."; });
55
53
  const requireReady = async () => { await ready; if (loadError || stopped) throw new Error(loadError ?? "Follow-through is stopping"); };
@@ -96,6 +94,14 @@ export default function contribute(server: PluginServerContext) {
96
94
  void engine.tick();
97
95
  return { projects: await gateway.projects(await store.snapshot()) };
98
96
  });
97
+ server.handle(rpc.prOwnerRpc, async ({ repo, number }, { paseo }) => {
98
+ await requireReady();
99
+ const owner = prOwner(await store.snapshot(), repo, number);
100
+ if (!owner) return { owner: null };
101
+ const agent = (await paseo.agents.ref(owner.agentId).refresh())?.agent;
102
+ if (!agent || agent.archivedAt || agent.workspaceId !== owner.workspaceId) return { owner: null };
103
+ return { owner };
104
+ });
99
105
  server.handle(rpc.listRpc, async () => { await ready; return { workspaces: (await store.snapshot()).workspaces.filter(w => w.enable_tracking), error: loadError ?? hookError ?? engine.error }; });
100
106
  server.handle(rpc.getRpc, async ({ workspaceId }) => { await ready; return { workspace: (await store.snapshot()).workspaces.find(w => w.id === workspaceId && w.enable_tracking) ?? null, error: loadError ?? hookError ?? engine.error }; });
101
107
  server.handle(rpc.syncWorkspaceRpc, async ({ agentId, summary }, { paseo }) => {
@@ -148,8 +154,8 @@ export default function contribute(server: PluginServerContext) {
148
154
  seen.add(ancestor); ancestor = w.prs.find(p => p.key === ancestor)?.parent ?? null;
149
155
  }
150
156
  const altered = !existing || existing.stack !== stack || existing.parent !== parent || disposition !== undefined && existing.disposition !== disposition;
151
- if (existing) { existing.stack = stack; existing.parent = parent; if (disposition !== undefined) existing.disposition = disposition; }
152
- else w.prs.push({ key, repo, number, stack, parent, disposition: disposition ?? null, snapshot: null, error: null });
157
+ if (existing) { existing.registeredAgentId = agentId; existing.stack = stack; existing.parent = parent; if (disposition !== undefined) existing.disposition = disposition; }
158
+ else w.prs.push({ registeredAgentId: agentId, key, repo, number, stack, parent, disposition: disposition ?? null, snapshot: null, error: null });
153
159
  if (altered) changed(w); return w;
154
160
  });
155
161
  void engine.tick(); return result;
@@ -238,7 +244,7 @@ export default function contribute(server: PluginServerContext) {
238
244
  void engine.tick(); return result;
239
245
  });
240
246
  server.handle(rpc.browserStatusRpc, async () => { await requireReady(); return browser.status(); });
241
- server.handle(rpc.browserExchangeRpc, async ({ clientId, ready: browserReady, acceptJobs, results }) => { await requireReady(); return { jobs: browser.exchange(clientId, browserReady, results, acceptJobs), accepted: results.map(r => r.id) }; });
247
+ server.handle(rpc.browserExchangeRpc, async ({ clientId, ready: browserReady, acceptJobs, results, repositories }) => { await requireReady(); return browser.exchange(clientId, browserReady, results, acceptJobs, repositories); });
242
248
  server.handle(rpc.prControlStatusRpc, async ({ workspaceId, prKey }) => { await requireReady(); return actions.status(workspaceId, prKey); });
243
249
  server.handle(rpc.preparePrActionRpc, async ({ workspaceId, prKey, kind }) => { await requireReady(); return actions.prepare(workspaceId, prKey, kind); });
244
250
  server.handle(rpc.confirmPrActionRpc, async ({ planId, scope }) => { await requireReady(); const result = await actions.confirm(planId, scope); void engine.tick(); return result; });
@@ -252,9 +258,9 @@ export default function contribute(server: PluginServerContext) {
252
258
  }),
253
259
  server.before("agent.session_open", async ({ request }) => {
254
260
  await ready;
255
- if (loadError || stopped) return request;
261
+ if (loadError || stopped || request.purpose !== "interactive") return request;
256
262
  const saved = await store.snapshot();
257
- let w = saved.workspaces.find(w => w.enable_tracking && (w.mainAgentId === request.agentId || w.batches.some(b => b.recipientAgentId === request.agentId)));
263
+ let w = saved.workspaces.find(w => w.enable_tracking && !w.archived && (w.mainAgentId === request.agentId || w.batches.some(b => b.recipientAgentId === request.agentId && !b.settledAt && b.tasks.every(t => t.kind === "inventory") && ["sending", "sent", "uncertain", "blocked"].includes(b.state))));
258
264
  if (!w && request.workspaceId && !saved.workspaces.some(w => w.id === request.workspaceId && (w.archived || w.mainAgentId !== request.agentId))) {
259
265
  const project = await gateway.projectForWorkspace(saved, request.workspaceId).catch(() => null);
260
266
  if (project?.enable_default_tracking) {
@@ -267,9 +273,10 @@ export default function contribute(server: PluginServerContext) {
267
273
  }
268
274
  }
269
275
  if (!w) return request;
270
- return { ...request, env: { ...request.env, FOLLOW_THROUGH_CLI: bridge, FOLLOW_THROUGH_CLIENT_ENTRY: clientEntry(), ...(w.mainAgentId !== request.agentId ? { FOLLOW_THROUGH_RECOVERY: "1" } : {}) } };
276
+ const recovery = w.mainAgentId !== request.agentId;
277
+ return withSessionMcp({ ...request, env: { ...request.env, FOLLOW_THROUGH_CLI: bridge, FOLLOW_THROUGH_CLIENT_ENTRY: clientEntry(), ...(recovery ? { FOLLOW_THROUGH_RECOVERY: "1" } : {}) } }, directory, clientEntry(), paseoHome, recovery);
271
278
  }),
272
- server.on("agent.turn_ended", async event => {
279
+ server.on("agent.turn_ended", async (event, { paseo, signal }) => {
273
280
  await ready;
274
281
  if (stopped || loadError) return;
275
282
  const interruption = event.outcome.kind === "canceled" || event.outcome.kind === "failed" ? { turnId: event.turnId, at: new Date().toISOString(), kind: event.outcome.kind } : null;
@@ -281,9 +288,13 @@ export default function contribute(server: PluginServerContext) {
281
288
  }
282
289
  if (!(await store.snapshot()).workspaces.some(w => w.enable_tracking && w.mainAgentId === event.agent.id)) return;
283
290
  gateway.markDirty(event.agent.id);
284
- await store.mutate(state => {
291
+ // Since Paseo 0.13 the lifecycle payload deliberately contains no history.
292
+ // Read a bounded tail through the plugin-owned API, outside the state lock.
293
+ const history = await paseo.agents.ref(event.agent.id).timeline.refetch({ direction: "tail", limit: 100, projection: "canonical" }).catch(() => null);
294
+ if (signal.aborted || stopped) return;
295
+ if (history) await store.mutate(state => {
285
296
  const w = state.workspaces.find(w => w.enable_tracking && w.mainAgentId === event.agent.id);
286
- if (w) applyHistory(w, extractHistory(event.timeline), null);
297
+ if (w) applyHistory(w, extractHistory(history.entries.map(entry => entry.item), w.inventory?.historyComplete ?? false, w.inventory?.historyError ?? null), null);
287
298
  });
288
299
  const guarded = interruption && await sessionGuardOwnsInterruption(event.agent.id, interruption).catch(() => false);
289
300
  if (["canceled", "failed"].includes(event.outcome.kind) && !guarded) await store.mutate(state => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "paseo-follow-up",
3
3
  "private": false,
4
- "version": "0.1.2",
4
+ "version": "0.3.0",
5
5
  "description": "Track workspace pull requests, queue follow-up work, and verify completion in Paseo.",
6
6
  "type": "module",
7
7
  "files": [
@@ -28,8 +28,8 @@
28
28
  "prepare": "node scripts/build.mjs"
29
29
  },
30
30
  "devDependencies": {
31
- "@getpaseo/client": "0.11.0",
32
- "@getpaseo/plugin": "0.11.0",
31
+ "@getpaseo/client": "0.14.0",
32
+ "@getpaseo/plugin": "0.14.0",
33
33
  "@tanstack/react-query": "^5.90.11",
34
34
  "@types/node": "^22.10.2",
35
35
  "@types/react": "~19.2.0",
package/paseo-plugin.json CHANGED
@@ -7,6 +7,6 @@
7
7
  "docs/screenshots/follow-up-overview.png"
8
8
  ],
9
9
  "requirements": {
10
- "paseo": ">=0.11.0"
10
+ "paseo": ">=0.14.0"
11
11
  }
12
12
  }
@@ -3,46 +3,59 @@ import { browserCommandSchema, type BrowserCommand, type BrowserPr, type Browser
3
3
 
4
4
  type Pending = { command: BrowserCommand; clientId: string | null; timer: ReturnType<typeof setTimeout>; resolve: (r: BrowserResult) => void };
5
5
  export class BrowserBridge {
6
- private clients = new Map<string, { at: number; ready: boolean }>();
6
+ private clients = new Map<string, { at: number; ready: boolean; repositories: string[] }>();
7
7
  private pending = new Map<string, Pending>();
8
8
  private observations = new Map<string, { at: number; pr: BrowserPr }>();
9
- status() {
10
- const ready = [...this.clients.values()].some(c => c.ready && Date.now() - c.at < 15_000);
11
- return { ready, reason: ready ? null : "Open Graphite in Chrome with the updated Send to Paseo extension to connect merge controls." };
9
+ status(repo?: string) {
10
+ const ready = [...this.clients.values()].some(c => c.ready && c.repositories.length > 0 && (!repo || c.repositories.includes(repo.toLowerCase())) && Date.now() - c.at < 15_000);
11
+ return { ready, reason: ready ? null : "Open Graphite in Chrome and grant this machine access to the repository in Send to Paseo settings." };
12
12
  }
13
13
  cached(repo: string, number: number, head: string): BrowserPr | null {
14
14
  const saved = this.observations.get(`${repo}#${number}`);
15
15
  return saved && Date.now() - saved.at < 15_000 && saved.pr.head === head ? saved.pr : null;
16
16
  }
17
- exchange(clientId: string, ready: boolean, results: BrowserResult[], acceptJobs = true): BrowserCommand[] {
18
- this.clients.set(clientId, { at: Date.now(), ready });
17
+ exchange(clientId: string, ready: boolean, results: BrowserResult[], acceptJobs = true, repositories: string[] = []): { jobs: BrowserCommand[]; accepted: string[] } {
18
+ const accepted: string[] = [];
19
+ for (const [id, client] of this.clients) if (Date.now() - client.at > 60_000) this.clients.delete(id);
20
+ if (!this.clients.has(clientId) && this.clients.size >= 100) return { jobs: [], accepted };
21
+ const scopes = repositories.map(r => r.toLowerCase());
22
+ this.clients.set(clientId, { at: Date.now(), ready, repositories: scopes });
19
23
  for (const result of results) {
20
24
  const job = this.pending.get(result.id);
21
- if (!job || job.clientId !== clientId || job.command.nonce !== result.nonce) continue;
25
+ if (!job || job.clientId !== clientId || job.command.nonce !== result.nonce || Date.parse(job.command.expiresAt) <= Date.now()) continue;
26
+ if (!scopes.includes(job.command.repo.toLowerCase()) && !(result.outcome === "failed" && !result.prs.length && !result.applied.length)) continue;
27
+ if (new Set(result.prs.map(p => p.number)).size !== result.prs.length || new Set(result.applied).size !== result.applied.length) continue;
28
+ if (result.outcome === "completed" && (result.prs.length !== job.command.prs.length || result.prs.some(p => {
29
+ const target = job.command.prs.find(t => t.number === p.number);
30
+ return !target || p.head !== target.head || job.command.op !== "read" && (p.state !== "OPEN" || p.mergeWhenReady !== (job.command.op === "enable_mwr"));
31
+ }) || job.command.op !== "read" && result.applied.length !== job.command.prs.length)) continue;
22
32
  if (result.prs.some(p => p.repo !== job.command.repo || !job.command.prs.some(t => t.number === p.number))) continue;
23
33
  if (result.applied.some(n => !job.command.prs.some(p => p.number === n))) continue;
24
34
  clearTimeout(job.timer); this.pending.delete(result.id);
25
35
  for (const pr of result.prs) this.observations.set(`${pr.repo}#${pr.number}`, { at: Date.now(), pr });
26
- job.resolve(result);
36
+ while (this.observations.size > 1000) this.observations.delete(this.observations.keys().next().value!);
37
+ job.resolve(result); accepted.push(result.id);
27
38
  }
28
- if (!ready || !acceptJobs) return [];
29
- const jobs = [...this.pending.values()].filter(p => !p.clientId && Date.parse(p.command.expiresAt) > Date.now()).slice(0, 1);
39
+ if (!ready || !acceptJobs) return { jobs: [], accepted };
40
+ const jobs = [...this.pending.values()].filter(p => !p.clientId && scopes.includes(p.command.repo.toLowerCase()) && Date.parse(p.command.expiresAt) > Date.now()).slice(0, 1);
30
41
  for (const job of jobs) job.clientId = clientId;
31
- return jobs.map(j => j.command);
42
+ return { jobs: jobs.map(j => j.command), accepted };
32
43
  }
33
44
  request(op: BrowserCommand["op"], repo: string, prs: BrowserCommand["prs"]): Promise<BrowserResult> {
34
- if (!this.status().ready) return Promise.reject(new Error(this.status().reason!));
35
- const command = browserCommandSchema.parse({ id: randomUUID(), nonce: randomUUID(), expiresAt: new Date(Date.now() + 20_000).toISOString(), op, repo, prs });
45
+ if (!this.status(repo).ready) return Promise.reject(new Error(this.status(repo).reason!));
46
+ if (this.pending.size >= 50) return Promise.reject(new Error("Too many browser requests are pending."));
47
+ const budget = op === "read" ? 20_000 : 120_000;
48
+ const command = browserCommandSchema.parse({ id: randomUUID(), nonce: randomUUID(), expiresAt: new Date(Date.now() + budget).toISOString(), op, repo, prs });
36
49
  return new Promise(resolve => {
37
50
  const timer = setTimeout(() => {
38
51
  const leased = !!this.pending.get(command.id)?.clientId; this.pending.delete(command.id);
39
52
  resolve({ id: command.id, nonce: command.nonce, outcome: op !== "read" && leased ? "uncertain" : "failed", prs: [], applied: [], error: leased ? "Graphite did not confirm the result. Check state before trying again." : "The browser disconnected before this request was delivered." });
40
- }, 20_000);
53
+ }, budget);
41
54
  this.pending.set(command.id, { command, clientId: null, timer, resolve });
42
55
  });
43
56
  }
44
57
  close() {
45
58
  for (const job of this.pending.values()) { clearTimeout(job.timer); job.resolve({ id: job.command.id, nonce: job.command.nonce, outcome: job.clientId && job.command.op !== "read" ? "uncertain" : "failed", prs: [], applied: [], error: "Follow-through reloaded before the request was confirmed. Check state." }); }
46
- this.pending.clear();
59
+ this.pending.clear(); this.clients.clear(); this.observations.clear();
47
60
  }
48
61
  }
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { downstack, mergeBlockers, type ActionPlan, type BrowserPr, type PrActionKind } from "../shared/pr-actions";
2
+ import { downstack, mergeBlockers, prActionBlocks, type ActionPlan, type BrowserPr, type PrActionKind } from "../shared/pr-actions";
3
3
  import type { Workspace, Pr } from "../shared/model";
4
4
  import { findWorkspace } from "./engine";
5
5
  import { Store } from "./store";
@@ -19,6 +19,7 @@ export class PrActions {
19
19
  private plans = new Map<string, { plan: ActionPlan; identity: string }>();
20
20
  private reads = new Map<string, Promise<BrowserPr[]>>();
21
21
  private running = new Set<Promise<unknown>>();
22
+ private reviewerRequests = new Map<string, Promise<{ state: "submitted" | "failed" | "uncertain"; result: string }>>();
22
23
  constructor(private store: Store, private github: Pick<Github, "observe"> & Partial<Pick<Github, "requestReviewers">>, private graphite: Pick<Graphite, "merge" | "invalidate">, readonly browser: BrowserBridge, private now = Date.now, private reviewersFor = defaultReviewers) {}
23
24
 
24
25
  private async fresh(workspaceId: string): Promise<Workspace> {
@@ -44,7 +45,7 @@ export class PrActions {
44
45
  const w = findWorkspace(await this.store.snapshot(), workspaceId); active(w);
45
46
  const p = w.prs.find(p => p.key === key);
46
47
  if (!p) throw new Error("This PR is outside the workspace's inventory.");
47
- const browser = this.browser.status();
48
+ const browser = this.browser.status(p.repo);
48
49
  if (!browser.ready || p.error || !p.snapshot?.complete || p.snapshot.state !== "OPEN") return { browser, pr: null, error: null };
49
50
  const cached = this.browser.cached(p.repo, p.number, p.snapshot.head);
50
51
  if (cached) return { browser, pr: cached, error: null };
@@ -59,7 +60,7 @@ export class PrActions {
59
60
  const prs = downstack(w.prs, key);
60
61
  if (!prs.length || prs.at(-1)?.key !== key) throw new Error("This PR is already merged.");
61
62
  const blockers: string[] = [];
62
- if (w.prActions.some(a => ["sending", "uncertain"].includes(a.state))) blockers.push("Check the previous PR action before sending another.");
63
+ if (w.prActions.some(a => prActionBlocks(a, prs.map(p => p.key)))) blockers.push("Check the previous PR action before sending another.");
63
64
  if (kind === "merge" && w.prActions.some(a => a.kind === "merge" && a.state === "submitted" && prs.some(p => a.heads[p.key] === p.snapshot?.head) && this.now() - Date.parse(a.createdAt) < 30_000)) blockers.push("A merge was just submitted for this revision. Wait for Graphite's result.");
64
65
  const rows = prs.map(p => ({ key: p.key, repo: p.repo, number: p.number, title: p.snapshot?.title ?? p.stack, head: p.snapshot?.head ?? "", parent: p.parent, base: p.snapshot?.base ?? "", trunk: p.snapshot?.trunk ?? "", blockers: kind === "merge" ? mergeBlockers(p) : p.error || !p.snapshot?.complete || p.snapshot.state !== "OPEN" ? ["Refresh an open PR"] : kind === "enable_mwr" && p.snapshot.draft ? ["Draft"] : [] }));
65
66
  if (kind === "merge") {
@@ -83,8 +84,12 @@ export class PrActions {
83
84
  return work;
84
85
  }
85
86
  requestReviewers(workspaceId: string, key: string) {
86
- const work = this.executeReviewers(workspaceId, key); this.running.add(work);
87
- void work.finally(() => this.running.delete(work)).catch(() => undefined);
87
+ const requestKey = JSON.stringify([workspaceId, key]);
88
+ const pending = this.reviewerRequests.get(requestKey);
89
+ if (pending) return pending;
90
+ const work = this.executeReviewers(workspaceId, key);
91
+ this.reviewerRequests.set(requestKey, work); this.running.add(work);
92
+ void work.finally(() => { this.reviewerRequests.delete(requestKey); this.running.delete(work); }).catch(() => undefined);
88
93
  return work;
89
94
  }
90
95
  private async executeReviewers(workspaceId: string, key: string) {
@@ -101,7 +106,7 @@ export class PrActions {
101
106
  const id = randomUUID();
102
107
  await this.store.mutate(state => {
103
108
  const current = findWorkspace(state, workspaceId); active(current);
104
- if (current.prActions.some(a => ["sending", "uncertain"].includes(a.state))) throw new Error("Check the previous PR action before sending another.");
109
+ if (current.prActions.some(a => prActionBlocks(a, [key]))) throw new Error("Check the previous PR action before sending another.");
105
110
  const latest = current.prs.find(p => p.key === key);
106
111
  if (!latest || latest.snapshot?.head !== pr.snapshot!.head || !noReviewers(latest)) throw new Error("PR status changed. Refresh before requesting reviews.");
107
112
  current.prActions.push({ id, kind: "request_reviewers", reviewers: reviewers.map(r => r.login), prs: [key], heads: { [key]: pr.snapshot!.head }, createdAt: new Date(this.now()).toISOString(), state: "sending", result: "Waiting for GitHub to confirm the review request." });
@@ -145,10 +150,10 @@ export class PrActions {
145
150
  const id = randomUUID();
146
151
  await this.store.mutate(state => {
147
152
  const current = findWorkspace(state, w.id); active(current);
148
- if (current.prActions.some(a => ["sending", "uncertain"].includes(a.state))) throw new Error("A PR action is already in progress or needs checking.");
153
+ if (current.prActions.some(a => prActionBlocks(a, prs.map(p => p.key)))) throw new Error("A PR action is already in progress or needs checking.");
149
154
  if (plan.kind === "merge" && current.prActions.some(a => a.kind === "merge" && a.state === "submitted" && prs.some(p => a.heads[p.key] === p.snapshot?.head) && this.now() - Date.parse(a.createdAt) < 30_000)) throw new Error("A merge was just submitted for this revision. Wait for Graphite's result.");
150
155
  if (identity(downstack(current.prs, plan.target)) !== saved.identity) throw new Error("The stack changed before submission.");
151
- current.prActions.push({ id, kind: plan.kind, prs: prs.map(p => p.key), heads: Object.fromEntries(prs.map(p => [p.key, p.snapshot!.head])), createdAt: new Date(this.now()).toISOString(), state: "sending", result: "Waiting for Graphite to confirm." });
156
+ current.prActions.push({ id, kind: plan.kind, prs: prs.map(p => p.key), heads: Object.fromEntries(prs.map(p => [p.key, p.snapshot!.head])), createdAt: new Date(this.now()).toISOString(), state: "sending", result: plan.kind === "merge" ? "Waiting for Graphite to confirm." : "Confirm this PR setting change in the Send to Paseo extension in Chrome." });
152
157
  current.completion = null;
153
158
  });
154
159
  let state: "submitted" | "failed" | "uncertain" = "submitted";
@@ -0,0 +1,9 @@
1
+ import type { State } from "../shared/model";
2
+
3
+ /** Duplicate claims are ambiguous. Never route by recency or by PR title. */
4
+ export function prOwner(state: State, repo: string, number: number) {
5
+ const claims = state.workspaces.filter(w => w.enable_tracking && !w.archived)
6
+ .flatMap(w => w.prs.filter(p => p.repo.toLowerCase() === repo.toLowerCase() && p.number === number)
7
+ .map(p => ({ workspaceId: w.id, agentId: p.registeredAgentId ?? w.mainAgentId })));
8
+ return claims.length === 1 ? claims[0] : null;
9
+ }
@@ -0,0 +1,15 @@
1
+ import type { PluginSessionOpenRequest } from "@getpaseo/plugin/server";
2
+ import { mcpDefinition } from "./agent-mcp";
3
+
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): PluginSessionOpenRequest {
6
+ if (request.purpose !== "interactive" || !["claude", "codex"].includes(request.provider) || request.labels["plugin-tools/enabled"] === "false") return request;
7
+ const definition = mcpDefinition(directory, clientEntry, home);
8
+ return { ...request, mcpServers: { ...request.mcpServers, "follow-through": { ...definition, env: {
9
+ ...definition.env, PASEO_AGENT_ID: request.agentId,
10
+ ...(recovery ? { FOLLOW_THROUGH_RECOVERY: "1" } : {}),
11
+ ...(env.PASEO_DAEMON_URL ? { PASEO_DAEMON_URL: env.PASEO_DAEMON_URL } : {}),
12
+ ...(env.PASEO_PASSWORD ? { PASEO_PASSWORD: env.PASEO_PASSWORD } : {}),
13
+ ...(env.PASEO_PASSWORD_FILE ? { PASEO_PASSWORD_FILE: env.PASEO_PASSWORD_FILE } : {}),
14
+ } } } };
15
+ }
@@ -41,3 +41,6 @@ export const projectSettingsRpc = defineRpc({ name: "follow.project-settings", i
41
41
  export const setProjectTrackingRpc = defineRpc({ name: "follow.set-project-tracking", input: z.object({ projectId: idSchema, enable_default_tracking: z.boolean() }), output: z.object({ projects: z.array(projectSchema) }) });
42
42
  export const refineTasksRpc = defineRpc({ name: "follow.refine-tasks", input: z.object({ workspaceId: idSchema, tasks: z.array(z.object({ id: idSchema, title: textSchema, afterPrs: z.array(idSchema), dependencyStage: z.enum(["merged", "deployed"]), enable_follow_up: z.boolean(), communication: communicationSchema.optional(), trigger: taskTriggerSchema.nullable().optional() })).max(50) }), output: workspaceSchema });
43
43
  export const taskTriggerRpc = defineRpc({ name: "follow.set-task-trigger", input: z.object({ workspaceId: idSchema, obligationId: idSchema, expectedRevision: z.number().int().nonnegative(), trigger: taskTriggerSchema.nullable(), afterPrs: z.array(idSchema) }), output: workspaceSchema });
44
+
45
+ // Narrow integration: no inventory, messages, paths, or credentials cross this RPC.
46
+ export const prOwnerRpc = defineRpc({ name: "follow.pr-owner", input: z.strictObject({ repo: z.string().regex(/^[\w.-]+\/[\w.-]+$/), number: z.number().int().positive() }), output: z.object({ owner: z.object({ workspaceId: idSchema, agentId: idSchema }).nullable() }) });
package/shared/model.ts CHANGED
@@ -26,6 +26,7 @@ export const prSchema = z.object({
26
26
  key: idSchema, repo: z.string().regex(/^[\w.-]+\/[\w.-]+$/), number: z.number().int().positive(),
27
27
  stack: textSchema, parent: idSchema.nullable(), disposition: z.string().nullable(),
28
28
  snapshot: snapshotSchema.nullable(), error: z.string().nullable(),
29
+ registeredAgentId: idSchema.optional(),
29
30
  deploymentAnchor: z.object({ mergeCommit: z.string(), at: z.string() }).optional(),
30
31
  });
31
32
  export const obligationSchema = z.object({
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { idSchema, textSchema, type Pr } from "./model";
2
+ import { idSchema, textSchema, type Pr, type Workspace } from "./model";
3
3
 
4
4
  export const prActionKind = z.enum(["merge", "enable_mwr", "disable_mwr"]);
5
5
  export const actionPrSchema = z.object({ key: idSchema, repo: z.string(), number: z.number(), title: z.string(), head: z.string(), parent: idSchema.nullable(), base: z.string(), trunk: z.string(), blockers: z.array(z.string()) });
@@ -13,7 +13,7 @@ export const browserCommandSchema = z.object({ id: idSchema, nonce: idSchema, ex
13
13
  export type BrowserCommand = z.infer<typeof browserCommandSchema>;
14
14
  export const browserResultSchema = z.object({ id: idSchema, nonce: idSchema, outcome: z.enum(["completed", "failed", "uncertain"]), prs: z.array(browserPrSchema).max(50).default([]), applied: z.array(z.number().int().positive()).max(50).default([]), error: z.string().max(500).default("") });
15
15
  export type BrowserResult = z.infer<typeof browserResultSchema>;
16
- export const browserExchangeSchema = z.object({ clientId: idSchema, ready: z.boolean(), acceptJobs: z.boolean().default(true), results: z.array(browserResultSchema).max(10).default([]) });
16
+ export const browserExchangeSchema = z.object({ clientId: idSchema, ready: z.boolean(), acceptJobs: z.boolean().default(true), results: z.array(browserResultSchema).max(10).default([]), repositories: z.array(browserPrSchema.shape.repo).max(100).default([]) });
17
17
  export const browserStatusSchema = z.object({ ready: z.boolean(), reason: z.string().nullable() });
18
18
  export const actionResultSchema = z.object({ state: z.enum(["submitted", "failed", "uncertain"]), result: textSchema });
19
19
 
@@ -45,3 +45,9 @@ export function mergeBlockers(pr: Pr): string[] {
45
45
  if (!["READY_TO_MERGE", "READY_TO_MERGE_AS_STACK", "READY_TO_MERGE_WITHOUT_CI", "READY_TO_MERGE_AS_STACK_WITHOUT_CI"].includes(s.graphiteMergeStatus ?? "")) blockers.push("Graphite is not ready to merge");
46
46
  return blockers;
47
47
  }
48
+
49
+ /** Reviewer requests on other PRs do not own this PR's action slot. */
50
+ export function prActionBlocks(action: Pick<Workspace["prActions"][number], "state" | "kind" | "prs">, keys: readonly string[]): boolean {
51
+ return ["sending", "uncertain"].includes(action.state) &&
52
+ (action.kind !== "request_reviewers" || action.prs.some(key => keys.includes(key)));
53
+ }