@alphacoder-v0/pi-loops 0.22.8 → 0.24.1
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 +717 -0
- package/CONTRACT.md +7 -7
- package/README.md +114 -78
- package/README.zh-CN.md +62 -41
- package/docs/cli.md +352 -91
- package/docs/configuration.md +86 -24
- package/docs/design.md +404 -61
- 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 +41 -28
- package/docs/web-ui-parity.md +702 -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/browser-open.ts +169 -0
- package/src/cli-entry.mjs +12 -3
- package/src/cli.ts +960 -143
- 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 -42
- 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 +1370 -473
- 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 +595 -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 +22 -7
- package/src/watch-plan.ts +174 -0
- package/src/web.d.mts +3 -0
- package/src/web.mjs +2261 -2340
- package/src/window-executor.ts +459 -0
- package/src/window-guard.ts +158 -0
- package/docs/agents/issue-tracker.md +0 -34
package/src/goal.ts
CHANGED
|
@@ -32,6 +32,15 @@ export function goalActive(state: GoalState): boolean {
|
|
|
32
32
|
return state.status === "pursuing" || state.status === "paused" || state.status === "budget_limited";
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Whether a queued continuation still has a goal to run for: only a pursued one. A paused goal (Esc,
|
|
37
|
+
* an evaluator that could not answer, the budget) keeps its condition but not the continuation it
|
|
38
|
+
* was about to run; `/goal resume` judges the next turn afresh.
|
|
39
|
+
*/
|
|
40
|
+
export function goalPursued(state: GoalState | undefined): boolean {
|
|
41
|
+
return state?.status === "pursuing";
|
|
42
|
+
}
|
|
43
|
+
|
|
35
44
|
export interface EvaluatorDecision {
|
|
36
45
|
ok: boolean;
|
|
37
46
|
reason: string;
|
|
@@ -170,6 +179,45 @@ export function branchMovedSince(entries: Array<{ id: string; type: string; mess
|
|
|
170
179
|
return entries.slice(from + 1).some((e) => e.type === "message" && e.message?.role === "user");
|
|
171
180
|
}
|
|
172
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Whether a continuation is still on its way: a `goal` row queued for the session, or handed to pi
|
|
184
|
+
* and not yet in the conversation. While one is, a settled turn is not judged again: the evaluator
|
|
185
|
+
* charged `iterations` when it asked for that continuation, and the drain delivers it instead. Once
|
|
186
|
+
* the continuation is in the conversation the turn it started is the next one to judge, so a row
|
|
187
|
+
* whose id `delivered` already holds does not count.
|
|
188
|
+
*/
|
|
189
|
+
export function continuationPending(queued: ReadonlyArray<{ id: string; source: string }>, inFlight: Iterable<string>, delivered: (id: string) => boolean): boolean {
|
|
190
|
+
if (queued.some((row) => row.source === "goal" && !delivered(row.id))) return true;
|
|
191
|
+
for (const id of inFlight) if (id.startsWith("goal-") && !delivered(id)) return true;
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* What `agent_settled` does, in order: judge the turn (`evaluate`, which hands over a continuation
|
|
197
|
+
* and says whether it did) unless a continuation is already on its way, in which case the drain
|
|
198
|
+
* delivers that one instead; then drain, unless the evaluator just handed a continuation over, since
|
|
199
|
+
* pi may not report itself busy with it yet and a second turn row would be queued behind it.
|
|
200
|
+
*/
|
|
201
|
+
export async function settledTurn(queued: ReadonlyArray<{ id: string; source: string }>, inFlight: Iterable<string>, delivered: (id: string) => boolean, evaluate: () => Promise<boolean>, drain: () => Promise<unknown>): Promise<{ pending: boolean; continued: boolean }> {
|
|
202
|
+
const pending = continuationPending(queued, inFlight, delivered);
|
|
203
|
+
const continued = !pending && (await evaluate());
|
|
204
|
+
if (!continued) await drain();
|
|
205
|
+
return { pending, continued };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Judge the settled turn, and judge again for as long as another turn settled while the evaluator
|
|
210
|
+
* ran (`skippedMeanwhile`, which takes the mark). Such a turn found the evaluator busy and was not
|
|
211
|
+
* judged by anyone, so a continuation held for it ("re-evaluated when this turn settles") waited
|
|
212
|
+
* for the person to type again. Not after a continuation went out, which settles into a judgement
|
|
213
|
+
* of its own, and not while pi is busy, since that turn will settle and be judged itself.
|
|
214
|
+
*/
|
|
215
|
+
export async function judgeCaughtUp(evaluate: () => Promise<boolean>, skippedMeanwhile: () => boolean, idle: () => boolean): Promise<boolean> {
|
|
216
|
+
let continued = await evaluate();
|
|
217
|
+
while (!continued && skippedMeanwhile() && idle()) continued = await evaluate();
|
|
218
|
+
return continued;
|
|
219
|
+
}
|
|
220
|
+
|
|
173
221
|
/** An evaluator that could not decide never loops the agent: it pauses and says why. */
|
|
174
222
|
export function pauseFor(state: GoalState, reason: string, now = new Date()): { state: GoalState; action: GoalAction } {
|
|
175
223
|
return { state: { ...state, status: "paused", lastReason: reason, updatedAt: stamp(now.getTime()) }, action: { kind: "pause", reason } };
|
|
@@ -186,6 +234,29 @@ export function abortedTurn(state: GoalState, now = new Date()): { state: GoalSt
|
|
|
186
234
|
return pauseFor(state, "you stopped the turn (Esc); /goal resume to continue", now);
|
|
187
235
|
}
|
|
188
236
|
|
|
237
|
+
/**
|
|
238
|
+
* `/goal resume`: pursuing again, and with the allowance started again when it is spent. Spent is
|
|
239
|
+
* the count, not the status: a budget-limited goal that was paused and then resumed is `paused`
|
|
240
|
+
* by then, and keeping its count sent the next "not satisfied" straight back to the limit, so the
|
|
241
|
+
* person had to resume twice (docs/goal.md: resume after a budget limit starts the allowance again).
|
|
242
|
+
*/
|
|
243
|
+
export function resumeGoal(state: GoalState, now = new Date()): GoalState {
|
|
244
|
+
const spent = state.status === "budget_limited" || state.iterations >= MAX_CONTINUATIONS;
|
|
245
|
+
return { ...state, status: "pursuing", iterations: spent ? 0 : state.iterations, updatedAt: stamp(now.getTime()) };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Whether an evaluation that just came back may write its verdict over `current`. Not if it was
|
|
250
|
+
* aborted: every abort is a `/goal` command or Esc, and each has already written the goal the person
|
|
251
|
+
* asked for. Checking only "still pursuing, same condition" let a `/goal resume`, or the same
|
|
252
|
+
* `/goal <condition>` typed again, be paused as "goal evaluator cancelled" by the evaluation it had
|
|
253
|
+
* just stopped. Nor if the goal was paused, cleared or replaced while it ran.
|
|
254
|
+
*/
|
|
255
|
+
export function evaluationApplies(current: GoalState | undefined, pursued: GoalState, aborted: boolean): boolean {
|
|
256
|
+
if (aborted) return false;
|
|
257
|
+
return !!current && current.status === "pursuing" && current.condition === pursued.condition;
|
|
258
|
+
}
|
|
259
|
+
|
|
189
260
|
/** One line for `/goal` and the panel. */
|
|
190
261
|
export function goalLine(state: GoalState): string {
|
|
191
262
|
const iter = `${state.iterations}/${MAX_CONTINUATIONS}`;
|
package/src/held-read.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How big a conversation this process parses: the page server reads held conversations itself, and
|
|
3
|
+
* the page child asks its pi for every entry. Both run in the process every page and loop shares,
|
|
4
|
+
* so both ask here first. A module of its own because src/page-server.ts imports
|
|
5
|
+
* src/page-children.ts, and both need it.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
|
|
9
|
+
/** The largest session file the page reads itself; a bigger one is answered by the page's pi. The read is synchronous, in the process every page shares. */
|
|
10
|
+
export const HELD_READ_MAX_BYTES = 32 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Whether this process asks pi for a conversation's entries (run cards, the panel's snapshot, find,
|
|
14
|
+
* undo, the model to resume on): pi answers with every entry, compacted and abandoned branches too,
|
|
15
|
+
* and this process parses them, so not for a session file over `HELD_READ_MAX_BYTES`. A file pi has
|
|
16
|
+
* not named yet is not read: its size is unknown. Nor is a file that is not on disk once it has been
|
|
17
|
+
* (`everOnDisk`), since it may have moved at any size; one pi has not written yet is read, since pi
|
|
18
|
+
* writes it at the first message and what it holds until then is small.
|
|
19
|
+
*/
|
|
20
|
+
export function entriesReadable(file: string | undefined, everOnDisk = true): boolean {
|
|
21
|
+
if (!file) return false; // not known yet: do not parse what we cannot size
|
|
22
|
+
try {
|
|
23
|
+
return fs.statSync(file).size <= HELD_READ_MAX_BYTES;
|
|
24
|
+
} catch (err: any) {
|
|
25
|
+
return err?.code === "ENOENT" && !everOnDisk; // not written yet is small; gone, or not ours to stat, is unknown
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/hooks.ts
CHANGED
|
@@ -32,6 +32,44 @@ const MAX_SUMMARY_CHARS = 2000;
|
|
|
32
32
|
/** What a hook may put in the log per run. The same bound stderr has used for the failure message. */
|
|
33
33
|
const MAX_OUTPUT_CHARS = 4000;
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* A stream's output, decoded as it arrives and kept up to a bound counted in characters.
|
|
37
|
+
*
|
|
38
|
+
* Each chunk used to be decoded on its own and the bound was `.length`, which counts UTF-16 code
|
|
39
|
+
* units: a character split across two chunks became a replacement glyph, an emoji-heavy hook was cut
|
|
40
|
+
* at about half the 4000 characters docs/hooks.md promises, and the cut could end on half of one.
|
|
41
|
+
* One streaming decoder keeps a split character whole; the count is characters. Once the bound is
|
|
42
|
+
* reached the rest is dropped, and `overflowed` says something was.
|
|
43
|
+
*/
|
|
44
|
+
export class BoundedText {
|
|
45
|
+
private readonly decoder = new TextDecoder();
|
|
46
|
+
private value = "";
|
|
47
|
+
private chars = 0;
|
|
48
|
+
private readonly max: number;
|
|
49
|
+
overflowed = false;
|
|
50
|
+
constructor(max: number) {
|
|
51
|
+
this.max = max;
|
|
52
|
+
}
|
|
53
|
+
push(chunk: Uint8Array): void {
|
|
54
|
+
if (this.chars >= this.max) {
|
|
55
|
+
this.overflowed = true;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const text = this.decoder.decode(chunk, { stream: true });
|
|
59
|
+
this.value += text;
|
|
60
|
+
this.chars += Array.from(text).length;
|
|
61
|
+
}
|
|
62
|
+
/** What was kept, with any bytes still held by the decoder flushed when nothing was dropped. */
|
|
63
|
+
text(): string {
|
|
64
|
+
if (!this.overflowed && this.chars < this.max) {
|
|
65
|
+
const rest = this.decoder.decode();
|
|
66
|
+
this.value += rest;
|
|
67
|
+
this.chars += Array.from(rest).length;
|
|
68
|
+
}
|
|
69
|
+
return this.value;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
35
73
|
export interface HookConfig {
|
|
36
74
|
event: HookEvent;
|
|
37
75
|
command?: string;
|
|
@@ -84,9 +122,8 @@ export interface HookPayload {
|
|
|
84
122
|
export type HookEventData = Pick<HookPayload, "event" | "message_kind" | "message_summary" | "assistant_event" | "tool_call_id" | "tool_name" | "tool_is_error" | "tool_args" | "tool_result_summary" | "compaction_trigger" | "compaction_tokens_before" | "compaction_summary" | "compaction_failed" | "run_job" | "run_id" | "run_ok" | "run_findings" | "run_error" | "run_cost_usd">;
|
|
85
123
|
|
|
86
124
|
/**
|
|
87
|
-
* The
|
|
88
|
-
*
|
|
89
|
-
* so both processes fire the object these two return.
|
|
125
|
+
* The two events around a scheduled run. Every loop runs in the host, which fires them; the
|
|
126
|
+
* interactive extension builds the same objects, so a run's payload has one shape wherever it came from.
|
|
90
127
|
*/
|
|
91
128
|
export function runStartEvent(job: LoopJob, runId: string): HookEventData {
|
|
92
129
|
return { event: "run_start", run_job: job.name ?? job.id, run_id: runId, message_summary: truncateSummary(`${job.name ?? job.id}: ${job.prompt}`) };
|
|
@@ -338,23 +375,17 @@ export class HookRunner {
|
|
|
338
375
|
// Own process group (setsid equivalent) so a timeout kills the whole tree, not just `sh`.
|
|
339
376
|
// stdout is piped only when there is somewhere to put it; otherwise it goes to /dev/null as before.
|
|
340
377
|
const proc = spawn(isWin ? "cmd" : "sh", [isWin ? "/C" : "-c", h.command!], { cwd: this.resolveCwd(h), env, stdio: ["ignore", this.opts.log ? "pipe" : "ignore", "pipe"], detached: !isWin });
|
|
341
|
-
|
|
342
|
-
proc.stderr?.on("data", (d) =>
|
|
343
|
-
if (stderr.length < MAX_OUTPUT_CHARS) stderr += d.toString();
|
|
344
|
-
});
|
|
378
|
+
const stderr = new BoundedText(MAX_OUTPUT_CHARS);
|
|
379
|
+
proc.stderr?.on("data", (d: Buffer) => stderr.push(d));
|
|
345
380
|
// What the hook itself printed. Bounded: a hook that prints a megabyte would otherwise put
|
|
346
381
|
// a megabyte in the log and rotate away everything the automation had recorded before it.
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
proc.stdout?.on("data", (d) => {
|
|
350
|
-
if (stdout.length >= MAX_OUTPUT_CHARS) overflowed = true;
|
|
351
|
-
else stdout += d.toString();
|
|
352
|
-
});
|
|
382
|
+
const stdout = new BoundedText(MAX_OUTPUT_CHARS);
|
|
383
|
+
proc.stdout?.on("data", (d: Buffer) => stdout.push(d));
|
|
353
384
|
const logOutput = () => {
|
|
354
|
-
const
|
|
355
|
-
if (!
|
|
356
|
-
const capped = overflowed ||
|
|
357
|
-
this.opts.log?.(`hook ${h.source} ${h.event}${h.tool ? ` (tool=${h.tool})` : ""}: ${capped ? `${
|
|
385
|
+
const chars = Array.from(stdout.text().trim());
|
|
386
|
+
if (!chars.length) return; // a silent hook stays silent
|
|
387
|
+
const capped = stdout.overflowed || chars.length > MAX_OUTPUT_CHARS;
|
|
388
|
+
this.opts.log?.(`hook ${h.source} ${h.event}${h.tool ? ` (tool=${h.tool})` : ""}: ${capped ? `${chars.slice(0, MAX_OUTPUT_CHARS).join("")}… (output truncated)` : chars.join("")}`);
|
|
358
389
|
};
|
|
359
390
|
const killTree = () => {
|
|
360
391
|
try {
|
|
@@ -388,7 +419,7 @@ export class HookRunner {
|
|
|
388
419
|
// managed to say before it was killed is exactly what the reader needs.
|
|
389
420
|
logOutput();
|
|
390
421
|
if (code === 0) finish();
|
|
391
|
-
else finish(new Error(`command exited ${code ?? -1}: ${stderr.trim()}`));
|
|
422
|
+
else finish(new Error(`command exited ${code ?? -1}: ${stderr.text().trim()}`));
|
|
392
423
|
});
|
|
393
424
|
});
|
|
394
425
|
}
|