@oh-my-pi/pi-coding-agent 17.1.2 → 17.1.4

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 (110) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/cli.js +3806 -3798
  3. package/dist/types/cli/__tests__/auth-gateway-catalog.test.d.ts +1 -0
  4. package/dist/types/cli/auth-gateway-cli.d.ts +8 -0
  5. package/dist/types/cli/update-cli.d.ts +41 -0
  6. package/dist/types/cli/usage-cli.d.ts +4 -2
  7. package/dist/types/commands/update.d.ts +1 -0
  8. package/dist/types/config/model-registry.d.ts +19 -0
  9. package/dist/types/config/settings-schema.d.ts +30 -7
  10. package/dist/types/cursor.d.ts +47 -1
  11. package/dist/types/eval/js/process-entry.d.ts +2 -1
  12. package/dist/types/eval/js/worker-core.d.ts +4 -1
  13. package/dist/types/extensibility/extensions/runner.d.ts +1 -0
  14. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
  15. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +4 -0
  16. package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +15 -7
  17. package/dist/types/extensibility/shared-events.d.ts +2 -0
  18. package/dist/types/modes/components/assistant-message.d.ts +9 -0
  19. package/dist/types/modes/components/custom-editor.d.ts +12 -8
  20. package/dist/types/plan-mode/approved-plan.d.ts +3 -2
  21. package/dist/types/secrets/obfuscator.d.ts +7 -31
  22. package/dist/types/session/agent-session.d.ts +19 -4
  23. package/dist/types/session/prewalk.d.ts +2 -1
  24. package/dist/types/session/session-advisors.d.ts +8 -0
  25. package/dist/types/session/session-metadata.d.ts +29 -0
  26. package/dist/types/session/turn-recovery.d.ts +5 -5
  27. package/dist/types/stt/sherpa-runtime.d.ts +38 -0
  28. package/dist/types/thinking.d.ts +24 -0
  29. package/dist/types/tools/auto-generated-guard.d.ts +5 -2
  30. package/dist/types/tools/bash.d.ts +5 -4
  31. package/dist/types/tools/computer/exposure.d.ts +8 -0
  32. package/dist/types/tools/computer/supervisor.d.ts +1 -1
  33. package/dist/types/tools/computer/worker-entry.d.ts +1 -1
  34. package/dist/types/tools/computer/worker.d.ts +1 -1
  35. package/dist/types/tools/computer.d.ts +6 -0
  36. package/dist/types/tools/memory-render.d.ts +1 -4
  37. package/dist/types/tools/shell-tokenize.d.ts +14 -0
  38. package/dist/types/tools/todo.d.ts +7 -1
  39. package/package.json +12 -12
  40. package/src/advisor/__tests__/advisor.test.ts +80 -10
  41. package/src/advisor/runtime.ts +27 -1
  42. package/src/cli/__tests__/auth-gateway-catalog.test.ts +111 -0
  43. package/src/cli/auth-gateway-cli.ts +63 -16
  44. package/src/cli/config-cli.ts +25 -7
  45. package/src/cli/update-cli.ts +229 -29
  46. package/src/cli/usage-cli.ts +144 -15
  47. package/src/cli.ts +21 -15
  48. package/src/commands/update.ts +6 -0
  49. package/src/config/__tests__/model-registry.test.ts +42 -7
  50. package/src/config/model-registry.ts +67 -4
  51. package/src/config/settings-schema.ts +39 -8
  52. package/src/config/settings.ts +24 -2
  53. package/src/cursor.ts +153 -0
  54. package/src/dap/session.ts +70 -11
  55. package/src/edit/hashline/filesystem.ts +1 -1
  56. package/src/edit/modes/patch.ts +1 -1
  57. package/src/eval/__tests__/js-context-manager.test.ts +9 -1
  58. package/src/eval/__tests__/process-entry-import.test.ts +111 -1
  59. package/src/eval/js/process-entry.ts +9 -5
  60. package/src/eval/js/worker-core.ts +6 -2
  61. package/src/exec/bash-executor.ts +4 -2
  62. package/src/extensibility/extensions/runner.ts +23 -8
  63. package/src/extensibility/legacy-pi-ai-shim.ts +9 -0
  64. package/src/extensibility/legacy-pi-coding-agent-shim.ts +14 -2
  65. package/src/extensibility/legacy-pi-tui-shim.ts +33 -0
  66. package/src/extensibility/shared-events.ts +2 -0
  67. package/src/main.ts +22 -1
  68. package/src/modes/acp/acp-agent.ts +6 -0
  69. package/src/modes/components/assistant-message.ts +88 -11
  70. package/src/modes/components/chat-transcript-builder.ts +2 -0
  71. package/src/modes/components/custom-editor.test.ts +170 -0
  72. package/src/modes/components/custom-editor.ts +79 -29
  73. package/src/modes/components/settings-defs.ts +5 -1
  74. package/src/modes/controllers/event-controller.ts +96 -4
  75. package/src/modes/controllers/selector-controller.ts +14 -0
  76. package/src/modes/interactive-mode.ts +34 -8
  77. package/src/modes/utils/context-usage.ts +19 -2
  78. package/src/modes/utils/interactive-context-helpers.ts +1 -0
  79. package/src/plan-mode/approved-plan.ts +18 -10
  80. package/src/prompts/system/custom-system-prompt.md +1 -1
  81. package/src/prompts/system/system-prompt.md +10 -1
  82. package/src/prompts/tools/ast-edit.md +1 -1
  83. package/src/sdk.ts +4 -0
  84. package/src/secrets/obfuscator.ts +36 -126
  85. package/src/session/agent-session.ts +69 -60
  86. package/src/session/prewalk.ts +8 -3
  87. package/src/session/session-advisors.ts +67 -3
  88. package/src/session/session-metadata.ts +53 -0
  89. package/src/session/session-provider-boundary.ts +0 -1
  90. package/src/session/session-tools.ts +35 -1
  91. package/src/session/stream-guards.ts +1 -1
  92. package/src/session/turn-recovery.ts +36 -19
  93. package/src/slash-commands/builtin-registry.ts +49 -7
  94. package/src/stt/asr-worker.ts +2 -37
  95. package/src/stt/sherpa-runtime.ts +71 -0
  96. package/src/task/executor.ts +6 -5
  97. package/src/thinking.ts +39 -0
  98. package/src/tools/auto-generated-guard.ts +18 -5
  99. package/src/tools/bash.ts +43 -15
  100. package/src/tools/computer/exposure.ts +38 -0
  101. package/src/tools/computer/supervisor.ts +61 -13
  102. package/src/tools/computer/worker-entry.ts +28 -19
  103. package/src/tools/computer/worker.ts +3 -7
  104. package/src/tools/computer.ts +65 -10
  105. package/src/tools/gh-cache-invalidation.ts +2 -82
  106. package/src/tools/memory-render.ts +11 -2
  107. package/src/tools/render-utils.ts +8 -3
  108. package/src/tools/shell-tokenize.ts +83 -0
  109. package/src/tools/todo.ts +44 -17
  110. package/src/tools/write.ts +1 -1
@@ -234,38 +234,50 @@ export type AnyUiMetadata = UiBase & {
234
234
  ordered?: boolean;
235
235
  };
236
236
 
237
- interface BooleanDef {
237
+ /**
238
+ * Marks a setting whose value is a credential.
239
+ *
240
+ * Lives at the top level rather than inside `ui` so it can also describe a
241
+ * setting the settings panel never shows and therefore cannot carry
242
+ * `ui.secret`. Read it through `isCredential`, which is the single accessor
243
+ * both the CLI and the settings panel consult.
244
+ */
245
+ interface CredentialMarker {
246
+ credential?: true;
247
+ }
248
+
249
+ interface BooleanDef extends CredentialMarker {
238
250
  type: "boolean";
239
251
  default: boolean | undefined;
240
252
  ui?: UiBoolean;
241
253
  }
242
254
 
243
- interface StringDef {
255
+ interface StringDef extends CredentialMarker {
244
256
  type: "string";
245
257
  default: string | undefined;
246
258
  ui?: UiString;
247
259
  }
248
260
 
249
- interface NumberDef {
261
+ interface NumberDef extends CredentialMarker {
250
262
  type: "number";
251
263
  default: number | undefined;
252
264
  ui?: UiNumber;
253
265
  }
254
266
 
255
- interface EnumDef<T extends readonly string[]> {
267
+ interface EnumDef<T extends readonly string[]> extends CredentialMarker {
256
268
  type: "enum";
257
269
  values: T;
258
270
  default: T[number];
259
271
  ui?: UiEnum<T>;
260
272
  }
261
273
 
262
- interface ArrayDef<T> {
274
+ interface ArrayDef<T> extends CredentialMarker {
263
275
  type: "array";
264
276
  default: T[];
265
277
  ui?: UiArray;
266
278
  }
267
279
 
268
- interface RecordDef<T> {
280
+ interface RecordDef<T> extends CredentialMarker {
269
281
  type: "record";
270
282
  default: Record<string, T>;
271
283
  ui?: UiBase;
@@ -378,7 +390,7 @@ export const SETTINGS_SCHEMA = {
378
390
  // Env (`OMP_AUTH_BROKER_URL` / `OMP_AUTH_BROKER_TOKEN`) takes precedence so
379
391
  // per-machine overrides remain trivial.
380
392
  "auth.broker.url": { type: "string", default: undefined },
381
- "auth.broker.token": { type: "string", default: undefined },
393
+ "auth.broker.token": { type: "string", default: undefined, credential: true },
382
394
 
383
395
  autoResume: {
384
396
  type: "boolean",
@@ -2776,6 +2788,7 @@ export const SETTINGS_SCHEMA = {
2776
2788
  },
2777
2789
  "mnemopi.embeddingApiKey": {
2778
2790
  type: "string",
2791
+ credential: true,
2779
2792
  default: undefined,
2780
2793
  ui: {
2781
2794
  tab: "memory",
@@ -2820,6 +2833,7 @@ export const SETTINGS_SCHEMA = {
2820
2833
  },
2821
2834
  "mnemopi.llmApiKey": {
2822
2835
  type: "string",
2836
+ credential: true,
2823
2837
  default: undefined,
2824
2838
  ui: {
2825
2839
  tab: "memory",
@@ -2862,6 +2876,7 @@ export const SETTINGS_SCHEMA = {
2862
2876
 
2863
2877
  "hindsight.apiToken": {
2864
2878
  type: "string",
2879
+ credential: true,
2865
2880
  default: undefined,
2866
2881
  ui: {
2867
2882
  tab: "memory",
@@ -2869,7 +2884,6 @@ export const SETTINGS_SCHEMA = {
2869
2884
  label: "Hindsight API Token",
2870
2885
  description: "Bearer token for authenticated Hindsight servers",
2871
2886
  condition: "hindsightActive",
2872
- secret: true,
2873
2887
  },
2874
2888
  },
2875
2889
 
@@ -5249,6 +5263,7 @@ export const SETTINGS_SCHEMA = {
5249
5263
  "searxng.token": {
5250
5264
  type: "string",
5251
5265
  default: undefined,
5266
+ credential: true,
5252
5267
  },
5253
5268
 
5254
5269
  "searxng.basicUsername": {
@@ -5259,6 +5274,7 @@ export const SETTINGS_SCHEMA = {
5259
5274
  "searxng.basicPassword": {
5260
5275
  type: "string",
5261
5276
  default: undefined,
5277
+ credential: true,
5262
5278
  },
5263
5279
 
5264
5280
  "searxng.categories": {
@@ -5314,6 +5330,7 @@ export const SETTINGS_SCHEMA = {
5314
5330
  "dev.autoqaPush.token": {
5315
5331
  type: "string",
5316
5332
  default: undefined,
5333
+ credential: true,
5317
5334
  },
5318
5335
 
5319
5336
  /**
@@ -5400,6 +5417,20 @@ export function hasUi(path: SettingPath): boolean {
5400
5417
  return "ui" in SETTINGS_SCHEMA[path];
5401
5418
  }
5402
5419
 
5420
+ /**
5421
+ * Whether a setting holds a credential and must never be printed or exported
5422
+ * without an explicit request. Drives both CLI redaction and settings-panel
5423
+ * masking, so the two cannot disagree.
5424
+ */
5425
+ export function isCredential(path: SettingPath): boolean {
5426
+ const def = SETTINGS_SCHEMA[path];
5427
+ if ("credential" in def && def.credential === true) return true;
5428
+ // `ui.secret` predates this marker and still means "never display". Reading
5429
+ // both here keeps ONE accessor, so the two spellings cannot produce
5430
+ // different behaviour on different surfaces.
5431
+ return getUi(path)?.secret === true;
5432
+ }
5433
+
5403
5434
  /** Get UI metadata for a path (undefined if no UI) */
5404
5435
  export function getUi(path: SettingPath): AnyUiMetadata | undefined {
5405
5436
  const def = SETTINGS_SCHEMA[path];
@@ -321,6 +321,10 @@ export class Settings {
321
321
  #project: RawSettings = {};
322
322
  /** Extra config.yml-style overlays passed by CLI */
323
323
  #configOverlay: RawSettings = {};
324
+ /** Project settings file that most recently supplied shellPath. */
325
+ #projectShellPathSource: string | undefined;
326
+ /** Explicit config overlay that most recently supplied shellPath. */
327
+ #overlayShellPathSource: string | undefined;
324
328
  /** Runtime overrides (not persisted) */
325
329
  #overrides: RawSettings = {};
326
330
  /** Merged view (global + project + overrides) */
@@ -592,8 +596,10 @@ export class Settings {
592
596
  cloned.#configPath = this.#configPath;
593
597
  cloned.#global = structuredClone(this.#global);
594
598
  cloned.#project = this.#persist ? await cloned.#loadProjectSettings() : structuredClone(this.#project);
599
+ if (!this.#persist) cloned.#projectShellPathSource = this.#projectShellPathSource;
595
600
  cloned.#configFiles = [...this.#configFiles];
596
601
  cloned.#configOverlay = structuredClone(this.#configOverlay);
602
+ cloned.#overlayShellPathSource = this.#overlayShellPathSource;
597
603
  cloned.#overrides = this.#buildOriginalOverrides();
598
604
  cloned.#rebuildMerged();
599
605
  cloned.#fireAllHooks();
@@ -652,7 +658,17 @@ export class Settings {
652
658
  */
653
659
  getShellConfig() {
654
660
  const shell = this.get("shellPath");
655
- return procmgr.getShellConfig(shell);
661
+ let configSource = this.#configPath ?? path.join(this.#agentDir, MAIN_CONFIG_FILENAMES[0]);
662
+ if (Object.hasOwn(this.#project, "shellPath")) {
663
+ configSource = this.#projectShellPathSource ?? "the active project configuration";
664
+ }
665
+ if (Object.hasOwn(this.#configOverlay, "shellPath")) {
666
+ configSource = this.#overlayShellPathSource ?? "the active config overlay";
667
+ }
668
+ if (Object.hasOwn(this.#overrides, "shellPath")) {
669
+ configSource = "the runtime settings override";
670
+ }
671
+ return procmgr.getShellConfig(shell, { configSource });
656
672
  }
657
673
 
658
674
  /**
@@ -1089,12 +1105,14 @@ export class Settings {
1089
1105
  }
1090
1106
 
1091
1107
  async #loadProjectSettings(): Promise<RawSettings> {
1108
+ this.#projectShellPathSource = undefined;
1092
1109
  try {
1093
1110
  const result = await loadCapability(settingsCapability.id, { cwd: this.#cwd });
1094
1111
  let merged: RawSettings = {};
1095
1112
  for (const item of result.items as SettingsCapabilityItem[]) {
1096
1113
  if (item.level === "project") {
1097
1114
  merged = this.#deepMerge(merged, item.data as RawSettings);
1115
+ if (Object.hasOwn(item.data, "shellPath")) this.#projectShellPathSource = item.path;
1098
1116
  }
1099
1117
  }
1100
1118
  const nativeProject = await this.#loadYaml(path.join(this.#cwd, ".omp", "config.yml"));
@@ -1104,14 +1122,18 @@ export class Settings {
1104
1122
  }
1105
1123
  return this.#migrateRawSettings(merged);
1106
1124
  } catch {
1125
+ this.#projectShellPathSource = undefined;
1107
1126
  return {};
1108
1127
  }
1109
1128
  }
1110
1129
 
1111
1130
  async #loadConfigOverlays(): Promise<RawSettings> {
1131
+ this.#overlayShellPathSource = undefined;
1112
1132
  let merged: RawSettings = {};
1113
1133
  for (const filePath of this.#configFiles) {
1114
- merged = this.#deepMerge(merged, await this.#loadOverlayYaml(filePath));
1134
+ const overlay = await this.#loadOverlayYaml(filePath);
1135
+ merged = this.#deepMerge(merged, overlay);
1136
+ if (Object.hasOwn(overlay, "shellPath")) this.#overlayShellPathSource = filePath;
1115
1137
  }
1116
1138
  return merged;
1117
1139
  }
package/src/cursor.ts CHANGED
@@ -10,11 +10,16 @@ import type {
10
10
  import type {
11
11
  CursorMcpCall,
12
12
  CursorShellStreamCallbacks,
13
+ CursorTodoSnapshot,
13
14
  CursorExecHandlers as ICursorExecHandlers,
14
15
  ToolResultMessage,
15
16
  } from "@oh-my-pi/pi-ai";
16
17
  import { sanitizeText } from "@oh-my-pi/pi-utils";
17
18
  import { resolveToCwd } from "./tools/path-utils";
19
+ import type { TodoPhase, TodoStatus } from "./tools/todo";
20
+
21
+ /** Phase used for Cursor-owned tasks with no local phase grouping. */
22
+ const CURSOR_TODO_PHASE = "Tasks";
18
23
 
19
24
  interface CursorExecBridgeOptions {
20
25
  cwd: string;
@@ -32,6 +37,18 @@ interface CursorExecBridgeOptions {
32
37
  * callers with a restricted tool set (advisors) opt out.
33
38
  */
34
39
  allowNativeDelete?: boolean;
40
+ /**
41
+ * Mirror Cursor's server-owned todo list into local session state. Cursor
42
+ * resolves `update_todos` / `read_todos` remotely, so without this bridge
43
+ * the provider's list and the local `todo` state diverge silently.
44
+ */
45
+ setTodoPhases?: (phases: TodoPhase[]) => void;
46
+ getTodoPhases?: () => TodoPhase[];
47
+ /**
48
+ * Persist the mirrored list to the session branch so it survives reloads.
49
+ * Cursor emits no local `todo` toolResult, so nothing else records it.
50
+ */
51
+ persistTodoPhases?: (phases: TodoPhase[]) => void;
35
52
  }
36
53
 
37
54
  function createToolResultMessage(
@@ -177,6 +194,49 @@ function formatMcpToolErrorMessage(toolName: string, availableTools: string[]):
177
194
  return `MCP tool "${toolName}" not found. Available tools: ${list}`;
178
195
  }
179
196
 
197
+ /**
198
+ * One-line summary for the synthesized todo result. Cursor's server-resolved
199
+ * call produces no local tool output, but the transcript entry still needs
200
+ * text content alongside the phases the UI renders.
201
+ */
202
+ function formatTodoSyncSummary(phases: TodoPhase[]): string {
203
+ const tasks = phases.flatMap(phase => phase.tasks);
204
+ if (tasks.length === 0) return "No todos";
205
+ const done = tasks.filter(task => task.status === "completed").length;
206
+ return `${done}/${tasks.length} tasks completed`;
207
+ }
208
+
209
+ /**
210
+ * Persisted result for a server-resolved todo call.
211
+ *
212
+ * `details` is only attached for an authoritative snapshot, and then
213
+ * `details.phases` is load-bearing rather than decoration: `todoToolRenderer`
214
+ * rebuilds the rendered list exclusively from it, so a mirrored update that
215
+ * omitted it would replay as `Todo 0 tasks` after a reload.
216
+ *
217
+ * A refusal or a server error carries no `details`. Echoing the current phases
218
+ * there would replay a call that changed nothing as if it had re-asserted the
219
+ * whole list — and `event-controller` feeds `details.phases` straight into
220
+ * `setTodos`, so a refused `read_todos` would overwrite live UI state.
221
+ */
222
+ function buildTodoSyncResult(
223
+ toolCallId: string,
224
+ phases: TodoPhase[] | undefined,
225
+ error: string | null,
226
+ ): ToolResultMessage {
227
+ return {
228
+ role: "toolResult",
229
+ toolCallId,
230
+ toolName: "todo",
231
+ content: [
232
+ { type: "text", text: error ?? (phases ? formatTodoSyncSummary(phases) : "Todo snapshot not mirrored") },
233
+ ],
234
+ details: phases ? { phases, storage: "session" } : undefined,
235
+ isError: error !== null,
236
+ timestamp: Date.now(),
237
+ };
238
+ }
239
+
180
240
  export class CursorExecHandlers implements ICursorExecHandlers {
181
241
  constructor(private options: CursorExecBridgeOptions) {}
182
242
 
@@ -341,6 +401,99 @@ export class CursorExecHandlers implements ICursorExecHandlers {
341
401
  return toolResultMessage;
342
402
  }
343
403
 
404
+ /**
405
+ * Settle a completed native Cursor todo call, mirroring its list when the
406
+ * server supplied an authoritative one.
407
+ *
408
+ * Cursor's snapshot is a flat list, so tasks already known locally keep
409
+ * their phase and only their status is updated; unknown tasks land in a
410
+ * single fallback phase. Statuses come straight from the server snapshot —
411
+ * no local normalization, or an all-pending remote list would gain a
412
+ * phantom in-progress task the remote list does not have.
413
+ *
414
+ * The snapshot is also persisted to the session branch. Every other
415
+ * provider's todo state survives a reload because `todo` runs locally and
416
+ * its `toolResult` (carrying `details.phases`) lands in the branch, which
417
+ * `#syncTodoPhasesFromBranch` replays. Cursor resolves the tool remotely and
418
+ * emits no such result, so without an explicit entry the list is in-memory
419
+ * only and every reload, rewind, compaction, or session switch drops it.
420
+ *
421
+ * This ALWAYS settles the call and returns the result to persist, even when
422
+ * nothing is mirrored. Two reasons it cannot bail out early:
423
+ *
424
+ * - the interactive card leaves `pendingTools` only on a matching
425
+ * `tool_execution_end`, so staying silent leaves it animating forever;
426
+ * - an unpaired `toolCall` is stripped as dangling by `buildSessionContext`,
427
+ * erasing the interaction from every rebuilt transcript.
428
+ *
429
+ * A `null` snapshot means nothing may be mirrored — a server `error`, or a
430
+ * benign refusal: a filtered, truncated, or empty read, or a snapshot the
431
+ * local model cannot represent. Local state is left untouched, and the result
432
+ * carries no `details` (text `"Todo snapshot not mirrored"`): `event-controller`
433
+ * feeds `details.phases` straight into `setTodos`, so echoing the current list
434
+ * back would let a call that changed nothing overwrite live UI state.
435
+ */
436
+ todoSync(snapshot: CursorTodoSnapshot | null, toolCallId: string, error: string | null = null): ToolResultMessage {
437
+ const setPhases = this.options.setTodoPhases;
438
+ const existing = this.options.getTodoPhases?.() ?? [];
439
+
440
+ // Mirroring is gated on having both a snapshot and somewhere to put it.
441
+ // Settling the call is NOT: the interactive card leaves `pendingTools`
442
+ // only on a matching `tool_execution_end`, so a refusal, a server error,
443
+ // or a host with no local todo state must still resolve it.
444
+ let phases: TodoPhase[] | undefined;
445
+ if (snapshot && setPhases) {
446
+ const phaseByContent = new Map<string, string>();
447
+ for (const phase of existing) {
448
+ for (const task of phase.tasks) phaseByContent.set(task.content, phase.name);
449
+ }
450
+
451
+ const grouped = new Map<string, TodoPhase["tasks"]>();
452
+ for (const todo of snapshot.todos) {
453
+ const name = phaseByContent.get(todo.content) ?? CURSOR_TODO_PHASE;
454
+ let tasks = grouped.get(name);
455
+ if (!tasks) {
456
+ tasks = [];
457
+ grouped.set(name, tasks);
458
+ }
459
+ tasks.push({ content: todo.content, status: todo.status as TodoStatus });
460
+ }
461
+
462
+ // Preserve the local phase order; phases new to this snapshot append.
463
+ const next: TodoPhase[] = [];
464
+ for (const phase of existing) {
465
+ const tasks = grouped.get(phase.name);
466
+ if (!tasks) continue;
467
+ next.push({ name: phase.name, tasks });
468
+ grouped.delete(phase.name);
469
+ }
470
+ for (const [name, tasks] of grouped) next.push({ name, tasks });
471
+ setPhases(next);
472
+ this.options.persistTodoPhases?.(next);
473
+ phases = next;
474
+ }
475
+
476
+ const result = buildTodoSyncResult(toolCallId, phases, error);
477
+ // This completion is emitted synchronously mid-parse, while the streamed
478
+ // `toolcall_start` that creates the visible card rides
479
+ // `AssistantMessageEventStream` and lands a microtask later. When Cursor
480
+ // packs start and completion into one HTTP/2 chunk the completion arrives
481
+ // first; the interactive controller holds it as an orphan and replays it
482
+ // once the streamed block creates the card (`event-controller.ts`,
483
+ // `#orphanedToolCompletions`). Emitting a synthetic `tool_execution_start`
484
+ // here instead was measured and rejected: settling deletes the pending
485
+ // entry, and the next cumulative `message_update` re-creates the card —
486
+ // one settled card plus one stuck forever.
487
+ this.options.emitEvent?.({
488
+ type: "tool_execution_end",
489
+ toolCallId,
490
+ toolName: "todo",
491
+ result: { content: result.content, details: result.details },
492
+ isError: error !== null,
493
+ });
494
+ return result;
495
+ }
496
+
344
497
  async mcp(call: CursorMcpCall) {
345
498
  const toolName = call.toolName || call.name;
346
499
  const toolCallId = decodeToolCallId(call.toolCallId);
@@ -960,16 +960,50 @@ export class DapSessionManager {
960
960
  signal?: AbortSignal,
961
961
  timeoutMs: number = 30_000,
962
962
  ): Promise<{ snapshot: DapSessionSummary; threads: DapThread[] }> {
963
- const session = this.#touchActiveSession();
964
- const response = await this.#sendRequestWithConfig<DapThreadsResponse>(
965
- session,
966
- "threads",
967
- undefined,
968
- signal,
969
- timeoutMs,
970
- );
971
- session.threads = response?.threads ?? [];
972
- return { snapshot: buildSummary(session), threads: session.threads };
963
+ const anchor = this.#touchActiveSession();
964
+ // A js-debug launch is a session tree: the root may be a threadless
965
+ // launcher while each real thread lives in a child (main script,
966
+ // `[worker N]`, …), and other adapters keep every thread on the root.
967
+ // Querying only the active session would surface just one session's
968
+ // threads, so aggregate across the whole live tree. No topology guess:
969
+ // a threadless launcher simply returns no threads (or an error we skip).
970
+ const targets = this.#liveTreeSessions(anchor);
971
+ const merged: DapThread[] = [];
972
+ const seen = new Set<string>();
973
+ for (const target of targets) {
974
+ let threads: DapThread[];
975
+ try {
976
+ const response = await this.#sendRequestWithConfig<DapThreadsResponse>(
977
+ target,
978
+ "threads",
979
+ undefined,
980
+ signal,
981
+ timeoutMs,
982
+ );
983
+ threads = response?.threads ?? [];
984
+ } catch (error) {
985
+ // Caller cancellation is not an adapter failure: propagate it instead
986
+ // of degrading a cancelled call into a successful partial result.
987
+ if (signal?.aborted) throw error;
988
+ logger.warn("Failed to list threads for debug session", {
989
+ sessionId: target.id,
990
+ error: toErrorMessage(error),
991
+ });
992
+ continue;
993
+ }
994
+ target.threads = threads;
995
+ // DAP thread IDs are scoped per client session, so identical IDs from
996
+ // different sessions (e.g. two identical worker scripts) are distinct
997
+ // live threads and MUST be preserved; only collapse an exact repeat
998
+ // within a single session's response.
999
+ for (const thread of threads) {
1000
+ const key = `${target.id}\0${thread.id}`;
1001
+ if (seen.has(key)) continue;
1002
+ seen.add(key);
1003
+ merged.push(thread);
1004
+ }
1005
+ }
1006
+ return { snapshot: buildSummary(anchor), threads: merged };
973
1007
  }
974
1008
 
975
1009
  async stackTrace(
@@ -1371,7 +1405,13 @@ export class DapSessionManager {
1371
1405
  if (parentSessionId) {
1372
1406
  this.#sessions.get(parentSessionId)?.childSessionIds.add(session.id);
1373
1407
  }
1374
- this.#activeSessionId = session.id;
1408
+ // Focus follows stops, not registrations: a lazily-attached child (e.g. a
1409
+ // js-debug `[worker N]` session) must not steal focus from a sibling that
1410
+ // is already stopped at a breakpoint / entry. Only claim focus when no
1411
+ // live, stopped session currently holds it.
1412
+ if (!this.#hasLiveStoppedActiveSession()) {
1413
+ this.#activeSessionId = session.id;
1414
+ }
1375
1415
  const heartbeat = setInterval(() => {
1376
1416
  if (!client.isAlive()) {
1377
1417
  session.status = "terminated";
@@ -1696,6 +1736,12 @@ export class DapSessionManager {
1696
1736
  return session;
1697
1737
  }
1698
1738
 
1739
+ /** True when the current active session is live and paused at a stop. */
1740
+ #hasLiveStoppedActiveSession(): boolean {
1741
+ const active = this.#getActiveSessionOrNull();
1742
+ return active !== null && active.status === "stopped" && active.client.isAlive();
1743
+ }
1744
+
1699
1745
  #getActiveSessionOrThrow(): DapSession {
1700
1746
  const session = this.#getActiveSessionOrNull();
1701
1747
  if (!session) {
@@ -1729,6 +1775,19 @@ export class DapSessionManager {
1729
1775
  return sessions;
1730
1776
  }
1731
1777
 
1778
+ /**
1779
+ * Live (non-terminated, connected) sessions in `session`'s tree, or the
1780
+ * session itself when the tree has collapsed. Used to fan `threads` out
1781
+ * across the whole tree; a threadless session just reports no threads, so
1782
+ * this makes no assumption about which node owns them.
1783
+ */
1784
+ #liveTreeSessions(session: DapSession): DapSession[] {
1785
+ const live = this.#getTreeSessions(session).filter(
1786
+ candidate => candidate.status !== "terminated" && candidate.client.isAlive(),
1787
+ );
1788
+ return live.length > 0 ? live : [session];
1789
+ }
1790
+
1732
1791
  #touchSessionAndAncestors(session: DapSession): void {
1733
1792
  const now = Date.now();
1734
1793
  let current: DapSession | undefined = session;
@@ -121,7 +121,7 @@ export class HashlineFilesystem extends Filesystem {
121
121
  throw error;
122
122
  }
123
123
  // Refuse edits against generated files (lockfiles, models.json, …).
124
- assertEditableFileContent(content, relativePath);
124
+ assertEditableFileContent(content, relativePath, this.session.settings);
125
125
  return content;
126
126
  }
127
127
 
@@ -1816,7 +1816,7 @@ export async function executePatchSingle(
1816
1816
  const resolvedPath = resolvePlanPath(session, path);
1817
1817
  const resolvedRename = rename ? resolvePlanPath(session, rename) : undefined;
1818
1818
 
1819
- await assertEditableFile(resolvedPath, path);
1819
+ await assertEditableFile(resolvedPath, path, session.settings);
1820
1820
 
1821
1821
  // Capture pre-edit content so we can verify the write actually hit disk.
1822
1822
  // `LspFileSystem.writeFile` delegates to a writethrough callback that, in
@@ -426,10 +426,18 @@ describe.skipIf(process.platform === "win32")("JavaScript eval process isolation
426
426
  expect(result.output.trim()).toBe("42");
427
427
  });
428
428
 
429
- it("keeps the isolated process alive after a stackless floated rejection", async () => {
429
+ it("keeps the isolated process alive after handled and stackless floated rejections", async () => {
430
430
  using tempDir = TempDir.createSync("@omp-js-process-rejection-");
431
431
  const session = makeSession(tempDir.path());
432
432
  const evalSessionId = `js-rejection:${crypto.randomUUID()}`;
433
+ const handled = await executeJs('await Promise.reject("handled rejection").catch(() => undefined); return 42;', {
434
+ cwd: tempDir.path(),
435
+ sessionId: evalSessionId,
436
+ session,
437
+ });
438
+ expect(handled.exitCode).toBe(0);
439
+ expect(handled.output.trim()).toBe("42");
440
+
433
441
  const rejected = await executeJs(
434
442
  'var savedAfterRejection = 41; Promise.reject("stackless rejection"); await Bun.sleep(10);',
435
443
  { cwd: tempDir.path(), sessionId: evalSessionId, session },
@@ -1,8 +1,9 @@
1
1
  import { expect, it } from "bun:test";
2
+ import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
4
  import { TempDir } from "@oh-my-pi/pi-utils";
4
5
 
5
- it("imports the JS process entry without loading dotenv before profile bootstrap", async () => {
6
+ it("imports the CLI entry graph without loading dotenv before profile bootstrap", async () => {
6
7
  using tempDir = TempDir.createSync("@omp-js-process-import-");
7
8
  await Bun.write(path.join(tempDir.path(), ".env"), "OMP_PROCESS_ENTRY_ENV_PROBE=loaded-too-early\n");
8
9
  const env = Object.fromEntries(
@@ -25,3 +26,112 @@ it("imports the JS process entry without loading dotenv before profile bootstrap
25
26
  expect(stdout).toBe("");
26
27
  expect(stderr).toBe("");
27
28
  });
29
+
30
+ async function pingComputerWorker(
31
+ entry: string,
32
+ id: string,
33
+ argv: string[] = ["__omp_worker_computer"],
34
+ ): Promise<unknown> {
35
+ const worker = new Worker(entry, {
36
+ type: "module",
37
+ argv,
38
+ });
39
+ const response = Promise.withResolvers<unknown>();
40
+ worker.addEventListener("message", event => response.resolve(event.data));
41
+ worker.addEventListener("error", event => response.reject(event.error ?? new Error(event.message)));
42
+ worker.postMessage({ type: "ping", id });
43
+ try {
44
+ return await response.promise;
45
+ } finally {
46
+ worker.terminate();
47
+ }
48
+ }
49
+
50
+ it("starts ordinary CLI paths without loading the native computer addon", async () => {
51
+ const cliPath = path.resolve(import.meta.dir, "../../cli.ts");
52
+ for (const args of [
53
+ ["--no-addons", cliPath, "--version"],
54
+ [cliPath, "--help"],
55
+ ]) {
56
+ const proc = Bun.spawn([process.execPath, ...args], {
57
+ stdout: "pipe",
58
+ stderr: "pipe",
59
+ });
60
+ const [exitCode, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
61
+ expect(exitCode, `${args.at(-1)}: ${stderr}`).toBe(0);
62
+ }
63
+ });
64
+
65
+ it("dispatches the computer worker through the CLI host selector in a child process", async () => {
66
+ const fixture = path.resolve(import.meta.dir, "../../../test/fixtures/computer-worker-cli-selector.ts");
67
+ const proc = Bun.spawn([process.execPath, "--no-addons", fixture], {
68
+ stdout: "pipe",
69
+ stderr: "pipe",
70
+ });
71
+ const [exitCode, stdout, stderr] = await Promise.all([
72
+ proc.exited,
73
+ new Response(proc.stdout).text(),
74
+ new Response(proc.stderr).text(),
75
+ ]);
76
+ expect(exitCode, stderr).toBe(0);
77
+ expect(stdout).toBe('{"type":"pong","id":"computer-cli-selector"}\n');
78
+ });
79
+
80
+ it("loads the computer worker module directly outside a declared CLI host", async () => {
81
+ const entry = new URL("../../tools/computer/worker-entry.ts", import.meta.url).href;
82
+ const response = await pingComputerWorker(entry, "computer-direct-module", []);
83
+ expect(response).toEqual({ type: "pong", id: "computer-direct-module" });
84
+ });
85
+
86
+ it("dispatches the computer worker from a single npm-style host bundle", async () => {
87
+ const packageDir = path.resolve(import.meta.dir, "../../..");
88
+ const outDir = fs.mkdtempSync(path.join(packageDir, ".computer-worker-bundle-"));
89
+ try {
90
+ const output = await Bun.build({
91
+ entrypoints: [path.join(packageDir, "test/fixtures/computer-worker-bundled-host.ts")],
92
+ outdir: outDir,
93
+ naming: "cli.js",
94
+ target: "bun",
95
+ external: ["@oh-my-pi/pi-natives"],
96
+ define: { "process.env.PI_BUNDLED": JSON.stringify("true") },
97
+ throw: false,
98
+ });
99
+ expect(output.logs).toEqual([]);
100
+ expect(output.outputs.map(file => path.basename(file.path))).toEqual(["cli.js"]);
101
+ const response = await pingComputerWorker(output.outputs[0]!.path, "computer-npm-bundle");
102
+ expect(response).toEqual({ type: "pong", id: "computer-npm-bundle" });
103
+ } finally {
104
+ fs.rmSync(outDir, { recursive: true, force: true });
105
+ }
106
+ });
107
+
108
+ it("keeps non-computer selectors isolated in a compiled single-entry worker host", async () => {
109
+ using tempDir = TempDir.createSync("@omp-compiled-worker-selector-");
110
+ const packageDir = path.resolve(import.meta.dir, "../../..");
111
+ const outfile = path.join(tempDir.path(), process.platform === "win32" ? "worker-host.exe" : "worker-host");
112
+ const build = Bun.spawn(
113
+ [
114
+ process.execPath,
115
+ "build",
116
+ "--compile",
117
+ "--target=bun",
118
+ `--outfile=${outfile}`,
119
+ path.join(packageDir, "test/fixtures/compiled-worker-selector-host.ts"),
120
+ ],
121
+ { cwd: packageDir, stdout: "pipe", stderr: "pipe" },
122
+ );
123
+ const [buildExitCode, buildStderr] = await Promise.all([build.exited, new Response(build.stderr).text()]);
124
+ expect(buildExitCode, buildStderr).toBe(0);
125
+ const proc = Bun.spawn([outfile], {
126
+ cwd: packageDir,
127
+ stdout: "pipe",
128
+ stderr: "pipe",
129
+ });
130
+ const [exitCode, stdout, stderr] = await Promise.all([
131
+ proc.exited,
132
+ new Response(proc.stdout).text(),
133
+ new Response(proc.stderr).text(),
134
+ ]);
135
+ expect(exitCode, stderr).toBe(0);
136
+ expect(stdout).toBe('{"ok":true,"kind":"pong"}\n');
137
+ });