@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.
- package/CHANGELOG.md +681 -0
- package/CONTRACT.md +7 -7
- package/README.md +91 -63
- package/README.zh-CN.md +59 -39
- package/docs/cli.md +272 -83
- package/docs/configuration.md +86 -24
- package/docs/design.md +395 -56
- package/docs/downstream.md +2 -2
- package/docs/goal.md +12 -3
- package/docs/hooks.md +6 -7
- package/docs/loops.md +288 -59
- package/docs/mcp.md +22 -14
- package/docs/recipes.md +6 -0
- package/docs/screenshot.png +0 -0
- package/docs/triggers.md +120 -26
- package/docs/troubleshooting.md +37 -26
- package/docs/web-ui-parity.md +691 -65
- package/package.json +4 -3
- package/recipes/autoresearch/recipe.toml +1 -0
- package/recipes/changelog-draft/recipe.toml +1 -0
- package/recipes/ci-sweeper/recipe.toml +1 -0
- package/recipes/daily-digest/recipe.toml +1 -0
- package/recipes/deps-sweeper/recipe.toml +1 -0
- package/recipes/ecosystem/recipe.toml +1 -0
- package/recipes/issue-loop/recipe.toml +1 -0
- package/recipes/pr-watch/recipe.toml +1 -0
- package/skills/pi-loops/SKILL.md +6 -4
- package/src/activity.ts +199 -0
- package/src/archive.ts +21 -9
- package/src/args.ts +6 -2
- package/src/cli-entry.mjs +12 -3
- package/src/cli.ts +746 -125
- package/src/config.ts +53 -5
- package/src/conversation-rows.ts +469 -0
- package/src/danger.ts +227 -6
- package/src/engine.ts +1240 -0
- package/src/goal.ts +71 -0
- package/src/held-read.ts +27 -0
- package/src/hooks.ts +49 -18
- package/src/host-control-channel.ts +455 -40
- package/src/host-control.ts +602 -111
- package/src/host-entry.mjs +5 -3
- package/src/host-runtime.ts +455 -41
- package/src/host.ts +659 -123
- package/src/inbox-card.ts +278 -0
- package/src/inbox.ts +139 -62
- package/src/job-edit.ts +7 -1
- package/src/job-health.ts +14 -2
- package/src/job-lines.ts +201 -13
- package/src/job-owner.ts +74 -13
- package/src/job-tune.ts +110 -0
- package/src/lease.ts +537 -0
- package/src/lock.ts +7 -2
- package/src/mcp-pool.ts +66 -14
- package/src/mcp.ts +1 -1
- package/src/open-file.ts +54 -0
- package/src/page-children.ts +1507 -0
- package/src/page-devices.ts +192 -0
- package/src/page-server.ts +3150 -0
- package/src/path-precheck.ts +215 -0
- package/src/paths.ts +24 -0
- package/src/pi-floor.ts +10 -0
- package/src/pi-loops.ts +1301 -450
- package/src/pi-resolver.d.mts +1 -0
- package/src/pi-resolver.mjs +110 -10
- package/src/presence.ts +12 -63
- package/src/protocol.ts +10 -1
- package/src/recipe.ts +43 -1
- package/src/requests.ts +354 -0
- package/src/rule-lines.ts +68 -0
- package/src/run-card.ts +105 -0
- package/src/runner.ts +18 -1
- package/src/schedule.ts +1 -2
- package/src/scheduler.ts +598 -245
- package/src/sdk-runner.ts +321 -27
- package/src/seen.ts +278 -0
- package/src/session-head.ts +21 -0
- package/src/share.ts +12 -2
- package/src/snapshot.ts +3 -2
- package/src/store.ts +81 -8
- package/src/tools.ts +196 -65
- package/src/trigger-runtime.ts +164 -127
- package/src/triggers.ts +39 -7
- package/src/trust.ts +46 -2
- package/src/ui-prefs.ts +16 -5
- package/src/watch-plan.ts +174 -0
- package/src/web.d.mts +3 -0
- package/src/web.mjs +2260 -2340
- package/src/window-executor.ts +459 -0
- package/src/window-guard.ts +158 -0
- 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.
|