pi-claude-supervisor 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,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import { spawn, type ChildProcess } from "node:child_process";
3
3
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import type {
@@ -25,6 +25,8 @@ export interface ProcessWorkerAdapterOptions {
25
25
  maxOutputChunks?: number;
26
26
  /** Maximum UTF-8 bytes of captured output retained per worker. */
27
27
  maxOutputBytes?: number;
28
+ /** Maximum UTF-8 bytes retained for an incomplete Claude JSONL record. */
29
+ maxProtocolBufferBytes?: number;
28
30
  /** Maximum time a blocked stdin write may hold lifecycle operations. */
29
31
  inputWriteTimeoutMs?: number;
30
32
  /** Linux descendant cleanup mode; auto uses cgroup v2 when available. */
@@ -43,7 +45,12 @@ interface ProcessRecord {
43
45
  lastInputAt?: string;
44
46
  activeRequests: number;
45
47
  turnSequence: number;
48
+ requestSequence: number;
49
+ activeRequestSequence?: number;
50
+ seenResultIds: Set<string>;
51
+ seenPermissionRequestIds: Set<string>;
46
52
  protocolBuffer: string;
53
+ discardProtocolLine: boolean;
47
54
  exitCode?: number | null;
48
55
  signal?: NodeJS.Signals;
49
56
  spawnError?: Error;
@@ -83,6 +90,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
83
90
  readonly #killGraceMs: number;
84
91
  readonly #maxOutputChunks: number;
85
92
  readonly #maxOutputBytes: number;
93
+ readonly #maxProtocolBufferBytes: number;
86
94
  readonly #inputWriteTimeoutMs: number;
87
95
  readonly #cgroupMode: "off" | "auto" | "required";
88
96
  readonly #cgroupParentPath?: string;
@@ -93,6 +101,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
93
101
  this.#killGraceMs = boundedDelay(options.killGraceMs ?? 500);
94
102
  this.#maxOutputChunks = boundedPositiveInteger(options.maxOutputChunks ?? 10_000, "maxOutputChunks");
95
103
  this.#maxOutputBytes = boundedPositiveInteger(options.maxOutputBytes ?? 8 * 1024 * 1024, "maxOutputBytes");
104
+ this.#maxProtocolBufferBytes = boundedPositiveInteger(options.maxProtocolBufferBytes ?? 256 * 1024, "maxProtocolBufferBytes");
96
105
  this.#inputWriteTimeoutMs = boundedDelay(options.inputWriteTimeoutMs ?? 10_000);
97
106
  this.#cgroupMode = options.cgroupMode ?? "auto";
98
107
  this.#cgroupParentPath = options.cgroupParentPath;
@@ -140,7 +149,12 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
140
149
  outputTruncated: false,
141
150
  activeRequests: input.task ? 1 : 0,
142
151
  turnSequence: 0,
152
+ requestSequence: input.task ? 1 : 0,
153
+ activeRequestSequence: input.task ? 1 : undefined,
154
+ seenResultIds: new Set(),
155
+ seenPermissionRequestIds: new Set(),
143
156
  protocolBuffer: "",
157
+ discardProtocolLine: false,
144
158
  exited,
145
159
  resolveExit,
146
160
  sentKeys: new Set(),
@@ -366,7 +380,10 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
366
380
  if (record.stopping) throw new Error("worker is stopping");
367
381
  if (record.sentKeys.has(idempotencyKey)) return;
368
382
  if (record.exitCode !== undefined) throw new Error("worker is not running");
369
- if (this.#mode === "claude-jsonl") record.activeRequests += 1;
383
+ if (this.#mode === "claude-jsonl") {
384
+ record.activeRequests += 1;
385
+ record.activeRequestSequence = ++record.requestSequence;
386
+ }
370
387
  try {
371
388
  await this.#writeInput(record, this.#encodeMessage(message));
372
389
  } catch (error) {
@@ -454,41 +471,76 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
454
471
  }
455
472
 
456
473
  #observeJsonl(record: ProcessRecord, chunk: string): void {
457
- record.protocolBuffer += chunk;
458
- let newline = record.protocolBuffer.indexOf("\n");
459
- while (newline >= 0) {
460
- const line = record.protocolBuffer.slice(0, newline).trim();
461
- record.protocolBuffer = record.protocolBuffer.slice(newline + 1);
462
- if (line) {
463
- try {
464
- const event = JSON.parse(line) as Record<string, unknown>;
465
- this.#emit(record, { type: "jsonl", handle: record.handle, record: event });
466
- if (event.type === "control_request") {
467
- const request = event.request;
468
- if (isPermissionRequest(event, request)) {
469
- this.#emit(record, {
470
- type: "permission_request",
471
- handle: record.handle,
472
- request: {
473
- requestId: String(event.request_id),
474
- toolUseId: request.tool_use_id,
475
- toolName: request.tool_name,
476
- input: request.input,
477
- raw: event,
478
- },
479
- });
480
- }
481
- }
482
- if (event.type === "result") {
483
- record.activeRequests = Math.max(0, record.activeRequests - 1);
484
- record.turnSequence += 1;
485
- this.#emit(record, { type: "turn_completed", handle: record.handle, result: event, sequence: record.turnSequence });
474
+ let offset = 0;
475
+ while (offset < chunk.length) {
476
+ if (record.discardProtocolLine) {
477
+ const newline = chunk.indexOf("\n", offset);
478
+ if (newline < 0) return;
479
+ record.discardProtocolLine = false;
480
+ record.protocolBuffer = "";
481
+ offset = newline + 1;
482
+ continue;
483
+ }
484
+ const newline = chunk.indexOf("\n", offset);
485
+ if (newline < 0) {
486
+ const tail = chunk.slice(offset);
487
+ if (Buffer.byteLength(record.protocolBuffer, "utf8") + Buffer.byteLength(tail, "utf8") > this.#maxProtocolBufferBytes) {
488
+ record.protocolBuffer = "";
489
+ record.discardProtocolLine = true;
490
+ record.outputTruncated = true;
491
+ } else {
492
+ record.protocolBuffer += tail;
493
+ }
494
+ return;
495
+ }
496
+ const linePart = chunk.slice(offset, newline);
497
+ if (Buffer.byteLength(record.protocolBuffer, "utf8") + Buffer.byteLength(linePart, "utf8") > this.#maxProtocolBufferBytes) {
498
+ record.outputTruncated = true;
499
+ } else {
500
+ this.#processJsonlLine(record, `${record.protocolBuffer}${linePart}`.trim());
501
+ }
502
+ record.protocolBuffer = "";
503
+ offset = newline + 1;
504
+ }
505
+ }
506
+
507
+ #processJsonlLine(record: ProcessRecord, line: string): void {
508
+ if (!line) return;
509
+ try {
510
+ const event = JSON.parse(line) as Record<string, unknown>;
511
+ this.#emit(record, { type: "jsonl", handle: record.handle, record: event });
512
+ if (event.type === "control_request") {
513
+ const request = event.request;
514
+ if (isPermissionRequest(event, request)) {
515
+ const requestId = String(event.request_id);
516
+ if (!record.seenPermissionRequestIds.has(requestId)) {
517
+ rememberBounded(record.seenPermissionRequestIds, requestId);
518
+ this.#emit(record, {
519
+ type: "permission_request",
520
+ handle: record.handle,
521
+ request: {
522
+ requestId,
523
+ toolUseId: request.tool_use_id,
524
+ toolName: request.tool_name,
525
+ input: request.input,
526
+ raw: event,
527
+ },
528
+ });
486
529
  }
487
- } catch {
488
- // Keep raw output for diagnostics; malformed output is not a completion signal.
489
530
  }
490
531
  }
491
- newline = record.protocolBuffer.indexOf("\n");
532
+ if (event.type === "result" && record.activeRequests > 0) {
533
+ const resultId = jsonlRecordId(event, record.activeRequestSequence);
534
+ if (!record.seenResultIds.has(resultId)) {
535
+ rememberBounded(record.seenResultIds, resultId);
536
+ record.activeRequests = Math.max(0, record.activeRequests - 1);
537
+ record.activeRequestSequence = undefined;
538
+ record.turnSequence += 1;
539
+ this.#emit(record, { type: "turn_completed", handle: record.handle, result: event, sequence: record.turnSequence });
540
+ }
541
+ }
542
+ } catch {
543
+ // Keep raw output for diagnostics; malformed output is not a completion signal.
492
544
  }
493
545
  }
494
546
 
@@ -665,6 +717,23 @@ async function cleanupCgroup(path: string, graceMs: number): Promise<void> {
665
717
  throw new Error(`worker cgroup ${path} did not empty before cleanup deadline`);
666
718
  }
667
719
 
720
+ function rememberBounded(values: Set<string>, value: string, limit = 2_000): void {
721
+ values.add(value);
722
+ while (values.size > limit) {
723
+ const first = values.values().next().value;
724
+ if (first === undefined) break;
725
+ values.delete(first);
726
+ }
727
+ }
728
+
729
+ function jsonlRecordId(record: Record<string, unknown>, requestSequence?: number): string {
730
+ for (const key of ["uuid", "request_id"]) {
731
+ if (typeof record[key] === "string" && record[key]) return `${key}:${record[key]}`;
732
+ }
733
+ const digest = createHash("sha256").update(JSON.stringify(record)).digest("hex").slice(0, 32);
734
+ return `request:${requestSequence ?? "unknown"}:${digest}`;
735
+ }
736
+
668
737
  function isPermissionRequest(event: Record<string, unknown>, request: unknown): request is { subtype: "can_use_tool"; tool_use_id: string; tool_name: string; input: unknown } {
669
738
  if (event.type !== "control_request" || typeof event.request_id !== "string" || !request || typeof request !== "object") return false;
670
739
  const value = request as { subtype?: unknown; tool_use_id?: unknown; tool_name?: unknown; input?: unknown };
@@ -264,10 +264,16 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
264
264
  const pane = await this.#paneStatus(record);
265
265
  record.paneDead = pane.dead;
266
266
  record.panePid = pane.pid;
267
- if (!pane.dead) await this.#rememberPaneIdentity(record, pane.pid);
267
+ if (pane.dead) record.cleanupError = undefined;
268
+ else await this.#rememberPaneIdentity(record, pane.pid);
268
269
  } catch (error) {
269
- if (isMissingSession(error) || isPaneIdentityError(error)) record.paneDead = true;
270
- else record.cleanupError = asError(error);
270
+ if (isMissingSession(error)) {
271
+ record.paneDead = true;
272
+ record.cleanupError = undefined;
273
+ } else if (isPaneIdentityError(error)) {
274
+ record.paneDead = false;
275
+ record.cleanupError = asError(error);
276
+ } else record.cleanupError = asError(error);
271
277
  }
272
278
  return this.#status(record, !record.paneDead);
273
279
  }
@@ -284,10 +290,11 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
284
290
  } catch (error) {
285
291
  if (isMissingSession(error)) {
286
292
  record.paneDead = true;
293
+ record.cleanupError = undefined;
287
294
  if (!record.cleanupComplete && record.owned) await this.#cleanup(record, false);
288
295
  } else {
289
296
  record.cleanupError = asError(error);
290
- if (isPaneIdentityError(error)) record.paneDead = true;
297
+ if (isPaneIdentityError(error)) record.paneDead = false;
291
298
  }
292
299
  }
293
300
  return this.#status(record, !record.paneDead && !record.released);
@@ -485,7 +492,7 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
485
492
  #startMonitor(record: TmuxRecord): void {
486
493
  record.monitor = setInterval(() => {
487
494
  void this.#monitor(record).catch((error) => {
488
- if (isPaneIdentityError(error)) record.paneDead = true;
495
+ if (isPaneIdentityError(error)) record.paneDead = false;
489
496
  if (!record.cleanupComplete && !isMissingSession(error)) record.cleanupError = asError(error);
490
497
  });
491
498
  }, this.#pollIntervalMs);