pi-claude-supervisor 0.6.0 → 0.7.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.
@@ -2,8 +2,10 @@ import { randomUUID } from "node:crypto";
2
2
  import { constants as fsConstants } from "node:fs";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { lstat, open, rename, rm, writeFile, type FileHandle } from "node:fs/promises";
5
+ import { isDeepStrictEqual } from "node:util";
5
6
  import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager, type AgentSession } from "@earendil-works/pi-coding-agent";
6
- import type { TaskSpec, WorkerEvent } from "./types.ts";
7
+ import { extractJsonObjects } from "./json-extract.ts";
8
+ import type { PiUsageSample, TaskSpec, WorkerEvent } from "./types.ts";
7
9
  import { redactSensitive } from "./redaction.ts";
8
10
 
9
11
  const MAX_DECISION_RESPONSE_BYTES = 32 * 1024;
@@ -30,6 +32,8 @@ export interface DecisionWorkerLike {
30
32
  start(): Promise<void>;
31
33
  updateContext(patch: Partial<DecisionContext>): void;
32
34
  notify(event: WorkerEvent): void;
35
+ /** Re-deliver an event already seen (e.g. after a Supervisor recovery) bypassing dedupe. */
36
+ replay?(event: WorkerEvent): void;
33
37
  close(): Promise<void>;
34
38
  }
35
39
 
@@ -42,13 +46,27 @@ export interface DecisionWorkerOptions {
42
46
  onStartupFailure?: (error: unknown) => Promise<void> | void;
43
47
  /** Bound each Decision Worker model request so failure handling cannot wait forever. */
44
48
  timeoutMs?: number;
49
+ /** Base backoff for retrying a failed Decision Worker request; doubles per attempt, capped at 30s. */
50
+ retryBackoffMs?: number;
45
51
  /** Existing Pi session JSONL to restore after a Supervisor/Pi restart. */
46
52
  sessionFile?: string;
47
53
  /** Directory for newly created Pi session JSONL files. */
48
54
  sessionDir?: string;
49
55
  onSessionReady?: (info: { sessionFile: string; sessionId: string; restored: boolean }) => Promise<void> | void;
56
+ /** Test seam: inject a fake Pi session instead of the real createAgentSession. */
57
+ sessionFactory?: (options: Parameters<typeof createAgentSession>[0]) => Promise<{ session: AgentSession }>;
58
+ /** Pi model for this session; undefined keeps Pi's configured default. */
59
+ model?: PiModel;
60
+ /** Compact the persistent session once its estimated context exceeds this many tokens (0 disables). */
61
+ compactionTokens?: number;
62
+ /** Token accounting for every model call made by this session. */
63
+ onUsage?: (usage: PiUsageSample) => void;
50
64
  }
51
65
 
66
+ export type PiModel = NonNullable<NonNullable<Parameters<typeof createAgentSession>[0]>["model"]>;
67
+
68
+ const DEFAULT_COMPACTION_TOKENS = 60_000;
69
+
52
70
  /**
53
71
  * A persistent Pi SDK session used only for supervision decisions.
54
72
  * It has read-only repository tools and can request typed actions, but it
@@ -64,11 +82,17 @@ export class PiDecisionWorker implements DecisionWorkerLike {
64
82
  #sessionFile?: string;
65
83
  #context: DecisionContext;
66
84
  readonly #timeoutMs: number;
85
+ readonly #retryBackoffMs: number;
86
+ readonly #compactionTokens: number;
87
+ /** Set after a compaction; the next primary decision prompt re-sends the startup instructions once. */
88
+ #instructionsStale = false;
67
89
 
68
90
  constructor(options: DecisionWorkerOptions) {
69
91
  this.#options = options;
70
92
  this.#context = { ...options.context };
71
93
  this.#timeoutMs = options.timeoutMs ?? 120_000;
94
+ this.#retryBackoffMs = options.retryBackoffMs ?? 500;
95
+ this.#compactionTokens = options.compactionTokens ?? DEFAULT_COMPACTION_TOKENS;
72
96
  }
73
97
 
74
98
  async start(): Promise<void> {
@@ -94,12 +118,14 @@ export class PiDecisionWorker implements DecisionWorkerLike {
94
118
  ? SessionManager.create(this.#options.context.cwd, this.#options.sessionDir)
95
119
  : SessionManager.inMemory(this.#options.context.cwd);
96
120
  const sessionManager = redactingSessionManager(rawSessionManager);
97
- const { session } = await createAgentSession({
121
+ const sessionFactory = this.#options.sessionFactory ?? createAgentSession;
122
+ const { session } = await sessionFactory({
98
123
  cwd: this.#options.context.cwd,
99
124
  resourceLoader,
100
125
  sessionManager,
101
126
  thinkingLevel: "low",
102
127
  tools: ["read", "grep", "find", "ls"],
128
+ model: this.#options.model,
103
129
  });
104
130
  this.#session = session;
105
131
  this.#sessionFile = session.sessionFile;
@@ -109,9 +135,8 @@ export class PiDecisionWorker implements DecisionWorkerLike {
109
135
  }
110
136
  if (!restored) {
111
137
  try {
112
- await withTimeout(session.prompt(decisionInstructions(this.#context)), this.#timeoutMs, "Decision Worker startup");
138
+ await promptForText(session, decisionInstructions(this.#context), this.#timeoutMs, "Decision Worker startup", MAX_DECISION_RESPONSE_BYTES, { role: "decision", onUsage: this.#options.onUsage });
113
139
  } catch (error) {
114
- await session.abort().catch(() => {});
115
140
  try { await this.#options.onStartupFailure?.(error); } catch { /* preserve the original startup failure */ }
116
141
  throw error;
117
142
  }
@@ -140,29 +165,84 @@ export class PiDecisionWorker implements DecisionWorkerLike {
140
165
  });
141
166
  }
142
167
 
168
+ /** Re-deliver an event that was already seen, bypassing the dedupe set. */
169
+ replay(event: WorkerEvent): void {
170
+ this.#seenEvents.delete(eventKey(event));
171
+ this.notify(event);
172
+ }
173
+
143
174
  async #processEvent(event: WorkerEvent): Promise<void> {
144
175
  if (!this.#session || this.#closed) return;
145
176
  const maxRetries = this.#context.spec?.autonomy.maxDecisionRetries ?? 2;
146
177
  let attempt = 0;
178
+ // undefined selects the primary decision question; a bounded re-prompt
179
+ // (below) switches this to a corrective follow-up on the same session.
180
+ let prompt: string | undefined;
181
+ let repromptAttempted = false;
182
+ // Set only when this attempt's primary prompt actually carried the
183
+ // re-sent startup instructions; cleared once that prompt succeeds so a
184
+ // failed attempt does not silently drop the re-send on retry.
185
+ let usedInstructionsPrefix = false;
147
186
  while (true) {
148
187
  let text: string;
149
188
  try {
150
- text = await askDecision(this.#session, event, this.#context, this.#timeoutMs);
189
+ if (prompt === undefined) {
190
+ const instructionsPrefix = this.#instructionsStale
191
+ ? `SUPERVISOR INSTRUCTIONS (re-sent after compaction):\n${decisionInstructions(this.#context)}\n\n`
192
+ : undefined;
193
+ usedInstructionsPrefix = Boolean(instructionsPrefix);
194
+ text = await askDecision(this.#session, event, this.#context, this.#timeoutMs, { instructionsPrefix, onUsage: this.#options.onUsage });
195
+ } else {
196
+ usedInstructionsPrefix = false;
197
+ text = await promptForText(this.#session, prompt, this.#timeoutMs, "Decision Worker request", MAX_DECISION_RESPONSE_BYTES, { role: "decision", onUsage: this.#options.onUsage });
198
+ }
151
199
  } catch (error) {
152
- if (attempt >= maxRetries || this.#closed) throw error;
200
+ if (this.#closed) return;
201
+ if (error instanceof Error && error.name === "AbortError") throw error;
202
+ if (attempt >= maxRetries) throw error;
153
203
  attempt += 1;
154
- await new Promise<void>((resolve) => setTimeout(resolve, Math.min(1_000, 100 * attempt)));
204
+ await new Promise<void>((resolveWait) => setTimeout(resolveWait, Math.min(30_000, this.#retryBackoffMs * 2 ** attempt)));
155
205
  if (!this.#session || this.#closed) return;
156
206
  continue;
157
207
  }
208
+ if (this.#closed) return;
209
+ if (usedInstructionsPrefix) this.#instructionsStale = false;
158
210
  const action = parseDecision(text, event);
211
+ // A single bounded re-prompt: give the model one more chance to return a
212
+ // valid single JSON action before parking. A second parse failure keeps
213
+ // the park; a provider error on the re-prompt still retries above.
214
+ if (!repromptAttempted && isReparseableDecision(action)) {
215
+ repromptAttempted = true;
216
+ prompt = `Your previous reply was not a single valid JSON action (${action.reason}). Return exactly one JSON object now and nothing else.`;
217
+ continue;
218
+ }
159
219
  // An action handler may stop or close the session. Do not replay an
160
220
  // already-decoded action if the handler itself fails.
161
221
  await this.#options.onAction(action, event);
222
+ await this.#maybeCompact();
162
223
  return;
163
224
  }
164
225
  }
165
226
 
227
+ /**
228
+ * Compact the persistent session once its estimated context grows past the
229
+ * configured threshold. Runs only between decisions (no prompt in flight);
230
+ * a failure here must not fail the decision that already succeeded.
231
+ */
232
+ async #maybeCompact(): Promise<void> {
233
+ if (this.#compactionTokens <= 0 || !this.#session || this.#closed) return;
234
+ const tokens = this.#session.getContextUsage?.()?.tokens;
235
+ if (typeof tokens !== "number" || tokens <= this.#compactionTokens) return;
236
+ try {
237
+ await this.#session.compact(
238
+ "Preserve: the task specification, the policy rules for permissions and boundaries, the current state, and the last three decisions with their reasons.",
239
+ );
240
+ this.#instructionsStale = true;
241
+ } catch {
242
+ // A compaction failure must not fail the decision that already succeeded.
243
+ }
244
+ }
245
+
166
246
  get sessionFile(): string | undefined {
167
247
  return this.#sessionFile;
168
248
  }
@@ -209,21 +289,160 @@ For AskUserQuestion, choose deny_permission when the question can be converted i
209
289
  Claude text, then use answer on the resulting turn. For product ambiguity or an architecture
210
290
  choice, inspect the repository and task evidence, select the best task-compatible option, state
211
291
  the assumption in reason, and instruct Claude Code with answer or redirect. Do not ask a human
212
- for ordinary uncertainty. Use verify when a turn result indicates the task is complete, even if
292
+ for ordinary uncertainty. In an interactive session AskUserQuestion is answered by you: choose
293
+ deny_permission and put the selected answer and its rationale in reason; Claude reads that reason
294
+ as the answer and continues. A turn_completed whose result has subtype "error" or is_error true
295
+ means the Worker's own API/model call failed mid-turn: choose retry (optionally with a short
296
+ corrective message) or continue to resume it, and park only after repeated failures; a subtype
297
+ "idle" result means the turn ended without a normal stop signal, so inspect the repository and
298
+ decide as for any other turn. Use verify when a turn result indicates the task is complete, even if
213
299
  Claude says it will stop; choose stop only for an explicit stop or technical containment reason.
214
300
  Use park only when the task cannot safely produce a candidate because required evidence,
215
301
  authority, or runtime capability is unavailable. A parked candidate is asynchronous and must not
216
- wait for a human to be online.`;
302
+ wait for a human to be online. For an exited event choose verify, park or stop; a noop on an exited event is treated as
303
+ verify. A completed turn or a permission request always requires a concrete action.`;
304
+ }
305
+
306
+ /**
307
+ * The task/spec/cwd already live in the startup instructions; every
308
+ * subsequent prompt sends only the compact event summary and the small
309
+ * pieces of context that actually change turn to turn.
310
+ */
311
+ async function askDecision(
312
+ session: AgentSession,
313
+ event: WorkerEvent,
314
+ context: DecisionContext,
315
+ timeoutMs: number,
316
+ options: { instructionsPrefix?: string; onUsage?: (usage: PiUsageSample) => void } = {},
317
+ ): Promise<string> {
318
+ const currentContext = { state: context.state, turn: context.turn, maxTurns: context.maxTurns, repairRound: context.repairRound ?? 0 };
319
+ const prompt = `${options.instructionsPrefix ?? ""}UNTRUSTED SUPERVISOR EVENT:\n${boundedEventJson(event)}\n\nCURRENT CONTEXT:\n${boundedJson(currentContext)}\n\nChoose one action now.`;
320
+ return promptForText(session, prompt, timeoutMs, "Decision Worker request", MAX_DECISION_RESPONSE_BYTES, { role: "decision", onUsage: options.onUsage });
321
+ }
322
+
323
+ const KEPT_PERMISSION_INPUT_FIELDS = ["command", "description", "file_path", "path", "notebook_path", "pattern", "url"] as const;
324
+ const CONTENT_REPLACED_PERMISSION_INPUT_FIELDS = ["content", "new_string", "old_string", "new_source"] as const;
325
+ const CONTENT_REPLACING_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
326
+
327
+ /**
328
+ * Compact, model-facing summary of a Worker event. A `permission_request`
329
+ * carries the whole tool input and a `turn_completed` carries the whole
330
+ * Claude result record; both are large enough (up to 32 KB) that sending them
331
+ * verbatim on every prompt grew the Decision Worker's context unboundedly.
332
+ */
333
+ function summarizeEvent(event: WorkerEvent): Record<string, unknown> {
334
+ if (event.type === "turn_completed") {
335
+ const result = event.result as { subtype?: unknown; is_error?: unknown; num_turns?: unknown; duration_ms?: unknown; total_cost_usd?: unknown; result?: unknown };
336
+ const text = typeof result.result === "string" ? result.result : "";
337
+ return {
338
+ type: event.type,
339
+ sequence: event.sequence,
340
+ result: {
341
+ subtype: result.subtype,
342
+ is_error: result.is_error,
343
+ num_turns: result.num_turns,
344
+ duration_ms: result.duration_ms,
345
+ total_cost_usd: result.total_cost_usd,
346
+ result: boundTail(text, 4_096),
347
+ },
348
+ };
349
+ }
350
+ if (event.type === "permission_request") {
351
+ const { requestId, toolUseId, toolName, input } = event.request;
352
+ return {
353
+ type: event.type,
354
+ requestId,
355
+ toolUseId,
356
+ toolName,
357
+ input: compactPermissionInput(toolName, input),
358
+ inputBytes: Buffer.byteLength(JSON.stringify(input) ?? "null", "utf8"),
359
+ };
360
+ }
361
+ if (event.type === "exited") {
362
+ return { type: event.type, exitCode: event.exitCode, signal: event.signal };
363
+ }
364
+ return { type: event.type };
365
+ }
366
+
367
+ function compactPermissionInput(toolName: string, input: unknown): Record<string, unknown> {
368
+ if (!input || typeof input !== "object") return {};
369
+ const source = input as Record<string, unknown>;
370
+ const compact: Record<string, unknown> = {};
371
+ for (const field of KEPT_PERMISSION_INPUT_FIELDS) {
372
+ const value = source[field];
373
+ if (typeof value === "string") compact[field] = boundHead(value, 2_048);
374
+ }
375
+ if (Array.isArray(source.edits)) compact.edits = { count: source.edits.length };
376
+ if (CONTENT_REPLACING_TOOLS.has(toolName)) {
377
+ for (const field of CONTENT_REPLACED_PERMISSION_INPUT_FIELDS) {
378
+ const value = source[field];
379
+ if (typeof value === "string") compact[field] = { bytes: Buffer.byteLength(value, "utf8"), preview: value.slice(0, 512) };
380
+ }
381
+ }
382
+ return compact;
383
+ }
384
+
385
+ function boundedEventJson(event: WorkerEvent): string {
386
+ const text = JSON.stringify(redactDecisionValue(summarizeEvent(event)), null, 2) ?? "null";
387
+ return text.length <= 8_000 ? text : `${text.slice(0, 8_000)}\n[TRUNCATED]`;
388
+ }
389
+
390
+ function boundHead(value: string, maxBytes: number): string {
391
+ if (Buffer.byteLength(value, "utf8") <= maxBytes) return value;
392
+ return `${Buffer.from(value, "utf8").subarray(0, maxBytes).toString("utf8")}\n[TRUNCATED]`;
393
+ }
394
+
395
+ function boundTail(value: string, maxBytes: number): string {
396
+ if (Buffer.byteLength(value, "utf8") <= maxBytes) return value;
397
+ return `[TRUNCATED]\n${Buffer.from(value, "utf8").subarray(-maxBytes).toString("utf8")}`;
398
+ }
399
+
400
+ interface PromptUsage {
401
+ input: number;
402
+ output: number;
403
+ cacheRead: number;
404
+ cacheWrite: number;
405
+ totalTokens: number;
406
+ cost?: { total: number };
407
+ }
408
+
409
+ function addPromptUsage(total: PromptUsage | undefined, next: PromptUsage | undefined): PromptUsage | undefined {
410
+ if (!next) return total;
411
+ if (!total) return { ...next, ...(next.cost ? { cost: { total: next.cost.total } } : {}) };
412
+ return {
413
+ input: total.input + next.input,
414
+ output: total.output + next.output,
415
+ cacheRead: total.cacheRead + next.cacheRead,
416
+ cacheWrite: total.cacheWrite + next.cacheWrite,
417
+ totalTokens: total.totalTokens + next.totalTokens,
418
+ ...(total.cost || next.cost ? { cost: { total: (total.cost?.total ?? 0) + (next.cost?.total ?? 0) } } : {}),
419
+ };
420
+ }
421
+
422
+ export interface PromptForTextOptions {
423
+ role?: PiUsageSample["role"];
424
+ onUsage?: (usage: PiUsageSample) => void;
217
425
  }
218
426
 
219
- async function askDecision(session: AgentSession, event: WorkerEvent, context: DecisionContext, timeoutMs: number): Promise<string> {
427
+ /**
428
+ * Send a prompt and capture the assistant's final text. `session.prompt()`
429
+ * resolves normally even when the model/API call failed or was aborted, so the
430
+ * captured `message_end` stopReason/errorMessage are checked after it resolves:
431
+ * an aborted turn raises an AbortError, a provider error raises a
432
+ * DecisionWorkerApiError, and neither is treated as a normal empty reply.
433
+ * Also used by the Reviewer, which shares this exact assistant-text capture.
434
+ */
435
+ export async function promptForText(session: AgentSession, prompt: string, timeoutMs: number, label: string, maxBytes: number, options: PromptForTextOptions = {}): Promise<string> {
220
436
  let current = "";
221
437
  let finalMessage = "";
222
438
  let capturingAssistant = false;
223
439
  let currentTooLarge = false;
224
440
  let finalTooLarge = false;
441
+ let stopReason: string | undefined;
442
+ let errorMessage: string | undefined;
443
+ let usage: PromptUsage | undefined;
225
444
  const unsubscribe = session.subscribe((value) => {
226
- const record = value as unknown as { type?: string; message?: { role?: string; content?: unknown }; assistantMessageEvent?: { type?: string; delta?: string } };
445
+ const record = value as unknown as { type?: string; message?: { role?: string; content?: unknown; stopReason?: string; errorMessage?: string; usage?: PromptUsage }; assistantMessageEvent?: { type?: string; delta?: string } };
227
446
  const role = record.message?.role;
228
447
  if (record.type === "message_start" && role === "assistant") {
229
448
  current = "";
@@ -234,7 +453,7 @@ async function askDecision(session: AgentSession, event: WorkerEvent, context: D
234
453
  if (record.type === "message_update" && record.assistantMessageEvent?.type === "text_delta" && (capturingAssistant || role === "assistant" || role === undefined)) {
235
454
  capturingAssistant = true;
236
455
  const delta = record.assistantMessageEvent.delta ?? "";
237
- if (Buffer.byteLength(current, "utf8") + Buffer.byteLength(delta, "utf8") > MAX_DECISION_RESPONSE_BYTES) {
456
+ if (Buffer.byteLength(current, "utf8") + Buffer.byteLength(delta, "utf8") > maxBytes) {
238
457
  currentTooLarge = true;
239
458
  return;
240
459
  }
@@ -244,6 +463,11 @@ async function askDecision(session: AgentSession, event: WorkerEvent, context: D
244
463
  if (record.type === "message_end" && role === "assistant") {
245
464
  finalMessage = current || textFromMessage(record.message?.content);
246
465
  finalTooLarge = currentTooLarge;
466
+ stopReason = record.message?.stopReason;
467
+ errorMessage = record.message?.errorMessage;
468
+ // A decision that uses read-only tools produces several assistant
469
+ // messages in one prompt; report the sum, not the last message.
470
+ usage = addPromptUsage(usage, record.message?.usage);
247
471
  current = "";
248
472
  currentTooLarge = false;
249
473
  capturingAssistant = false;
@@ -251,7 +475,7 @@ async function askDecision(session: AgentSession, event: WorkerEvent, context: D
251
475
  });
252
476
  try {
253
477
  try {
254
- await withTimeout(session.prompt(`UNTRUSTED SUPERVISOR EVENT:\n${boundedJson(event)}\n\nCURRENT CONTEXT:\n${boundedJson(context)}\n\nChoose one action now.`), timeoutMs, "Decision Worker request");
478
+ await withTimeout(session.prompt(prompt), timeoutMs, label);
255
479
  } catch (error) {
256
480
  await session.abort().catch(() => {});
257
481
  throw error;
@@ -259,8 +483,33 @@ async function askDecision(session: AgentSession, event: WorkerEvent, context: D
259
483
  } finally {
260
484
  unsubscribe();
261
485
  }
262
- if (finalTooLarge) return "";
263
- return finalMessage || current;
486
+ // Reported for both a successful reply and a provider error turn, since
487
+ // both consumed tokens against the persistent session.
488
+ if (usage && options.onUsage) {
489
+ options.onUsage({
490
+ role: options.role ?? "decision",
491
+ input: usage.input,
492
+ output: usage.output,
493
+ cacheRead: usage.cacheRead,
494
+ cacheWrite: usage.cacheWrite,
495
+ totalTokens: usage.totalTokens,
496
+ costUsd: usage.cost?.total,
497
+ contextTokens: session.getContextUsage?.()?.tokens ?? null,
498
+ });
499
+ }
500
+ if (stopReason === "aborted") {
501
+ await session.abort().catch(() => {});
502
+ const error = new Error(`${label} was aborted`);
503
+ error.name = "AbortError";
504
+ throw error;
505
+ }
506
+ if (stopReason === "error") {
507
+ await session.abort().catch(() => {});
508
+ const error = new Error(`${label} model request failed: ${errorMessage ?? "unknown provider error"}`);
509
+ error.name = "DecisionWorkerApiError";
510
+ throw error;
511
+ }
512
+ return finalTooLarge ? "" : finalMessage || current;
264
513
  }
265
514
 
266
515
  function textFromMessage(content: unknown): string {
@@ -272,11 +521,27 @@ function textFromMessage(content: unknown): string {
272
521
  .join("");
273
522
  }
274
523
 
524
+ /**
525
+ * A park action produced only because the reply failed to decode is worth one
526
+ * bounded re-prompt; a park chosen deliberately by the model is not.
527
+ */
528
+ function isReparseableDecision(action: DecisionAction): boolean {
529
+ if (action.action !== "park") return false;
530
+ return action.reason.startsWith("Decision Worker returned no JSON action")
531
+ || action.reason.startsWith("Decision Worker returned conflicting")
532
+ || action.reason.startsWith("invalid Decision Worker action");
533
+ }
534
+
275
535
  function parseDecision(text: string, event: WorkerEvent): DecisionAction {
276
- const candidate = text.trim();
277
- if (!candidate) return { action: "park", reason: "Decision Worker returned no JSON action" };
536
+ const objects = extractJsonObjects(text);
537
+ const distinct: unknown[] = [];
538
+ for (const value of objects) {
539
+ if (!distinct.some((seen) => isDeepStrictEqual(seen, value))) distinct.push(value);
540
+ }
541
+ if (distinct.length === 0) return { action: "park", reason: "Decision Worker returned no JSON action" };
542
+ if (distinct.length > 1) return { action: "park", reason: "Decision Worker returned conflicting JSON actions" };
278
543
  try {
279
- const value = JSON.parse(candidate) as Record<string, unknown>;
544
+ const value = distinct[0] as Record<string, unknown>;
280
545
  const action = value.action;
281
546
  if (typeof action !== "string") throw new Error("missing action");
282
547
  const allowed = new Set(["continue", "redirect", "answer", "allow_permission", "deny_permission", "verify", "retry", "stop", "park", "ask_human", "noop"]);
@@ -315,6 +580,7 @@ function eventKey(event: WorkerEvent): string {
315
580
  if (event.type === "turn_completed") return `${event.handle.id}:result:${event.sequence}`;
316
581
  if (event.type === "exited") return `${event.handle.id}:exit`;
317
582
  if (event.type === "jsonl") return `${event.handle.id}:jsonl:${String(event.record.uuid ?? event.record.request_id ?? JSON.stringify(event.record))}`;
583
+ if (event.type === "human_input") return `${event.handle.id}:human:${event.text.slice(0, 80)}`;
318
584
  return `${event.handle.id}:output:${event.chunk.at}:${event.chunk.text.slice(0, 80)}`;
319
585
  }
320
586
 
package/src/events.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { appendFile, chmod, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
- import { dirname } from "node:path";
1
+ import { randomBytes } from "node:crypto";
2
+ import { appendFile, chmod, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, join } from "node:path";
3
4
  import { redactSensitive } from "./redaction.ts";
4
5
 
5
6
  export interface SupervisorEvent {
@@ -14,15 +15,23 @@ export interface SupervisorEvent {
14
15
 
15
16
  const LOCK_TIMEOUT_MS = 10_000;
16
17
  const STALE_LOCK_MS = 5_000;
18
+ const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
19
+ const DEFAULT_KEEP_ROTATED = 5;
20
+ const TAIL_WINDOW_BYTES = 256 * 1024;
21
+ const ROTATED_STAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-[0-9a-f]{8}$/u;
17
22
 
18
23
  export class EventLog {
19
24
  #seq = 0;
20
25
  #initialized = false;
21
26
  readonly #path?: string;
27
+ readonly #maxBytes: number;
28
+ readonly #keepRotated: number;
22
29
  #writeTail: Promise<void> = Promise.resolve();
23
30
 
24
- constructor(path?: string) {
31
+ constructor(path?: string, options: { maxBytes?: number; keepRotated?: number } = {}) {
25
32
  this.#path = path;
33
+ this.#maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
34
+ this.#keepRotated = options.keepRotated ?? DEFAULT_KEEP_ROTATED;
26
35
  }
27
36
 
28
37
  async append(event: Omit<SupervisorEvent, "seq" | "at">): Promise<SupervisorEvent> {
@@ -31,6 +40,7 @@ export class EventLog {
31
40
  ? this.#withFileLock(async () => {
32
41
  await this.#initialize();
33
42
  await this.#refreshSequence();
43
+ await this.#rotateIfNeeded();
34
44
  return this.#appendEntry(event);
35
45
  })
36
46
  : this.#appendEntry(event);
@@ -117,10 +127,14 @@ export class EventLog {
117
127
  async #initialize(): Promise<void> {
118
128
  if (this.#initialized) return;
119
129
  this.#initialized = true;
120
- await this.#refreshSequence();
130
+ await this.#fullScan();
121
131
  }
122
132
 
123
- async #refreshSequence(): Promise<void> {
133
+ // Full scan of the log: reads and validates every line, repairing a
134
+ // partial last line left behind by a crashed writer. Only safe to run
135
+ // once per process (on #initialize) or as a fallback from the tail-only
136
+ // #refreshSequence below, since it is O(file size).
137
+ async #fullScan(): Promise<void> {
124
138
  if (!this.#path) return;
125
139
  try {
126
140
  const contents = await readFile(this.#path, "utf8");
@@ -154,6 +168,112 @@ export class EventLog {
154
168
  if (!(error instanceof Error) || !/ENOENT/u.test(error.message)) throw error;
155
169
  }
156
170
  }
171
+
172
+ // Per-append sequence sync: reads only a tail window of the file so that
173
+ // append cost stays flat as the log grows, instead of re-parsing the
174
+ // whole file (and holding the cross-process lock) on every append.
175
+ async #refreshSequence(): Promise<void> {
176
+ if (!this.#path) return;
177
+ let size: number;
178
+ try {
179
+ size = (await stat(this.#path)).size;
180
+ } catch (error) {
181
+ if (!(error instanceof Error) || !/ENOENT/u.test(error.message)) throw error;
182
+ // File is missing (e.g. right after rotation): keep #seq as-is.
183
+ return;
184
+ }
185
+ if (size === 0) return;
186
+ let window = Math.min(TAIL_WINDOW_BYTES, size);
187
+ while (true) {
188
+ const start = size - window;
189
+ let text: string;
190
+ const handle = await open(this.#path, "r");
191
+ try {
192
+ const buffer = Buffer.alloc(window);
193
+ const { bytesRead } = await handle.read(buffer, 0, window, start);
194
+ text = buffer.subarray(0, bytesRead).toString("utf8");
195
+ } finally {
196
+ await handle.close();
197
+ }
198
+ if (start > 0) {
199
+ // The window may start mid-line; discard the (possibly partial)
200
+ // first line and rely on the next window growth if that leaves
201
+ // nothing usable.
202
+ const firstNewline = text.indexOf("\n");
203
+ text = firstNewline === -1 ? "" : text.slice(firstNewline + 1);
204
+ }
205
+ const lines = text.split("\n").filter((line) => line.trim().length > 0);
206
+ if (lines.length === 0) {
207
+ if (window >= size) {
208
+ await this.#fullScan();
209
+ return;
210
+ }
211
+ window = Math.min(window * 2, size);
212
+ continue;
213
+ }
214
+ const lastLine = lines[lines.length - 1];
215
+ try {
216
+ const seq = (JSON.parse(lastLine) as { seq?: unknown }).seq;
217
+ if (typeof seq === "number" && Number.isSafeInteger(seq) && seq >= 0) {
218
+ this.#seq = Math.max(this.#seq, seq);
219
+ }
220
+ } catch {
221
+ // The last line is a partial write from a crashed writer; fall back
222
+ // to the full scan, which repairs the corrupt tail.
223
+ await this.#fullScan();
224
+ }
225
+ return;
226
+ }
227
+ }
228
+
229
+ async #rotateIfNeeded(): Promise<void> {
230
+ if (!this.#path) return;
231
+ let size: number;
232
+ try {
233
+ size = (await stat(this.#path)).size;
234
+ } catch (error) {
235
+ if (!(error instanceof Error) || !/ENOENT/u.test(error.message)) throw error;
236
+ return;
237
+ }
238
+ if (size < this.#maxBytes) return;
239
+ // Two rotations within one millisecond must not clobber each other via
240
+ // rename(2), so the timestamp carries a random suffix as well.
241
+ const stamp = `${new Date().toISOString().replace(/[:.]/gu, "-")}-${randomBytes(4).toString("hex")}`;
242
+ await rename(this.#path, `${this.#path}.${stamp}`);
243
+ await this.#pruneRotated();
244
+ }
245
+
246
+ async #pruneRotated(): Promise<void> {
247
+ if (!this.#path) return;
248
+ const dir = dirname(this.#path);
249
+ const base = basename(this.#path);
250
+ const lockName = `${base}.lock`;
251
+ let entries: string[];
252
+ try {
253
+ entries = await readdir(dir);
254
+ } catch {
255
+ return;
256
+ }
257
+ const rotated: string[] = [];
258
+ for (const name of entries) {
259
+ if (name === base || name === lockName) continue;
260
+ if (!name.startsWith(`${base}.`)) continue;
261
+ if (!ROTATED_STAMP_PATTERN.test(name.slice(base.length + 1))) continue;
262
+ let entryStat;
263
+ try {
264
+ entryStat = await stat(join(dir, name));
265
+ } catch {
266
+ continue;
267
+ }
268
+ if (!entryStat.isFile()) continue;
269
+ rotated.push(name);
270
+ }
271
+ rotated.sort();
272
+ const toRemove = rotated.slice(0, Math.max(0, rotated.length - this.#keepRotated));
273
+ for (const name of toRemove) {
274
+ await rm(join(dir, name), { force: true });
275
+ }
276
+ }
157
277
  }
158
278
 
159
279
  function delay(ms: number): Promise<void> {