omegon 0.8.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/extensions/cleave/dispatcher.ts +213 -20
  2. package/extensions/cleave/index.ts +24 -8
  3. package/extensions/cleave/rpc-child.ts +269 -0
  4. package/extensions/cleave/types.ts +52 -0
  5. package/extensions/openspec/spec.ts +3 -0
  6. package/node_modules/@types/node/README.md +3 -3
  7. package/node_modules/@types/node/assert/strict.d.ts +11 -5
  8. package/node_modules/@types/node/assert.d.ts +173 -50
  9. package/node_modules/@types/node/async_hooks.d.ts +8 -28
  10. package/node_modules/@types/node/buffer.buffer.d.ts +7 -1
  11. package/node_modules/@types/node/buffer.d.ts +168 -44
  12. package/node_modules/@types/node/child_process.d.ts +70 -27
  13. package/node_modules/@types/node/cluster.d.ts +332 -240
  14. package/node_modules/@types/node/compatibility/disposable.d.ts +14 -0
  15. package/node_modules/@types/node/compatibility/index.d.ts +9 -0
  16. package/node_modules/@types/node/compatibility/indexable.d.ts +20 -0
  17. package/node_modules/@types/node/compatibility/iterators.d.ts +0 -1
  18. package/node_modules/@types/node/console.d.ts +350 -49
  19. package/node_modules/@types/node/constants.d.ts +4 -3
  20. package/node_modules/@types/node/crypto.d.ts +1110 -630
  21. package/node_modules/@types/node/dgram.d.ts +51 -15
  22. package/node_modules/@types/node/diagnostics_channel.d.ts +6 -4
  23. package/node_modules/@types/node/dns/promises.d.ts +4 -4
  24. package/node_modules/@types/node/dns.d.ts +133 -132
  25. package/node_modules/@types/node/domain.d.ts +17 -13
  26. package/node_modules/@types/node/events.d.ts +663 -734
  27. package/node_modules/@types/node/fs/promises.d.ts +9 -43
  28. package/node_modules/@types/node/fs.d.ts +411 -628
  29. package/node_modules/@types/node/globals.d.ts +30 -8
  30. package/node_modules/@types/node/globals.typedarray.d.ts +0 -63
  31. package/node_modules/@types/node/http.d.ts +265 -364
  32. package/node_modules/@types/node/http2.d.ts +715 -551
  33. package/node_modules/@types/node/https.d.ts +239 -65
  34. package/node_modules/@types/node/index.d.ts +6 -24
  35. package/node_modules/@types/node/inspector.d.ts +53 -69
  36. package/node_modules/@types/node/inspector.generated.d.ts +410 -759
  37. package/node_modules/@types/node/module.d.ts +186 -52
  38. package/node_modules/@types/node/net.d.ts +194 -70
  39. package/node_modules/@types/node/os.d.ts +11 -12
  40. package/node_modules/@types/node/package.json +3 -13
  41. package/node_modules/@types/node/path.d.ts +133 -120
  42. package/node_modules/@types/node/perf_hooks.d.ts +643 -318
  43. package/node_modules/@types/node/process.d.ts +132 -223
  44. package/node_modules/@types/node/punycode.d.ts +5 -5
  45. package/node_modules/@types/node/querystring.d.ts +4 -4
  46. package/node_modules/@types/node/readline/promises.d.ts +3 -3
  47. package/node_modules/@types/node/readline.d.ts +120 -68
  48. package/node_modules/@types/node/repl.d.ts +100 -87
  49. package/node_modules/@types/node/sea.d.ts +1 -10
  50. package/node_modules/@types/node/sqlite.d.ts +19 -363
  51. package/node_modules/@types/node/stream/consumers.d.ts +10 -10
  52. package/node_modules/@types/node/stream/promises.d.ts +15 -136
  53. package/node_modules/@types/node/stream/web.d.ts +502 -176
  54. package/node_modules/@types/node/stream.d.ts +475 -581
  55. package/node_modules/@types/node/string_decoder.d.ts +4 -4
  56. package/node_modules/@types/node/test.d.ts +196 -308
  57. package/node_modules/@types/node/timers/promises.d.ts +4 -4
  58. package/node_modules/@types/node/timers.d.ts +132 -4
  59. package/node_modules/@types/node/tls.d.ts +226 -110
  60. package/node_modules/@types/node/trace_events.d.ts +9 -9
  61. package/node_modules/@types/node/ts5.6/buffer.buffer.d.ts +7 -1
  62. package/node_modules/@types/node/ts5.6/globals.typedarray.d.ts +0 -2
  63. package/node_modules/@types/node/ts5.6/index.d.ts +6 -26
  64. package/node_modules/@types/node/tty.d.ts +16 -58
  65. package/node_modules/@types/node/url.d.ts +573 -130
  66. package/node_modules/@types/node/util.d.ts +1100 -181
  67. package/node_modules/@types/node/v8.d.ts +8 -76
  68. package/node_modules/@types/node/vm.d.ts +72 -280
  69. package/node_modules/@types/node/wasi.d.ts +4 -25
  70. package/node_modules/@types/node/web-globals/abortcontroller.d.ts +2 -27
  71. package/node_modules/@types/node/web-globals/events.d.ts +0 -9
  72. package/node_modules/@types/node/web-globals/fetch.d.ts +0 -14
  73. package/node_modules/@types/node/web-globals/navigator.d.ts +0 -3
  74. package/node_modules/@types/node/worker_threads.d.ts +335 -268
  75. package/node_modules/@types/node/zlib.d.ts +74 -9
  76. package/node_modules/undici-types/agent.d.ts +12 -13
  77. package/node_modules/undici-types/api.d.ts +26 -26
  78. package/node_modules/undici-types/balanced-pool.d.ts +12 -13
  79. package/node_modules/undici-types/client.d.ts +19 -19
  80. package/node_modules/undici-types/connector.d.ts +2 -2
  81. package/node_modules/undici-types/cookies.d.ts +0 -2
  82. package/node_modules/undici-types/diagnostics-channel.d.ts +10 -18
  83. package/node_modules/undici-types/dispatcher.d.ts +103 -123
  84. package/node_modules/undici-types/env-http-proxy-agent.d.ts +3 -4
  85. package/node_modules/undici-types/errors.d.ts +54 -66
  86. package/node_modules/undici-types/eventsource.d.ts +4 -9
  87. package/node_modules/undici-types/fetch.d.ts +20 -22
  88. package/node_modules/undici-types/file.d.ts +39 -0
  89. package/node_modules/undici-types/filereader.d.ts +54 -0
  90. package/node_modules/undici-types/formdata.d.ts +7 -7
  91. package/node_modules/undici-types/global-dispatcher.d.ts +4 -4
  92. package/node_modules/undici-types/global-origin.d.ts +5 -5
  93. package/node_modules/undici-types/handlers.d.ts +8 -8
  94. package/node_modules/undici-types/header.d.ts +1 -157
  95. package/node_modules/undici-types/index.d.ts +47 -64
  96. package/node_modules/undici-types/interceptors.d.ts +8 -64
  97. package/node_modules/undici-types/mock-agent.d.ts +18 -36
  98. package/node_modules/undici-types/mock-client.d.ts +4 -6
  99. package/node_modules/undici-types/mock-errors.d.ts +3 -3
  100. package/node_modules/undici-types/mock-interceptor.d.ts +20 -21
  101. package/node_modules/undici-types/mock-pool.d.ts +4 -6
  102. package/node_modules/undici-types/package.json +1 -1
  103. package/node_modules/undici-types/patch.d.ts +4 -0
  104. package/node_modules/undici-types/pool-stats.d.ts +8 -8
  105. package/node_modules/undici-types/pool.d.ts +13 -15
  106. package/node_modules/undici-types/proxy-agent.d.ts +4 -5
  107. package/node_modules/undici-types/readable.d.ts +16 -19
  108. package/node_modules/undici-types/retry-agent.d.ts +1 -1
  109. package/node_modules/undici-types/retry-handler.d.ts +10 -19
  110. package/node_modules/undici-types/util.d.ts +3 -3
  111. package/node_modules/undici-types/webidl.d.ts +29 -142
  112. package/node_modules/undici-types/websocket.d.ts +10 -46
  113. package/package.json +2 -1
  114. package/skills/cleave/SKILL.md +62 -2
  115. package/node_modules/@types/node/inspector/promises.d.ts +0 -41
  116. package/node_modules/@types/node/path/posix.d.ts +0 -8
  117. package/node_modules/@types/node/path/win32.d.ts +0 -8
  118. package/node_modules/@types/node/quic.d.ts +0 -910
  119. package/node_modules/@types/node/test/reporters.d.ts +0 -96
  120. package/node_modules/@types/node/ts5.6/compatibility/float16array.d.ts +0 -71
  121. package/node_modules/@types/node/ts5.7/compatibility/float16array.d.ts +0 -72
  122. package/node_modules/@types/node/ts5.7/index.d.ts +0 -117
  123. package/node_modules/@types/node/util/types.d.ts +0 -558
  124. package/node_modules/@types/node/web-globals/blob.d.ts +0 -23
  125. package/node_modules/@types/node/web-globals/console.d.ts +0 -9
  126. package/node_modules/@types/node/web-globals/crypto.d.ts +0 -39
  127. package/node_modules/@types/node/web-globals/encoding.d.ts +0 -11
  128. package/node_modules/@types/node/web-globals/importmeta.d.ts +0 -13
  129. package/node_modules/@types/node/web-globals/messaging.d.ts +0 -23
  130. package/node_modules/@types/node/web-globals/performance.d.ts +0 -45
  131. package/node_modules/@types/node/web-globals/streams.d.ts +0 -115
  132. package/node_modules/@types/node/web-globals/timers.d.ts +0 -44
  133. package/node_modules/@types/node/web-globals/url.d.ts +0 -24
  134. package/node_modules/undici-types/cache-interceptor.d.ts +0 -173
  135. package/node_modules/undici-types/client-stats.d.ts +0 -15
  136. package/node_modules/undici-types/h2c-client.d.ts +0 -73
  137. package/node_modules/undici-types/mock-call-history.d.ts +0 -111
  138. package/node_modules/undici-types/round-robin-pool.d.ts +0 -41
  139. package/node_modules/undici-types/snapshot-agent.d.ts +0 -109
  140. package/node_modules/undici-types/utility.d.ts +0 -7
@@ -21,8 +21,9 @@ import { readFileSync } from "node:fs";
21
21
  import { join } from "node:path";
22
22
  import type { ExtensionAPI } from "@styrene-lab/pi-coding-agent";
23
23
  import { DASHBOARD_UPDATE_EVENT, sharedState } from "../lib/shared-state.ts";
24
- import type { ChildState, CleaveState, ModelTier } from "./types.ts";
24
+ import type { ChildState, CleaveState, ModelTier, RpcChildEvent, RpcProgressUpdate } from "./types.ts";
25
25
  import { computeDispatchWaves } from "./planner.ts";
26
+ import { sendRpcCommand, buildPromptCommand, parseRpcEventStream, mapEventToProgress } from "./rpc-child.ts";
26
27
  import { executeWithReview, type ReviewConfig, type ReviewExecutor, DEFAULT_REVIEW_CONFIG } from "./review.ts";
27
28
  import { saveState } from "./workspace.ts";
28
29
  import { resolveTier, getDefaultPolicy, getViableModels, type ProviderRoutingPolicy, type RegistryModel } from "../lib/model-routing.ts";
@@ -83,7 +84,7 @@ export function resolveModelIdForTier(
83
84
  export function emitCleaveChildProgress(
84
85
  pi: Pick<ExtensionAPI, "events">,
85
86
  childId: number,
86
- patch: { status?: "pending" | "running" | "done" | "failed"; elapsed?: number; startedAt?: number; lastLine?: string; worktreePath?: string },
87
+ patch: { status?: "pending" | "running" | "done" | "failed"; elapsed?: number; startedAt?: number; lastLine?: string; worktreePath?: string; rpcProgress?: RpcProgressUpdate },
87
88
  ): void {
88
89
  const cleaveState = (sharedState as any).cleave;
89
90
  if (!cleaveState?.children?.[childId]) return;
@@ -99,8 +100,16 @@ export function emitCleaveChildProgress(
99
100
  if (patch.worktreePath !== undefined) {
100
101
  cleaveState.children[childId].worktreePath = patch.worktreePath;
101
102
  }
102
- if (patch.lastLine !== undefined) {
103
- // Update lastLine for backward compat
103
+ if (patch.rpcProgress !== undefined) {
104
+ // Structured RPC progress — use summary as lastLine for backward compat
105
+ const summary = patch.rpcProgress.summary;
106
+ cleaveState.children[childId].lastLine = summary;
107
+ const child = cleaveState.children[childId];
108
+ if (!child.recentLines) child.recentLines = [];
109
+ child.recentLines.push(summary);
110
+ if (child.recentLines.length > 30) child.recentLines.splice(0, child.recentLines.length - 30);
111
+ } else if (patch.lastLine !== undefined) {
112
+ // Update lastLine for backward compat (pipe mode)
104
113
  cleaveState.children[childId].lastLine = patch.lastLine;
105
114
  // Append to ring buffer (cap at 30)
106
115
  const child = cleaveState.children[childId];
@@ -364,7 +373,11 @@ function stripAnsiForStatus(s: string): string {
364
373
  return s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").trim();
365
374
  }
366
375
 
367
- async function spawnChild(
376
+ /**
377
+ * Spawn a child in pipe mode (legacy).
378
+ * Uses `pi -p --no-session`, writes prompt to stdin, closes stdin.
379
+ */
380
+ async function spawnChildPipe(
368
381
  prompt: string,
369
382
  cwd: string,
370
383
  timeoutMs: number,
@@ -389,15 +402,13 @@ async function spawnChild(
389
402
  detached: true,
390
403
  env: {
391
404
  ...process.env,
392
- // Prevent nested detection issues
393
405
  PI_CHILD: "1",
394
- // https://warhammer40k.fandom.com/wiki/Alpha_Legion
395
406
  I_AM: "alpharius",
396
407
  },
397
408
  });
398
409
  registerCleaveProc(proc);
399
410
 
400
- // Write prompt to stdin
411
+ // Write prompt to stdin and close (pipe mode)
401
412
  if (proc.stdin) {
402
413
  proc.stdin.write(prompt);
403
414
  proc.stdin.end();
@@ -408,7 +419,6 @@ async function spawnChild(
408
419
  const chunk = data.toString();
409
420
  stdout += chunk;
410
421
  if (onLine) {
411
- // Parse line by line and forward meaningful lines
412
422
  lineBuf += chunk;
413
423
  const parts = lineBuf.split("\n");
414
424
  lineBuf = parts.pop() ?? "";
@@ -420,7 +430,6 @@ async function spawnChild(
420
430
  });
421
431
  proc.stderr?.on("data", (data) => { stderr += data.toString(); });
422
432
 
423
- // SIGKILL escalation helper — sends SIGKILL by process group with fallback
424
433
  let escalationTimer: ReturnType<typeof setTimeout> | undefined;
425
434
  const scheduleEscalation = () => {
426
435
  escalationTimer = setTimeout(() => {
@@ -434,15 +443,12 @@ async function spawnChild(
434
443
  }, 5_000);
435
444
  };
436
445
 
437
- // Timeout enforcement
438
446
  const timer = setTimeout(() => {
439
447
  killed = true;
440
448
  killCleaveProc(proc);
441
449
  scheduleEscalation();
442
450
  }, timeoutMs);
443
451
 
444
- // Abort signal support (with SIGKILL escalation — detached processes
445
- // won't receive SIGHUP on parent exit, so SIGTERM alone is insufficient)
446
452
  const onAbort = () => {
447
453
  killed = true;
448
454
  killCleaveProc(proc);
@@ -481,6 +487,168 @@ async function spawnChild(
481
487
  });
482
488
  }
483
489
 
490
+ /** Events collected during an RPC child session. */
491
+ interface RpcChildResult extends ChildResult {
492
+ events: RpcChildEvent[];
493
+ pipeBroken: boolean;
494
+ }
495
+
496
+ /**
497
+ * Spawn a child in RPC mode.
498
+ * Uses `--mode rpc --no-session`, sends prompt via sendRpcCommand on stdin,
499
+ * parses stdout as a JSON event stream. Stdin stays open for the session lifetime.
500
+ */
501
+ async function spawnChildRpc(
502
+ prompt: string,
503
+ cwd: string,
504
+ timeoutMs: number,
505
+ signal?: AbortSignal,
506
+ localModel?: string,
507
+ onEvent?: (event: RpcChildEvent) => void,
508
+ ): Promise<RpcChildResult> {
509
+ const omegon = resolveOmegonSubprocess();
510
+ const args = [...omegon.argvPrefix, "--mode", "rpc", "--no-session"];
511
+ if (localModel) {
512
+ args.push("--model", localModel);
513
+ }
514
+
515
+ return new Promise<RpcChildResult>((resolve) => {
516
+ let stderr = "";
517
+ let killed = false;
518
+ const events: RpcChildEvent[] = [];
519
+ let pipeBroken = false;
520
+
521
+ const proc = spawn(omegon.command, args, {
522
+ cwd,
523
+ stdio: ["pipe", "pipe", "pipe"],
524
+ detached: true,
525
+ env: {
526
+ ...process.env,
527
+ PI_CHILD: "1",
528
+ I_AM: "alpharius",
529
+ },
530
+ });
531
+ registerCleaveProc(proc);
532
+
533
+ // Send prompt via RPC command on stdin — keep stdin open
534
+ if (proc.stdin) {
535
+ const cmd = buildPromptCommand(prompt);
536
+ sendRpcCommand(proc.stdin, cmd);
537
+ // Do NOT close stdin — child may need it for the session lifetime
538
+ }
539
+
540
+ // Collect stderr
541
+ proc.stderr?.on("data", (data) => { stderr += data.toString(); });
542
+
543
+ // Parse stdout exclusively via RPC event stream (no competing data listener)
544
+ let eventsFinished: Promise<void> = Promise.resolve();
545
+ if (proc.stdout) {
546
+ eventsFinished = (async () => {
547
+ try {
548
+ for await (const event of parseRpcEventStream(proc.stdout!)) {
549
+ events.push(event);
550
+ if (event.type === "pipe_closed") {
551
+ pipeBroken = true;
552
+ }
553
+ onEvent?.(event);
554
+ }
555
+ } catch {
556
+ // Stream parsing error — treat as pipe break
557
+ pipeBroken = true;
558
+ }
559
+ })();
560
+ }
561
+
562
+ let escalationTimer: ReturnType<typeof setTimeout> | undefined;
563
+ const scheduleEscalation = () => {
564
+ escalationTimer = setTimeout(() => {
565
+ if (!proc.killed) {
566
+ try {
567
+ if (proc.pid) process.kill(-proc.pid, "SIGKILL");
568
+ } catch {
569
+ try { proc.kill("SIGKILL"); } catch { /* already dead */ }
570
+ }
571
+ }
572
+ }, 5_000);
573
+ };
574
+
575
+ const timer = setTimeout(() => {
576
+ killed = true;
577
+ killCleaveProc(proc);
578
+ scheduleEscalation();
579
+ }, timeoutMs);
580
+
581
+ const onAbort = () => {
582
+ killed = true;
583
+ killCleaveProc(proc);
584
+ scheduleEscalation();
585
+ };
586
+ signal?.addEventListener("abort", onAbort, { once: true });
587
+
588
+ let settled = false;
589
+ proc.on("close", async (code) => {
590
+ if (settled) return;
591
+ settled = true;
592
+ deregisterCleaveProc(proc);
593
+ clearTimeout(timer);
594
+ clearTimeout(escalationTimer);
595
+ signal?.removeEventListener("abort", onAbort);
596
+
597
+ // Close stdin if still open (child has exited)
598
+ try { proc.stdin?.end(); } catch { /* already closed */ }
599
+
600
+ // Wait for all RPC events to be consumed before resolving
601
+ await eventsFinished;
602
+
603
+ resolve({
604
+ exitCode: killed ? -1 : (code ?? 1),
605
+ stdout: "",
606
+ stderr: killed ? `Killed (timeout or abort)\n${stderr}` : stderr,
607
+ events,
608
+ pipeBroken,
609
+ });
610
+ });
611
+
612
+ proc.on("error", (err) => {
613
+ if (settled) return;
614
+ settled = true;
615
+ deregisterCleaveProc(proc);
616
+ clearTimeout(timer);
617
+ clearTimeout(escalationTimer);
618
+ signal?.removeEventListener("abort", onAbort);
619
+ resolve({
620
+ exitCode: 1,
621
+ stdout: "",
622
+ stderr: `Failed to spawn pi: ${err.message}`,
623
+ events,
624
+ pipeBroken: true,
625
+ });
626
+ });
627
+ });
628
+ }
629
+
630
+ /**
631
+ * Spawn a child process — dispatches to RPC or pipe mode.
632
+ *
633
+ * @param useRpc When true (default), uses RPC mode with structured events.
634
+ * When false, uses legacy pipe mode.
635
+ */
636
+ async function spawnChild(
637
+ prompt: string,
638
+ cwd: string,
639
+ timeoutMs: number,
640
+ signal?: AbortSignal,
641
+ localModel?: string,
642
+ onLine?: (line: string) => void,
643
+ useRpc?: boolean,
644
+ onEvent?: (event: RpcChildEvent) => void,
645
+ ): Promise<ChildResult> {
646
+ if (useRpc) {
647
+ return spawnChildRpc(prompt, cwd, timeoutMs, signal, localModel, onEvent);
648
+ }
649
+ return spawnChildPipe(prompt, cwd, timeoutMs, signal, localModel, onLine);
650
+ }
651
+
484
652
  // ─── Concurrency control ────────────────────────────────────────────────────
485
653
 
486
654
  /**
@@ -674,8 +842,20 @@ async function dispatchSingleChild(
674
842
  // Mirror to sharedState for live dashboard updates (include startedAt for elapsed ticker)
675
843
  emitCleaveChildProgress(pi, child.childId, { status: "running", startedAt: startedAtMs, worktreePath: child.worktreePath });
676
844
 
677
- // Debounced last-line emitter: buffers stdout lines and pushes to shared
678
- // state at most once per 500ms to avoid flooding the event bus.
845
+ // ── Progress callbacks ──────────────────────────────────────────────────
846
+ // RPC mode: direct event forwarding (no debounce)
847
+ // Pipe mode: debounced line emitter (legacy)
848
+ const useRpc = true;
849
+
850
+ // RPC event handler — forward structured progress directly
851
+ const onRpcEvent = (event: RpcChildEvent) => {
852
+ const progress = mapEventToProgress(event);
853
+ if (progress) {
854
+ emitCleaveChildProgress(pi, child.childId, { rpcProgress: progress });
855
+ }
856
+ };
857
+
858
+ // Pipe mode fallback: debounced last-line emitter
679
859
  let pendingLine: string | undefined;
680
860
  let debounceTimer: ReturnType<typeof setTimeout> | undefined;
681
861
  const flushLine = () => {
@@ -738,14 +918,15 @@ async function dispatchSingleChild(
738
918
  // Build executor adapter for the review loop
739
919
  const executor: ReviewExecutor = {
740
920
  execute: async (execPrompt: string, execCwd: string, execModelFlag?: string) => {
741
- return spawnChild(execPrompt, execCwd, timeoutMs, signal, execModelFlag, onChildLine);
921
+ // Execution uses RPC mode for structured events
922
+ return spawnChild(execPrompt, execCwd, timeoutMs, signal, execModelFlag,
923
+ useRpc ? undefined : onChildLine, useRpc, useRpc ? onRpcEvent : undefined);
742
924
  },
743
925
  review: async (reviewPrompt: string, reviewCwd: string) => {
744
- // Reviews always use gloriana (D4: highest available tier) resolve to explicit ID
926
+ // Reviews always use pipe mode (Phase 1) + gloriana tier
745
927
  const reviewModelId = resolveModelIdForTier("gloriana", registryModels, activePolicy, localModel);
746
- // Review runs don't stream lastLine — they're short and we don't want
747
- // review commentary to overwrite the last execution status line.
748
- return spawnChild(reviewPrompt, reviewCwd, timeoutMs, signal, reviewModelId);
928
+ return spawnChild(reviewPrompt, reviewCwd, timeoutMs, signal, reviewModelId,
929
+ undefined, false /* pipe mode for review */);
749
930
  },
750
931
  readFile: (path: string) => readFileSync(path, "utf-8"),
751
932
  };
@@ -799,6 +980,18 @@ async function dispatchSingleChild(
799
980
  child.error = result.stderr.slice(0, 2000) || `Exit code ${result.exitCode}`;
800
981
  }
801
982
 
983
+ // RPC pipe-break handling: if stdout closed unexpectedly, mark failed
984
+ // but preserve worktree and branch for recovery
985
+ if (useRpc && "pipeBroken" in result && (result as RpcChildResult).pipeBroken) {
986
+ // Only override status if it wasn't already set to completed (child may
987
+ // have finished before the pipe break was detected)
988
+ if (child.status !== "completed") {
989
+ child.status = "failed";
990
+ child.error = "RPC pipe break: stdout closed unexpectedly — worktree preserved for recovery";
991
+ // Do NOT clean up worktree — preserve for manual recovery
992
+ }
993
+ }
994
+
802
995
  // If review escalated, mark the child as failed
803
996
  if (reviewResult.finalDecision === "escalated") {
804
997
  child.status = "failed";
@@ -352,7 +352,7 @@ async function checkpointRelatedChanges(
352
352
  * Prevents infinite loops when the agent repeatedly picks actions
353
353
  * that don't resolve the dirty tree.
354
354
  */
355
- const MAX_PREFLIGHT_ATTEMPTS = 3;
355
+ export const MAX_PREFLIGHT_ATTEMPTS = 3;
356
356
 
357
357
  /**
358
358
  * Verify the tree is clean after an action. If only volatile files remain,
@@ -435,12 +435,23 @@ export async function runDirtyTreePreflight(pi: ExtensionAPI, options: DirtyTree
435
435
  throw new Error(summary + "\n\nInteractive input is unavailable, so cleave cannot resolve the dirty tree automatically.");
436
436
  }
437
437
 
438
- // Mutable classification — refreshed after each action attempt.
438
+ // Mutable classification — refreshed after each resolution action.
439
439
  let currentClassification = classification;
440
- let attempts = 0;
441
-
442
- while (attempts < MAX_PREFLIGHT_ATTEMPTS) {
443
- attempts++;
440
+ // Only resolution actions (checkpoint, stash) increment this counter.
441
+ // Invalid input, empty guards, cancel, and proceed-without-cleave do NOT
442
+ // consume attempts they are navigational, not resolution attempts.
443
+ let resolutionAttempts = 0;
444
+
445
+ // Outer safety cap: total loop iterations including non-resolution turns.
446
+ // Prevents truly pathological loops (e.g. select always returning garbage).
447
+ const MAX_TOTAL_ITERATIONS = MAX_PREFLIGHT_ATTEMPTS * 3;
448
+ let totalIterations = 0;
449
+
450
+ while (resolutionAttempts < MAX_PREFLIGHT_ATTEMPTS) {
451
+ totalIterations++;
452
+ if (totalIterations > MAX_TOTAL_ITERATIONS) {
453
+ break; // Fall through to the exhaustion error below
454
+ }
444
455
 
445
456
  let answer: string | undefined;
446
457
  if (hasSelect) {
@@ -458,6 +469,7 @@ export async function runDirtyTreePreflight(pi: ExtensionAPI, options: DirtyTree
458
469
  try {
459
470
  switch (answer) {
460
471
  case "checkpoint": {
472
+ resolutionAttempts++;
461
473
  const currentCheckpointPlan = buildCheckpointPlan(currentClassification, { changeName, openspecContext });
462
474
  await checkpointRelatedChanges(pi, options.repoPath, currentClassification, currentCheckpointPlan.message, options.ui);
463
475
 
@@ -493,6 +505,7 @@ export async function runDirtyTreePreflight(pi: ExtensionAPI, options: DirtyTree
493
505
  });
494
506
  break;
495
507
  }
508
+ resolutionAttempts++;
496
509
  await stashPaths(pi, options.repoPath, "cleave-preflight-unrelated", toStash);
497
510
  const { clean, classification: postClassification } = await verifyCleanAfterAction(
498
511
  pi, options.repoPath, changeName, openspecContext, options.onUpdate,
@@ -509,6 +522,7 @@ export async function runDirtyTreePreflight(pi: ExtensionAPI, options: DirtyTree
509
522
  });
510
523
  break;
511
524
  }
525
+ resolutionAttempts++;
512
526
  await stashPaths(pi, options.repoPath, "cleave-preflight-volatile", currentClassification.volatile);
513
527
  const { clean, classification: postClassification } = await verifyCleanAfterAction(
514
528
  pi, options.repoPath, changeName, openspecContext, options.onUpdate,
@@ -529,6 +543,8 @@ export async function runDirtyTreePreflight(pi: ExtensionAPI, options: DirtyTree
529
543
  });
530
544
  }
531
545
  } catch (error) {
546
+ // Resolution action threw (e.g. git commit failed) — still counts
547
+ // as a resolution attempt since work was attempted.
532
548
  const message = error instanceof Error ? error.message : String(error);
533
549
  options.onUpdate?.({
534
550
  content: [{ type: "text", text: `Preflight action failed: ${message}` }],
@@ -537,7 +553,7 @@ export async function runDirtyTreePreflight(pi: ExtensionAPI, options: DirtyTree
537
553
  }
538
554
  }
539
555
 
540
- // Exhausted attempts — report remaining dirty files and bail.
556
+ // Exhausted resolution attempts — report remaining dirty files and bail.
541
557
  const remaining = [
542
558
  ...currentClassification.related,
543
559
  ...currentClassification.unrelated,
@@ -545,7 +561,7 @@ export async function runDirtyTreePreflight(pi: ExtensionAPI, options: DirtyTree
545
561
  ...currentClassification.volatile,
546
562
  ];
547
563
  throw new Error(
548
- `Dirty tree not resolved after ${MAX_PREFLIGHT_ATTEMPTS} attempts. Remaining files:\n` +
564
+ `Dirty tree not resolved after ${resolutionAttempts} resolution attempt(s). Remaining files:\n` +
549
565
  remaining.map((f) => ` • ${f.path}`).join("\n") +
550
566
  "\n\nResolve manually (git commit/stash/checkout) and retry /cleave.",
551
567
  );
@@ -0,0 +1,269 @@
1
+ /**
2
+ * cleave/rpc-child — RPC child communication module.
3
+ *
4
+ * Provides JSON line framing for stdin commands, stdout event stream parsing,
5
+ * event-to-progress mapping, and pipe-break handling for cleave child processes
6
+ * running in `--mode rpc`.
7
+ *
8
+ * This module is a building block for the dispatcher to use when spawning
9
+ * children in RPC mode instead of pipe mode.
10
+ */
11
+
12
+ import type { Writable, Readable } from "node:stream";
13
+ import { StringDecoder } from "node:string_decoder";
14
+ import type { RpcChildEvent, RpcProgressUpdate } from "./types.ts";
15
+
16
+ // ─── JSON Line Framing (stdin commands) ─────────────────────────────────────
17
+
18
+ /**
19
+ * Serialize and write a JSON command to a child's stdin.
20
+ * Uses strict LF-only JSONL framing (matching pi-mono's serializeJsonLine).
21
+ *
22
+ * @returns true if the write succeeded, false if stdin is not writable
23
+ */
24
+ export function sendRpcCommand(
25
+ stdin: Writable,
26
+ command: Record<string, unknown>,
27
+ ): boolean {
28
+ if (!stdin.writable) return false;
29
+ try {
30
+ stdin.write(`${JSON.stringify(command)}\n`);
31
+ return true;
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Build a prompt command for a cleave child task.
39
+ */
40
+ export function buildPromptCommand(
41
+ message: string,
42
+ id?: string,
43
+ ): Record<string, unknown> {
44
+ const cmd: Record<string, unknown> = { type: "prompt", message };
45
+ if (id !== undefined) cmd.id = id;
46
+ return cmd;
47
+ }
48
+
49
+ /**
50
+ * Build an abort command.
51
+ */
52
+ export function buildAbortCommand(id?: string): Record<string, unknown> {
53
+ const cmd: Record<string, unknown> = { type: "abort" };
54
+ if (id !== undefined) cmd.id = id;
55
+ return cmd;
56
+ }
57
+
58
+ // ─── Stdout Event Stream Parser ─────────────────────────────────────────────
59
+
60
+ /**
61
+ * Parse an RPC event stream from a child's stdout as an async iterator.
62
+ *
63
+ * Yields typed RpcChildEvent objects for each valid JSON line received.
64
+ * Non-JSON lines are silently skipped (child may emit debug output to stdout).
65
+ *
66
+ * When stdout closes (end event), the iterator emits a synthetic
67
+ * `{ type: "pipe_closed" }` event and completes — it does NOT throw.
68
+ * This enables graceful degradation: the caller decides how to handle
69
+ * pipe breaks vs normal completion.
70
+ */
71
+ export async function* parseRpcEventStream(
72
+ stdout: Readable,
73
+ ): AsyncGenerator<RpcChildEvent, void, undefined> {
74
+ // We implement a manual async iteration over the stream data,
75
+ // using LF-only splitting (matching pi-mono's jsonl.ts approach).
76
+ const decoder = new StringDecoder("utf8");
77
+ let buffer = "";
78
+ let done = false;
79
+
80
+ // Queue for parsed events, with a resolver for the consumer
81
+ const queue: RpcChildEvent[] = [];
82
+ let waitResolve: (() => void) | null = null;
83
+
84
+ function enqueue(event: RpcChildEvent) {
85
+ queue.push(event);
86
+ if (waitResolve) {
87
+ const r = waitResolve;
88
+ waitResolve = null;
89
+ r();
90
+ }
91
+ }
92
+
93
+ function processBuffer() {
94
+ while (true) {
95
+ const idx = buffer.indexOf("\n");
96
+ if (idx === -1) break;
97
+ const line = buffer.slice(0, idx);
98
+ buffer = buffer.slice(idx + 1);
99
+ // Strip optional CR
100
+ const clean = line.endsWith("\r") ? line.slice(0, -1) : line;
101
+ if (clean.length === 0) continue;
102
+ try {
103
+ const parsed = JSON.parse(clean);
104
+ if (parsed && typeof parsed === "object" && typeof parsed.type === "string") {
105
+ enqueue(parsed as RpcChildEvent);
106
+ }
107
+ } catch {
108
+ // Non-JSON line — skip silently
109
+ }
110
+ }
111
+ }
112
+
113
+ const onData = (chunk: Buffer | string) => {
114
+ buffer += typeof chunk === "string" ? chunk : decoder.write(chunk);
115
+ processBuffer();
116
+ };
117
+
118
+ const onEnd = () => {
119
+ buffer += decoder.end();
120
+ processBuffer();
121
+ // Emit synthetic pipe_closed event
122
+ enqueue({ type: "pipe_closed" });
123
+ done = true;
124
+ // Wake up consumer if waiting
125
+ if (waitResolve) {
126
+ const r = waitResolve;
127
+ waitResolve = null;
128
+ r();
129
+ }
130
+ };
131
+
132
+ const onError = (_err: Error) => {
133
+ enqueue({ type: "pipe_closed" });
134
+ done = true;
135
+ if (waitResolve) {
136
+ const r = waitResolve;
137
+ waitResolve = null;
138
+ r();
139
+ }
140
+ };
141
+
142
+ stdout.on("data", onData);
143
+ stdout.on("end", onEnd);
144
+ stdout.on("error", onError);
145
+
146
+ try {
147
+ while (true) {
148
+ if (queue.length > 0) {
149
+ const event = queue.shift()!;
150
+ yield event;
151
+ if (event.type === "pipe_closed") return;
152
+ } else if (done) {
153
+ return;
154
+ } else {
155
+ // Wait for next event
156
+ await new Promise<void>((resolve) => {
157
+ waitResolve = resolve;
158
+ });
159
+ }
160
+ }
161
+ } finally {
162
+ stdout.off("data", onData);
163
+ stdout.off("end", onEnd);
164
+ stdout.off("error", onError);
165
+ }
166
+ }
167
+
168
+ // ─── Event-to-Progress Mapping ──────────────────────────────────────────────
169
+
170
+ /**
171
+ * Map an RpcChildEvent to a structured progress update for the dashboard.
172
+ *
173
+ * Returns null for events that don't produce meaningful progress (e.g. turn_start).
174
+ */
175
+ export function mapEventToProgress(event: RpcChildEvent): RpcProgressUpdate | null {
176
+ switch (event.type) {
177
+ case "agent_start":
178
+ return { kind: "lifecycle", summary: "Agent started" };
179
+
180
+ case "agent_end":
181
+ return { kind: "lifecycle", summary: "Agent completed" };
182
+
183
+ case "turn_start":
184
+ return null; // No meaningful progress
185
+
186
+ case "turn_end":
187
+ return { kind: "lifecycle", summary: "Turn completed" };
188
+
189
+ case "message_start":
190
+ return null; // Wait for content
191
+
192
+ case "message_update":
193
+ return null; // Too noisy for dashboard
194
+
195
+ case "message_end":
196
+ return { kind: "lifecycle", summary: "Message completed" };
197
+
198
+ case "tool_execution_start":
199
+ return {
200
+ kind: "tool",
201
+ summary: `tool: ${event.toolName}${formatToolArgs(event.toolName, event.args)}`,
202
+ toolName: event.toolName,
203
+ };
204
+
205
+ case "tool_execution_update":
206
+ return null; // Partial results too noisy
207
+
208
+ case "tool_execution_end":
209
+ return {
210
+ kind: "tool",
211
+ summary: `tool: ${event.toolName} ${event.isError ? "✗" : "✓"}`,
212
+ toolName: event.toolName,
213
+ };
214
+
215
+ case "auto_compaction_start":
216
+ return { kind: "lifecycle", summary: "Compacting context…" };
217
+
218
+ case "auto_compaction_end":
219
+ return { kind: "lifecycle", summary: event.aborted ? "Compaction aborted" : "Compaction done" };
220
+
221
+ case "auto_retry_start":
222
+ return { kind: "lifecycle", summary: `Retry ${event.attempt}/${event.maxAttempts}` };
223
+
224
+ case "auto_retry_end":
225
+ return { kind: "lifecycle", summary: event.success ? "Retry succeeded" : "Retry failed" };
226
+
227
+ case "response":
228
+ // RPC response to our command — not progress-relevant
229
+ return null;
230
+
231
+ case "pipe_closed":
232
+ return { kind: "error", summary: "Pipe closed" };
233
+
234
+ case "extension_ui_request":
235
+ // UI requests from child extensions — not progress-relevant
236
+ return null;
237
+
238
+ default: {
239
+ // Exhaustiveness check: if a new event type is added to RpcChildEvent
240
+ // but not handled here, TypeScript will report an error on this line.
241
+ const _exhaustive: never = event;
242
+ return null;
243
+ }
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Format tool arguments for display in a concise summary line.
249
+ */
250
+ function formatToolArgs(toolName: string, args: unknown): string {
251
+ if (!args || typeof args !== "object") return "";
252
+ const a = args as Record<string, unknown>;
253
+ switch (toolName) {
254
+ case "read":
255
+ case "write":
256
+ case "view":
257
+ return a.path ? ` ${a.path}` : "";
258
+ case "edit":
259
+ return a.path ? ` ${a.path}` : "";
260
+ case "bash":
261
+ if (typeof a.command === "string") {
262
+ const cmd = a.command.length > 60 ? a.command.slice(0, 57) + "…" : a.command;
263
+ return ` ${cmd}`;
264
+ }
265
+ return "";
266
+ default:
267
+ return "";
268
+ }
269
+ }