@bermudi/pi-delegate 0.1.0 → 0.1.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/status.ts ADDED
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Background-activity visibility: footer status, settle warning, and
3
+ * session-replacement guards for live async tickets.
4
+ *
5
+ * Async tickets keep subagents running after the parent turn settles, but pi
6
+ * renders an idle session — nothing tells the human work is still in flight,
7
+ * and quitting silently kills it. This module owns the three signals that
8
+ * close that gap:
9
+ *
10
+ * 1. A persistent footer status (`ctx.ui.setStatus`) while any ticket is
11
+ * active — the only always-on indicator that background subagents exist.
12
+ * 2. A one-shot warning notification at the first `agent_settled` with each
13
+ * active ticket — the moment a user is most likely to assume everything
14
+ * is done and close the session.
15
+ * 3. A confirm guard on the session-replacement paths pi lets extensions
16
+ * cancel (`session_before_switch`, `session_before_fork`), plus a distinct
17
+ * prompt for `/tree` navigation, which re-targets results rather than
18
+ * killing them (see `guardTreeNavigation`).
19
+ * 4. A notification when a ticket completes after the session navigated away
20
+ * from its spawn leaf: delivery is downgraded to non-waking, so without
21
+ * this the human gets no signal that the ticket finished at all.
22
+ *
23
+ * Quit (Ctrl+C×2 / Ctrl+D / /quit) and /reload CANNOT be intercepted from an
24
+ * extension — `session_shutdown` is advisory, not cancellable. The footer
25
+ * status plus the exit/reload trace in extension.ts are the mitigations
26
+ * there.
27
+ */
28
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
29
+ import { requestTicketCancel, ticketRegistry } from "./tickets.ts";
30
+ import type { AsyncTicket } from "./types.ts";
31
+
32
+ const STATUS_KEY = "delegate";
33
+
34
+ export interface ActiveTicketSummary {
35
+ /** Tickets in a non-terminal state (running or cancelling). */
36
+ tickets: AsyncTicket[];
37
+ /** Progress rows still executing or queued across active tickets. */
38
+ activeSubagents: number;
39
+ }
40
+
41
+ /** Snapshot the live background work from the ticket registry. */
42
+ export function activeTicketSummary(): ActiveTicketSummary {
43
+ const tickets: AsyncTicket[] = [];
44
+ let activeSubagents = 0;
45
+ for (const ticket of ticketRegistry.values()) {
46
+ if (ticket.status !== "running" && ticket.status !== "cancelling") continue;
47
+ tickets.push(ticket);
48
+ activeSubagents += ticket.progress.filter(
49
+ (p) => p.status === "running" || p.status === "pending",
50
+ ).length;
51
+ }
52
+ return { tickets, activeSubagents };
53
+ }
54
+
55
+ function plural(n: number, noun: string): string {
56
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
57
+ }
58
+
59
+ /** Footer status text, or undefined when nothing is active. */
60
+ export function buildStatusText(
61
+ summary: ActiveTicketSummary,
62
+ ): string | undefined {
63
+ const { tickets, activeSubagents } = summary;
64
+ if (tickets.length === 0) return undefined;
65
+ // Wind-down window: tasks have settled but the ticket has not flipped to a
66
+ // terminal status yet. "settling" is more honest than "0 subagents".
67
+ if (activeSubagents === 0) {
68
+ return tickets.length === 1
69
+ ? `⏳ ${tickets[0]!.id} settling…`
70
+ : `⏳ ${plural(tickets.length, "ticket")} settling…`;
71
+ }
72
+ return tickets.length === 1
73
+ ? `⏳ ${plural(activeSubagents, "subagent")} · ${tickets[0]!.id}`
74
+ : `⏳ ${plural(activeSubagents, "subagent")} · ${plural(tickets.length, "ticket")}`;
75
+ }
76
+
77
+ /** Most recent context with UI access. Refreshed by every sync call that
78
+ * receives a ctx, so event-driven updates can reach the footer without a
79
+ * direct ctx of their own. Single runtime per process — a replaced runtime
80
+ * refreshes this on its first delegate call or session event. */
81
+ let lastCtx: ExtensionContext | undefined;
82
+ /** Last text pushed to the footer — setStatus triggers a render, so dedupe. */
83
+ let lastStatusText: string | undefined;
84
+ /** Tickets already warned about at settle. Pruned to active tickets on sync. */
85
+ const settledWarnedTicketIds = new Set<string>();
86
+
87
+ /** Recompute the footer status from the registry and push it when changed.
88
+ * Called on every ticket lifecycle mutation (create, progress, complete,
89
+ * cancel); event-driven only — no timers, so the text never goes stale
90
+ * (counts are the only content). */
91
+ export function syncDelegateStatus(ctx?: ExtensionContext): void {
92
+ if (ctx) lastCtx = ctx;
93
+
94
+ const summary = activeTicketSummary();
95
+ const text = buildStatusText(summary);
96
+
97
+ if (settledWarnedTicketIds.size) {
98
+ const activeIds = new Set(summary.tickets.map((t) => t.id));
99
+ for (const id of settledWarnedTicketIds) {
100
+ if (!activeIds.has(id)) settledWarnedTicketIds.delete(id);
101
+ }
102
+ }
103
+
104
+ if (!lastCtx || text === lastStatusText) return;
105
+ try {
106
+ // The `ui` getter itself throws on a stale ctx — pi invalidates the old
107
+ // runtime on session replacement, and an unwinding ticket can race the
108
+ // teardown. A footer update must never crash the host.
109
+ lastCtx.ui.setStatus(STATUS_KEY, text);
110
+ lastStatusText = text;
111
+ } catch {
112
+ // Drop the stale ctx; the next live event re-caches a fresh one.
113
+ lastCtx = undefined;
114
+ lastStatusText = undefined;
115
+ }
116
+ }
117
+
118
+ /** Drop the cached ctx and warn-set on session shutdown — the runtime is
119
+ * about to be invalidated, and any post-teardown sync (e.g. an aborted
120
+ * ticket unwinding) must become a no-op instead of touching a stale ctx. */
121
+ export function clearDelegateStatusContext(): void {
122
+ lastCtx = undefined;
123
+ lastStatusText = undefined;
124
+ settledWarnedTicketIds.clear();
125
+ }
126
+
127
+ /** Warn once per ticket at the first agent_settled with that ticket active —
128
+ * the "looks idle but isn't" moment. The persistent footer status carries
129
+ * the information from then on, so later settles stay quiet. */
130
+ export function notifyActiveTicketsOnSettled(ctx: ExtensionContext): void {
131
+ lastCtx = ctx;
132
+ const summary = activeTicketSummary();
133
+ const fresh = summary.tickets.filter(
134
+ (t) => !settledWarnedTicketIds.has(t.id),
135
+ );
136
+ if (!fresh.length) return;
137
+
138
+ const subagents = fresh.reduce(
139
+ (n, t) =>
140
+ n +
141
+ t.progress.filter((p) => p.status === "running" || p.status === "pending")
142
+ .length,
143
+ 0,
144
+ );
145
+ const detail =
146
+ fresh.length === 1
147
+ ? `(ticket ${fresh[0]!.id})`
148
+ : `across ${plural(fresh.length, "ticket")} (${fresh.map((t) => t.id).join(", ")})`;
149
+ try {
150
+ ctx.ui.notify(
151
+ `⏳ ${plural(subagents, "background subagent")} still running ${detail} — quitting pi aborts them`,
152
+ "warning",
153
+ );
154
+ for (const t of fresh) settledWarnedTicketIds.add(t.id);
155
+ } catch {
156
+ // Stale or headless ctx: drop it so the next live event re-caches one.
157
+ lastCtx = undefined;
158
+ lastStatusText = undefined;
159
+ }
160
+ }
161
+
162
+ /** Block a session replacement (switch/fork) while background subagents are
163
+ * live, unless the human confirms. Returns `{ cancel: true }` to abort the
164
+ * replacement, undefined to let it proceed. Headless contexts (no dialog
165
+ * capability) are never blocked — automation must not deadlock on a
166
+ * confirm it cannot answer. */
167
+ export async function guardSessionReplacement(
168
+ ctx: ExtensionContext,
169
+ action: "switch" | "fork",
170
+ ): Promise<{ cancel: true } | undefined> {
171
+ lastCtx = ctx;
172
+ const summary = activeTicketSummary();
173
+ if (!summary.tickets.length || !ctx.hasUI) return undefined;
174
+
175
+ const ids = summary.tickets.map((t) => t.id).join(", ");
176
+ const verb =
177
+ action === "switch" ? "Switching sessions" : "Forking this session";
178
+ const proceed = await ctx.ui.confirm(
179
+ "Background subagents still running",
180
+ `${plural(summary.activeSubagents, "subagent")} (${ids}) still working. ` +
181
+ `${verb} aborts them — work already done is not rolled back. Continue anyway?`,
182
+ );
183
+ return proceed ? undefined : { cancel: true };
184
+ }
185
+
186
+ /** `/tree` navigation is not a session replacement: the runtime survives and
187
+ * the subagents keep running, but the eventual result no longer belongs to
188
+ * the branch the user is on. Offer the three honest outcomes instead of the
189
+ * destructive switch/fork confirm. Dismissal keeps the user where they are —
190
+ * the conservative choice, since navigating is what creates the hazard.
191
+ *
192
+ * This guard is UX, not the correctness mechanism: navigation that never
193
+ * reaches it (dismissed dialog, headless ctx, `ctx.navigateTree` from another
194
+ * extension) is still handled at delivery time via leaf affinity. */
195
+ export async function guardTreeNavigation(
196
+ ctx: ExtensionContext,
197
+ ): Promise<{ cancel: true } | undefined> {
198
+ lastCtx = ctx;
199
+ const summary = activeTicketSummary();
200
+ if (!summary.tickets.length || !ctx.hasUI) return undefined;
201
+
202
+ const ids = summary.tickets.map((t) => t.id).join(", ");
203
+ const hold = "Navigate — hold results (poll to read them)";
204
+ const cancel = "Navigate — cancel the background subagents";
205
+ const stay = "Stay on this branch";
206
+ let choice: string | undefined;
207
+ try {
208
+ choice = await ctx.ui.select(
209
+ `${plural(summary.activeSubagents, "background subagent")} (${ids}) still running — ` +
210
+ "navigating means their results arrive on a different branch",
211
+ [hold, cancel, stay],
212
+ );
213
+ } catch {
214
+ // pi does not surface handler rejections, so a throwing dialog (stale ctx,
215
+ // TUI failure) would become an unhandled rejection. Let the navigation
216
+ // through rather than trapping the user: leaf affinity at delivery time is
217
+ // the correctness mechanism, not this prompt.
218
+ return undefined;
219
+ }
220
+
221
+ if (choice === hold) return undefined;
222
+ if (choice === cancel) {
223
+ for (const ticket of summary.tickets) requestTicketCancel(ticket);
224
+ syncDelegateStatus(ctx);
225
+ return undefined;
226
+ }
227
+ return { cancel: true };
228
+ }
229
+
230
+ /** Tell the human a ticket landed on a branch they had left. Delivery used
231
+ * `nextTurn` (no wake-up), so this notification and the pollable ticket are
232
+ * the only signals that the work finished. */
233
+ export function notifyCrossLeafDelivery(ticket: AsyncTicket): void {
234
+ if (!lastCtx) return;
235
+ try {
236
+ lastCtx.ui.notify(
237
+ `Ticket ${ticket.id} finished (${ticket.status}) on a branch you navigated away from — ` +
238
+ `results are held for your next message; read them with delegate poll.`,
239
+ "warning",
240
+ );
241
+ } catch {
242
+ lastCtx = undefined;
243
+ lastStatusText = undefined;
244
+ }
245
+ }
246
+
247
+ /** One-line description of live work for shutdown traces. */
248
+ export function describeActiveTickets(
249
+ summary: ActiveTicketSummary = activeTicketSummary(),
250
+ ): string {
251
+ const ids = summary.tickets.map((t) => t.id).join(", ");
252
+ const agents = [
253
+ ...new Set(
254
+ summary.tickets.flatMap((t) =>
255
+ t.progress
256
+ .filter((p) => p.status === "running" || p.status === "pending")
257
+ .map((p) => p.agent),
258
+ ),
259
+ ),
260
+ ];
261
+ const agentList = agents.length ? ` [${agents.join(", ")}]` : "";
262
+ return `${plural(summary.activeSubagents, "background subagent")}${agentList} (ticket${summary.tickets.length === 1 ? "" : "s"}: ${ids})`;
263
+ }
264
+
265
+ export function _resetDelegateStatusForTesting(): void {
266
+ lastCtx = undefined;
267
+ lastStatusText = undefined;
268
+ settledWarnedTicketIds.clear();
269
+ }