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

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/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.2
104
+ paseo plugin update follow-through --version 1.2.0-beta.4
105
105
  # or, for a fresh install:
106
106
  paseo plugin install npm:paseo-follow-up@beta
107
107
  ```
@@ -117,7 +117,7 @@ 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.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.
120
+ - **Auto-send** is on for every workspace tracked for the first time (1.2.0-beta.4), 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.
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.2",
4
+ "version": "1.2.0-beta.4",
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": [
package/server/daemon.ts CHANGED
@@ -137,9 +137,11 @@ export class PaseoGateway implements Gateway {
137
137
  const candidates = agents.map(row => row.agent).filter(a => a.workspaceId === workspace.id && !a.archivedAt && !a.labels["follow-through.recovery"]);
138
138
  const roots: Agent[] = [];
139
139
  for (const agent of candidates) if (await isWorkspaceRoot(agent, findParent)) roots.push(agent);
140
- const main = chooseMainAgent(roots, existing?.mainAgentId);
140
+ const successor = successorMainAgent(roots, existing?.mainAgentId);
141
+ const main = successor ?? chooseMainAgent(roots, existing?.mainAgentId);
141
142
  if (!main && !existing) continue;
142
- const row: DiscoveredWorkspace = { id: workspace.id, title: workspace.name || main?.title || existing!.title, mainAgentId: existing?.mainAgentId ?? main!.id, projectId: workspace.projectId, messageAt: main?.lastUserMessageAt ?? null, enable_default_tracking: defaults.has(workspace.projectId ?? ""), contributorAgentIds: roots.filter(a => a.id !== existing?.mainAgentId && a.id !== main?.id).map(a => a.id) };
143
+ const owner = successor?.id ?? existing?.mainAgentId ?? main!.id;
144
+ const row: DiscoveredWorkspace = { id: workspace.id, title: workspace.name || main?.title || existing!.title, mainAgentId: owner, ...(successor ? { replacesMainAgentId: existing!.mainAgentId } : {}), projectId: workspace.projectId, messageAt: main?.lastUserMessageAt ?? null, enable_default_tracking: defaults.has(workspace.projectId ?? ""), contributorAgentIds: roots.filter(a => a.id !== owner && a.id !== existing?.mainAgentId && a.id !== main?.id).map(a => a.id) };
143
145
  // Preserve the designated owner even when it is no longer indexed. Other root
144
146
  // sessions can still contribute their PRs without acquiring completion authority.
145
147
  const project = catalog.projects.find(p => p.projectId === workspace.projectId);
@@ -364,3 +366,15 @@ export function chooseMainAgent(roots: Agent[], existingId?: string): Agent | un
364
366
  if (existingId) return roots.find(a => a.id === existingId);
365
367
  return [...roots].sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id))[0];
366
368
  }
369
+
370
+ /**
371
+ * The recorded main agent is closed, archived or gone while the workspace still has a live root agent: the live
372
+ * one takes over (the one Tom wrote to last, then the newest). A live recorded main is never replaced.
373
+ */
374
+ export function successorMainAgent(roots: Agent[], existingId: string | undefined): Agent | undefined {
375
+ if (!existingId) return undefined;
376
+ const current = roots.find(a => a.id === existingId);
377
+ if (current && current.status !== "closed") return undefined;
378
+ const live = roots.filter(a => a.id !== existingId && a.status !== "closed");
379
+ return live.sort((a, b) => (b.lastUserMessageAt ?? "").localeCompare(a.lastUserMessageAt ?? "") || b.createdAt.localeCompare(a.createdAt) || a.id.localeCompare(b.id))[0];
380
+ }
@@ -4,7 +4,7 @@ import { mergeArtifacts } from "./spec";
4
4
  import type { State, Workspace } from "../shared/model";
5
5
 
6
6
  export type History = { candidates: string[]; goal: string | null; complete: boolean; error: string | null };
7
- export type DiscoveredWorkspace = { id: string; title: string; mainAgentId: string; messageAt: string | null; projectId?: string | null; enable_default_tracking?: boolean; history?: History; contributorAgentIds?: string[]; submissions?: Submission[]; artifacts?: Artifact[] };
7
+ export type DiscoveredWorkspace = { id: string; title: string; mainAgentId: string; /** Set when mainAgentId is a live successor to this closed or gone main agent. */ replacesMainAgentId?: string; messageAt: string | null; projectId?: string | null; enable_default_tracking?: boolean; history?: History; contributorAgentIds?: string[]; submissions?: Submission[]; artifacts?: Artifact[] };
8
8
 
9
9
  export function extractHistory(items: readonly unknown[], complete = true, error: string | null = null): History {
10
10
  const candidates = new Set<string>();
@@ -42,7 +42,12 @@ export function applyDiscovery(state: State, discovered: DiscoveredWorkspace[],
42
42
  state.workspaces.push(w);
43
43
  }
44
44
  w.title = row.title;
45
- // An unavailable original owner is surfaced instead of silently transferring its authority.
45
+ // A closed or gone main agent hands the workspace to its live successor (discovery decides; see
46
+ // successorMainAgent). Any other mismatch keeps the recorded owner.
47
+ if (row.replacesMainAgentId && row.replacesMainAgentId === w.mainAgentId && !w.archived) {
48
+ w.mainAgentId = row.mainAgentId; w.runtimeReason = null;
49
+ for (const b of w.batches) if (b.state === "pending" && b.recipientAgentId === row.replacesMainAgentId) b.recipientAgentId = null;
50
+ }
46
51
  if (w.mainAgentId !== row.mainAgentId || w.archived) continue;
47
52
  if (row.contributorAgentIds) w.contributorAgentIds = row.contributorAgentIds;
48
53
  if (row.enable_default_tracking && !w.enable_tracking && (!row.projectId || state.projectDefaults?.[row.projectId] !== false)) { w.enable_auto_sends = firstTracking(w); w.enable_tracking = true; }
package/server/inbox.ts CHANGED
@@ -90,6 +90,8 @@ export type InboxOptions = {
90
90
  guard: () => Promise<GuardPause[] | null>;
91
91
  now?: () => number;
92
92
  graceMs?: number;
93
+ /** How long a turn end waits for Paseo to stop reporting "running" (F7). Tests shorten it. */
94
+ settleMs?: number; settleStepMs?: number;
93
95
  };
94
96
  /** Decides the brief step for one event from the entry before and after it. */
95
97
  export type BriefDecider = (prev: Entry, next: Entry, at: string) => BriefEvent | null;
@@ -128,6 +130,21 @@ export class Inbox {
128
130
  private cache: { at: number; agents: AgentInfo[]; workspaces: { id: string; name: string }[] } | null = null;
129
131
  constructor(directory: string, private o: InboxOptions) { this.store = new InboxStore(directory); }
130
132
  private now() { return this.o.now?.() ?? Date.now(); }
133
+ /**
134
+ * Paseo still reports "running" for a moment after a turn completes, so a status read at turn end would make
135
+ * every plain finish look like background work (F7) and flag nothing. Re-read until the agent leaves "running"
136
+ * or the window ends; only an agent still running after it counts as running.
137
+ */
138
+ private async settledStatus(agentId: string): Promise<string | null> {
139
+ const read = () => this.o.directory.agent(agentId).then(a => a?.status ?? null, () => null);
140
+ const step = this.o.settleStepMs ?? 500, steps = Math.max(1, Math.ceil((this.o.settleMs ?? 6_000) / step));
141
+ let status = await read();
142
+ for (let i = 0; i < steps && (status === "running" || status === "initializing"); i++) {
143
+ await new Promise(r => setTimeout(r, step));
144
+ status = await read();
145
+ }
146
+ return status;
147
+ }
131
148
  private iso() { return new Date(this.now()).toISOString(); }
132
149
  private async on() { try { return (await this.o.enabled()).enable_inbox; } catch { return true; } }
133
150
 
@@ -247,7 +264,7 @@ export class Inbox {
247
264
  const reply = [...items].reverse().find(i => (i as { type?: string })?.type === "assistant_message") as { text?: string } | undefined;
248
265
  // F7: needs_you kind=done means nothing for the developer, and while Paseo still shows the agent running
249
266
  // (background work) its own running state is enough. Neither flags; a decision, action or review still does.
250
- const status = await this.o.directory.agent(agent.id).then(a => a?.status ?? null, () => null);
267
+ const status = await this.settledStatus(agent.id);
251
268
  const quiet = finishIsQuiet(stated.agent?.kind ?? null, status);
252
269
  // The ask replaces the reply's last line as the reason's detail (§4); the fallback keeps the old detail.
253
270
  const brief = await this.creator(root.rootId, turnId, "", "finished", stated, !quiet || stated.agent?.kind === "done", quiet);