@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
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}`;
@@ -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 same two events the interactive extension and the headless host fire. Whether a run happens
88
- * here or there is an accident of who held the clock, and a hook rule should not be able to tell —
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
- let stderr = "";
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
- let stdout = "";
348
- let overflowed = false;
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 text = stdout.trim();
355
- if (!text) return; // a silent hook stays silent
356
- const capped = overflowed || text.length > MAX_OUTPUT_CHARS;
357
- this.opts.log?.(`hook ${h.source} ${h.event}${h.tool ? ` (tool=${h.tool})` : ""}: ${capped ? `${text.slice(0, MAX_OUTPUT_CHARS)}… (output truncated)` : text}`);
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
  }