@alphacoder-v0/pi-loops 0.22.7 → 0.24.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +681 -0
  2. package/CONTRACT.md +7 -7
  3. package/README.md +91 -63
  4. package/README.zh-CN.md +59 -39
  5. package/docs/cli.md +272 -83
  6. package/docs/configuration.md +86 -24
  7. package/docs/design.md +395 -56
  8. package/docs/downstream.md +2 -2
  9. package/docs/goal.md +12 -3
  10. package/docs/hooks.md +6 -7
  11. package/docs/loops.md +288 -59
  12. package/docs/mcp.md +22 -14
  13. package/docs/recipes.md +6 -0
  14. package/docs/screenshot.png +0 -0
  15. package/docs/triggers.md +120 -26
  16. package/docs/troubleshooting.md +37 -26
  17. package/docs/web-ui-parity.md +691 -65
  18. package/package.json +4 -3
  19. package/recipes/autoresearch/recipe.toml +1 -0
  20. package/recipes/changelog-draft/recipe.toml +1 -0
  21. package/recipes/ci-sweeper/recipe.toml +1 -0
  22. package/recipes/daily-digest/recipe.toml +1 -0
  23. package/recipes/deps-sweeper/recipe.toml +1 -0
  24. package/recipes/ecosystem/recipe.toml +1 -0
  25. package/recipes/issue-loop/recipe.toml +1 -0
  26. package/recipes/pr-watch/recipe.toml +1 -0
  27. package/skills/pi-loops/SKILL.md +6 -4
  28. package/src/activity.ts +199 -0
  29. package/src/archive.ts +21 -9
  30. package/src/args.ts +6 -2
  31. package/src/cli-entry.mjs +12 -3
  32. package/src/cli.ts +746 -125
  33. package/src/config.ts +53 -5
  34. package/src/conversation-rows.ts +469 -0
  35. package/src/danger.ts +227 -6
  36. package/src/engine.ts +1240 -0
  37. package/src/goal.ts +71 -0
  38. package/src/held-read.ts +27 -0
  39. package/src/hooks.ts +49 -18
  40. package/src/host-control-channel.ts +455 -40
  41. package/src/host-control.ts +602 -111
  42. package/src/host-entry.mjs +5 -3
  43. package/src/host-runtime.ts +455 -41
  44. package/src/host.ts +659 -123
  45. package/src/inbox-card.ts +278 -0
  46. package/src/inbox.ts +139 -62
  47. package/src/job-edit.ts +7 -1
  48. package/src/job-health.ts +14 -2
  49. package/src/job-lines.ts +201 -13
  50. package/src/job-owner.ts +74 -13
  51. package/src/job-tune.ts +110 -0
  52. package/src/lease.ts +537 -0
  53. package/src/lock.ts +7 -2
  54. package/src/mcp-pool.ts +66 -14
  55. package/src/mcp.ts +1 -1
  56. package/src/open-file.ts +54 -0
  57. package/src/page-children.ts +1507 -0
  58. package/src/page-devices.ts +192 -0
  59. package/src/page-server.ts +3150 -0
  60. package/src/path-precheck.ts +215 -0
  61. package/src/paths.ts +24 -0
  62. package/src/pi-floor.ts +10 -0
  63. package/src/pi-loops.ts +1301 -450
  64. package/src/pi-resolver.d.mts +1 -0
  65. package/src/pi-resolver.mjs +110 -10
  66. package/src/presence.ts +12 -63
  67. package/src/protocol.ts +10 -1
  68. package/src/recipe.ts +43 -1
  69. package/src/requests.ts +354 -0
  70. package/src/rule-lines.ts +68 -0
  71. package/src/run-card.ts +105 -0
  72. package/src/runner.ts +18 -1
  73. package/src/schedule.ts +1 -2
  74. package/src/scheduler.ts +598 -245
  75. package/src/sdk-runner.ts +321 -27
  76. package/src/seen.ts +278 -0
  77. package/src/session-head.ts +21 -0
  78. package/src/share.ts +12 -2
  79. package/src/snapshot.ts +3 -2
  80. package/src/store.ts +81 -8
  81. package/src/tools.ts +196 -65
  82. package/src/trigger-runtime.ts +164 -127
  83. package/src/triggers.ts +39 -7
  84. package/src/trust.ts +46 -2
  85. package/src/ui-prefs.ts +16 -5
  86. package/src/watch-plan.ts +174 -0
  87. package/src/web.d.mts +3 -0
  88. package/src/web.mjs +2260 -2340
  89. package/src/window-executor.ts +459 -0
  90. package/src/window-guard.ts +158 -0
  91. package/docs/agents/issue-tracker.md +0 -34
@@ -0,0 +1,459 @@
1
+ /**
2
+ * The engine's executor in a window: the pi this extension runs in, holding its conversation's
3
+ * lease. An `append` row goes into the chat through `pi.sendMessage`: inserted at once while idle,
4
+ * with no turn; as a follow-up during a turn, which runs one when the current one ends
5
+ * (docs/design.md decision 9). A `turn` row goes through `pi.sendUserMessage`, and only while pi is
6
+ * idle (the engine decides that), so Esc has nothing of ours to drop. `handedOver` remembers what
7
+ * pi was given and has not written yet, so a second drain does not hand it over twice; `settled()`
8
+ * forgets what pi dropped (Esc clears its queues), so the next drain offers it again.
9
+ */
10
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
+ import { CEILING, type ConversationView, type Executor, type ExecutorView, type Producer, carriesPrefix, conversationView, landed, modeOf, personTextMatches, queuedTriggeredTurn, requestFingerprint, sameProducer } from "./engine.ts";
12
+ import { type LeaseBinding, type ProcessProbe, systemProbe } from "./lease.ts";
13
+ import type { ImageContent, RequestMode, SessionRequest } from "./requests.ts";
14
+ import { stamp } from "./schedule.ts";
15
+
16
+ /**
17
+ * Once pi has settled, a row it was handed and never wrote was dropped: Esc clears pi's queues, a
18
+ * follow-up with them. Forget those, so the next drain offers them again instead of waiting for
19
+ * them for as long as the window lives. Returns the ids forgotten.
20
+ */
21
+ export function forgetDropped(handedOver: Set<string>, alreadyDelivered: (id: string) => boolean): string[] {
22
+ const dropped = [...handedOver].filter((id) => !alreadyDelivered(id));
23
+ for (const id of dropped) handedOver.delete(id);
24
+ return dropped;
25
+ }
26
+
27
+ /**
28
+ * Whose turn the agent is in, and under what cap (design §4.4, §6.3): a pi-loops row, with the mode
29
+ * and the declared tools its turn runs under, or a message the person typed or queued, which is
30
+ * theirs and capped by nothing of ours. Known from the user message that starts the turn, and in
31
+ * force until the turn settles: pi's retries and follow-ups start runs with no user message, and a
32
+ * cap that ended with the run would end at the first provider error. A message the person queues
33
+ * into a scheduled run makes what follows theirs.
34
+ */
35
+ export type CurrentTurn = { kind: "row"; id: string; mode: RequestMode; tools?: readonly string[]; builtinTools?: readonly string[] } | { kind: "person" };
36
+
37
+ /** Whether a call made now is a scheduled turn's, which the window caps (`src/window-guard.ts`). */
38
+ export function unattendedTurn(current: CurrentTurn | undefined): current is CurrentTurn & { kind: "row" } {
39
+ return current?.kind === "row" && current.mode === "unattended";
40
+ }
41
+
42
+ /** The `[Trigger <id>] ` id a message starts with, when it does. */
43
+ export function triggerIdOf(message: unknown): string | undefined {
44
+ const value = message as { role?: unknown; content?: unknown } | undefined;
45
+ if (!value || value.role !== "user") return undefined;
46
+ const content = value.content;
47
+ const text = typeof content === "string" ? content : Array.isArray(content) ? (content.find((part) => part?.type === "text") as { text?: string } | undefined)?.text : undefined;
48
+ const match = text?.match(/^\[Trigger ([^\]]+)\] /);
49
+ return match?.[1];
50
+ }
51
+
52
+ /**
53
+ * The system line when a triggered turn starts (or is queued behind the current one). The id is a
54
+ * rule's trace for a trigger and a run id for a plain job, which has no trace; the line calls it a
55
+ * trace either way, because that is the word people have learned to look for.
56
+ */
57
+ export function triggeredTurnLine(ctx: Pick<ExtensionContext, "hasUI" | "ui"> | undefined, runId: string, idle: boolean): void {
58
+ if (!ctx?.hasUI) return;
59
+ ctx.ui.notify(idle ? `running triggered turn (trace ${runId.slice(0, 8)})` : queuedTriggeredTurn(runId), "info");
60
+ }
61
+
62
+ /**
63
+ * Whether the process that wrote a row is still that process, on that conversation, by the rule a
64
+ * lease holder is judged by (src/lease.ts holderAlive): the pid lives; a known start time that
65
+ * differs is a reused pid; with either start time unknown, a row older than this boot is a
66
+ * previous boot's. A producer that left the conversation said so in `created_by.left`.
67
+ */
68
+ export function producerAlive(row: Pick<SessionRequest, "created_by" | "created_at">, probe: ProcessProbe = systemProbe): boolean {
69
+ const by = row.created_by;
70
+ if (by.left !== undefined || !probe.alive(by.pid)) return false;
71
+ const start = probe.startTime(by.pid);
72
+ if (by.procStart !== undefined && start !== undefined && start !== by.procStart) return false;
73
+ if ((by.procStart === undefined || start === undefined) && Date.parse(row.created_at) < probe.bootedAt() - 60_000) return false;
74
+ return true;
75
+ }
76
+
77
+ /**
78
+ * Which turn rows announce themselves with that line: the ones that arrive out of the clock or a
79
+ * server, which the person did not ask for just now. A claim, a recipe hand-off, `/new-trigger` and a
80
+ * goal continuation keep the notices they print themselves.
81
+ */
82
+ export function showsTriggeredTurnLine(source: SessionRequest["source"]): boolean {
83
+ return source.startsWith("cron:") || source.startsWith("cron-run:") || source.startsWith("push:");
84
+ }
85
+
86
+ type WindowCtx = Pick<ExtensionContext, "isIdle" | "mode" | "sessionManager" | "hasUI" | "ui" | "abort">;
87
+
88
+ /** What the executor asks the lease binding: the session bound and whether it is held, and whether another process holding it delivers person rows. */
89
+ type WindowBinding = Pick<LeaseBinding, "here"> & Partial<Pick<LeaseBinding, "otherHolderDelivers">>;
90
+
91
+ /** The default image reader: a window wired without one cannot send a row's images, and says so rather than sending the text without them. */
92
+ const noImages = (row: SessionRequest): ImageContent[] => {
93
+ throw new Error(`request ${row.id} has images and this window has no way to read them`);
94
+ };
95
+
96
+ /** The text of a user message, as `landed` matches a person row against it. */
97
+ function userTextOf(message: unknown): string | undefined {
98
+ const value = message as { role?: unknown; content?: unknown } | undefined;
99
+ if (!value || value.role !== "user") return undefined;
100
+ const content = value.content;
101
+ return typeof content === "string" ? content : Array.isArray(content) ? ((content.find((part) => part?.type === "text") as { text?: string } | undefined)?.text ?? "") : undefined;
102
+ }
103
+
104
+ export class WindowExecutor implements Executor {
105
+ private readonly pi: Pick<ExtensionAPI, "sendMessage" | "sendUserMessage">;
106
+ private readonly binding: WindowBinding;
107
+ private readonly ctx: () => WindowCtx | undefined;
108
+ /** Rows this process has handed pi and pi has not written yet. */
109
+ readonly handedOver = new Set<string>();
110
+ /** What the turn rows in `handedOver` run under, until each one's turn starts and takes it as `current`. */
111
+ private readonly caps = new Map<string, CurrentTurn & { kind: "row" }>();
112
+ /**
113
+ * The text-proved rows in `handedOver` (a person's message, a claim, a recipe hand-off,
114
+ * `/new-trigger`, a goal continuation), oldest first, with the text each one's user message will
115
+ * carry: such a row has no `[Trigger]` prefix, so its turn is known by its text at `message_start`.
116
+ */
117
+ private readonly personCaps: Array<{ cap: CurrentTurn & { kind: "row" }; text: string }> = [];
118
+ /**
119
+ * Caps of person rows pi was judged to have refused, moved aside when the next person row is
120
+ * handed over: a message that is one of these, sent late by pi, still runs under it. Claimed only
121
+ * when no row handed over since matches, so "fix" cannot take the turn of "fix it and push".
122
+ */
123
+ private readonly lateCaps: Array<{ cap: CurrentTurn & { kind: "row" }; text: string }> = [];
124
+ /** When each turn row in `handedOver` was handed to pi, while pi has started no run since (`ExecutorView.inFlightSince`). */
125
+ private readonly handedIdle = new Map<string, number>();
126
+ /** Rows pi was handed and had not written when it settled (`ExecutorView.dropped`), until the engine counts the attempt or hands them over again. */
127
+ private readonly dropped = new Set<string>();
128
+ /** The run pi is in, and the row it started from when a `[Trigger <id>]` message began it. */
129
+ private running: ExecutorView["running"];
130
+ /** The rows still in the request file: the cap of a turn this instance did not hand over itself, and the person rows a proof is matched among. */
131
+ private readonly rows: () => SessionRequest[];
132
+ /**
133
+ * The rows this instance wrote (`produced`), by id, with what they said. An attended source is
134
+ * trusted only on one of these, and only while the row in the file still says it: a row somebody
135
+ * else wrote with `recipe:` or `inbox:` on it, or one of ours a capped turn rewrote in the file,
136
+ * runs as unattended with no declared tools, so the person is asked before anything dangerous.
137
+ */
138
+ private readonly producedRows = new Map<string, string>();
139
+ /** Whose turn pi is in now: set at a user `message_start`, cleared when the turn settles. */
140
+ private current: CurrentTurn | undefined;
141
+ /**
142
+ * Submissions the person made (pi's `input` event) whose user message has not started yet. A user
143
+ * message without a row of ours behind it is the person's only while one of these is pending;
144
+ * otherwise it is another extension's, and the cap in force stays.
145
+ */
146
+ private personPending = 0;
147
+ /**
148
+ * Rows a stop named while pi held them unstarted: each is aborted at the `message_start` that
149
+ * begins its turn. One pi dropped instead (Esc) moves to `stopped`, and the next drain removes it
150
+ * as cancelled rather than offering it again: the stop was for that row.
151
+ */
152
+ private readonly abortOnStart = new Set<string>();
153
+ private readonly stopped = new Set<string>();
154
+ /**
155
+ * Rows whose turn started with a message that does not carry their prefix (another extension
156
+ * rewrote it): they ran, so they are done, though the file will never prove it. Offered again they
157
+ * would run a second time, which 0.22 never did.
158
+ */
159
+ private readonly ranUnproved = new Set<string>();
160
+ private readonly self: Producer;
161
+ private readonly alive: (row: SessionRequest) => boolean;
162
+ /** The bytes of a person row's images (`RequestStore.images`): read as the row is handed over, not kept with the row. */
163
+ private readonly images: (row: SessionRequest) => ImageContent[];
164
+
165
+ constructor(pi: Pick<ExtensionAPI, "sendMessage" | "sendUserMessage">, binding: WindowBinding, ctx: () => WindowCtx | undefined, self: Producer, alive: (row: SessionRequest) => boolean = producerAlive, rows: () => SessionRequest[] = () => [], images: (row: SessionRequest) => ImageContent[] = noImages) {
166
+ this.rows = rows;
167
+ this.pi = pi;
168
+ this.binding = binding;
169
+ this.ctx = ctx;
170
+ this.self = self;
171
+ this.alive = alive;
172
+ this.images = images;
173
+ }
174
+
175
+ /**
176
+ * This window holds `sessionId`, and is a place a result should land: a one-shot `pi -p` / json
177
+ * run holds its conversation but would swallow a result into a turn that is about to exit, so the
178
+ * background process appends it after, and a turn row waits for a window. The rows this process
179
+ * produced itself (what the person typed here, its goal's continuations) are run here in any
180
+ * mode, lease or not: `mine`, which `drain` asks beside this.
181
+ */
182
+ holds(sessionId: string): boolean {
183
+ const here = this.binding.here();
184
+ const ctx = this.ctx();
185
+ return !!ctx && here.sessionId === sessionId && here.holds && (ctx.mode === "tui" || ctx.mode === "rpc");
186
+ }
187
+
188
+ entries(_sessionId: string): ConversationView {
189
+ const ctx = this.ctx();
190
+ return conversationView(() => ctx?.sessionManager.getEntries() ?? []);
191
+ }
192
+
193
+ /**
194
+ * Whether row `id`'s delivery is in this conversation: by its prefix, or by its text since its
195
+ * hand-over stamp (src/engine.ts `landed`), read off the row in the request file. A row gone from
196
+ * the file is nobody's to wait for; what is left to ask is whether its prefix is in the conversation.
197
+ */
198
+ delivered(id: string): boolean {
199
+ const row = this.rows().find((r) => r.id === id);
200
+ return row ? landed(row, this.entries("")) : this.entries("").has(id);
201
+ }
202
+
203
+ view(sessionId: string): ExecutorView {
204
+ // What pi has written is in the conversation, not in flight any more.
205
+ for (const id of [...this.handedOver]) {
206
+ if (!this.delivered(id)) continue;
207
+ this.handedOver.delete(id);
208
+ this.handedIdle.delete(id);
209
+ }
210
+ // A person row's cap lives as long as the row: one pi refused keeps it, since pi may yet run
211
+ // the send late (see `refused`); one gone from the file has nothing left to start.
212
+ const queued = new Set(this.rows().map((row) => row.id));
213
+ for (let at = this.personCaps.length - 1; at >= 0; at--) if (!queued.has(this.personCaps[at].cap.id) && !this.handedOver.has(this.personCaps[at].cap.id)) this.personCaps.splice(at, 1);
214
+ for (let at = this.lateCaps.length - 1; at >= 0; at--) if (!queued.has(this.lateCaps[at].cap.id)) this.lateCaps.splice(at, 1);
215
+ // What this window produced is remembered for as long as the row can still start a turn: a
216
+ // window open for weeks produces a row per slot, and the file is where the rows that matter are.
217
+ for (const id of [...this.producedRows.keys()]) if (!queued.has(id) && !this.handedOver.has(id)) this.producedRows.delete(id);
218
+ for (const id of [...this.dropped]) if (!queued.has(id)) this.dropped.delete(id);
219
+ for (const id of [...this.ranUnproved]) if (!queued.has(id)) this.ranUnproved.delete(id);
220
+ return {
221
+ kind: "window",
222
+ ceiling: CEILING.window,
223
+ admits: () => true,
224
+ idle: this.ctx()?.isIdle() ?? true,
225
+ running: this.running,
226
+ inFlight: this.handedOver,
227
+ stopped: this.stopped,
228
+ ran: this.ranUnproved,
229
+ inFlightSince: this.handedIdle,
230
+ dropped: this.dropped,
231
+ believed: (row) => this.producedRows.get(row.id) === requestFingerprint(row),
232
+ connected: () => true,
233
+ producerAlive: (row) => this.alive(row),
234
+ // A page's row this window was rung for is this window's as a local row is: the ring that
235
+ // makes it the person's also names the pi it was typed into.
236
+ mine: (row) => this.binding.here().sessionId === row.session_id && row.created_by.left === undefined && (sameProducer(row.created_by, this.self) || (row.source === "person" && this.producedRows.get(row.id) === requestFingerprint(row))),
237
+ holderDelivers: this.binding.otherHolderDelivers?.(),
238
+ };
239
+ }
240
+
241
+ /** A row this window just produced: remembered, so its source is believed when its turn starts. */
242
+ produced(row: SessionRequest): void {
243
+ this.producedRows.set(row.id, requestFingerprint(row));
244
+ // A ring that arrives after the hand-over (the page rang while pi was emitting agent_settled,
245
+ // and the drain in that handler went first): the row is the page's all the same, so its turn,
246
+ // started or not, runs under the cap the ring vouches for.
247
+ const pending = this.personCaps.find((p) => p.cap.id === row.id);
248
+ if (pending) pending.cap = this.capOf(row);
249
+ if (this.current?.kind === "row" && this.current.id === row.id) this.current = this.capOf(row);
250
+ }
251
+
252
+ /**
253
+ * The cap a row's turn runs under here. The mode is recomputed from the source, never read from
254
+ * the file; and an attended source is believed only on a row this instance wrote itself. Any other
255
+ * attended row (one written into the file by hand, or by a capped turn; a local row the holder runs
256
+ * once its producer left) runs as unattended with nothing declared: the danger check alone, which
257
+ * asks the person in the window.
258
+ */
259
+ private capOf(row: SessionRequest): CurrentTurn & { kind: "row" } {
260
+ const mode = modeOf(row.kind, row.source, row.wake);
261
+ if (mode === "attended" && this.producedRows.get(row.id) !== requestFingerprint(row)) return { kind: "row", id: row.id, mode: "unattended" };
262
+ return { kind: "row", id: row.id, mode, tools: row.payload?.tools, builtinTools: row.payload?.builtin_tools };
263
+ }
264
+
265
+ append(row: SessionRequest, wakeAgent: boolean): void {
266
+ const content = row.payload?.text ?? "";
267
+ if (wakeAgent) this.pi.sendMessage({ customType: "pi-loops:trigger", content, display: true }, { triggerTurn: true, deliverAs: "followUp" });
268
+ else this.pi.sendMessage({ customType: "pi-loops:trigger", content, display: true }, { triggerTurn: false });
269
+ this.handedOver.add(row.id);
270
+ }
271
+
272
+ /**
273
+ * Handed over only while pi is idle, so `followUp` matters in one case: the person started a run
274
+ * between the engine's idle check and this call, and pi then queues the row behind that run. A
275
+ * row proved by its text (a person's message, a claim, a recipe hand-off, `/new-trigger`, a goal
276
+ * continuation) goes in its own words, a person's images with it; its cap is taken by the next
277
+ * user message with its text (`messageStarted`), there being no prefix to name it by. A person row
278
+ * this window was not rung for (`produced`), one a terminal runs for a page or a forged one, is
279
+ * capped as unattended by `capOf`, so the window asks before a dangerous command.
280
+ */
281
+ turn(row: SessionRequest, now = Date.now()): void {
282
+ const text = row.payload?.text ?? "";
283
+ // Read before anything is handed over: images that cannot be read, or are not the row's, fail
284
+ // the hand-over (the engine records the error on the row) instead of sending the text alone.
285
+ const images = row.payload?.images?.length ? this.images(row) : undefined;
286
+ if (carriesPrefix(row.source)) {
287
+ this.pi.sendUserMessage(text, { deliverAs: "followUp" });
288
+ this.caps.set(row.id, this.capOf(row));
289
+ } else {
290
+ // A cap kept for a row pi refused (`refused`) ends here: a later text-proved row is handed
291
+ // over, and a message that starts with the refused row's words is more likely that one's.
292
+ for (let at = this.personCaps.length - 1; at >= 0; at--) if (!this.handedOver.has(this.personCaps[at].cap.id)) this.lateCaps.push(...this.personCaps.splice(at, 1));
293
+ this.pi.sendUserMessage(images?.length ? [{ type: "text", text }, ...images] : text, { deliverAs: "followUp" });
294
+ this.personCaps.push({ cap: this.capOf(row), text });
295
+ }
296
+ this.handedOver.add(row.id);
297
+ this.handedIdle.set(row.id, now);
298
+ this.dropped.delete(row.id);
299
+ if (showsTriggeredTurnLine(row.source)) triggeredTurnLine(this.ctx(), row.id, true);
300
+ }
301
+
302
+ /** Whose turn pi is in now, for the `tool_call` cap: a row's, the person's, or none. */
303
+ currentTurn(): CurrentTurn | undefined {
304
+ return this.current;
305
+ }
306
+
307
+ /** pi's own abort, as Esc is: it stops the current turn and nothing else; what is queued runs once pi is idle. */
308
+ abort(): void {
309
+ this.ctx()?.abort();
310
+ }
311
+
312
+ abortWhenStarted(id: string): void {
313
+ this.abortOnStart.add(id);
314
+ }
315
+
316
+ /**
317
+ * pi did not write the row (a `fail` action): not in flight any more, and the attempt is counted.
318
+ * A person row's cap stays (`view` drops it with the row): a send pi runs late still runs under
319
+ * it. A pi-loops row's turn, started late, takes its cap from the row in the file (`messageStarted`).
320
+ */
321
+ refused(id: string): void {
322
+ this.handedOver.delete(id);
323
+ this.handedIdle.delete(id);
324
+ this.caps.delete(id);
325
+ this.dropped.delete(id);
326
+ }
327
+
328
+ private forgetPersonCap(id: string): void {
329
+ const at = this.personCaps.findIndex((p) => p.cap.id === id);
330
+ if (at >= 0) this.personCaps.splice(at, 1);
331
+ }
332
+
333
+ /**
334
+ * At `agent_settled`: the turn is over, with its retries and follow-ups, so the cap in force ends
335
+ * here and nowhere earlier. What pi dropped is offered again by the next drain, unless a stop
336
+ * named it. A person submission still pending was dropped with pi's queue. Returns the ids forgotten.
337
+ */
338
+ settled(): string[] {
339
+ this.current = undefined;
340
+ this.personPending = 0;
341
+ const dropped = forgetDropped(this.handedOver, (id) => this.delivered(id));
342
+ for (const id of dropped) {
343
+ this.caps.delete(id);
344
+ this.handedIdle.delete(id);
345
+ this.forgetPersonCap(id);
346
+ if (this.abortOnStart.delete(id)) this.stopped.add(id);
347
+ else this.dropped.add(id);
348
+ }
349
+ return dropped;
350
+ }
351
+
352
+ /**
353
+ * At `agent_start`: a run is on; which row began it is known at its first user message. A retry
354
+ * starts one too, under the cap in force. Whatever rows were handed over, pi took something since:
355
+ * none of them was refused, and a dropped one is found at `settled`.
356
+ */
357
+ agentStarted(now: number): void {
358
+ this.running = { startedAt: stamp(now) };
359
+ this.handedIdle.clear();
360
+ }
361
+
362
+ /**
363
+ * pi emitted `input` (ours or anyone's) or began a message: it is in its pre-run phase for
364
+ * something, so what was handed over is not refused yet. The grace counts again from here.
365
+ */
366
+ inputSeen(now: number): void {
367
+ for (const id of this.handedIdle.keys()) this.handedIdle.set(id, now);
368
+ }
369
+
370
+ /**
371
+ * At `message_start`, for a user message (a custom one changes nothing). One that starts with
372
+ * `[Trigger <id>] ` names a row: the one this instance handed over, else the one still in the
373
+ * request file (an earlier instance in this process handed it over, before a /reload), and that
374
+ * row's cap is the turn's from here. Any other user message is the person's when they submitted
375
+ * one that has not started yet (pi's `input` event, counted in `personInput`); otherwise it is
376
+ * another extension's and changes nothing while a row's cap is in force; with none in force it is
377
+ * the person's, or nobody's, and uncapped. A row a stop named is aborted here; pi still writes the
378
+ * message at `message_end` (its agent loop emits the two back to back), so the proof lands and the
379
+ * row is done, not offered again.
380
+ */
381
+ messageStarted(message: unknown, now = Date.now()): void {
382
+ const text = userTextOf(message);
383
+ if (text === undefined) return;
384
+ this.inputSeen(now);
385
+ const id = triggerIdOf(message);
386
+ if (id !== undefined) {
387
+ if (this.running) this.running = { ...this.running, requestId: id };
388
+ if (this.abortOnStart.delete(id)) this.ctx()?.abort();
389
+ const cap = this.caps.get(id);
390
+ this.caps.delete(id);
391
+ const row = cap ? undefined : this.rows().find((r) => r.id === id);
392
+ if (cap || row) {
393
+ // A row's turn starting: a submission counted before it that never became a message (an
394
+ // input another extension handled, a prompt pi refused) is not what follows. Reset
395
+ // whatever cycle this is: at worst the person's next message is asked about once.
396
+ this.personPending = 0;
397
+ this.current = cap ?? this.capOf(row!);
398
+ return;
399
+ }
400
+ }
401
+ // A person row's turn: of the rows handed over whose text this message is (`personTextMatches`:
402
+ // pi appends a note when it resizes or converts an image), the one with the longest text, the
403
+ // most recently handed over on a tie, so "fix" does not take the turn of "fix it and push
404
+ // --force". Before a submission pending from the terminal, since the row was handed over first.
405
+ const claim = (caps: typeof this.personCaps) => {
406
+ let person = -1;
407
+ for (let at = 0; at < caps.length; at++) {
408
+ if (!personTextMatches(text, caps[at].text)) continue;
409
+ if (person < 0 || caps[at].text.length >= caps[person].text.length) person = at;
410
+ }
411
+ return person < 0 ? undefined : caps.splice(person, 1)[0];
412
+ };
413
+ // What the person submitted comes before a refused row's kept cap; a send pi runs late is
414
+ // claimed only by a message nobody else accounts for.
415
+ const claimed = claim(this.personCaps) ?? (this.personPending > 0 ? undefined : claim(this.lateCaps));
416
+ if (claimed) {
417
+ const { cap } = claimed;
418
+ if (this.running) this.running = { ...this.running, requestId: cap.id };
419
+ if (this.abortOnStart.delete(cap.id)) this.ctx()?.abort();
420
+ this.personPending = 0;
421
+ this.current = cap;
422
+ return;
423
+ }
424
+ // A message with no prefix at all while a pi-loops row is handed over and unstarted: another
425
+ // extension's `input` handler rewrote the text. pi runs what it was given in order, so the
426
+ // oldest handed over is the turn starting now, and it runs under its cap. The person's own
427
+ // pending submission wins; a message with a prefix that names no row of ours is somebody else's.
428
+ const oldest = this.caps.entries().next();
429
+ if (id === undefined && this.personPending === 0 && !oldest.done) {
430
+ const [handedId, cap] = oldest.value;
431
+ this.caps.delete(handedId);
432
+ this.handedOver.delete(handedId);
433
+ this.handedIdle.delete(handedId);
434
+ this.ranUnproved.add(handedId);
435
+ if (this.running) this.running = { ...this.running, requestId: handedId };
436
+ if (this.abortOnStart.delete(handedId)) this.ctx()?.abort();
437
+ this.current = cap;
438
+ return;
439
+ }
440
+ if (this.personPending > 0) {
441
+ this.personPending--;
442
+ this.current = { kind: "person" };
443
+ } else if (this.current?.kind !== "row") this.current = { kind: "person" };
444
+ }
445
+
446
+ /**
447
+ * At `agent_end`: no run is on. An aborted run (Esc, Stop) is over like any other: what is queued
448
+ * runs when pi is idle, as messages queued in a chat do (owner decision, 2026-11-02; the queue is
449
+ * visible and cancellable instead, `/cron queue`). The cap in force outlives the run (`settled`).
450
+ */
451
+ agentEnded(): void {
452
+ this.running = undefined;
453
+ }
454
+
455
+ /** The person submitted something (pi's `input` event, not from an extension): the next user message is theirs. */
456
+ personInput(): void {
457
+ this.personPending++;
458
+ }
459
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The cap on a scheduled turn in an open window (design §6.3, owner decision O1). A plain job's
3
+ * prompt runs under the job's declared tools and the dangerous-command policy wherever it runs: in
4
+ * the background the sub-agent guard (src/subagent-guard.ts) refuses outright, since nobody is there;
5
+ * in a window the person is, so the same call asks them, with a countdown, and is refused when
6
+ * nobody answers. `guardDecision` is the whole decision as a function; `windowGuard` is the
7
+ * `tool_call` handler that applies it, built here so a test can drive it without the extension.
8
+ */
9
+ import type { ExtensionContext, ToolCallEventResult } from "@earendil-works/pi-coding-agent";
10
+ import { dangerousCommandReason } from "./danger.ts";
11
+ import { PI_BUILTIN_TOOL_NAMES } from "./mcp.ts";
12
+ import { capRedacted } from "./redact.ts";
13
+ import { type CurrentTurn, unattendedTurn } from "./window-executor.ts";
14
+
15
+ /** How long the person has to answer before an unanswered question refuses the call. */
16
+ export const WINDOW_GUARD_CONFIRM_MS = 60_000;
17
+
18
+ /**
19
+ * The two answers to the guard's question, the refusing one first: pi's selector starts on the
20
+ * first option and Enter picks it, so a reflexive Enter refuses. pi's confirm starts on Yes.
21
+ */
22
+ export const GUARD_CHOICES = { refuse: "No, refuse it", allow: "Yes, run it once" } as const;
23
+
24
+ /** Pure. Only the allowing answer allows: no answer (the timeout), Escape and anything else refuse. */
25
+ export function allows(choice: string | undefined): boolean {
26
+ return choice === GUARD_CHOICES.allow;
27
+ }
28
+
29
+ /** What the question came to: the allowing answer, the person's refusal, or no answer (the timeout, Escape, no UI). */
30
+ export type GuardAnswer = "yes" | "no" | "none";
31
+ /** How close to its timeout a false confirm counts as the timeout: timers fire slightly early. */
32
+ const TIMEOUT_SLACK_MS = 50;
33
+
34
+ /**
35
+ * The question, put so that the reflexive answer refuses. In a terminal pi draws a confirm with the
36
+ * cursor on Yes, so it is a select with the refusal first. The browser draws a select's title as one
37
+ * heading line, and its confirm already puts cancel first, so there it stays a confirm. No answer
38
+ * within `timeout` is a refusal either way; the model is told which it was, so a person's No is not
39
+ * reported as nobody being there. pi's confirm resolves false for both, so over rpc the clock tells
40
+ * them apart.
41
+ */
42
+ export async function askRefusingFirst(ctx: Pick<ExtensionContext, "ui" | "mode">, title: string, body: string, timeout: number): Promise<GuardAnswer> {
43
+ if (ctx.mode === "rpc") {
44
+ const asked = Date.now();
45
+ if (await ctx.ui.confirm(title, body, { timeout })) return "yes";
46
+ // Timers fire a millisecond or so early against Date.now(); a No in the last instants of the
47
+ // countdown cannot be told from the countdown anyway.
48
+ return Date.now() - asked >= timeout - Math.min(TIMEOUT_SLACK_MS, timeout / 10) ? "none" : "no";
49
+ }
50
+ const choice = await ctx.ui.select(`${title}\n${body}`, [GUARD_CHOICES.refuse, GUARD_CHOICES.allow], { timeout });
51
+ return allows(choice) ? "yes" : choice === GUARD_CHOICES.refuse ? "no" : "none";
52
+ }
53
+
54
+ export interface GuardConfig {
55
+ /** `[engine] window_guard`; false is 0.23's uncapped window. */
56
+ windowGuard: boolean;
57
+ /** `[danger] allow`: commands the dangerous-command policy lets through, as in the background. */
58
+ allowCommands: readonly string[];
59
+ }
60
+
61
+ /**
62
+ * The pi-loops tools a declared tool list lets through undeclared, as the background does (they are
63
+ * every run's `customTools`): the ones that only read (src/tools.ts), and the ones that carry their
64
+ * own gate. A job made in a capped turn is capped to that turn's tools (src/pi-loops.ts `toolHost`,
65
+ * as src/host-runtime.ts scopes a run's), so `cron_create` widens nothing; the rest ask through
66
+ * `confirmTool` exactly where the background's preflight refuses, and the guard asking as well made
67
+ * two questions of one call.
68
+ */
69
+ export const READ_ONLY_AUTOMATION_TOOLS: readonly string[] = ["cron_list", "list_triggers"];
70
+ /** The pi-loops tools whose gate is their own (`confirmTool`, or the cap on what they create): the guard adds no question. */
71
+ export const SELF_GATED_AUTOMATION_TOOLS: readonly string[] = ["cron_create", "cron_remove", "set_cron_job_state", "new_trigger", "remove_trigger", "set_trigger_state"];
72
+ const AUTOMATION_TOOLS: readonly string[] = [...READ_ONLY_AUTOMATION_TOOLS, ...SELF_GATED_AUTOMATION_TOOLS];
73
+
74
+ export type GuardDecision = { kind: "allow" } | { kind: "ask"; reason: string };
75
+
76
+ /** The shell command a call carries, for the tools that run one. */
77
+ function commandOf(call: { toolName: string; input?: unknown }): string | undefined {
78
+ if (call.toolName !== "bash" && call.toolName !== "powershell") return undefined;
79
+ const command = (call.input as { command?: unknown } | undefined)?.command;
80
+ return typeof command === "string" ? command : undefined;
81
+ }
82
+
83
+ /**
84
+ * Pure. Everything is allowed unless the turn is a scheduled row's and the guard is on. Then a call
85
+ * asks when the tool is outside what the row declared (`--tools` names the whole set; a built-in
86
+ * list caps only pi's built-ins, so extension and MCP tools pass; neither declares nothing), or when
87
+ * the command is one the background would refuse.
88
+ */
89
+ export function guardDecision(call: { toolName: string; input?: unknown }, current: CurrentTurn | undefined, cfg: GuardConfig): GuardDecision {
90
+ if (!cfg.windowGuard || !unattendedTurn(current)) return { kind: "allow" };
91
+ if (current.tools) {
92
+ if (!current.tools.includes(call.toolName) && !AUTOMATION_TOOLS.includes(call.toolName)) return { kind: "ask", reason: `${call.toolName} is not among the tools the job declared (${current.tools.join(", ") || "none"})` };
93
+ } else if (current.builtinTools && PI_BUILTIN_TOOL_NAMES.includes(call.toolName) && !current.builtinTools.includes(call.toolName)) {
94
+ return { kind: "ask", reason: `${call.toolName} is not among the built-in tools the job declared (${current.builtinTools.join(", ") || "none"})` };
95
+ }
96
+ const command = commandOf(call);
97
+ const danger = command === undefined ? undefined : dangerousCommandReason(command, undefined, cfg.allowCommands);
98
+ return danger === undefined ? { kind: "allow" } : { kind: "ask", reason: danger };
99
+ }
100
+
101
+ /**
102
+ * What the model is told: the sub-agent guard's wording, with who refused. `nobody` is the default:
103
+ * the question timed out, was escaped, or there was no UI to ask; `person` is a No the person gave.
104
+ */
105
+ export function refusal(reason: string, by: "nobody" | "person" = "nobody"): string {
106
+ const who = by === "person" ? "The person at the window refused it" : "This scheduled turn ran with nobody answering";
107
+ return `refused by pi-loops: ${reason}. ${who}; commands that can destroy data or the machine, and tools the job did not declare, are not allowed; do the safe part and report what you would need a human for.`;
108
+ }
109
+
110
+ /**
111
+ * How much of the command the question shows. Cut in characters and marked with `…`, so a person
112
+ * asked to say yes can tell when they have not seen all of it: a code-unit cut with no marker showed
113
+ * a 300-character command as if it ended at 200, and could end it on half of an emoji.
114
+ */
115
+ export const WINDOW_GUARD_COMMAND_CHARS = 200;
116
+
117
+ export interface WindowGuardDeps {
118
+ current(): CurrentTurn | undefined;
119
+ config(): GuardConfig;
120
+ log(line: string): void;
121
+ /** WINDOW_GUARD_CONFIRM_MS unless a test shortens it. */
122
+ confirmTimeoutMs?: number;
123
+ }
124
+
125
+ /**
126
+ * The `tool_call` handler: decide, ask when the decision says so, refuse when the answer is not the
127
+ * allowing one or never comes. pi resolves an unanswered or escaped select to undefined, at the
128
+ * timeout in the terminal and over rpc alike, so the countdown is pi's and the refusal is ours. The
129
+ * question has no body of its own, so the body rides in the title. This is a boundary that fails closed:
130
+ * with no UI to ask, or with anything in here throwing, a scheduled turn's call is refused. The
131
+ * person's own turn goes on as if the guard were not there, and so does a turn with no row in force
132
+ * (nothing of ours is running: between settle cycles), the one unknown that is allowed. Whose turn
133
+ * it is comes first, and a failure to learn even that refuses.
134
+ */
135
+ export function windowGuard(deps: WindowGuardDeps): (event: { toolName: string; input?: unknown }, ctx: Pick<ExtensionContext, "hasUI" | "ui" | "mode"> | undefined) => Promise<ToolCallEventResult | undefined> {
136
+ const timeout = deps.confirmTimeoutMs ?? WINDOW_GUARD_CONFIRM_MS;
137
+ return async (event, ctx) => {
138
+ let current: CurrentTurn | undefined;
139
+ try {
140
+ current = deps.current();
141
+ } catch (err: any) {
142
+ deps.log(`window guard failed on ${event.toolName}: ${err?.message ?? err}`);
143
+ return { block: true, reason: refusal("the guard could not decide") };
144
+ }
145
+ try {
146
+ const decision = guardDecision(event, current, deps.config());
147
+ if (decision.kind === "allow" || !unattendedTurn(current)) return undefined;
148
+ const command = commandOf(event);
149
+ const body = [`A scheduled prompt (trace ${current.id.slice(0, 8)}) wants to run ${event.toolName}: ${decision.reason}.`, ...(command === undefined ? [] : [capRedacted(command, WINDOW_GUARD_COMMAND_CHARS)]), `Yes runs it this once. No, or no answer in ${Math.round(timeout / 1000)} s, refuses it and tells the model why.`].join("\n");
150
+ const answer = ctx?.hasUI ? await askRefusingFirst(ctx, "Allow this in a scheduled turn?", body, timeout) : "none";
151
+ deps.log(`${answer === "yes" ? "allowed" : "refused"} ${event.toolName} in scheduled turn ${current.id.slice(0, 8)}: ${decision.reason}${ctx?.hasUI ? (answer === "no" ? " (the person said no)" : "") : " (no UI to ask)"}`);
152
+ return answer === "yes" ? undefined : { block: true, reason: refusal(decision.reason, answer === "no" ? "person" : "nobody") };
153
+ } catch (err: any) {
154
+ deps.log(`window guard failed on ${event.toolName}: ${err?.message ?? err}`);
155
+ return unattendedTurn(current) ? { block: true, reason: refusal("the guard could not decide") } : undefined;
156
+ }
157
+ };
158
+ }
@@ -1,34 +0,0 @@
1
- # Issue tracker: GitHub
2
-
3
- Work items for this repository live as GitHub Issues in `alphacoder-v0/pi-loops`; pull requests
4
- are the review surface. Use the `gh` CLI for every operation; it infers the repository from the
5
- remote when run in a clone. Loops shell out to `gh`, so the account must be authenticated in the
6
- environment a loop runs in — `gh auth status` answers that.
7
-
8
- ## Operations
9
-
10
- - **List**: `gh issue list --state open --limit 200 --json number,title,labels,updatedAt,author` (add `--label <state>` to filter by state)
11
- - **Read**: `gh issue view <n> --comments --json number,title,body,labels,author,createdAt,comments`
12
- - **Comment**: `gh issue comment <n> --body "..."` (a heredoc for multi-line bodies)
13
- - **Change state**: `gh issue edit <n> --add-label "<new state>" --remove-label "<old state>"` — exactly one state label at a time
14
- - **Claim**: `gh issue edit <n> --add-label agent-working --remove-label ready-for-agent --add-assignee @me`
15
- - **Close**: `gh issue close <n> --comment "..."`
16
- - **Pull requests**: `gh pr create`, `gh pr list --state open --json number,url,headRefName,reviewDecision,comments,updatedAt`, `gh pr view <n> --comments`, `gh pr diff <n>`
17
-
18
- ## State vocabulary
19
-
20
- One category label (`bug` | `enhancement`) and exactly one state label per open issue. The state
21
- labels are `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `agent-working`,
22
- `in-review`, `agent-blocked`, `wontfix`. They already exist in this repository; `labels.sh`
23
- refreshes their colour and description and touches no issue. A playbook that moves an issue names
24
- the label it moves to.
25
-
26
- ## Blocking
27
-
28
- A `Blocked by: #<n>, #<m>` line near the top of an issue body (or the tracker's native
29
- dependency) gates it: the item is unblocked when every listed issue is closed.
30
-
31
- ## Who counts as the agent
32
-
33
- Comments and assignments made by loops are made as the account `gh` is signed in as — currently
34
- `alphacoder-v0`. Every comment a loop posts starts with a line saying it was generated by AI.