@oh-my-pi/pi-coding-agent 16.5.0 → 16.5.1

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 (121) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/cli.js +3336 -3318
  3. package/dist/types/advisor/advise-tool.d.ts +12 -1
  4. package/dist/types/advisor/runtime.d.ts +41 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/update-cli.d.ts +4 -1
  7. package/dist/types/cli/usage-cli.d.ts +3 -0
  8. package/dist/types/cli/usage-error.d.ts +4 -0
  9. package/dist/types/config/api-key-resolver.d.ts +2 -2
  10. package/dist/types/config/model-registry.d.ts +3 -3
  11. package/dist/types/config/model-resolver.d.ts +8 -1
  12. package/dist/types/config/models-config.d.ts +1 -1
  13. package/dist/types/eval/__tests__/process-entry-import.test.d.ts +1 -0
  14. package/dist/types/eval/bridge-timeout.d.ts +9 -1
  15. package/dist/types/eval/js/context-manager.d.ts +5 -3
  16. package/dist/types/eval/js/process-entry.d.ts +6 -0
  17. package/dist/types/eval/js/worker-core.d.ts +15 -1
  18. package/dist/types/eval/py/spawn-options.d.ts +10 -0
  19. package/dist/types/eval/py/tool-bridge.d.ts +1 -0
  20. package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
  21. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  22. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  23. package/dist/types/internal-urls/memory-protocol.d.ts +6 -7
  24. package/dist/types/main.d.ts +1 -0
  25. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  26. package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
  27. package/dist/types/modes/orchestrate.d.ts +1 -1
  28. package/dist/types/modes/rpc/host-tools.d.ts +2 -0
  29. package/dist/types/modes/rpc/rpc-mode.d.ts +26 -6
  30. package/dist/types/modes/ultrathink.d.ts +1 -1
  31. package/dist/types/modes/utils/transcript-render-helpers.d.ts +12 -0
  32. package/dist/types/modes/workflow.d.ts +1 -1
  33. package/dist/types/session/agent-session.d.ts +6 -0
  34. package/dist/types/session/exit-diagnostics.d.ts +11 -0
  35. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +11 -0
  36. package/dist/types/subprocess/worker-client.d.ts +6 -0
  37. package/dist/types/tools/bash-skill-urls.d.ts +1 -0
  38. package/dist/types/web/search/provider.d.ts +10 -3
  39. package/dist/types/web/search/providers/codex.d.ts +5 -4
  40. package/package.json +12 -12
  41. package/src/advisor/__tests__/advisor.test.ts +830 -42
  42. package/src/advisor/advise-tool.ts +17 -1
  43. package/src/advisor/runtime.ts +288 -67
  44. package/src/autolearn/controller.ts +15 -3
  45. package/src/cli/args.ts +12 -0
  46. package/src/cli/auth-broker-cli.ts +30 -11
  47. package/src/cli/auth-gateway-cli.ts +5 -1
  48. package/src/cli/dry-balance-cli.ts +14 -4
  49. package/src/cli/flag-tables.ts +21 -7
  50. package/src/cli/update-cli.ts +62 -11
  51. package/src/cli/usage-cli.ts +58 -5
  52. package/src/cli/usage-error.ts +7 -0
  53. package/src/cli.ts +23 -1
  54. package/src/commands/acp.ts +11 -2
  55. package/src/commands/launch.ts +12 -3
  56. package/src/commands/token.ts +3 -1
  57. package/src/config/api-key-resolver.ts +12 -3
  58. package/src/config/config-file.ts +30 -12
  59. package/src/config/model-registry.ts +7 -7
  60. package/src/config/model-resolver.ts +21 -7
  61. package/src/config/models-config.ts +1 -1
  62. package/src/eval/__tests__/agent-bridge.test.ts +19 -14
  63. package/src/eval/__tests__/bridge-timeout.test.ts +106 -0
  64. package/src/eval/__tests__/js-context-manager.test.ts +158 -1
  65. package/src/eval/__tests__/kernel-spawn.test.ts +12 -0
  66. package/src/eval/__tests__/process-entry-import.test.ts +27 -0
  67. package/src/eval/agent-bridge.ts +121 -116
  68. package/src/eval/bridge-timeout.ts +20 -2
  69. package/src/eval/executor-base.ts +85 -7
  70. package/src/eval/jl/kernel.ts +2 -1
  71. package/src/eval/js/context-manager.ts +109 -32
  72. package/src/eval/js/process-entry.ts +27 -0
  73. package/src/eval/js/shared/runtime.ts +1 -1
  74. package/src/eval/js/worker-core.ts +70 -9
  75. package/src/eval/js/worker-entry.ts +1 -1
  76. package/src/eval/py/kernel.ts +2 -1
  77. package/src/eval/py/spawn-options.ts +13 -0
  78. package/src/eval/py/tool-bridge.ts +13 -14
  79. package/src/eval/rb/kernel.ts +2 -1
  80. package/src/extensibility/custom-tools/types.ts +3 -0
  81. package/src/extensibility/extensions/runner.ts +3 -0
  82. package/src/extensibility/extensions/types.ts +3 -0
  83. package/src/extensibility/plugins/manager.ts +21 -0
  84. package/src/internal-urls/memory-protocol.ts +13 -9
  85. package/src/lsp/client.ts +7 -1
  86. package/src/main.ts +29 -0
  87. package/src/mcp/tool-bridge.ts +57 -6
  88. package/src/modes/components/chat-transcript-builder.ts +22 -1
  89. package/src/modes/components/status-line/component.ts +10 -1
  90. package/src/modes/components/transcript-container.ts +110 -7
  91. package/src/modes/controllers/command-controller.ts +12 -4
  92. package/src/modes/controllers/event-controller.ts +80 -15
  93. package/src/modes/controllers/selector-controller.ts +15 -3
  94. package/src/modes/magic-keyword-boundary.ts +23 -0
  95. package/src/modes/orchestrate.ts +6 -5
  96. package/src/modes/print-mode.ts +9 -0
  97. package/src/modes/rpc/host-tools.ts +15 -0
  98. package/src/modes/rpc/rpc-mode.ts +123 -48
  99. package/src/modes/ultrathink.ts +6 -5
  100. package/src/modes/utils/transcript-render-helpers.ts +54 -0
  101. package/src/modes/utils/ui-helpers.ts +27 -1
  102. package/src/modes/workflow.ts +6 -5
  103. package/src/prompts/advisor/system.md +1 -0
  104. package/src/sdk.ts +33 -3
  105. package/src/session/agent-session.ts +239 -17
  106. package/src/session/exit-diagnostics.ts +108 -0
  107. package/src/session/streaming-output.ts +40 -12
  108. package/src/slash-commands/helpers/active-oauth-account.ts +22 -2
  109. package/src/slash-commands/helpers/logout.ts +23 -3
  110. package/src/slash-commands/helpers/usage-report.ts +14 -2
  111. package/src/subprocess/worker-client.ts +9 -2
  112. package/src/task/executor.ts +8 -0
  113. package/src/task/render.test.ts +36 -0
  114. package/src/task/render.ts +55 -43
  115. package/src/tools/bash-skill-urls.ts +4 -1
  116. package/src/tools/bash.ts +1 -0
  117. package/src/tools/write.ts +82 -9
  118. package/src/tools/yield.ts +29 -1
  119. package/src/web/search/index.ts +39 -22
  120. package/src/web/search/provider.ts +33 -16
  121. package/src/web/search/providers/codex.ts +68 -21
@@ -258,7 +258,7 @@ export interface ProviderDiscoveryState {
258
258
  error?: string;
259
259
  }
260
260
 
261
- /** Result of loading custom models from models.json */
261
+ /** Result of loading custom models config. */
262
262
  interface CustomModelsResult {
263
263
  models?: CustomModelOverlay[];
264
264
  overrides?: Map<string, ProviderOverride>;
@@ -309,7 +309,7 @@ interface CommandApiKeyResolution {
309
309
  value?: string;
310
310
  }
311
311
  /**
312
- * Resolve a models.yml secret/config value to an actual value.
312
+ * Resolve a models.yml/models.yaml secret/config value to an actual value.
313
313
  * `!cmd` runs a shell command and returns trimmed stdout, otherwise env vars are
314
314
  * checked first and the input falls back to a literal value.
315
315
  */
@@ -822,7 +822,7 @@ export class ModelRegistry {
822
822
  }
823
823
 
824
824
  /**
825
- * Reload models from disk (built-in + custom from models.json).
825
+ * Reload models from disk (built-in + custom config).
826
826
  */
827
827
  async refresh(strategy: ModelRefreshStrategy = "online-if-uncached"): Promise<void> {
828
828
  this.#reloadStaticModels();
@@ -938,7 +938,7 @@ export class ModelRegistry {
938
938
  #reloadStaticModels(): void {
939
939
  const currentMtime = this.#modelsConfigFile.getMtimeMs();
940
940
  if (currentMtime !== null && currentMtime === this.#lastStaticLoadMtime) {
941
- // models.json unchanged since last load; reloading would be redundant.
941
+ // Models config unchanged since last load; reloading would be redundant.
942
942
  return;
943
943
  }
944
944
  this.#modelsConfigFile.invalidate();
@@ -962,14 +962,14 @@ export class ModelRegistry {
962
962
  }
963
963
 
964
964
  /**
965
- * Get any error from loading models.json (undefined if no error).
965
+ * Get any error from loading custom models config (undefined if no error).
966
966
  */
967
967
  getError(): ConfigError | undefined {
968
968
  return this.#configError;
969
969
  }
970
970
 
971
971
  #loadModels() {
972
- // Load custom models from models.json first (to know which providers to override)
972
+ // Load custom config first (to know which providers to override).
973
973
  const {
974
974
  models: customModels = [],
975
975
  overrides = new Map(),
@@ -1909,7 +1909,7 @@ export class ModelRegistry {
1909
1909
 
1910
1910
  /**
1911
1911
  * Get all models (built-in + custom).
1912
- * If models.json had errors, returns only built-in models.
1912
+ * If custom config had errors, returns only built-in models.
1913
1913
  */
1914
1914
  getAll(): Model<Api>[] {
1915
1915
  return this.#models;
@@ -1209,20 +1209,27 @@ export function resolveModelOverride(
1209
1209
  modelPatterns: string[],
1210
1210
  modelRegistry: ModelLookupRegistry,
1211
1211
  settings?: Settings,
1212
- ): { model?: Model<Api>; thinkingLevel?: ConfiguredThinkingLevel; explicitThinkingLevel: boolean } {
1212
+ ): { model?: Model<Api>; thinkingLevel?: ConfiguredThinkingLevel; explicitThinkingLevel: boolean; warning?: string } {
1213
1213
  if (modelPatterns.length === 0) return { explicitThinkingLevel: false };
1214
1214
  const availableModels = modelRegistry.getAvailable();
1215
1215
  const matchPreferences = getModelMatchPreferences(settings);
1216
+ let warning: string | undefined;
1216
1217
  for (const pattern of modelPatterns) {
1217
- const { model, thinkingLevel, explicitThinkingLevel } = resolveModelRoleValue(pattern, availableModels, {
1218
+ const {
1219
+ model,
1220
+ thinkingLevel,
1221
+ explicitThinkingLevel,
1222
+ warning: patternWarning,
1223
+ } = resolveModelRoleValue(pattern, availableModels, {
1218
1224
  settings,
1219
1225
  matchPreferences,
1220
1226
  });
1221
1227
  if (model) {
1222
- return { model, thinkingLevel, explicitThinkingLevel };
1228
+ return { model, thinkingLevel, explicitThinkingLevel, warning: patternWarning };
1223
1229
  }
1230
+ if (!warning && patternWarning) warning = patternWarning;
1224
1231
  }
1225
- return { explicitThinkingLevel: false };
1232
+ return { explicitThinkingLevel: false, warning };
1226
1233
  }
1227
1234
 
1228
1235
  /**
@@ -1236,6 +1243,11 @@ export function resolveModelOverride(
1236
1243
  * `modelRoles.task` pointing at an unqualified id whose only available
1237
1244
  * provider variant has no configured credentials — see #985).
1238
1245
  *
1246
+ * `sessionId` is forwarded to `getApiKey` so that session-sticky OAuth
1247
+ * credentials resolve correctly during the pre-flight auth check. Without it,
1248
+ * providers with multiple OAuth accounts may return `undefined` even though
1249
+ * the credential is usable once the subagent session starts — see #5325.
1250
+ *
1239
1251
  * Keyless-by-design providers (llama.cpp, ollama, lm-studio) advertise the
1240
1252
  * `kNoAuth` sentinel from `getApiKey` to signal that they do not require
1241
1253
  * credentials. Those are treated as authenticated here so an explicitly
@@ -1251,18 +1263,20 @@ export async function resolveModelOverrideWithAuthFallback(
1251
1263
  parentActiveModelPattern: string | undefined,
1252
1264
  modelRegistry: ModelLookupRegistry & Pick<ModelRegistry, "getApiKey">,
1253
1265
  settings?: Settings,
1266
+ sessionId?: string,
1254
1267
  ): Promise<{
1255
1268
  model?: Model<Api>;
1256
1269
  thinkingLevel?: ConfiguredThinkingLevel;
1257
1270
  explicitThinkingLevel: boolean;
1258
1271
  authFallbackUsed: boolean;
1272
+ warning?: string;
1259
1273
  }> {
1260
1274
  const primary = resolveModelOverride(modelPatterns, modelRegistry, settings);
1261
1275
  if (!primary.model || !parentActiveModelPattern) {
1262
1276
  return { ...primary, authFallbackUsed: false };
1263
1277
  }
1264
1278
 
1265
- const primaryKey = await modelRegistry.getApiKey(primary.model);
1279
+ const primaryKey = await modelRegistry.getApiKey(primary.model, sessionId);
1266
1280
  if (primaryKey === kNoAuth || isAuthenticated(primaryKey)) {
1267
1281
  return { ...primary, authFallbackUsed: false };
1268
1282
  }
@@ -1274,12 +1288,12 @@ export async function resolveModelOverrideWithAuthFallback(
1274
1288
  if (modelsAreEqual(fallback.model, primary.model)) {
1275
1289
  return { ...primary, authFallbackUsed: false };
1276
1290
  }
1277
- const fallbackKey = await modelRegistry.getApiKey(fallback.model);
1291
+ const fallbackKey = await modelRegistry.getApiKey(fallback.model, sessionId);
1278
1292
  if (!isAuthenticated(fallbackKey)) {
1279
1293
  return { ...primary, authFallbackUsed: false };
1280
1294
  }
1281
1295
 
1282
- return { ...fallback, authFallbackUsed: true };
1296
+ return { ...fallback, authFallbackUsed: true, warning: primary.warning ?? fallback.warning };
1283
1297
  }
1284
1298
 
1285
1299
  /**
@@ -1,5 +1,5 @@
1
1
  /**
2
- * models.json config file handle and provider configuration validation.
2
+ * Custom model/provider config file handle and validation.
3
3
  */
4
4
 
5
5
  import type { Api, ModelSpec } from "@oh-my-pi/pi-ai/types";
@@ -603,21 +603,22 @@ describe("agent() through eval runtimes", () => {
603
603
  });
604
604
  const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "py-agent-interrupt", settings);
605
605
  mockAgents();
606
- // Subagents that ignore the abort for far longer than the kernel's SIGINT
607
- // escalation window. Each kernel worker thread blocks in a synchronous
608
- // `urllib` bridge call, joined by `parallel()`'s ThreadPoolExecutor exit.
609
- // The host must respond the instant the cell aborts so the kernel can
610
- // unwind via KeyboardInterrupt instead of being hard-killed (which used to
611
- // surface "[kernel] Python kernel shutdown" and lose all session state).
606
+ // Each kernel worker thread blocks in a synchronous `urllib` bridge call,
607
+ // joined by `parallel()`'s ThreadPoolExecutor exit. The host must keep
608
+ // those already-started calls attached until they settle, then interrupt
609
+ // the kernel before `parallel()` launches another wave.
612
610
  let inFlight = 0;
611
+ let completed = 0;
613
612
  let markSaturated: (() => void) | undefined;
614
613
  const saturated = new Promise<void>(resolve => {
615
614
  markSaturated = resolve;
616
615
  });
617
- vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
616
+ const releaseAgents = Promise.withResolvers<void>();
617
+ const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
618
618
  // task.maxConcurrency=6 → six bridge calls block at once; signal then.
619
619
  if (++inFlight >= 6) markSaturated?.();
620
- await Bun.sleep(9000); // deliberately ignores options.signal
620
+ await releaseAgents.promise;
621
+ completed++;
621
622
  return singleResult(options, { output: options.assignment ?? "" });
622
623
  });
623
624
 
@@ -640,8 +641,7 @@ describe("agent() through eval runtimes", () => {
640
641
  // bridge calls (condition-driven) instead of waiting a fixed wall second.
641
642
  void saturated.then(() => ac.abort(new Error("external interrupt")));
642
643
 
643
- const start = Date.now();
644
- const result = await executePython(
644
+ const resultPromise = executePython(
645
645
  "import json\nprint(json.dumps(parallel([lambda n=n: agent(str(n)) for n in range(12)])))",
646
646
  {
647
647
  cwd: tempDir.path(),
@@ -653,13 +653,18 @@ describe("agent() through eval runtimes", () => {
653
653
  signal: ac.signal,
654
654
  },
655
655
  );
656
- const elapsed = Date.now() - start;
656
+ await saturated;
657
+ await Promise.resolve();
658
+ expect(completed).toBe(0);
659
+ releaseAgents.resolve();
660
+ const result = await resultPromise;
657
661
 
658
- // Cancelled, but cleanly: no hard-kill, settled well within the kernel's 5s
659
- // SIGINT escalation window rather than ~6s after it.
662
+ // Cancelled, but cleanly: no hard-kill, no orphaned bridge calls, and no
663
+ // second fan-out wave started after the deferred abort was delivered.
660
664
  expect(result.cancelled).toBe(true);
661
665
  expect(result.output).not.toContain("Python kernel shutdown");
662
- expect(elapsed).toBeLessThan(4000);
666
+ expect(completed).toBe(6);
667
+ expect(runSpy).toHaveBeenCalledTimes(6);
663
668
 
664
669
  // The persistent kernel survived the interrupt: prior state is intact.
665
670
  const after = await executePython("print(PREP_MARKER)", {
@@ -5,7 +5,9 @@ import {
5
5
  isEvalTimeoutControlEvent,
6
6
  withBridgeTimeoutPause,
7
7
  } from "../bridge-timeout";
8
+ import { executeWithKernelBase, type GenericKernel } from "../executor-base";
8
9
  import type { JsStatusEvent } from "../js/shared/types";
10
+ import type { KernelDisplayOutput } from "../py/display";
9
11
 
10
12
  describe("withBridgeTimeoutPause", () => {
11
13
  it("emits one pause before the operation and one resume after it settles", async () => {
@@ -17,10 +19,12 @@ describe("withBridgeTimeoutPause", () => {
17
19
  await Bun.sleep(80);
18
20
  return "done";
19
21
  },
22
+ { deferExternalAbort: true },
20
23
  );
21
24
 
22
25
  expect(value).toBe("done");
23
26
  expect(events.map(event => event.op)).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP]);
27
+ expect(events.every(event => event.deferExternalAbort === true)).toBe(true);
24
28
 
25
29
  const settledCount = events.length;
26
30
  await Bun.sleep(40);
@@ -62,3 +66,105 @@ describe("withBridgeTimeoutPause", () => {
62
66
  expect(isEvalTimeoutControlEvent({ op: "agent", id: "subagent-1" })).toBe(false);
63
67
  });
64
68
  });
69
+
70
+ class TestCancelledError extends Error {
71
+ readonly timedOut: boolean;
72
+
73
+ constructor(timedOut: boolean) {
74
+ super(timedOut ? "timed out" : "cancelled");
75
+ this.name = "TestCancelledError";
76
+ this.timedOut = timedOut;
77
+ }
78
+ }
79
+
80
+ it("defers external aborts until an in-flight agent bridge call resumes", async () => {
81
+ const abortController = new AbortController();
82
+ const entered = Promise.withResolvers<void>();
83
+ const triggerAbort = Promise.withResolvers<void>();
84
+ const observed = Promise.withResolvers<boolean>();
85
+ const release = Promise.withResolvers<void>();
86
+ const kernel: GenericKernel<Record<string, string | null>> = {
87
+ async execute(_code, options) {
88
+ entered.resolve();
89
+ await triggerAbort.promise;
90
+ options.onDisplay({
91
+ type: "status",
92
+ event: { op: EVAL_TIMEOUT_PAUSE_OP, deferExternalAbort: true },
93
+ } satisfies KernelDisplayOutput);
94
+ abortController.abort(new Error("external interrupt"));
95
+ observed.resolve(options.signal?.aborted ?? false);
96
+ await release.promise;
97
+ options.onDisplay({
98
+ type: "status",
99
+ event: { op: EVAL_TIMEOUT_RESUME_OP, deferExternalAbort: true },
100
+ } satisfies KernelDisplayOutput);
101
+ return { status: "ok", cancelled: false, timedOut: false };
102
+ },
103
+ };
104
+
105
+ const resultPromise = executeWithKernelBase({
106
+ kernel,
107
+ code: "agent('slow')",
108
+ options: { signal: abortController.signal },
109
+ runIdPrefix: "test",
110
+ errorLogLabel: "test",
111
+ cancelledErrorClass: TestCancelledError,
112
+ buildKernelEnvPatch: () => ({}),
113
+ formatKernelTimeoutAnnotation: () => "kernel timed out",
114
+ formatTimeoutAnnotation: () => "timed out",
115
+ });
116
+
117
+ await entered.promise;
118
+ triggerAbort.resolve();
119
+ expect(await observed.promise).toBe(false);
120
+ release.resolve();
121
+ const result = await resultPromise;
122
+ expect(result.cancelled).toBe(true);
123
+ expect(result.exitCode).toBeUndefined();
124
+ });
125
+
126
+ it("does not defer external aborts for a completion bridge call", async () => {
127
+ const abortController = new AbortController();
128
+ const entered = Promise.withResolvers<void>();
129
+ const triggerAbort = Promise.withResolvers<void>();
130
+ const observed = Promise.withResolvers<boolean>();
131
+ const release = Promise.withResolvers<void>();
132
+ const kernel: GenericKernel<Record<string, string | null>> = {
133
+ async execute(_code, options) {
134
+ entered.resolve();
135
+ await triggerAbort.promise;
136
+ options.onDisplay({
137
+ type: "status",
138
+ event: { op: EVAL_TIMEOUT_PAUSE_OP },
139
+ } satisfies KernelDisplayOutput);
140
+ abortController.abort(new Error("external interrupt"));
141
+ observed.resolve(options.signal?.aborted ?? false);
142
+ await release.promise;
143
+ options.onDisplay({
144
+ type: "status",
145
+ event: { op: EVAL_TIMEOUT_RESUME_OP },
146
+ } satisfies KernelDisplayOutput);
147
+ return { status: "ok", cancelled: false, timedOut: false };
148
+ },
149
+ };
150
+
151
+ const resultPromise = executeWithKernelBase({
152
+ kernel,
153
+ code: "completion('slow')",
154
+ options: { signal: abortController.signal },
155
+ runIdPrefix: "test",
156
+ errorLogLabel: "test",
157
+ cancelledErrorClass: TestCancelledError,
158
+ buildKernelEnvPatch: () => ({}),
159
+ formatKernelTimeoutAnnotation: () => "kernel timed out",
160
+ formatTimeoutAnnotation: () => "timed out",
161
+ });
162
+
163
+ await entered.promise;
164
+ triggerAbort.resolve();
165
+ expect(await observed.promise).toBe(true);
166
+ release.resolve();
167
+ const result = await resultPromise;
168
+ expect(result.cancelled).toBe(true);
169
+ expect(result.exitCode).toBeUndefined();
170
+ });
@@ -1,8 +1,14 @@
1
1
  import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
2
4
  import { TempDir } from "@oh-my-pi/pi-utils";
3
5
  import { Settings } from "../../config/settings";
4
6
  import type { ToolSession } from "../../tools";
5
- import { disposeAllVmContexts, setWorkerCloseTimeoutMsForTests } from "../js/context-manager";
7
+ import {
8
+ disposeAllVmContexts,
9
+ setJsEvalWorkerThreadForTests,
10
+ setWorkerCloseTimeoutMsForTests,
11
+ } from "../js/context-manager";
6
12
  import { executeJs } from "../js/executor";
7
13
 
8
14
  const originalWorker = globalThis.Worker;
@@ -181,7 +187,9 @@ function installFakeWorker(stats: FakeWorkerStats, behavior: FakeWorkerBehavior)
181
187
 
182
188
  describe("JavaScript eval worker lifecycle", () => {
183
189
  let restoreCloseTimeoutMs = 0;
190
+ let restoreWorkerThread = false;
184
191
  beforeEach(() => {
192
+ restoreWorkerThread = setJsEvalWorkerThreadForTests(true);
185
193
  // Shrink the graceful-close grace period so the "close acked but the worker
186
194
  // never exits -> force terminate" contract is proven without a real 1s wait.
187
195
  restoreCloseTimeoutMs = setWorkerCloseTimeoutMsForTests(1);
@@ -197,6 +205,7 @@ describe("JavaScript eval worker lifecycle", () => {
197
205
  writable: true,
198
206
  value: originalWorker,
199
207
  });
208
+ setJsEvalWorkerThreadForTests(restoreWorkerThread);
200
209
  });
201
210
 
202
211
  it("exits a real worker on graceful close even with ref'ed user handles", async () => {
@@ -271,6 +280,73 @@ describe("JavaScript eval worker lifecycle", () => {
271
280
  expect(stats.terminateCalls).toBe(1);
272
281
  });
273
282
 
283
+ it("falls back to a Bun Worker when the subprocess cannot spawn", async () => {
284
+ using tempDir = TempDir.createSync("@omp-js-spawn-fallback-");
285
+ // Exercise the production ladder (process -> worker -> inline), not the
286
+ // worker-thread test seam the surrounding describe enables.
287
+ setJsEvalWorkerThreadForTests(false);
288
+ const stats: FakeWorkerStats = { closeRequests: 0, terminateCalls: 0 };
289
+ installFakeWorker(stats, { exitOnClose: true, settleRuns: true });
290
+ const originalSpawn = Bun.spawn;
291
+ let spawnAttempts = 0;
292
+ Bun.spawn = ((): never => {
293
+ spawnAttempts++;
294
+ throw new Error("subprocess spawn unavailable");
295
+ }) as unknown as typeof Bun.spawn;
296
+
297
+ try {
298
+ const session = makeSession(tempDir.path());
299
+ const sessionId = `js-spawn-fallback:${crypto.randomUUID()}`;
300
+ // The fake Worker settles runs without executing the cell, so an empty
301
+ // output proves the middle rung handled it — the inline fallback would
302
+ // have actually evaluated the expression and printed 42.
303
+ const result = await executeJs("return String(6 * 7);", { cwd: tempDir.path(), sessionId, session });
304
+ expect(result.exitCode).toBe(0);
305
+ expect(result.output.trim()).toBe("");
306
+ expect(spawnAttempts).toBe(1);
307
+ } finally {
308
+ Bun.spawn = originalSpawn;
309
+ }
310
+ });
311
+
312
+ it("falls back to a Bun Worker when the subprocess fails during initialization", async () => {
313
+ using tempDir = TempDir.createSync("@omp-js-init-fallback-");
314
+ // Exercise the production ladder (process -> worker -> inline), not the
315
+ // worker-thread test seam the surrounding describe enables.
316
+ setJsEvalWorkerThreadForTests(false);
317
+ const stats: FakeWorkerStats = { closeRequests: 0, terminateCalls: 0 };
318
+ installFakeWorker(stats, { exitOnClose: true, settleRuns: true });
319
+ const originalSpawn = Bun.spawn;
320
+ let spawnAttempts = 0;
321
+ Bun.spawn = ((options: unknown) => {
322
+ spawnAttempts++;
323
+ const spawnOptions = options as {
324
+ onExit?: (proc: unknown, exitCode: number | null, signalCode: string | null) => void;
325
+ };
326
+ const fakeProcess = {
327
+ send: () => undefined,
328
+ kill: () => undefined,
329
+ unref: () => undefined,
330
+ };
331
+ queueMicrotask(() => spawnOptions.onExit?.(fakeProcess, 1, null));
332
+ return fakeProcess;
333
+ }) as unknown as typeof Bun.spawn;
334
+
335
+ try {
336
+ const session = makeSession(tempDir.path());
337
+ const sessionId = `js-init-fallback:${crypto.randomUUID()}`;
338
+ // The fake Worker settles runs without executing the cell, so empty
339
+ // output proves the middle rung handled the retry. Inline execution
340
+ // would evaluate the expression and print 42.
341
+ const result = await executeJs("return String(6 * 7);", { cwd: tempDir.path(), sessionId, session });
342
+ expect(result.exitCode).toBe(0);
343
+ expect(result.output.trim()).toBe("");
344
+ expect(spawnAttempts).toBe(1);
345
+ } finally {
346
+ Bun.spawn = originalSpawn;
347
+ }
348
+ });
349
+
274
350
  it("falls back to the inline worker when the spawned worker errors during startup", async () => {
275
351
  using tempDir = TempDir.createSync("@omp-js-worker-error-");
276
352
  const stats: FakeWorkerStats = { closeRequests: 0, terminateCalls: 0 };
@@ -289,3 +365,84 @@ describe("JavaScript eval worker lifecycle", () => {
289
365
  expect(stats.terminateCalls).toBe(1);
290
366
  });
291
367
  });
368
+
369
+ describe.skipIf(process.platform === "win32")("JavaScript eval process isolation", () => {
370
+ afterEach(async () => {
371
+ await disposeAllVmContexts();
372
+ });
373
+
374
+ it("runs spawned commands in the isolated POSIX process group", async () => {
375
+ using tempDir = TempDir.createSync("@omp-js-process-isolation-");
376
+ const session = makeSession(tempDir.path());
377
+ const evalSessionId = `js-isolation:${crypto.randomUUID()}`;
378
+ const result = await executeJs(
379
+ [
380
+ `const child = Bun.spawn(["/bin/sh", "-c", 'pgid=$(ps -o pgid= -p $$); printf "%s %s\\n" "$pgid" "$PPID"'], { stdout: "pipe" });`,
381
+ "return await new Response(child.stdout).text();",
382
+ ].join("\n"),
383
+ { cwd: tempDir.path(), sessionId: evalSessionId, session },
384
+ );
385
+ const [processGroupId, parentProcessId] = result.output.trim().split(/\s+/).map(Number);
386
+ expect(parentProcessId).not.toBe(process.pid);
387
+ expect(processGroupId).toBe(parentProcessId);
388
+
389
+ await executeJs("var saved = 41; function increment(value) { return value + 1; }", {
390
+ cwd: tempDir.path(),
391
+ sessionId: evalSessionId,
392
+ session,
393
+ });
394
+ const reused = await executeJs("return increment(saved);", {
395
+ cwd: tempDir.path(),
396
+ sessionId: evalSessionId,
397
+ session,
398
+ });
399
+ expect(reused.output.trim()).toBe("42");
400
+ });
401
+
402
+ it("mirrors the session cwd onto the subprocess's real cwd", async () => {
403
+ using tempDir = TempDir.createSync("@omp-js-process-cwd-");
404
+ const session = makeSession(tempDir.path());
405
+ const evalSessionId = `js-cwd:${crypto.randomUUID()}`;
406
+ const result = await executeJs("return process.cwd();", {
407
+ cwd: tempDir.path(),
408
+ sessionId: evalSessionId,
409
+ session,
410
+ });
411
+ // process.chdir resolves symlinks (macOS tempdirs live under /var ->
412
+ // /private/var), so compare physical paths.
413
+ expect(result.output.trim()).toBe(fs.realpathSync(tempDir.path()));
414
+ });
415
+
416
+ it("still runs cells when the session cwd does not exist", async () => {
417
+ using tempDir = TempDir.createSync("@omp-js-process-cwd-missing-");
418
+ const missingCwd = path.join(tempDir.path(), "deleted");
419
+ const session = makeSession(missingCwd);
420
+ const result = await executeJs("return String(6 * 7);", {
421
+ cwd: missingCwd,
422
+ sessionId: `js-cwd-missing:${crypto.randomUUID()}`,
423
+ session,
424
+ });
425
+ expect(result.exitCode).toBe(0);
426
+ expect(result.output.trim()).toBe("42");
427
+ });
428
+
429
+ it("keeps the isolated process alive after a stackless floated rejection", async () => {
430
+ using tempDir = TempDir.createSync("@omp-js-process-rejection-");
431
+ const session = makeSession(tempDir.path());
432
+ const evalSessionId = `js-rejection:${crypto.randomUUID()}`;
433
+ const rejected = await executeJs(
434
+ 'var savedAfterRejection = 41; Promise.reject("stackless rejection"); await Bun.sleep(10);',
435
+ { cwd: tempDir.path(), sessionId: evalSessionId, session },
436
+ );
437
+ expect(rejected.exitCode).toBe(1);
438
+ expect(rejected.output).toContain("Unhandled rejection (missing await?): stackless rejection");
439
+
440
+ const reused = await executeJs("return savedAfterRejection + 1;", {
441
+ cwd: tempDir.path(),
442
+ sessionId: evalSessionId,
443
+ session,
444
+ });
445
+ expect(reused.exitCode).toBe(0);
446
+ expect(reused.output.trim()).toBe("42");
447
+ });
448
+ });
@@ -3,9 +3,21 @@ import {
3
3
  __resetWindowsConsoleProbeCache,
4
4
  consoleAttachedViaTTY,
5
5
  hostHasInheritableConsole,
6
+ shouldDetachKernel,
6
7
  shouldHideKernelWindow,
7
8
  } from "../py/spawn-options";
8
9
 
10
+ describe("shouldDetachKernel", () => {
11
+ it("starts POSIX kernels in a new session", () => {
12
+ expect(shouldDetachKernel("darwin")).toBe(true);
13
+ expect(shouldDetachKernel("linux")).toBe(true);
14
+ });
15
+
16
+ it("leaves Windows console inheritance to windowsHide", () => {
17
+ expect(shouldDetachKernel("win32")).toBe(false);
18
+ });
19
+ });
20
+
9
21
  /**
10
22
  * `shouldHideKernelWindow` decides whether the long-lived Python kernel
11
23
  * subprocess is spawned with `windowsHide: true`. On Windows, Bun maps that
@@ -0,0 +1,27 @@
1
+ import { expect, it } from "bun:test";
2
+ import * as path from "node:path";
3
+ import { TempDir } from "@oh-my-pi/pi-utils";
4
+
5
+ it("imports the JS process entry without loading dotenv before profile bootstrap", async () => {
6
+ using tempDir = TempDir.createSync("@omp-js-process-import-");
7
+ await Bun.write(path.join(tempDir.path(), ".env"), "OMP_PROCESS_ENTRY_ENV_PROBE=loaded-too-early\n");
8
+ const env = Object.fromEntries(
9
+ Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
10
+ );
11
+ delete env.OMP_PROCESS_ENTRY_ENV_PROBE;
12
+ env.HOME = tempDir.path();
13
+ const fixture = path.resolve(import.meta.dir, "../../../test/fixtures/js-process-entry-import.ts");
14
+ const proc = Bun.spawn([process.execPath, fixture], {
15
+ env,
16
+ stdout: "pipe",
17
+ stderr: "pipe",
18
+ });
19
+ const [exitCode, stdout, stderr] = await Promise.all([
20
+ proc.exited,
21
+ new Response(proc.stdout).text(),
22
+ new Response(proc.stderr).text(),
23
+ ]);
24
+ expect(exitCode).toBe(0);
25
+ expect(stdout).toBe("");
26
+ expect(stderr).toBe("");
27
+ });