@hank-warren/pi-loop 0.4.1 → 0.6.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.
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Persistence for `run` tasks (`~/.pi/agent/loop/schedules.json`) and the
3
+ * single-writer lease that decides which live Pi session is allowed to fire
4
+ * them.
5
+ *
6
+ * The lease exists because headless tasks are shared state: every open
7
+ * session reads the same file, and without arbitration a task scheduled for
8
+ * 09:00 fires once per session that happens to be running. A lockfile holding
9
+ * a pid and a heartbeat is enough — a holder that dies stops renewing, and
10
+ * the next session takes over after the stale window. Deliberately not a real
11
+ * distributed lock: the failure it must prevent is *duplicate work*, and the
12
+ * worst case it can produce is one skipped tick.
13
+ *
14
+ * Everything here is best-effort. A read-only home directory means "no
15
+ * headless scheduling", never a crash.
16
+ */
17
+
18
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
19
+ import { randomUUID } from "node:crypto";
20
+ import { dirname, join } from "node:path";
21
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
22
+ import { normalizeTask, type ScheduledTask } from "./model.js";
23
+
24
+ export const SCHEDULES_FILE = "schedules.json";
25
+ export const LEASE_FILE = "schedules.lease";
26
+ export const RUNS_DIR = "runs";
27
+
28
+ /** A lease older than this is assumed abandoned. */
29
+ export const LEASE_STALE_MS = 90_000;
30
+ /** How often the holder refreshes it. */
31
+ export const LEASE_HEARTBEAT_MS = 30_000;
32
+
33
+ export interface SchedulePaths {
34
+ dir: string;
35
+ schedules: string;
36
+ lease: string;
37
+ runs: string;
38
+ }
39
+
40
+ export function schedulePaths(agentDir = getAgentDir()): SchedulePaths {
41
+ const dir = join(agentDir, "loop");
42
+ return {
43
+ dir,
44
+ schedules: join(dir, SCHEDULES_FILE),
45
+ lease: join(dir, LEASE_FILE),
46
+ runs: join(dir, RUNS_DIR),
47
+ };
48
+ }
49
+
50
+ /** Read persisted tasks, fail-open: an unreadable file means no tasks. */
51
+ export function readTasks(paths: SchedulePaths): ScheduledTask[] {
52
+ let contents: string;
53
+ try {
54
+ contents = readFileSync(paths.schedules, "utf8");
55
+ } catch {
56
+ return [];
57
+ }
58
+ try {
59
+ const parsed: unknown = JSON.parse(contents);
60
+ if (!Array.isArray(parsed)) return [];
61
+ // One malformed entry drops itself, not the whole schedule.
62
+ return parsed
63
+ .map((value) => normalizeTask(value))
64
+ .filter((task): task is ScheduledTask => task !== undefined);
65
+ } catch {
66
+ return [];
67
+ }
68
+ }
69
+
70
+ /** Atomically replace the persisted tasks. Returns a failure reason, if any. */
71
+ export function writeTasks(paths: SchedulePaths, tasks: ScheduledTask[]): string | undefined {
72
+ const document = `${JSON.stringify(tasks, null, 2)}\n`;
73
+ const temporary = join(paths.dir, `.${SCHEDULES_FILE}.${randomUUID()}.tmp`);
74
+ try {
75
+ mkdirSync(paths.dir, { recursive: true });
76
+ writeFileSync(temporary, document, { encoding: "utf8", flag: "wx" });
77
+ renameSync(temporary, paths.schedules);
78
+ return undefined;
79
+ } catch (error) {
80
+ return error instanceof Error ? error.message : String(error);
81
+ } finally {
82
+ try {
83
+ rmSync(temporary, { force: true });
84
+ } catch {
85
+ // Best-effort cleanup must not replace the write result.
86
+ }
87
+ }
88
+ }
89
+
90
+ export interface LeaseRecord {
91
+ pid: number;
92
+ heartbeat: number;
93
+ owner: string;
94
+ }
95
+
96
+ /**
97
+ * Take or renew the lease. Returns true when this process may fire headless
98
+ * tasks. `owner` identifies this runtime so a restarted process with a reused
99
+ * pid cannot mistake someone else's lease for its own.
100
+ */
101
+ export function acquireLease(
102
+ paths: SchedulePaths,
103
+ owner: string,
104
+ now: number,
105
+ pid: number = process.pid,
106
+ ): boolean {
107
+ const current = readLease(paths);
108
+ if (current && current.owner !== owner) {
109
+ const fresh = now - current.heartbeat < LEASE_STALE_MS;
110
+ if (fresh && isProcessAlive(current.pid)) return false;
111
+ }
112
+ return writeLease(paths, { pid, heartbeat: now, owner });
113
+ }
114
+
115
+ /** Release the lease if this owner holds it. */
116
+ export function releaseLease(paths: SchedulePaths, owner: string): void {
117
+ const current = readLease(paths);
118
+ if (!current || current.owner !== owner) return;
119
+ try {
120
+ rmSync(paths.lease, { force: true });
121
+ } catch {
122
+ // A lease we cannot delete simply goes stale.
123
+ }
124
+ }
125
+
126
+ export function readLease(paths: SchedulePaths): LeaseRecord | undefined {
127
+ try {
128
+ const parsed: unknown = JSON.parse(readFileSync(paths.lease, "utf8"));
129
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
130
+ const record = parsed as Record<string, unknown>;
131
+ if (
132
+ typeof record.pid !== "number" ||
133
+ typeof record.heartbeat !== "number" ||
134
+ typeof record.owner !== "string" ||
135
+ !record.owner
136
+ ) {
137
+ return undefined;
138
+ }
139
+ return { pid: record.pid, heartbeat: record.heartbeat, owner: record.owner };
140
+ } catch {
141
+ return undefined;
142
+ }
143
+ }
144
+
145
+ function writeLease(paths: SchedulePaths, lease: LeaseRecord): boolean {
146
+ const temporary = join(dirname(paths.lease), `.${LEASE_FILE}.${randomUUID()}.tmp`);
147
+ try {
148
+ mkdirSync(paths.dir, { recursive: true });
149
+ writeFileSync(temporary, `${JSON.stringify(lease)}\n`, { encoding: "utf8", flag: "wx" });
150
+ renameSync(temporary, paths.lease);
151
+ return true;
152
+ } catch {
153
+ return false;
154
+ } finally {
155
+ try {
156
+ rmSync(temporary, { force: true });
157
+ } catch {
158
+ // Best-effort.
159
+ }
160
+ }
161
+ }
162
+
163
+ function isProcessAlive(pid: number): boolean {
164
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
165
+ try {
166
+ // Signal 0 tests for existence without delivering anything.
167
+ process.kill(pid, 0);
168
+ return true;
169
+ } catch (error) {
170
+ // EPERM means it exists and belongs to someone else.
171
+ return isNodeError(error) && error.code === "EPERM";
172
+ }
173
+ }
174
+
175
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
176
+ return error instanceof Error && "code" in error;
177
+ }
178
+
179
+ /** Where a headless run's output is logged. */
180
+ export function runLogPath(paths: SchedulePaths, taskId: string, at: number): string {
181
+ const stamp = new Date(at).toISOString().replace(/[:.]/g, "-");
182
+ return join(paths.runs, taskId, `${stamp}.log`);
183
+ }
package/src/settings.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Global pi-loop settings: `~/.pi/agent/pi-loop.json`. Follows the sibling
3
- * convention (pi-goal, pi-plan-mode): an absent file means defaults and is
3
+ * convention (pi-plan-mode): an absent file means defaults and is
4
4
  * never created implicitly, saves are atomic and preserve unknown fields, and
5
5
  * an invalid file warns and falls back to defaults without being overwritten.
6
6
  */
@@ -22,16 +22,44 @@ export interface LoopCompactionSettings {
22
22
  }
23
23
 
24
24
  export interface LoopSettings {
25
- /** Delivered-poke cap; null means unlimited (explicit opt-in). */
25
+ /** Delivered-wake cap; null means unlimited (explicit opt-in). */
26
26
  maxIterations: number | null;
27
+ /**
28
+ * Cap on turns the loop itself causes (settle continuations plus fallback
29
+ * pokes); null means unlimited. A settle-paced loop can run many turns per
30
+ * wake, so this is the bound that actually holds it.
31
+ */
32
+ automaticTurns: number | null;
33
+ /**
34
+ * Consecutive tool-free loop turns with identical output that pause the
35
+ * loop; null disables the breaker.
36
+ */
37
+ noProgressTurns: number | null;
27
38
  /** Wall-clock expiry for a loop, e.g. "7d" (research: bound forgotten loops). */
28
39
  maxLoopDuration: string;
40
+ /**
41
+ * Detect an inline `/loop` token or a `loop:` prefixed line mid-prompt and
42
+ * point the model at the `loop_start` tool. Pi only dispatches `/loop` from
43
+ * position 0, so without this a mid-prompt invocation is silently prose.
44
+ */
45
+ inlineInvocation: boolean;
46
+ /**
47
+ * Fallback heartbeat used by an inline invocation that names no interval.
48
+ * In a settle-paced loop the interval is only a fallback — the settle
49
+ * boundary is the pacemaker — so this value is far less consequential than
50
+ * it looks; it is still clamped to MIN_INTERVAL_MS.
51
+ */
52
+ defaultInterval: string;
29
53
  compaction: LoopCompactionSettings;
30
54
  }
31
55
 
32
56
  export const DEFAULT_LOOP_SETTINGS: LoopSettings = {
33
57
  maxIterations: 25,
58
+ automaticTurns: 25,
59
+ noProgressTurns: 3,
34
60
  maxLoopDuration: "7d",
61
+ inlineInvocation: true,
62
+ defaultInterval: "10m",
35
63
  compaction: {
36
64
  enabled: true,
37
65
  threshold: 0.7,
@@ -51,6 +79,15 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
51
79
  const maxIterations = normalizeCap(record.maxIterations, DEFAULT_LOOP_SETTINGS.maxIterations);
52
80
  if (maxIterations === false) return undefined;
53
81
 
82
+ const automaticTurns = normalizeCap(record.automaticTurns, DEFAULT_LOOP_SETTINGS.automaticTurns);
83
+ if (automaticTurns === false) return undefined;
84
+
85
+ const noProgressTurns = normalizeCap(
86
+ record.noProgressTurns,
87
+ DEFAULT_LOOP_SETTINGS.noProgressTurns,
88
+ );
89
+ if (noProgressTurns === false) return undefined;
90
+
54
91
  const maxLoopDuration = Object.hasOwn(record, "maxLoopDuration")
55
92
  ? record.maxLoopDuration
56
93
  : DEFAULT_LOOP_SETTINGS.maxLoopDuration;
@@ -58,12 +95,24 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
58
95
  return undefined;
59
96
  }
60
97
 
98
+ const inlineInvocation = Object.hasOwn(record, "inlineInvocation")
99
+ ? record.inlineInvocation
100
+ : DEFAULT_LOOP_SETTINGS.inlineInvocation;
101
+ if (typeof inlineInvocation !== "boolean") return undefined;
102
+
103
+ const defaultInterval = Object.hasOwn(record, "defaultInterval")
104
+ ? record.defaultInterval
105
+ : DEFAULT_LOOP_SETTINGS.defaultInterval;
106
+ if (typeof defaultInterval !== "string" || parseDuration(defaultInterval) === undefined) {
107
+ return undefined;
108
+ }
109
+
61
110
  const compactionValue = Object.hasOwn(record, "compaction") ? record.compaction : undefined;
62
111
  if (compactionValue !== undefined && !ownRecord(compactionValue)) return undefined;
63
112
  const compactionRecord = ownRecord(compactionValue) ?? {};
64
- // `postCompactContinuation` was removed in favour of pi-goal owning the
65
- // post-compaction re-prompt; a file still carrying it is preserved as an
66
- // unknown field and ignored, never rejected.
113
+ // `postCompactContinuation` was removed in favour of the loop's own
114
+ // re-anchor; a file still carrying it is preserved as an unknown field and
115
+ // ignored, never rejected.
67
116
  const enabled = readBoolean(compactionRecord, "enabled", DEFAULT_LOOP_SETTINGS.compaction.enabled);
68
117
  const threshold = Object.hasOwn(compactionRecord, "threshold")
69
118
  ? compactionRecord.threshold
@@ -86,7 +135,11 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
86
135
 
87
136
  return {
88
137
  maxIterations,
138
+ automaticTurns,
139
+ noProgressTurns,
89
140
  maxLoopDuration,
141
+ inlineInvocation,
142
+ defaultInterval,
90
143
  compaction: { enabled, threshold, instructions },
91
144
  };
92
145
  }
@@ -172,7 +225,11 @@ export function saveLoopSettings(settings: LoopSettings, settingsPath = loopSett
172
225
  {
173
226
  ...raw,
174
227
  maxIterations: normalized.maxIterations,
228
+ automaticTurns: normalized.automaticTurns,
229
+ noProgressTurns: normalized.noProgressTurns,
175
230
  maxLoopDuration: normalized.maxLoopDuration,
231
+ inlineInvocation: normalized.inlineInvocation,
232
+ defaultInterval: normalized.defaultInterval,
176
233
  compaction: { ...compaction, ...normalized.compaction },
177
234
  },
178
235
  null,
@@ -0,0 +1,145 @@
1
+ /**
2
+ * `loop_start`: the model-invoked way into a loop.
3
+ *
4
+ * Pi only dispatches `/loop` when it starts the message, so a mid-prompt
5
+ * `quick check /loop 10m get CI green` arrives as ordinary prose. The
6
+ * inline-invocation hooks append a one-turn reminder to the system prompt for
7
+ * exactly that message, and this tool is what the reminder points at. It
8
+ * reuses `LoopController.startLoop`, the same path the `/loop` command takes,
9
+ * so replacement rules, the loop_complete availability guard, the ledger, the
10
+ * kickoff anchor, and persistence all behave identically.
11
+ *
12
+ * The hard armed-gate is the deliberate divergence from pi-goal's equivalent
13
+ * tool, which relied on prompt guidelines alone. A loop is *self-continuing*:
14
+ * a spurious start does not produce one unwanted answer, it produces turns
15
+ * until a cap. So the tool refuses outright unless the inline hint armed for
16
+ * the turn that is calling it.
17
+ *
18
+ * Registered unconditionally, like the other loop tools: the tool set is part
19
+ * of the cached request prefix, so it never changes with loop state.
20
+ */
21
+
22
+ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
23
+ import { Type } from "typebox";
24
+ import { LOOP_COMPLETE_TOOL } from "./complete-tool.js";
25
+ import { formatDuration, parseDuration, parseInterval } from "./interval.js";
26
+ import type { InlineInvocationState } from "./inline-invocation.js";
27
+ import type { LoopController } from "./loop.js";
28
+
29
+ export const LOOP_START_TOOL = "loop_start";
30
+
31
+ /** Long enough for a real objective, short enough to reject a pasted file. */
32
+ const MAX_OBJECTIVE_LENGTH = 4_000;
33
+
34
+ export function registerLoopStartTool(
35
+ pi: ExtensionAPI,
36
+ controller: LoopController,
37
+ invocation: InlineInvocationState,
38
+ ) {
39
+ pi.registerTool(
40
+ defineTool({
41
+ name: LOOP_START_TOOL,
42
+ label: "Loop Start",
43
+ description:
44
+ "Start a /loop for an objective the user explicitly invoked with an inline /loop or loop: token in their message. Only for explicit invocations: never start a loop from general conversation, your own initiative, or an instruction that merely sounds loop-like. The objective is the text following the token.",
45
+ promptSnippet:
46
+ "Start a /loop when the user's message contains an explicit inline /loop or loop: invocation",
47
+ promptGuidelines: [
48
+ "Call loop_start only when the user's message contains an explicit `/loop <objective>` or `loop: <objective>` token. Never start a loop without that token, no matter how loop-like the request sounds; the tool refuses when the turn carries no inline invocation.",
49
+ "If the user is discussing, quoting, or documenting the /loop command rather than invoking it — asking how it works, pasting a transcript, or editing text that mentions it — do not call loop_start.",
50
+ "Pass the objective text that follows the token, without the token itself. A leading interval (`10m`, `2h`) and flags like `--max 5` or `--expires 3d` become the interval, max, and expires parameters, not part of the objective.",
51
+ "Call loop_start before doing any of the objective's work, then continue working toward it in the same turn.",
52
+ "Never call loop_complete in the same turn as loop_start: the starting turn has not done the work, and completion needs cited evidence per criterion.",
53
+ ],
54
+ parameters: Type.Object({
55
+ objective: Type.String({
56
+ minLength: 1,
57
+ maxLength: MAX_OBJECTIVE_LENGTH,
58
+ description:
59
+ "The loop objective, including how the loop knows it is done: the user's text following the /loop or loop: token, verbatim, without the token, the interval, or flags.",
60
+ }),
61
+ interval: Type.Optional(
62
+ Type.String({
63
+ description:
64
+ "Fallback wake interval from the invocation, e.g. '10m' or '2h'. Omit when the user named none.",
65
+ }),
66
+ ),
67
+ max: Type.Optional(
68
+ Type.Integer({
69
+ minimum: 1,
70
+ description: "Wake cap from a --max flag in the invocation.",
71
+ }),
72
+ ),
73
+ expires: Type.Optional(
74
+ Type.String({
75
+ description: "Loop lifetime from an --expires flag in the invocation, e.g. '3d'.",
76
+ }),
77
+ ),
78
+ }),
79
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
80
+ // The gate. Everything below is ordinary validation; this is the
81
+ // one check that makes a self-continuing tool safe to expose.
82
+ if (!invocation.invokedThisTurn) {
83
+ return refusal(
84
+ `${LOOP_START_TOOL} is only available on a turn whose user message contains an explicit inline /loop or loop: invocation. This turn has none, so no loop was started. If the user wants one, they can type /loop <interval> <objective>.`,
85
+ {},
86
+ );
87
+ }
88
+ const objective = params.objective.trim();
89
+ if (!objective) {
90
+ return refusal("Loop not started: the objective is empty.", {});
91
+ }
92
+ const existing = controller.state;
93
+ if (existing && existing.status !== "stopped") {
94
+ return refusal(
95
+ `Loop not started: a loop already exists in this session (${existing.status}). The user can replace it with /loop, or stop it with /loop stop.`,
96
+ { existingLoopId: existing.id },
97
+ );
98
+ }
99
+ const intervalToken = params.interval?.trim() || controller.settings.defaultInterval;
100
+ const interval = parseInterval(intervalToken);
101
+ if (!interval) {
102
+ return refusal(
103
+ `Loop not started: invalid interval ${intervalToken}. Use <number><unit> with unit s, m, h, or d, e.g. 10m.`,
104
+ { objective },
105
+ );
106
+ }
107
+ let expiresInMs: number | undefined;
108
+ if (params.expires !== undefined) {
109
+ expiresInMs = parseDuration(params.expires.trim());
110
+ if (expiresInMs === undefined) {
111
+ return refusal(
112
+ `Loop not started: invalid expiry ${params.expires}. Use <number><unit> with unit s, m, h, or d, e.g. 3d.`,
113
+ { objective },
114
+ );
115
+ }
116
+ }
117
+ const result = controller.startLoop(ctx, {
118
+ kind: "start",
119
+ requestedMs: interval.requestedMs,
120
+ intervalMs: interval.effectiveMs,
121
+ clamped: interval.clamped,
122
+ ...(params.max === undefined ? {} : { maxIterations: params.max }),
123
+ ...(expiresInMs === undefined ? {} : { expiresInMs }),
124
+ prompt: objective,
125
+ });
126
+ if (!result.ok) return refusal(`Loop not started: ${result.message}`, { objective });
127
+ const loop = result.loop;
128
+ return {
129
+ content: toolContent(
130
+ `Loop started (loop_id ${loop.id}): ${objective}. Fallback wake every ${formatDuration(loop.intervalMs)}. Keep working the objective this turn; the loop continues at every idle boundary until you call ${LOOP_COMPLETE_TOOL} with this loop_id and cited evidence for every criterion, a cap is reached, or the user stops it. Do not call ${LOOP_COMPLETE_TOOL} in this turn.`,
131
+ ),
132
+ details: { loopId: loop.id, objective, intervalMs: loop.intervalMs },
133
+ };
134
+ },
135
+ }),
136
+ );
137
+ }
138
+
139
+ function refusal(text: string, details: Record<string, unknown>) {
140
+ return { content: toolContent(text), details, isError: true };
141
+ }
142
+
143
+ function toolContent(text: string) {
144
+ return [{ type: "text" as const, text }];
145
+ }