@oh-my-pi/pi-coding-agent 15.13.2 → 16.0.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 (84) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/cli.js +587 -499
  3. package/dist/types/advisor/__tests__/advisor.test.d.ts +1 -0
  4. package/dist/types/advisor/advise-tool.d.ts +58 -0
  5. package/dist/types/advisor/index.d.ts +3 -0
  6. package/dist/types/advisor/runtime.d.ts +52 -0
  7. package/dist/types/advisor/watchdog.d.ts +5 -0
  8. package/dist/types/config/model-roles.d.ts +1 -1
  9. package/dist/types/config/settings-schema.d.ts +75 -5
  10. package/dist/types/eval/js/context-manager.d.ts +15 -0
  11. package/dist/types/modes/components/advisor-message.d.ts +9 -0
  12. package/dist/types/modes/components/assistant-message.d.ts +1 -0
  13. package/dist/types/modes/controllers/command-controller.d.ts +3 -1
  14. package/dist/types/modes/interactive-mode.d.ts +4 -1
  15. package/dist/types/modes/types.d.ts +9 -1
  16. package/dist/types/sdk.d.ts +3 -3
  17. package/dist/types/session/agent-session.d.ts +71 -2
  18. package/dist/types/session/session-history-format.d.ts +4 -0
  19. package/dist/types/session/unexpected-stop-classifier.d.ts +13 -0
  20. package/dist/types/session/yield-queue.d.ts +2 -0
  21. package/dist/types/stt/asr-client.d.ts +1 -1
  22. package/dist/types/tiny/title-client.d.ts +1 -1
  23. package/dist/types/tools/job.d.ts +1 -0
  24. package/dist/types/tools/path-utils.d.ts +1 -0
  25. package/dist/types/tools/report-tool-issue.d.ts +0 -1
  26. package/dist/types/tts/tts-client.d.ts +1 -1
  27. package/dist/types/utils/thinking-display.d.ts +1 -17
  28. package/package.json +13 -13
  29. package/src/advisor/__tests__/advisor.test.ts +586 -0
  30. package/src/advisor/advise-tool.ts +87 -0
  31. package/src/advisor/index.ts +3 -0
  32. package/src/advisor/runtime.ts +248 -0
  33. package/src/advisor/watchdog.ts +83 -0
  34. package/src/cli.ts +25 -12
  35. package/src/config/model-registry.ts +6 -2
  36. package/src/config/model-roles.ts +13 -1
  37. package/src/config/settings-schema.ts +67 -5
  38. package/src/eval/__tests__/agent-bridge.test.ts +106 -46
  39. package/src/eval/__tests__/js-context-manager.test.ts +12 -2
  40. package/src/eval/js/context-manager.ts +40 -3
  41. package/src/eval/js/worker-entry.ts +7 -0
  42. package/src/export/html/template.js +18 -22
  43. package/src/internal-urls/docs-index.generated.ts +8 -5
  44. package/src/main.ts +19 -5
  45. package/src/modes/acp/acp-agent.ts +2 -2
  46. package/src/modes/acp/acp-event-mapper.ts +2 -2
  47. package/src/modes/components/advisor-message.ts +99 -0
  48. package/src/modes/components/agent-hub.ts +38 -7
  49. package/src/modes/components/assistant-message.ts +110 -15
  50. package/src/modes/components/snapcompact-shape-preview-doc.md +2 -2
  51. package/src/modes/components/snapcompact-shape-preview.ts +2 -2
  52. package/src/modes/components/status-line/segments.ts +20 -7
  53. package/src/modes/components/tree-selector.ts +3 -2
  54. package/src/modes/controllers/command-controller.ts +69 -2
  55. package/src/modes/controllers/event-controller.ts +3 -3
  56. package/src/modes/controllers/input-controller.ts +7 -1
  57. package/src/modes/controllers/streaming-reveal.ts +4 -4
  58. package/src/modes/interactive-mode.ts +14 -2
  59. package/src/modes/types.ts +9 -1
  60. package/src/modes/utils/ui-helpers.ts +12 -3
  61. package/src/prompts/advisor/advise-tool.md +1 -0
  62. package/src/prompts/advisor/system.md +31 -0
  63. package/src/prompts/agents/oracle.md +0 -1
  64. package/src/prompts/agents/reviewer.md +0 -1
  65. package/src/prompts/system/unexpected-stop-classifier.md +17 -0
  66. package/src/prompts/system/unexpected-stop-retry.md +4 -0
  67. package/src/sdk.ts +52 -13
  68. package/src/session/agent-session.ts +722 -21
  69. package/src/session/session-dump-format.ts +15 -142
  70. package/src/session/session-history-format.ts +30 -11
  71. package/src/session/unexpected-stop-classifier.ts +129 -0
  72. package/src/session/yield-queue.ts +5 -1
  73. package/src/slash-commands/builtin-registry.ts +102 -4
  74. package/src/stt/asr-client.ts +1 -1
  75. package/src/system-prompt.ts +1 -1
  76. package/src/tiny/title-client.ts +1 -1
  77. package/src/tools/browser/tab-supervisor.ts +1 -1
  78. package/src/tools/browser/tab-worker-entry.ts +12 -4
  79. package/src/tools/job.ts +1 -0
  80. package/src/tools/path-utils.ts +33 -2
  81. package/src/tools/report-tool-issue.ts +2 -7
  82. package/src/tts/tts-client.ts +1 -1
  83. package/src/utils/thinking-display.ts +8 -34
  84. package/src/web/scrapers/docs-rs.ts +2 -3
@@ -121,6 +121,34 @@ function makeEvalSession(
121
121
  return { session, sessionFile, sessionId: `${prefix}:${crypto.randomUUID()}` };
122
122
  }
123
123
 
124
+ /**
125
+ * Spy `runSubprocess` so a `parallel()` fan-out overlaps deterministically: every
126
+ * bridge call parks until the pool saturates at `limit` concurrent calls in flight,
127
+ * then all proceed. Proves the pool reaches its ceiling without a wall-clock sleep —
128
+ * the pool itself caps how many run at once, so an unbounded pool would drive
129
+ * `maxInFlight` past `limit` and fail the bound.
130
+ */
131
+ function spyConcurrencyBarrier(limit: number): { maxInFlight: () => number } {
132
+ let inFlight = 0;
133
+ let max = 0;
134
+ let saturate: (() => void) | undefined;
135
+ const saturated = new Promise<void>(resolve => {
136
+ saturate = resolve;
137
+ });
138
+ vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
139
+ inFlight++;
140
+ max = Math.max(max, inFlight);
141
+ if (inFlight >= limit) saturate?.();
142
+ try {
143
+ await saturated;
144
+ return singleResult(options, { output: options.assignment ?? "" });
145
+ } finally {
146
+ inFlight--;
147
+ }
148
+ });
149
+ return { maxInFlight: () => max };
150
+ }
151
+
124
152
  describe("runEvalAgent", () => {
125
153
  afterEach(() => {
126
154
  vi.restoreAllMocks();
@@ -298,8 +326,17 @@ describe("runEvalAgent", () => {
298
326
  });
299
327
 
300
328
  describe("agent() through eval runtimes", () => {
329
+ // One shared JS worker backs every agent() JavaScript test below. Spawning a
330
+ // worker (thread + module-graph import) is fixed infrastructure cost, not
331
+ // behavior under test; reusing it keeps the suite fast. Each run still threads
332
+ // its own ToolSession (settings/mock are read live through the bridge per call)
333
+ // and top-level `const`/`let` are demoted to `var`, so reuse never leaks state
334
+ // these tests observe. Torn down in afterAll via disposeAllVmContexts().
335
+ const sharedJsSessionId = "agent-bridge-shared-js";
336
+
301
337
  afterEach(() => {
302
338
  vi.restoreAllMocks();
339
+ vi.useRealTimers();
303
340
  });
304
341
 
305
342
  afterAll(async () => {
@@ -309,7 +346,7 @@ describe("agent() through eval runtimes", () => {
309
346
 
310
347
  it("exposes agent() in JavaScript and parses structured output", async () => {
311
348
  using tempDir = TempDir.createSync("@omp-eval-agent-js-");
312
- const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "js-agent");
349
+ const { session, sessionFile } = makeEvalSession(tempDir, "js-agent");
313
350
  mockAgents();
314
351
  vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options =>
315
352
  singleResult(options, {
@@ -319,7 +356,7 @@ describe("agent() through eval runtimes", () => {
319
356
 
320
357
  const result = await executeJs(
321
358
  'const text = await agent("hi"); const data = await agent("json", { schema: { type: "object" } }); return JSON.stringify([text, data]);',
322
- { cwd: tempDir.path(), sessionId, session, sessionFile },
359
+ { cwd: tempDir.path(), sessionId: sharedJsSessionId, session, sessionFile },
323
360
  );
324
361
 
325
362
  expect(result.exitCode).toBe(0);
@@ -334,35 +371,24 @@ describe("agent() through eval runtimes", () => {
334
371
  "task.enableLsp": true,
335
372
  "task.maxConcurrency": 2,
336
373
  });
337
- const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "js-agent-parallel", settings);
374
+ const { session, sessionFile } = makeEvalSession(tempDir, "js-agent-parallel", settings);
338
375
  mockAgents();
339
- let inFlight = 0;
340
- let maxInFlight = 0;
341
- vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
342
- inFlight++;
343
- maxInFlight = Math.max(maxInFlight, inFlight);
344
- try {
345
- await Bun.sleep(options.assignment === "a" ? 30 : 10);
346
- return singleResult(options, { output: options.assignment ?? "" });
347
- } finally {
348
- inFlight--;
349
- }
350
- });
376
+ const barrier = spyConcurrencyBarrier(2);
351
377
 
352
378
  const result = await executeJs(
353
379
  'const values = await parallel(["a", "b", "c", "d"].map(name => () => agent(name))); return JSON.stringify(values);',
354
- { cwd: tempDir.path(), sessionId, session, sessionFile },
380
+ { cwd: tempDir.path(), sessionId: sharedJsSessionId, session, sessionFile },
355
381
  );
356
382
 
357
383
  expect(result.exitCode).toBe(0);
358
384
  expect(JSON.parse(result.output.trim())).toEqual(["a", "b", "c", "d"]);
359
- expect(maxInFlight).toBeGreaterThan(1);
360
- expect(maxInFlight).toBeLessThanOrEqual(2);
385
+ expect(barrier.maxInFlight()).toBeGreaterThan(1);
386
+ expect(barrier.maxInFlight()).toBeLessThanOrEqual(2);
361
387
  });
362
388
 
363
389
  it("propagates JavaScript parallel() rejections", async () => {
364
390
  using tempDir = TempDir.createSync("@omp-eval-agent-js-reject-");
365
- const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "js-agent-reject");
391
+ const { session, sessionFile } = makeEvalSession(tempDir, "js-agent-reject");
366
392
  mockAgents();
367
393
  vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
368
394
  if (options.assignment === "bad") {
@@ -373,7 +399,7 @@ describe("agent() through eval runtimes", () => {
373
399
 
374
400
  const result = await executeJs('await parallel([() => agent("ok"), () => agent("bad")]);', {
375
401
  cwd: tempDir.path(),
376
- sessionId,
402
+ sessionId: sharedJsSessionId,
377
403
  session,
378
404
  sessionFile,
379
405
  });
@@ -416,18 +442,7 @@ describe("agent() through eval runtimes", () => {
416
442
  });
417
443
  const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "py-agent-parallel", settings);
418
444
  mockAgents();
419
- let inFlight = 0;
420
- let maxInFlight = 0;
421
- vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
422
- inFlight++;
423
- maxInFlight = Math.max(maxInFlight, inFlight);
424
- try {
425
- await Bun.sleep(options.assignment === "a" ? 30 : 10);
426
- return singleResult(options, { output: options.assignment ?? "" });
427
- } finally {
428
- inFlight--;
429
- }
430
- });
445
+ const barrier = spyConcurrencyBarrier(2);
431
446
 
432
447
  const result = await executePython(
433
448
  'import json\nprint(json.dumps(parallel([lambda n=n: agent(n) for n in ["a", "b", "c", "d"]])))',
@@ -440,8 +455,8 @@ describe("agent() through eval runtimes", () => {
440
455
 
441
456
  expect(result.exitCode).toBe(0);
442
457
  expect(JSON.parse(result.output.trim())).toEqual(["a", "b", "c", "d"]);
443
- expect(maxInFlight).toBeGreaterThan(1);
444
- expect(maxInFlight).toBeLessThanOrEqual(2);
458
+ expect(barrier.maxInFlight()).toBeGreaterThan(1);
459
+ expect(barrier.maxInFlight()).toBeLessThanOrEqual(2);
445
460
  });
446
461
 
447
462
  it("interrupting a Python parallel() fan-out settles the kernel cleanly and preserves session state", async () => {
@@ -526,7 +541,7 @@ describe("agent() through eval runtimes", () => {
526
541
 
527
542
  it("streams enriched agent progress through onStatus before the cell finishes", async () => {
528
543
  using tempDir = TempDir.createSync("@omp-eval-agent-progress-");
529
- const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "js-agent-progress");
544
+ const { session, sessionFile } = makeEvalSession(tempDir, "js-agent-progress");
530
545
  mockAgents();
531
546
 
532
547
  const makeProgress = (options: ExecutorOptions, overrides: Partial<AgentProgress>): AgentProgress => ({
@@ -580,7 +595,7 @@ describe("agent() through eval runtimes", () => {
580
595
  const events: Array<{ op: string; [key: string]: unknown }> = [];
581
596
  const result = await executeJs('await agent("investigate", { label: "Scout" });', {
582
597
  cwd: tempDir.path(),
583
- sessionId,
598
+ sessionId: sharedJsSessionId,
584
599
  session,
585
600
  sessionFile,
586
601
  onStatus: event => events.push(event),
@@ -622,16 +637,28 @@ describe("agent() through eval runtimes", () => {
622
637
  mockAgents();
623
638
 
624
639
  // runSubprocess runs far past the eval timeout budget and emits NO progress
625
- // of its own. The bridge pause must make that delegated time invisible to
626
- // the watchdog.
640
+ // of its own; the bridge pause must make that delegated time invisible to
641
+ // the watchdog. Fake timers replace the real wait: the subprocess parks on
642
+ // `released` so the test can advance the clock past the budget while the
643
+ // bridge call is provably in flight, then release it deterministically.
644
+ let release: (() => void) | undefined;
645
+ const released = new Promise<void>(resolve => {
646
+ release = resolve;
647
+ });
648
+ let markInFlight: (() => void) | undefined;
649
+ const inFlight = new Promise<void>(resolve => {
650
+ markInFlight = resolve;
651
+ });
627
652
  vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
628
- await Bun.sleep(40);
653
+ markInFlight?.();
654
+ await released;
629
655
  return singleResult(options, { output: "done" });
630
656
  });
631
657
 
632
658
  const ops: string[] = [];
659
+ vi.useFakeTimers();
633
660
  using idle = new IdleTimeout(20);
634
- const result = await runEvalAgent(
661
+ const resultPromise = runEvalAgent(
635
662
  { prompt: "investigate" },
636
663
  {
637
664
  session,
@@ -644,11 +671,22 @@ describe("agent() through eval runtimes", () => {
644
671
  },
645
672
  );
646
673
 
674
+ // The bridge paused the watchdog; the subprocess is now blocked in flight.
675
+ await inFlight;
676
+ // Burn far more than the 20ms budget while paused: the watchdog stays armed-off.
677
+ vi.advanceTimersByTime(1_000);
678
+ expect(idle.signal.aborted).toBe(false);
679
+
680
+ release?.();
681
+ const result = await resultPromise;
682
+
647
683
  expect(result.text).toBe("done");
648
684
  expect(ops).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP]);
649
685
  expect(idle.signal.aborted).toBe(false);
650
686
 
651
- await Bun.sleep(60);
687
+ // RESUME re-armed a fresh window; once the runtime stays idle past it the
688
+ // watchdog finally fires.
689
+ vi.advanceTimersByTime(idle.idleMs + 5);
652
690
  expect(idle.signal.aborted).toBe(true);
653
691
  });
654
692
 
@@ -657,9 +695,20 @@ describe("agent() through eval runtimes", () => {
657
695
  const { session } = makeEvalSession(tempDir, "js-agent-progress-timeout-pause");
658
696
  mockAgents();
659
697
 
660
- // Stream frequent progress snapshots (op:"agent") for well past the budget.
698
+ // Stream frequent progress snapshots (op:"agent") well past the budget.
661
699
  // They render as status, but timeout accounting is controlled only by the
662
- // bridge pause/resume events.
700
+ // bridge pause/resume events — so even a flood of snapshots must not re-arm
701
+ // the watchdog. Fake timers make "past the budget" deterministic: the
702
+ // subprocess emits its snapshots, parks on `released`, and the test advances
703
+ // the clock far past the window before releasing it.
704
+ let release: (() => void) | undefined;
705
+ const released = new Promise<void>(resolve => {
706
+ release = resolve;
707
+ });
708
+ let markInFlight: (() => void) | undefined;
709
+ const inFlight = new Promise<void>(resolve => {
710
+ markInFlight = resolve;
711
+ });
663
712
  vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
664
713
  for (let i = 0; i < 20; i++) {
665
714
  options.onProgress?.({
@@ -679,15 +728,16 @@ describe("agent() through eval runtimes", () => {
679
728
  cost: 0,
680
729
  durationMs: i * 10,
681
730
  });
682
- await Bun.sleep(40);
683
731
  }
732
+ markInFlight?.();
733
+ await released;
684
734
  return singleResult(options, { output: "done" });
685
735
  });
686
736
 
687
737
  const ops: string[] = [];
688
- // Timing invariant (keep, do not re-tighten): total mock work (20*40ms = 800ms) > idle window (250ms) > scheduling jitter (~tens of ms).
738
+ vi.useFakeTimers();
689
739
  using idle = new IdleTimeout(250);
690
- const result = await runEvalAgent(
740
+ const resultPromise = runEvalAgent(
691
741
  { prompt: "investigate" },
692
742
  {
693
743
  session,
@@ -700,6 +750,16 @@ describe("agent() through eval runtimes", () => {
700
750
  },
701
751
  );
702
752
 
753
+ // All snapshots have streamed and the subprocess is blocked in flight.
754
+ await inFlight;
755
+ // Far exceed the 250ms budget while paused: the snapshots already delivered
756
+ // must not have re-armed the watchdog.
757
+ vi.advanceTimersByTime(10_000);
758
+ expect(idle.signal.aborted).toBe(false);
759
+
760
+ release?.();
761
+ const result = await resultPromise;
762
+
703
763
  expect(result.text).toBe("done");
704
764
  expect(ops[0]).toBe(EVAL_TIMEOUT_PAUSE_OP);
705
765
  expect(ops).toContain("agent");
@@ -1,8 +1,8 @@
1
- import { afterEach, describe, expect, it } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
2
  import { TempDir } from "@oh-my-pi/pi-utils";
3
3
  import { Settings } from "../../config/settings";
4
4
  import type { ToolSession } from "../../tools";
5
- import { disposeAllVmContexts } from "../js/context-manager";
5
+ import { disposeAllVmContexts, setWorkerCloseTimeoutMsForTests } from "../js/context-manager";
6
6
  import { executeJs } from "../js/executor";
7
7
 
8
8
  const originalWorker = globalThis.Worker;
@@ -180,8 +180,18 @@ function installFakeWorker(stats: FakeWorkerStats, behavior: FakeWorkerBehavior)
180
180
  }
181
181
 
182
182
  describe("JavaScript eval worker lifecycle", () => {
183
+ let restoreCloseTimeoutMs = 0;
184
+ beforeEach(() => {
185
+ // Shrink the graceful-close grace period so the "close acked but the worker
186
+ // never exits -> force terminate" contract is proven without a real 1s wait.
187
+ restoreCloseTimeoutMs = setWorkerCloseTimeoutMsForTests(1);
188
+ });
189
+
183
190
  afterEach(async () => {
191
+ // Dispose while the shrunk timeout is still active so a hung worker's afterEach
192
+ // close also force-terminates instantly, then restore the production default.
184
193
  await disposeAllVmContexts();
194
+ setWorkerCloseTimeoutMsForTests(restoreCloseTimeoutMs);
185
195
  Object.defineProperty(globalThis, "Worker", {
186
196
  configurable: true,
187
197
  writable: true,
@@ -60,6 +60,22 @@ const resettingSessions = new Map<string, Promise<void>>();
60
60
  // SIGILL/SIGSEGV. Callers that pass a larger per-cell budget still dominate.
61
61
  const WORKER_INIT_TIMEOUT_MS = 15_000;
62
62
  const WORKER_CLOSE_TIMEOUT_MS = 1_000;
63
+ // Active graceful-close grace period before a worker that ack'd `close` but never
64
+ // emitted its `close` event is force-terminated. Defaults to the production floor;
65
+ // tests override it (and restore it) to exercise the close-timeout -> terminate
66
+ // path without a real wall-clock wait.
67
+ let workerCloseTimeoutMs: number = WORKER_CLOSE_TIMEOUT_MS;
68
+
69
+ /**
70
+ * Test-only seam: override the graceful-close grace period (ms). Returns the
71
+ * previous value so callers can restore it. Production always uses
72
+ * {@link WORKER_CLOSE_TIMEOUT_MS}; never call this outside tests.
73
+ */
74
+ export function setWorkerCloseTimeoutMsForTests(ms: number): number {
75
+ const previous = workerCloseTimeoutMs;
76
+ workerCloseTimeoutMs = ms;
77
+ return previous;
78
+ }
63
79
 
64
80
  export async function executeInVmContext(options: {
65
81
  sessionKey: string;
@@ -125,6 +141,27 @@ export async function disposeAllVmContexts(): Promise<void> {
125
141
  await Promise.all(all.map(session => killSession(session, new ToolError("JS context disposed"), { force: false })));
126
142
  }
127
143
 
144
+ /**
145
+ * Smoke probe: spawn the JS eval worker through the worker-host entry and prove
146
+ * it answers the `init` handshake on a real worker thread (not the inline
147
+ * fallback). Catches the silent worker-load and init-message-drop regressions
148
+ * that otherwise strand every cell on the init timeout in a distribution build —
149
+ * the failure mode that motivated `installWorkerInbox`. Wired into
150
+ * `omp --smoke-test` so binary / source / tarball installs all exercise it.
151
+ */
152
+ export async function smokeTestJsEvalWorker(): Promise<void> {
153
+ const worker = spawnJsWorker();
154
+ const session: JsSession = { sessionKey: "smoke", worker, state: "alive", pending: new Map() };
155
+ try {
156
+ await initWorker(session, { cwd: process.cwd(), sessionId: "smoke" }, WORKER_INIT_TIMEOUT_MS);
157
+ if (worker.mode !== "worker") {
158
+ throw new Error("JS eval worker smoke fell back to the inline worker (real worker failed to start)");
159
+ }
160
+ } finally {
161
+ await worker.terminate().catch(() => undefined);
162
+ }
163
+ }
164
+
128
165
  async function runOnce(
129
166
  session: JsSession,
130
167
  options: {
@@ -431,7 +468,7 @@ function spawnJsWorker(): WorkerHandle {
431
468
  try {
432
469
  const hostEntry = workerHostEntry();
433
470
  const worker = hostEntry
434
- ? new Worker(hostEntry, { type: "module", argv: ["__omp_js_eval_worker"] })
471
+ ? new Worker(hostEntry, { type: "module", argv: ["__omp_worker_js_eval"] })
435
472
  : new Worker(new URL("./worker-entry.ts", import.meta.url).href, { type: "module" });
436
473
  return wrapBunWorker(worker);
437
474
  } catch (err) {
@@ -492,7 +529,7 @@ function wrapBunWorker(worker: Worker): WorkerHandle {
492
529
  finishIfClosed();
493
530
  });
494
531
  worker.addEventListener("close", onClose);
495
- timeout = setTimeout(() => finish(false), WORKER_CLOSE_TIMEOUT_MS);
532
+ timeout = setTimeout(() => finish(false), workerCloseTimeoutMs);
496
533
  worker.postMessage({ type: "close" } satisfies WorkerInbound);
497
534
  return await closed;
498
535
  },
@@ -557,7 +594,7 @@ function spawnInlineWorker(): WorkerHandle {
557
594
  if (msg.type === "closed") finish(true);
558
595
  });
559
596
  this.send({ type: "close" });
560
- timeout = setTimeout(() => finish(false), WORKER_CLOSE_TIMEOUT_MS);
597
+ timeout = setTimeout(() => finish(false), workerCloseTimeoutMs);
561
598
  return await closed;
562
599
  },
563
600
  async terminate() {
@@ -1,13 +1,20 @@
1
1
  import { parentPort } from "node:worker_threads";
2
+ import { consumeWorkerInbox } from "@oh-my-pi/pi-utils/worker-host";
2
3
  import { WorkerCore } from "./worker-core";
3
4
  import type { Transport, WorkerInbound, WorkerOutbound } from "./worker-protocol";
4
5
 
5
6
  if (!parentPort) throw new Error("js worker-entry: missing parentPort");
6
7
 
7
8
  const port = parentPort;
9
+ // When the CLI host pre-buffered messages (it imports this module dynamically),
10
+ // bind that inbox so the parent's already-delivered `init` is replayed. Loaded
11
+ // directly (test/SDK fallback), this module's top-level runs synchronously at
12
+ // worker start, so the direct `parentPort.on` below wins the flush on its own.
13
+ const inbox = consumeWorkerInbox();
8
14
  const transport: Transport = {
9
15
  send: (msg: WorkerOutbound) => port.postMessage(msg),
10
16
  onMessage: handler => {
17
+ if (inbox) return inbox.bind(data => handler(data as WorkerInbound));
11
18
  const wrap = (data: unknown): void => handler(data as WorkerInbound);
12
19
  port.on("message", wrap);
13
20
  return () => port.off("message", wrap);
@@ -278,10 +278,12 @@
278
278
  let searchQuery = '';
279
279
 
280
280
  function hasTextContent(content) {
281
- if (typeof content === 'string') return content.trim().length > 0;
281
+ if (typeof content === 'string') return Boolean(canonicalizeMessage(content));
282
282
  if (Array.isArray(content)) {
283
283
  for (const c of content) {
284
- if (c.type === 'text' && c.text && c.text.trim().length > 0) return true;
284
+ if (c.type === 'text' && c.text) {
285
+ if (canonicalizeMessage(c.text)) return true;
286
+ }
285
287
  }
286
288
  }
287
289
  return false;
@@ -450,24 +452,16 @@
450
452
  return div.innerHTML;
451
453
  }
452
454
 
453
- function isDotOnlyThinking(text) {
454
- let sawDot = false;
455
- for (let i = 0; i < text.length; i++) {
456
- const code = text.charCodeAt(i);
457
- if (code === 0x2e || code === 0x2026) {
458
- sawDot = true;
459
- continue;
455
+ function canonicalizeMessage(text) {
456
+ if (!text) return '';
457
+ const trimmed = text.trim();
458
+ for (let i = 0; i < trimmed.length; i++) {
459
+ const code = trimmed.charCodeAt(i);
460
+ if (code !== 0x2e && code !== 0x2026 && code !== 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) {
461
+ return trimmed;
460
462
  }
461
- if (code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d) continue;
462
- return false;
463
463
  }
464
- return sawDot;
465
- }
466
-
467
- function visibleThinkingText(block) {
468
- const text = block.thinking.trim();
469
- if (!text) return '';
470
- return isDotOnlyThinking(text) ? '' : text;
464
+ return '';
471
465
  }
472
466
 
473
467
  /**
@@ -1074,10 +1068,13 @@
1074
1068
  let html = `<div class="assistant-message" id="${entryId}">${copyBtnHtml}${tsHtml}`;
1075
1069
 
1076
1070
  for (const block of msg.content) {
1077
- if (block.type === 'text' && block.text.trim()) {
1078
- html += `<div class="assistant-text markdown-content">${safeMarkedParse(block.text)}</div>`;
1071
+ if (block.type === 'text') {
1072
+ const canon = canonicalizeMessage(block.text);
1073
+ if (canon) {
1074
+ html += `<div class="assistant-text markdown-content">${safeMarkedParse(block.text)}</div>`;
1075
+ }
1079
1076
  } else if (block.type === 'thinking') {
1080
- const thinking = visibleThinkingText(block);
1077
+ const thinking = canonicalizeMessage(block.thinking);
1081
1078
  if (!thinking) continue;
1082
1079
  html += `<div class="thinking-block">
1083
1080
  <div class="thinking-text">${escapeHtml(thinking)}</div>
@@ -1085,7 +1082,6 @@
1085
1082
  </div>`;
1086
1083
  }
1087
1084
  }
1088
-
1089
1085
  for (const block of msg.content) {
1090
1086
  if (block.type === 'toolCall') {
1091
1087
  html += renderToolCall(block, sctx);