@hank-warren/pi-loop 0.4.0 → 0.5.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.
@@ -1,13 +1,19 @@
1
1
  /**
2
2
  * `loop_complete`: the standalone loop's terminal tool.
3
3
  *
4
- * Deliberately thin. pi-goal's `goal_complete` carries a long evidence-audit
5
- * rules block and a stale-turn guard because a premature goal completion
6
- * abandons autonomous work and asserts the task is done. Stopping a loop
7
- * early only stops the pacemaker the user restarts it — so the same
8
- * hardening would be duplicated cost for a much smaller blast radius. The one
9
- * guard kept is the loop_id match, which is cheap and prevents a stale turn
10
- * from stopping a newer loop.
4
+ * It used to be deliberately thin, on the argument that stopping a pacemaker
5
+ * has a small blast radius: the user just restarts it. That argument does not
6
+ * survive the loop becoming the *only* long-work mechanism. A premature
7
+ * completion now abandons autonomous work outright, and the model doing the
8
+ * abandoning is the same one that decided the work was done.
9
+ *
10
+ * So completion is gated on the loop's own `criteria.json`: every criterion
11
+ * must be answered with cited evidence, and a criterion the file still
12
+ * records as unmet must be addressed by name. The gate is deliberately
13
+ * mechanical — it cannot judge whether the evidence is *good*, only that the
14
+ * model was made to look at every requirement and say something specific
15
+ * about each. The audit rules that make the evidence worth citing live in the
16
+ * tool description and the system append.
11
17
  *
12
18
  * Registered unconditionally, never added or removed with loop state: tools
13
19
  * are part of the cached request prefix, so mutating the tool set mid-session
@@ -19,10 +25,29 @@
19
25
  import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
26
  import { Type } from "typebox";
21
27
  import type { LoopController } from "./loop.js";
28
+ import type { LoopCriterion } from "./ledger.js";
22
29
 
23
30
  export const LOOP_COMPLETE_TOOL = "loop_complete";
24
31
 
25
32
  const MAX_SUMMARY_LENGTH = 4_000;
33
+ const MAX_EVIDENCE_LENGTH = 4_000;
34
+ /** Shorter than this is an assertion, not a citation. */
35
+ const MIN_EVIDENCE_LENGTH = 12;
36
+ const EMPTY_EVIDENCE = new Set([
37
+ "done",
38
+ "ok",
39
+ "okay",
40
+ "yes",
41
+ "true",
42
+ "passes",
43
+ "passed",
44
+ "complete",
45
+ "completed",
46
+ "verified",
47
+ "confirmed",
48
+ "n/a",
49
+ "none",
50
+ ]);
26
51
 
27
52
  export function registerLoopCompleteTool(pi: ExtensionAPI, controller: LoopController) {
28
53
  pi.registerTool(
@@ -30,13 +55,18 @@ export function registerLoopCompleteTool(pi: ExtensionAPI, controller: LoopContr
30
55
  name: LOOP_COMPLETE_TOOL,
31
56
  label: "Loop Complete",
32
57
  description:
33
- "Stop the active /loop when its objective's completion criteria are demonstrably met. Only for a standalone loop that carries its own objective; it stops the scheduled wakeups and does not assert that unrelated work is finished.",
34
- promptSnippet: "Stop the active standalone /loop once its completion criteria are met",
58
+ "Stop the active /loop when every completion criterion in the loop's criteria.json is proven by authoritative evidence. Requires one cited piece of evidence per criterion id. It stops the scheduled wakeups and does not assert that unrelated work is finished.",
59
+ promptSnippet: "Stop the active standalone /loop once every criterion is proven",
35
60
  promptGuidelines: [
36
- "Call loop_complete only when the completion criteria stated in the loop objective are demonstrably met, verified against authoritative current state.",
61
+ "Call loop_complete only when every criterion in the loop's criteria.json is demonstrably met, verified against authoritative current state.",
62
+ "Before calling it, treat completion as unproven and audit requirement by requirement: for every criterion, inspect authoritative evidence and match the verification scope to the requirement scope.",
63
+ "Authoritative means the current worktree, command output, test results, runtime behaviour, PR state, or external state. Previous conversation, plans, and summaries are context, not proof.",
64
+ "Weak, indirect, missing, or merely consistent evidence is not enough; gather stronger evidence and keep working.",
65
+ "Effort exhaustion is not completion. Running long, running out of ideas, or reaching a cap is never a reason to call loop_complete.",
66
+ "Pass evidence as a map of criterion id to a specific citation (the command you ran and what it printed, the file and what it now contains, the URL and its state). Every criterion id must appear.",
37
67
  "Pass the exact loop_id from the active /loop objective in the system prompt. A mismatched id means the loop changed and the call is refused.",
38
68
  "loop_complete stops scheduled wakeups only. It does not mean an unrelated goal or task is complete.",
39
- "If the criteria are not met, do not call it: keep working and expect another scheduled wake.",
69
+ "If the criteria are not met, do not call it: keep working and expect another continuation.",
40
70
  ],
41
71
  parameters: Type.Object({
42
72
  loop_id: Type.String({
@@ -44,6 +74,14 @@ export function registerLoopCompleteTool(pi: ExtensionAPI, controller: LoopContr
44
74
  maxLength: 200,
45
75
  description: "The exact loop_id from the active /loop objective in the system prompt.",
46
76
  }),
77
+ evidence: Type.Record(
78
+ Type.String(),
79
+ Type.String({ maxLength: MAX_EVIDENCE_LENGTH }),
80
+ {
81
+ description:
82
+ "Map of criterion id (c1, c2, …, from the loop's criteria.json) to the authoritative evidence proving it: the command run and its output, the file and its current contents, the external state and how it was checked.",
83
+ },
84
+ ),
47
85
  summary: Type.Optional(
48
86
  Type.String({
49
87
  maxLength: MAX_SUMMARY_LENGTH,
@@ -78,19 +116,88 @@ export function registerLoopCompleteTool(pi: ExtensionAPI, controller: LoopContr
78
116
  isError: true,
79
117
  };
80
118
  }
119
+ const evidence = normalizeEvidence(params.evidence);
120
+ const refusal = auditEvidence(controller.criteria(), evidence);
121
+ if (refusal) {
122
+ return {
123
+ content: toolContent(refusal),
124
+ details: { loopId: loop.id, criteria: controller.criteria() ?? [] },
125
+ isError: true,
126
+ };
127
+ }
81
128
  const summary = params.summary?.trim();
82
129
  controller.completeLoop(summary);
83
130
  return {
84
131
  content: toolContent(
85
- `Loop stopped: completion criteria met.${summary ? ` ${summary}` : ""}`,
132
+ `Loop stopped: every criterion answered with evidence.${summary ? ` ${summary}` : ""}`,
86
133
  ),
87
- details: { loopId: loop.id, ...(summary ? { summary } : {}) },
134
+ details: { loopId: loop.id, evidence, ...(summary ? { summary } : {}) },
88
135
  };
89
136
  },
90
137
  }),
91
138
  );
92
139
  }
93
140
 
141
+ function normalizeEvidence(evidence: Record<string, string>): Record<string, string> {
142
+ const normalized: Record<string, string> = {};
143
+ for (const [id, value] of Object.entries(evidence)) {
144
+ const trimmed = typeof value === "string" ? value.trim() : "";
145
+ if (!trimmed) continue;
146
+ normalized[id.trim()] = trimmed;
147
+ }
148
+ return normalized;
149
+ }
150
+
151
+ /**
152
+ * The mechanical half of the completion gate. Returns the refusal text, or
153
+ * undefined when the call may proceed.
154
+ *
155
+ * With no readable criteria the loop cannot say what completion means, so the
156
+ * gate degrades to "cite something specific" rather than blocking a loop
157
+ * whose ledger is missing — the ledger is fail-open everywhere else too.
158
+ */
159
+ export function auditEvidence(
160
+ criteria: LoopCriterion[] | undefined,
161
+ evidence: Record<string, string>,
162
+ ): string | undefined {
163
+ const answeredIds = new Set(Object.keys(evidence));
164
+ if (!criteria) {
165
+ return Object.values(evidence).some(isSubstantive)
166
+ ? undefined
167
+ : "This loop has no readable criteria.json, so loop_complete still needs at least one specific citation: the command you ran and what it printed, or the state you inspected and what it showed.";
168
+ }
169
+ const missing = criteria.filter((criterion) => !answeredIds.has(criterion.id));
170
+ if (missing.length > 0) {
171
+ return [
172
+ `loop_complete refused: ${missing.length} of ${criteria.length} criteria have no cited evidence.`,
173
+ ...missing.map(
174
+ (criterion) =>
175
+ `- ${criterion.id}${criterion.passes ? "" : " (still recorded as unmet)"}: ${criterion.description}`,
176
+ ),
177
+ "Audit each one against authoritative current state — command output, file contents, external state — and pass the evidence keyed by criterion id. Weak or merely consistent evidence is not enough, and effort exhaustion is not completion.",
178
+ ].join("\n");
179
+ }
180
+ const unknown = [...answeredIds].filter(
181
+ (id) => !criteria.some((criterion) => criterion.id === id),
182
+ );
183
+ if (unknown.length > 0) {
184
+ return `loop_complete refused: evidence cites unknown criterion id(s) ${unknown.join(", ")}. Use the ids from the loop's criteria.json (${criteria.map((criterion) => criterion.id).join(", ")}).`;
185
+ }
186
+ const weak = Object.entries(evidence)
187
+ .filter(([id]) => criteria.some((criterion) => criterion.id === id))
188
+ .filter(([, value]) => !isSubstantive(value));
189
+ if (weak.length > 0) {
190
+ return `loop_complete refused: the evidence for ${weak.map(([id]) => id).join(", ")} asserts completion instead of citing it. Give the command and its output, the file and its contents, or the external state and how it was checked.`;
191
+ }
192
+ return undefined;
193
+ }
194
+
195
+ function isSubstantive(value: string): boolean {
196
+ const trimmed = value.trim();
197
+ if (trimmed.length < MIN_EVIDENCE_LENGTH) return false;
198
+ return !EMPTY_EVIDENCE.has(trimmed.toLowerCase().replace(/[.!]+$/u, ""));
199
+ }
200
+
94
201
  function toolContent(text: string) {
95
202
  return [{ type: "text" as const, text }];
96
203
  }
package/src/decide.ts CHANGED
@@ -1,17 +1,22 @@
1
1
  /**
2
- * Pure tick decision. The engine gathers the environment and this function
3
- * decides what a wakeup does, so the full decision matrix is unit-testable
4
- * without timers or a Pi runtime.
2
+ * Pure decisions for the two things that can move a loop forward, so the full
3
+ * matrix is unit-testable without timers or a Pi runtime:
5
4
  *
6
- * Precedence: loop liveness expiry plan mode busy → mode-specific stop
7
- * criteria iteration cap poke.
5
+ * - `decideContinuation` runs at every settled idle boundary and is the
6
+ * primary driver of a standalone loop. The session going idle with the
7
+ * objective unfinished *is* the signal to continue; no clock is involved.
8
+ * - `decideTick` runs when the fallback heartbeat fires. It is the fault
9
+ * handler for a lost continuation or an external wait, not the pacemaker.
10
+ *
11
+ * Both share one precedence prefix: loop liveness → expiry → plan mode →
12
+ * compaction → busy → mode-specific stop criteria → caps → act.
8
13
  *
9
14
  * The mode-specific step is the whole difference between the two kinds of
10
15
  * loop. A goal-bound loop delegates "is the work done" to pi-goal and reads
11
- * its `goal-state`, so a missing goal pauses it and a safety state holds it.
12
- * A standalone loop owns its own objective, reads no goal state at all, and
13
- * ends only through `loop_complete`, a cap, or the user so pi-goal being
14
- * absent is not an error for it.
16
+ * its `goal-state`, so a missing goal pauses it and a safety state holds it;
17
+ * pi-goal also owns its settle continuations, so `decideContinuation` never
18
+ * acts for one. A standalone loop owns its own objective, reads no goal state
19
+ * at all, and ends only through `loop_complete`, a cap, or the user.
15
20
  */
16
21
 
17
22
  import { GOAL_SAFETY_STATUSES, type GoalSnapshot, isStandaloneLoop, type LoopState } from "./state.js";
@@ -26,30 +31,115 @@ export interface TickEnvironment {
26
31
  goal: GoalSnapshot | undefined;
27
32
  }
28
33
 
34
+ /** A `loop_wait` whose deadline has passed is due, not waiting. */
35
+ function isWaiting(loop: LoopState, now: number): boolean {
36
+ const waiting = loop.waiting;
37
+ if (!waiting) return false;
38
+ return waiting.resumeAt === undefined || now < waiting.resumeAt;
39
+ }
40
+
41
+ /**
42
+ * An expiring standalone loop gets one last turn to write its state into the
43
+ * ledger before it stops; every other expiry stops immediately.
44
+ */
45
+ export type ExpiryReason = "loop-expired" | "expiry-final-wake";
46
+
47
+ export type SkipReason =
48
+ | "plan-mode-active"
49
+ | "agent-busy"
50
+ | "compaction-in-flight"
51
+ | "loop-waiting";
52
+
29
53
  export type TickDecision =
30
54
  | { action: "none"; reason: "loop-not-active" }
31
- | { action: "expire"; reason: "loop-expired" }
32
- | { action: "skip"; reason: "plan-mode-active" | "agent-busy" | "compaction-in-flight" }
33
- | { action: "stop"; reason: "goal-complete" | "max-iterations" }
55
+ | { action: "expire"; reason: ExpiryReason }
56
+ | { action: "skip"; reason: SkipReason }
57
+ | { action: "stop"; reason: "goal-complete" | "max-iterations" | "max-automatic-turns" }
34
58
  | { action: "pause"; reason: "goal-safety"; cause: string }
35
59
  | { action: "pause"; reason: "goal-missing" }
36
- | { action: "poke"; reason: "goal-stalled" | "goal-waiting" | "objective-stalled" };
60
+ | {
61
+ action: "poke";
62
+ reason: "goal-stalled" | "goal-waiting" | "objective-stalled" | "wait-elapsed";
63
+ };
37
64
 
38
- export function decideTick(loop: LoopState, env: TickEnvironment): TickDecision {
65
+ export type ContinuationDecision =
66
+ | { action: "none"; reason: "loop-not-active" | "goal-bound" }
67
+ | { action: "expire"; reason: ExpiryReason }
68
+ | { action: "skip"; reason: SkipReason }
69
+ | { action: "stop"; reason: "max-iterations" | "max-automatic-turns" }
70
+ | { action: "continue"; reason: "settled-idle" };
71
+
72
+ /** Shared prefix: everything that holds or ends a loop before mode matters. */
73
+ function decideCommonPrefix(
74
+ loop: LoopState,
75
+ env: TickEnvironment,
76
+ ): Extract<TickDecision, { action: "none" | "expire" | "skip" }> | undefined {
39
77
  if (loop.status !== "active") return { action: "none", reason: "loop-not-active" };
40
- if (env.now >= loop.expiresAt) return { action: "expire", reason: "loop-expired" };
78
+ if (env.now >= loop.expiresAt) {
79
+ // The final wake is a standalone loop's own summarise-and-stop turn; a
80
+ // goal-bound loop has no ledger of its own to write, and pi-goal owns
81
+ // that conversation.
82
+ return {
83
+ action: "expire",
84
+ reason:
85
+ isStandaloneLoop(loop) && !loop.expiring ? "expiry-final-wake" : "loop-expired",
86
+ };
87
+ }
41
88
  if (env.planModeEnabled) return { action: "skip", reason: "plan-mode-active" };
42
89
  if (env.compacting) return { action: "skip", reason: "compaction-in-flight" };
43
90
  if (env.busy) return { action: "skip", reason: "agent-busy" };
91
+ // A declared external wait holds both drivers: the loop is not stalled, it
92
+ // is waiting on the world, and its own deadline is the next thing to speak.
93
+ if (isWaiting(loop, env.now)) return { action: "skip", reason: "loop-waiting" };
94
+ return undefined;
95
+ }
96
+
97
+ /**
98
+ * Caps are checked in a fixed order so a loop that trips both reports the
99
+ * wake cap first — it is the one the user configured with `--max`.
100
+ */
101
+ function decideCaps(
102
+ loop: LoopState,
103
+ ): { action: "stop"; reason: "max-iterations" | "max-automatic-turns" } | undefined {
104
+ if (loop.maxIterations !== null && loop.iteration >= loop.maxIterations) {
105
+ return { action: "stop", reason: "max-iterations" };
106
+ }
107
+ if (loop.maxAutomaticTurns !== null && loop.automaticTurns >= loop.maxAutomaticTurns) {
108
+ return { action: "stop", reason: "max-automatic-turns" };
109
+ }
110
+ return undefined;
111
+ }
112
+
113
+ /**
114
+ * The settled-idle boundary of a standalone loop: the session finished a turn
115
+ * with the objective unfinished, so the loop continues immediately instead of
116
+ * waiting out an interval of idle wall time.
117
+ */
118
+ export function decideContinuation(loop: LoopState, env: TickEnvironment): ContinuationDecision {
119
+ const prefix = decideCommonPrefix(loop, env);
120
+ if (prefix) return prefix;
121
+ // pi-goal drives its own settle continuations for a goal-bound loop; two
122
+ // extensions continuing the same session would double every turn.
123
+ if (!isStandaloneLoop(loop)) return { action: "none", reason: "goal-bound" };
124
+ const capped = decideCaps(loop);
125
+ if (capped) return capped;
126
+ return { action: "continue", reason: "settled-idle" };
127
+ }
128
+
129
+ export function decideTick(loop: LoopState, env: TickEnvironment): TickDecision {
130
+ const prefix = decideCommonPrefix(loop, env);
131
+ if (prefix) return prefix;
44
132
 
45
133
  // A standalone loop carries its own objective, so it never consults
46
134
  // pi-goal: it runs until loop_complete stops it, a cap is reached, or the
47
135
  // user intervenes.
48
136
  if (isStandaloneLoop(loop)) {
49
- if (loop.maxIterations !== null && loop.iteration >= loop.maxIterations) {
50
- return { action: "stop", reason: "max-iterations" };
51
- }
52
- return { action: "poke", reason: "objective-stalled" };
137
+ const capped = decideCaps(loop);
138
+ if (capped) return capped;
139
+ // The prefix already let a still-waiting loop skip, so a wait surviving
140
+ // to here is one whose deadline has come due: this wake is the wake it
141
+ // asked for, and it counts against the wake cap like any other.
142
+ return { action: "poke", reason: loop.waiting ? "wait-elapsed" : "objective-stalled" };
53
143
  }
54
144
 
55
145
  const goal = env.goal;
@@ -67,9 +157,8 @@ export function decideTick(loop: LoopState, env: TickEnvironment): TickDecision
67
157
  return { action: "pause", reason: "goal-safety", cause: goal.status };
68
158
  }
69
159
 
70
- if (loop.maxIterations !== null && loop.iteration >= loop.maxIterations) {
71
- return { action: "stop", reason: "max-iterations" };
72
- }
160
+ const capped = decideCaps(loop);
161
+ if (capped) return capped;
73
162
 
74
163
  // An idle session with an active goal is exactly the stall/wait case:
75
164
  // pi-goal continues on its own at every idle boundary, so idleness at
package/src/errors.ts ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Classification of an interrupted loop turn.
3
+ *
4
+ * Without this every provider hiccup looks the same to a loop, and the loop
5
+ * answers all of them identically: continue. That is wrong in both
6
+ * directions. Retrying into an exhausted usage quota burns the loop's caps
7
+ * against a window that has not reset, and pausing on a transient 503 strands
8
+ * a multi-day loop on a blip. Context overflow is a third answer again: the
9
+ * turn failed because the conversation is too big, so the fix is a
10
+ * compaction, not a retry of the same oversized request.
11
+ *
12
+ * Ported from pi-goal's classifier, trimmed to what a loop acts on.
13
+ */
14
+
15
+ import {
16
+ isContextOverflow,
17
+ isRetryableAssistantError,
18
+ type AssistantMessage,
19
+ type Usage,
20
+ } from "@earendil-works/pi-ai";
21
+
22
+ export type LoopInterruption =
23
+ /** The turn finished normally. */
24
+ | "none"
25
+ /** The user (or another extension) aborted the turn. */
26
+ | "aborted"
27
+ /** Provider quota or billing exhaustion: retrying cannot help. */
28
+ | "usage-limited"
29
+ /** The request no longer fits: compact, then continue. */
30
+ | "context-overflow"
31
+ /** Transient: continue as usual. */
32
+ | "retryable"
33
+ /** Auth or another error a retry cannot fix. */
34
+ | "fatal";
35
+
36
+ const USAGE_LIMIT_PATTERNS = [
37
+ /usage[_\s-]*(?:limit|cap)|chatgpt.{0,32}usage/i,
38
+ // Exhaustion phrased as spent allowance rather than a named limit. These
39
+ // matter most when the provider reports them as a 429, because the
40
+ // retryable patterns match the status code and the loop would otherwise
41
+ // retry against a quota window that has not reset. Deliberately narrow: a
42
+ // plain "rate limit, try again later" 429 must stay retryable.
43
+ /used all (?:the |your )?(?:included |available |free |remaining )*usage/i,
44
+ /draw from your extra usage/i,
45
+ /quota.{0,32}(?:reached|exceeded|exhausted|depleted)|(?:reached|exceeded|exhausted|depleted).{0,32}quota/i,
46
+ /insufficient[_\s-]*(?:quota|credits?)|out of credits|out of budget|available balance|payment required/i,
47
+ /(?:credit|balance).{0,32}(?:low|exhausted|depleted)|billing/i,
48
+ ] as const;
49
+
50
+ const NON_RETRYABLE_PATTERN =
51
+ /multi-auth rotation failed|credentials tried|unauthori[sz]ed|invalid api key/i;
52
+
53
+ const RETRYABLE_PATTERNS = [
54
+ /overloaded|rate.?limit|too many requests|\b(?:429|500|502|503|504)\b|service.?unavailable|server.?error|internal.?error/i,
55
+ /provider.?returned.?error|you can retry your request|try your request again|please retry your request/i,
56
+ /network.?error|connection.?(?:error|refused|lost)|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up/i,
57
+ /timed? out|timeout|terminated|websocket.?(?:closed|error)|ended without|stream ended before message_stop|http2 request did not get a response|retry delay/i,
58
+ /context[_\s-]*length[_\s-]*exceeded|input exceeds the context window/i,
59
+ ] as const;
60
+
61
+ interface AssistantLike {
62
+ stopReason?: string;
63
+ errorMessage?: string;
64
+ content?: unknown;
65
+ api?: string;
66
+ provider?: string;
67
+ model?: string;
68
+ }
69
+
70
+ export function classifyInterruption(messages: readonly unknown[]): LoopInterruption {
71
+ const assistant = findFinalAssistantMessage(messages);
72
+ if (!assistant) return "none";
73
+ if (assistant.stopReason === "aborted") return "aborted";
74
+ if (assistant.stopReason !== "error") return "none";
75
+ const errorMessage = assistant.errorMessage ?? "";
76
+ // Usage limits are checked first on purpose: providers report them as 429,
77
+ // which the retryable patterns also match.
78
+ if (USAGE_LIMIT_PATTERNS.some((pattern) => pattern.test(errorMessage))) return "usage-limited";
79
+ if (NON_RETRYABLE_PATTERN.test(errorMessage)) return "fatal";
80
+ if (isContextOverflow(toPiAssistantMessage(assistant))) return "context-overflow";
81
+ if (
82
+ isRetryableAssistantError(toPiAssistantMessage(assistant)) ||
83
+ RETRYABLE_PATTERNS.some((pattern) => pattern.test(errorMessage))
84
+ ) {
85
+ return "retryable";
86
+ }
87
+ return "fatal";
88
+ }
89
+
90
+ function findFinalAssistantMessage(messages: readonly unknown[]): AssistantLike | undefined {
91
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
92
+ const message = messages[index];
93
+ if (!message || typeof message !== "object" || Array.isArray(message)) continue;
94
+ const record = message as Record<string, unknown>;
95
+ if (record.role !== "assistant") continue;
96
+ return {
97
+ ...(typeof record.stopReason === "string" ? { stopReason: record.stopReason } : {}),
98
+ ...(typeof record.errorMessage === "string" ? { errorMessage: record.errorMessage } : {}),
99
+ ...(Array.isArray(record.content) ? { content: record.content } : {}),
100
+ ...(typeof record.api === "string" ? { api: record.api } : {}),
101
+ ...(typeof record.provider === "string" ? { provider: record.provider } : {}),
102
+ ...(typeof record.model === "string" ? { model: record.model } : {}),
103
+ };
104
+ }
105
+ return undefined;
106
+ }
107
+
108
+ function toPiAssistantMessage(assistant: AssistantLike): AssistantMessage {
109
+ return {
110
+ role: "assistant",
111
+ content: (assistant.content ?? []) as AssistantMessage["content"],
112
+ api: (assistant.api ?? "openai-responses") as AssistantMessage["api"],
113
+ provider: assistant.provider ?? "unknown",
114
+ model: assistant.model ?? "unknown",
115
+ usage: zeroUsage(),
116
+ stopReason: "error",
117
+ errorMessage: assistant.errorMessage,
118
+ timestamp: Date.now(),
119
+ };
120
+ }
121
+
122
+ function zeroUsage(): Usage {
123
+ return {
124
+ input: 0,
125
+ output: 0,
126
+ cacheRead: 0,
127
+ cacheWrite: 0,
128
+ totalTokens: 0,
129
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
130
+ };
131
+ }
package/src/index.ts CHANGED
@@ -10,25 +10,37 @@
10
10
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
11
11
  import { completeLoopArguments, parseLoopCommand } from "./command.js";
12
12
  import { registerLoopCompleteTool } from "./complete-tool.js";
13
+ import { registerLoopWaitTool } from "./wait-tool.js";
13
14
  import { LoopController, type LoopControllerOptions } from "./loop.js";
14
15
  import { showLoopManager, showLoopSettings } from "./manager.js";
15
16
  import { buildLoopObjectivePrompt } from "./objective.js";
16
17
  import { registerLoopMessageRendering } from "./render.js";
18
+ import { completeScheduleArguments, parseScheduleCommand } from "./schedule/command.js";
19
+ import { describeTask, listTasks, showScheduleManager } from "./schedule/manager.js";
20
+ import { describeSchedule } from "./schedule/model.js";
21
+ import { Scheduler } from "./schedule/runner.js";
17
22
 
18
23
  export default function loop(pi: ExtensionAPI, options: LoopControllerOptions = {}) {
19
24
  const controller = new LoopController(pi, options);
25
+ const scheduler = new Scheduler(pi, {
26
+ ...(options.agentDir === undefined ? {} : { agentDir: options.agentDir }),
27
+ ...(options.now === undefined ? {} : { now: options.now }),
28
+ });
20
29
  // Registered unconditionally and never toggled with loop state: tools are
21
30
  // part of the cached request prefix, so mutating the tool set mid-session
22
31
  // would invalidate the whole conversation cache. It refuses when no
23
32
  // standalone loop is active.
24
33
  registerLoopCompleteTool(pi, controller);
34
+ // Registered on the same terms and for the same reason: the tool set is
35
+ // part of the cached prefix, so it never changes with loop state.
36
+ registerLoopWaitTool(pi, controller);
25
37
  // Collapse loop pokes into one-line transcript chips (display-only; the
26
38
  // stored message and model context are untouched).
27
39
  registerLoopMessageRendering(pi);
28
40
 
29
41
  pi.registerCommand("loop", {
30
42
  description:
31
- "Wake the session on an interval to keep the active /goal moving: /loop [--max N] [--compact-at 60%] <interval> [focus]",
43
+ "Work an objective across many turns, waking the session if it goes quiet: /loop [--max N] [--compact-at 60%] [--expires 3d] <interval> [objective]",
32
44
  getArgumentCompletions: (prefix: string) => completeLoopArguments(prefix),
33
45
  handler: async (args: string, ctx: ExtensionCommandContext) => {
34
46
  const command = parseLoopCommand(args);
@@ -79,14 +91,95 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
79
91
  },
80
92
  });
81
93
 
94
+ // The scheduler is user-typed only, exactly like /loop: the model gets no
95
+ // scheduling tools, because a model that can schedule its own future turns
96
+ // can schedule its way around every limit the loop imposes.
97
+ pi.registerCommand("schedule", {
98
+ description:
99
+ 'Schedule prompts and headless runs: /schedule [every <dur>|at <time>|cron "<expr>"] [--run] <prompt>, or list/pause/resume/run/status/delete',
100
+ getArgumentCompletions: (prefix: string) => completeScheduleArguments(prefix),
101
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
102
+ const command = parseScheduleCommand(args, { cwd: ctx.cwd });
103
+ switch (command.kind) {
104
+ case "show":
105
+ await showScheduleManager(scheduler, ctx);
106
+ return;
107
+ case "list":
108
+ ctx.ui.notify(listTasks(scheduler).join("\n"), "info");
109
+ return;
110
+ case "error":
111
+ ctx.ui.notify(command.message, "error");
112
+ return;
113
+ case "create": {
114
+ const { task, warning } = scheduler.create(command);
115
+ if (warning) {
116
+ ctx.ui.notify(`Scheduled task not persisted: ${warning}`, "warning");
117
+ }
118
+ if (command.clampedFrom !== undefined) {
119
+ ctx.ui.notify("Intervals below 1 minute are raised to the minimum.", "warning");
120
+ }
121
+ ctx.ui.notify(
122
+ [
123
+ `Scheduled "${task.name}" (${task.id}): ${describeSchedule(task.schedule)}.`,
124
+ task.task.kind === "run"
125
+ ? `Runs headlessly in ${task.task.cwd}; wakes this session on ${task.task.wakeOn}.`
126
+ : "Injects a prompt into this session at an idle boundary; it dies with the session.",
127
+ `Runs: ${task.maxRuns === null ? "unlimited" : `at most ${task.maxRuns}`}; expires ${new Date(task.expiresAt).toLocaleDateString()}.`,
128
+ ].join("\n"),
129
+ "info",
130
+ );
131
+ return;
132
+ }
133
+ default: {
134
+ const task = scheduler.find(command.id);
135
+ if (!task) {
136
+ ctx.ui.notify(
137
+ `No scheduled task matches ${command.id}. Run /schedule list to see them.`,
138
+ "error",
139
+ );
140
+ return;
141
+ }
142
+ if (command.kind === "status") {
143
+ ctx.ui.notify(describeTask(task).join("\n"), "info");
144
+ return;
145
+ }
146
+ if (command.kind === "pause" || command.kind === "resume") {
147
+ const status = command.kind === "pause" ? "paused" : "active";
148
+ scheduler.update({ ...task, status });
149
+ ctx.ui.notify(`Task "${task.name}" is now ${status}.`, "info");
150
+ return;
151
+ }
152
+ if (command.kind === "run") {
153
+ scheduler.fireNow(task);
154
+ ctx.ui.notify(`Running "${task.name}" now.`, "info");
155
+ return;
156
+ }
157
+ scheduler.remove(task.id);
158
+ ctx.ui.notify(`Deleted "${task.name}".`, "info");
159
+ }
160
+ }
161
+ },
162
+ });
163
+
82
164
  pi.on("session_start", async (_event, ctx) => {
83
165
  controller.onSessionStart(ctx);
166
+ scheduler.onSessionStart(ctx);
84
167
  });
85
168
  pi.on("session_shutdown", async () => {
86
169
  controller.onSessionShutdown();
170
+ scheduler.onSessionShutdown();
171
+ });
172
+ // The pacemaker of a standalone loop: agent_end records the intent to
173
+ // continue, agent_settled delivers it once Pi will accept a message.
174
+ pi.on("agent_start", async (_event, ctx) => {
175
+ controller.onAgentStart(ctx);
176
+ });
177
+ pi.on("agent_end", async (event, ctx) => {
178
+ controller.onAgentEnd(ctx, event.messages ?? []);
87
179
  });
88
180
  pi.on("agent_settled", async (_event, ctx) => {
89
181
  controller.onAgentSettled(ctx);
182
+ scheduler.onAgentSettled(ctx);
90
183
  });
91
184
  // A standalone loop carries its own objective, so it injects it the way
92
185
  // pi-goal does for a goal-bound one: a byte-stable system append, which is
@@ -95,7 +188,7 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
95
188
  pi.on("before_agent_start", (event) => {
96
189
  const loop = controller.state;
97
190
  if (!loop || loop.status !== "active") return;
98
- const objectivePrompt = buildLoopObjectivePrompt(loop);
191
+ const objectivePrompt = buildLoopObjectivePrompt(loop, controller.ledger);
99
192
  if (objectivePrompt === undefined) return;
100
193
  return { systemPrompt: `${event.systemPrompt}\n\n${objectivePrompt}` };
101
194
  });