@oh-my-pi/pi-coding-agent 17.0.1 → 17.0.3

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 (179) hide show
  1. package/CHANGELOG.md +162 -40
  2. package/dist/cli.js +4559 -4543
  3. package/dist/types/advisor/config.d.ts +14 -6
  4. package/dist/types/advisor/runtime.d.ts +20 -11
  5. package/dist/types/autolearn/controller.d.ts +1 -0
  6. package/dist/types/config/model-discovery.d.ts +17 -0
  7. package/dist/types/config/model-resolver.d.ts +6 -2
  8. package/dist/types/config/settings-schema.d.ts +39 -10
  9. package/dist/types/config/settings.d.ts +50 -0
  10. package/dist/types/cursor.d.ts +11 -0
  11. package/dist/types/discovery/helpers.d.ts +5 -2
  12. package/dist/types/eval/agent-bridge.d.ts +7 -19
  13. package/dist/types/exec/bash-executor.d.ts +2 -0
  14. package/dist/types/extensibility/extensions/managed-timers.d.ts +15 -0
  15. package/dist/types/extensibility/extensions/runner.d.ts +7 -0
  16. package/dist/types/extensibility/extensions/types.d.ts +18 -0
  17. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +55 -1
  18. package/dist/types/extensibility/shared-events.d.ts +6 -0
  19. package/dist/types/extensibility/utils.d.ts +2 -2
  20. package/dist/types/lsp/client.d.ts +2 -0
  21. package/dist/types/lsp/types.d.ts +3 -0
  22. package/dist/types/mcp/transports/stdio.d.ts +42 -1
  23. package/dist/types/modes/components/advisor-config.d.ts +8 -1
  24. package/dist/types/modes/components/agent-hub.d.ts +15 -0
  25. package/dist/types/modes/components/hook-editor.d.ts +7 -0
  26. package/dist/types/modes/components/model-hub.d.ts +5 -2
  27. package/dist/types/modes/components/session-selector.d.ts +2 -0
  28. package/dist/types/modes/controllers/command-controller.d.ts +8 -0
  29. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  30. package/dist/types/modes/print-mode.d.ts +1 -1
  31. package/dist/types/modes/warp-events.d.ts +24 -0
  32. package/dist/types/modes/warp-events.test.d.ts +1 -0
  33. package/dist/types/plan-mode/model-transition.d.ts +47 -0
  34. package/dist/types/plan-mode/model-transition.test.d.ts +1 -0
  35. package/dist/types/registry/agent-lifecycle.d.ts +26 -1
  36. package/dist/types/registry/persisted-agents.d.ts +3 -0
  37. package/dist/types/sdk.d.ts +27 -5
  38. package/dist/types/session/agent-session.d.ts +34 -10
  39. package/dist/types/session/session-entries.d.ts +6 -1
  40. package/dist/types/session/session-history-format.d.ts +10 -0
  41. package/dist/types/session/session-manager.d.ts +19 -0
  42. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +9 -0
  43. package/dist/types/task/executor.d.ts +26 -1
  44. package/dist/types/task/index.d.ts +1 -1
  45. package/dist/types/task/label.d.ts +1 -1
  46. package/dist/types/task/parallel.d.ts +14 -0
  47. package/dist/types/task/structured-subagent.d.ts +111 -0
  48. package/dist/types/task/types.d.ts +51 -0
  49. package/dist/types/telemetry-export.d.ts +34 -9
  50. package/dist/types/tools/approval.d.ts +8 -0
  51. package/dist/types/tools/bash.d.ts +2 -0
  52. package/dist/types/tools/essential-tools.d.ts +29 -0
  53. package/dist/types/tools/fetch.d.ts +4 -5
  54. package/dist/types/tools/hub/messaging.d.ts +5 -1
  55. package/dist/types/tools/image-gen.d.ts +2 -1
  56. package/dist/types/tools/index.d.ts +17 -1
  57. package/dist/types/tools/todo.d.ts +31 -0
  58. package/dist/types/tools/xdev.d.ts +11 -2
  59. package/dist/types/tui/tree-list.d.ts +7 -0
  60. package/dist/types/utils/markit.d.ts +11 -0
  61. package/dist/types/utils/title-generator.d.ts +2 -1
  62. package/dist/types/web/search/providers/kimi.d.ts +4 -1
  63. package/dist/types/web/search/types.d.ts +1 -1
  64. package/package.json +21 -16
  65. package/src/advisor/__tests__/advisor.test.ts +1304 -42
  66. package/src/advisor/__tests__/config.test.ts +58 -2
  67. package/src/advisor/config.ts +76 -24
  68. package/src/advisor/runtime.ts +445 -92
  69. package/src/autolearn/controller.ts +23 -28
  70. package/src/cli/file-processor.ts +1 -2
  71. package/src/cli.ts +5 -1
  72. package/src/config/model-discovery.ts +81 -21
  73. package/src/config/model-registry.ts +25 -6
  74. package/src/config/model-resolver.ts +14 -7
  75. package/src/config/settings-schema.ts +46 -9
  76. package/src/config/settings.ts +405 -25
  77. package/src/cursor.ts +20 -3
  78. package/src/debug/report-bundle.ts +40 -4
  79. package/src/discovery/helpers.ts +28 -5
  80. package/src/eval/__tests__/agent-bridge.test.ts +133 -47
  81. package/src/eval/__tests__/prelude-agent.test.ts +29 -0
  82. package/src/eval/agent-bridge.ts +104 -477
  83. package/src/eval/jl/prelude.jl +7 -6
  84. package/src/eval/js/shared/prelude.txt +5 -4
  85. package/src/eval/py/prelude.py +11 -39
  86. package/src/eval/rb/prelude.rb +5 -6
  87. package/src/exec/bash-executor.ts +14 -5
  88. package/src/extensibility/custom-tools/loader.ts +3 -3
  89. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  90. package/src/extensibility/extensions/loader.ts +3 -3
  91. package/src/extensibility/extensions/managed-timers.ts +83 -0
  92. package/src/extensibility/extensions/runner.ts +26 -0
  93. package/src/extensibility/extensions/types.ts +18 -0
  94. package/src/extensibility/extensions/wrapper.ts +2 -1
  95. package/src/extensibility/hooks/loader.ts +3 -3
  96. package/src/extensibility/legacy-pi-coding-agent-shim.ts +149 -8
  97. package/src/extensibility/plugins/legacy-pi-compat.ts +225 -22
  98. package/src/extensibility/plugins/manager.ts +2 -2
  99. package/src/extensibility/shared-events.ts +6 -0
  100. package/src/extensibility/utils.ts +91 -25
  101. package/src/irc/bus.ts +22 -3
  102. package/src/launch/broker.ts +3 -2
  103. package/src/launch/client.ts +2 -2
  104. package/src/launch/presence.ts +2 -2
  105. package/src/lsp/client.ts +58 -1
  106. package/src/lsp/index.ts +62 -6
  107. package/src/lsp/types.ts +3 -0
  108. package/src/main.ts +11 -8
  109. package/src/mcp/manager.ts +9 -3
  110. package/src/mcp/oauth-flow.ts +20 -0
  111. package/src/mcp/transports/stdio.test.ts +269 -1
  112. package/src/mcp/transports/stdio.ts +255 -24
  113. package/src/modes/components/advisor-config.ts +65 -3
  114. package/src/modes/components/agent-hub.ts +1 -72
  115. package/src/modes/components/ask-dialog.ts +1 -1
  116. package/src/modes/components/bash-execution.ts +7 -3
  117. package/src/modes/components/eval-execution.ts +3 -1
  118. package/src/modes/components/hook-editor.ts +18 -3
  119. package/src/modes/components/model-hub.ts +138 -42
  120. package/src/modes/components/session-selector.ts +4 -0
  121. package/src/modes/components/status-line/component.test.ts +1 -0
  122. package/src/modes/components/status-line/segments.ts +21 -6
  123. package/src/modes/controllers/command-controller.ts +167 -47
  124. package/src/modes/controllers/event-controller.ts +5 -0
  125. package/src/modes/controllers/extension-ui-controller.ts +4 -22
  126. package/src/modes/controllers/input-controller.ts +12 -12
  127. package/src/modes/controllers/selector-controller.ts +191 -31
  128. package/src/modes/interactive-mode.ts +169 -62
  129. package/src/modes/print-mode.ts +3 -3
  130. package/src/modes/rpc/host-tools.ts +2 -1
  131. package/src/modes/rpc/rpc-mode.ts +19 -4
  132. package/src/modes/warp-events.test.ts +794 -0
  133. package/src/modes/warp-events.ts +232 -0
  134. package/src/plan-mode/model-transition.test.ts +60 -0
  135. package/src/plan-mode/model-transition.ts +51 -0
  136. package/src/prompts/system/system-prompt.md +1 -1
  137. package/src/prompts/tools/eval.md +5 -2
  138. package/src/prompts/tools/read.md +1 -1
  139. package/src/prompts/tools/task.md +12 -8
  140. package/src/prompts/tools/write.md +1 -1
  141. package/src/registry/agent-lifecycle.ts +133 -18
  142. package/src/registry/persisted-agents.ts +74 -0
  143. package/src/sdk.ts +343 -122
  144. package/src/session/agent-session.ts +1359 -368
  145. package/src/session/session-entries.ts +6 -1
  146. package/src/session/session-history-format.ts +20 -5
  147. package/src/session/session-manager.ts +57 -0
  148. package/src/session/streaming-output.ts +41 -1
  149. package/src/slash-commands/builtin-registry.ts +7 -0
  150. package/src/slash-commands/helpers/active-oauth-account.ts +16 -0
  151. package/src/system-prompt.ts +7 -2
  152. package/src/task/executor.ts +100 -22
  153. package/src/task/index.ts +258 -429
  154. package/src/task/label.ts +2 -0
  155. package/src/task/parallel.ts +43 -0
  156. package/src/task/persisted-revive.ts +19 -7
  157. package/src/task/structured-subagent.ts +642 -0
  158. package/src/task/types.ts +58 -0
  159. package/src/telemetry-export.ts +453 -97
  160. package/src/tools/__tests__/eval-description.test.ts +1 -1
  161. package/src/tools/approval.ts +11 -0
  162. package/src/tools/bash.ts +71 -38
  163. package/src/tools/essential-tools.ts +45 -0
  164. package/src/tools/fetch.ts +28 -105
  165. package/src/tools/gh.ts +169 -2
  166. package/src/tools/hub/messaging.ts +16 -3
  167. package/src/tools/image-gen.ts +69 -7
  168. package/src/tools/index.ts +50 -15
  169. package/src/tools/path-utils.ts +1 -0
  170. package/src/tools/read.ts +62 -29
  171. package/src/tools/todo.ts +126 -13
  172. package/src/tools/write.ts +22 -4
  173. package/src/tools/xdev.ts +14 -3
  174. package/src/tui/tree-list.ts +39 -0
  175. package/src/utils/markit.ts +12 -0
  176. package/src/utils/title-generator.ts +15 -4
  177. package/src/utils/zip.ts +16 -1
  178. package/src/web/search/providers/kimi.ts +18 -12
  179. package/src/web/search/types.ts +6 -1
@@ -519,7 +519,7 @@ function completion(prompt::String; model="default", system=nothing, schema=noth
519
519
  return schema === nothing ? text : Main.json_parse(string(text))
520
520
  end
521
521
 
522
- function agent(prompt::String; agent="task", model=nothing, label=nothing, schema=nothing, isolated=nothing, apply=nothing, merge=nothing, handle=false, kwargs...)
522
+ function agent(prompt::String; agent="task", model=nothing, label=nothing, schema=nothing, schema_mode=nothing, isolated=nothing, apply=nothing, merge=nothing, handle=false, kwargs...)
523
523
  args_dict = Dict{String, Any}("prompt" => prompt)
524
524
  if agent !== nothing
525
525
  args_dict["agent"] = agent
@@ -533,8 +533,9 @@ function agent(prompt::String; agent="task", model=nothing, label=nothing, schem
533
533
  if schema !== nothing
534
534
  args_dict["schema"] = schema
535
535
  end
536
- # Isolation knobs mirror the `task` tool: strict opt-in via `isolated`,
537
- # with `apply`/`merge` controlling the post-run patch/branch merge.
536
+ if schema_mode !== nothing
537
+ args_dict["schemaMode"] = schema_mode
538
+ end
538
539
  if isolated !== nothing
539
540
  args_dict["isolated"] = Bool(isolated)
540
541
  end
@@ -548,13 +549,13 @@ function agent(prompt::String; agent="task", model=nothing, label=nothing, schem
548
549
  for (k, v) in kwargs
549
550
  args_dict[string(k)] = v
550
551
  end
551
- # Tell the bridge a handle is wanted so it preserves the backing artifacts.
552
552
  if handle_result
553
553
  args_dict["handle"] = true
554
554
  end
555
555
  res = __omp_call_bridge("__agent__", args_dict)
556
556
  text = res isa AbstractDict ? get(res, "text", res) : res
557
- parsed = schema === nothing ? text : Main.json_parse(string(text))
557
+ has_data = res isa AbstractDict && haskey(res, "data")
558
+ parsed = has_data ? res["data"] : (schema === nothing ? text : Main.json_parse(string(text)))
558
559
  if !handle_result
559
560
  return parsed
560
561
  end
@@ -569,7 +570,7 @@ function agent(prompt::String; agent="task", model=nothing, label=nothing, schem
569
570
  "id" => get(details, "id", nothing),
570
571
  "agent" => get(details, "agent", nothing)
571
572
  )
572
- if schema !== nothing
573
+ if has_data || schema !== nothing
573
574
  node["data"] = parsed
574
575
  end
575
576
  for (src_key, dst_key) in (
@@ -104,20 +104,21 @@ if (!globalThis.__omp_js_prelude_loaded__) {
104
104
  "agent",
105
105
  opts,
106
106
  rest,
107
- ["agent", "model", "label", "schema", "isolated", "apply", "merge"],
108
- "{ agent, model, label, schema, isolated, apply, merge, handle }",
107
+ ["agent", "model", "label", "schema", "isolated", "apply", "merge", "schemaMode"],
108
+ "{ agent, model, label, schema, isolated, apply, merge, schemaMode, handle }",
109
109
  );
110
110
  const { handle, ...callArgs } = o;
111
111
  const res = await globalThis.__omp_call_tool__("__agent__", { prompt, ...callArgs, handle: Boolean(handle) });
112
112
  const text = res && typeof res === "object" ? res.text : res;
113
- const parsed = hasOwn(callArgs, "schema") ? JSON.parse(text) : text;
113
+ const hasData = res && typeof res === "object" && hasOwn(res, "data");
114
+ const parsed = hasData ? res.data : hasOwn(callArgs, "schema") ? JSON.parse(text) : text;
114
115
  if (!handle) return parsed;
115
116
  const details = res && typeof res === "object" ? res.details : undefined;
116
117
  if (!details || typeof details !== "object" || details.id == null) {
117
118
  return { text, output: text, handle: null, id: null, agent: null };
118
119
  }
119
120
  const node = { text, output: text, handle: `agent://${details.id}`, id: details.id, agent: details.agent ?? null };
120
- if (hasOwn(callArgs, "schema")) node.data = parsed;
121
+ if (hasData || hasOwn(callArgs, "schema")) node.data = parsed;
121
122
  for (const key of ["isolated", "patchPath", "branchName", "nestedPatches", "changesApplied", "isolationSummary"]) {
122
123
  if (details[key] !== undefined) node[key] = details[key];
123
124
  }
@@ -487,48 +487,17 @@ if "__omp_prelude_loaded__" not in globals():
487
487
  model=None,
488
488
  label=None,
489
489
  schema=None,
490
+ schema_mode=None,
490
491
  isolated=None,
491
492
  apply=None,
492
493
  merge=None,
493
494
  handle=False,
494
495
  ):
495
- """Run a subagent and return its final output.
496
-
497
- `agent` selects the subagent definition (default "task"). Pass
498
- `model` to override that agent's model, `label` for the output artifact
499
- id, and `schema` to request structured JSON output; when `schema` is
500
- supplied the parsed object is returned. Share background by writing a
501
- local:// file and referencing it in the prompt.
502
-
503
- Pass `isolated=True` to run the subagent inside an isolation worktree
504
- (copy-on-write of the parent repo) so parallel `agent()` spawns can
505
- edit overlapping files safely. Strict opt-in, mirroring the `task`
506
- tool: the default is non-isolated regardless of `task.isolation.mode`.
507
- `isolated=True` while the setting is `"none"` errors out instead of
508
- silently downgrading.
509
-
510
- When isolated, `apply=False` keeps captured changes inside the
511
- worktree and surfaces the root patch path, branch name, and nested
512
- repository patches through the DAG node dict (combine with
513
- `handle=True` to receive them — see below; the bare return type
514
- stays bytes/string/parsed object and has nowhere to expose artifacts).
515
- `merge=False` forces patch mode even when `task.isolation.merge` is
516
- `"branch"`, avoiding the per-call git lock + repo mutation that branch
517
- mode performs.
518
-
519
- Set `handle=True` to receive a DAG node dict instead of bare
520
- text: ``{"text", "output", "handle", "id", "agent"}`` where ``handle``
521
- is the spawned agent's recoverable ``agent://<id>`` URI. A downstream
522
- ``pipeline``/``parallel`` stage embeds that ``handle`` (or ``output``)
523
- in its prompt so a large transcript flows through the graph by
524
- reference, never re-inlined. When ``schema`` is also set the parsed
525
- object lands under ``"data"``. When the spawn ran isolated the node
526
- also carries ``"isolated"`` and, when present, ``"patch_path"``,
527
- ``"branch_name"``, ``"nested_patches"``, ``"changes_applied"``
528
- (``True``/``False``/``None`` — ``None`` means ``apply=False``), and
529
- ``"isolation_summary"``. If
530
- the bridge returns no recoverable id the node still resolves with
531
- ``handle=None`` — the helper never throws.
496
+ """Run a subagent and return its final output or structured data.
497
+
498
+ `schema` overrides agent and session schemas. `schema_mode` is
499
+ `"permissive"` or `"strict"`. `handle=True` returns the child output
500
+ reference and metadata, with parsed data under `"data"` when available.
532
501
  """
533
502
  args = {"prompt": prompt}
534
503
  if agent is not None:
@@ -539,6 +508,8 @@ if "__omp_prelude_loaded__" not in globals():
539
508
  args["label"] = label
540
509
  if schema is not None:
541
510
  args["schema"] = schema
511
+ if schema_mode is not None:
512
+ args["schemaMode"] = schema_mode
542
513
  if isolated is not None:
543
514
  args["isolated"] = bool(isolated)
544
515
  if apply is not None:
@@ -549,7 +520,8 @@ if "__omp_prelude_loaded__" not in globals():
549
520
  args["handle"] = True
550
521
  res = _bridge_call("__agent__", args)
551
522
  text = res.get("text") if isinstance(res, dict) else res
552
- parsed = json.loads(text) if schema is not None else text
523
+ has_data = isinstance(res, dict) and "data" in res
524
+ parsed = res["data"] if has_data else json.loads(text) if schema is not None else text
553
525
  if not handle:
554
526
  return parsed
555
527
  details = res.get("details") if isinstance(res, dict) else None
@@ -568,7 +540,7 @@ if "__omp_prelude_loaded__" not in globals():
568
540
  "id": details["id"],
569
541
  "agent": details.get("agent"),
570
542
  }
571
- if schema is not None:
543
+ if has_data or schema is not None:
572
544
  node["data"] = parsed
573
545
  for src_key, dst_key in (
574
546
  ("isolated", "isolated"),
@@ -392,22 +392,21 @@ unless defined?($__omp_prelude_loaded) && $__omp_prelude_loaded
392
392
  schema.nil? ? text : JSON.parse(text)
393
393
  end
394
394
 
395
- def agent(prompt, agent: "task", model: nil, label: nil, schema: nil, isolated: nil, apply: nil, merge: nil, handle: false)
395
+ def agent(prompt, agent: "task", model: nil, label: nil, schema: nil, schema_mode: nil, isolated: nil, apply: nil, merge: nil, handle: false)
396
396
  args = { "prompt" => prompt }
397
397
  args["agent"] = agent unless agent.nil?
398
398
  args["model"] = model unless model.nil?
399
399
  args["label"] = label unless label.nil?
400
400
  args["schema"] = schema unless schema.nil?
401
- # Isolation knobs mirror the `task` tool: strict opt-in via `isolated`,
402
- # with `apply`/`merge` controlling the post-run patch/branch merge.
401
+ args["schemaMode"] = schema_mode unless schema_mode.nil?
403
402
  args["isolated"] = !!isolated unless isolated.nil?
404
403
  args["apply"] = !!apply unless apply.nil?
405
404
  args["merge"] = !!merge unless merge.nil?
406
- # Tell the bridge a handle is wanted so it preserves the backing artifacts.
407
405
  args["handle"] = true if handle
408
406
  res = OmpBridge.call("__agent__", args)
409
407
  text = res.is_a?(Hash) ? res["text"] : res
410
- parsed = schema.nil? ? text : JSON.parse(text)
408
+ has_data = res.is_a?(Hash) && res.key?("data")
409
+ parsed = has_data ? res["data"] : (schema.nil? ? text : JSON.parse(text))
411
410
  return parsed unless handle
412
411
  details = res.is_a?(Hash) ? res["details"] : nil
413
412
  if !details.is_a?(Hash) || details["id"].nil?
@@ -420,7 +419,7 @@ unless defined?($__omp_prelude_loaded) && $__omp_prelude_loaded
420
419
  "id" => details["id"],
421
420
  "agent" => details["agent"],
422
421
  }
423
- node["data"] = parsed unless schema.nil?
422
+ node["data"] = parsed if has_data || !schema.nil?
424
423
  {
425
424
  "isolated" => "isolated",
426
425
  "patchPath" => "patch_path",
@@ -45,6 +45,8 @@ export interface BashResult {
45
45
  output: string;
46
46
  exitCode: number | undefined;
47
47
  cancelled: boolean;
48
+ /** True when the command was killed by its timeout deadline (not a user abort). */
49
+ timedOut?: boolean;
48
50
  truncated: boolean;
49
51
  totalLines: number;
50
52
  totalBytes: number;
@@ -70,6 +72,9 @@ const shellSessionsInUse = new Set<string>();
70
72
  */
71
73
  const retainedShells = new Set<Shell>();
72
74
  const RETAIN_REAP_INTERVAL_MS = 5_000;
75
+ // Native cancellation may spend two seconds unwinding the shell before its
76
+ // N-API chunk bridge drains. The JS watchdog must not race that teardown.
77
+ const NATIVE_TIMEOUT_FALLBACK_GRACE_MS = 5_000;
73
78
 
74
79
  async function retainShellWithLiveBackgroundJobs(shell: Shell): Promise<void> {
75
80
  let live: number;
@@ -306,16 +311,18 @@ export async function executeBash(command: string, options?: BashExecutorOptions
306
311
  const nativeTimeoutMs = requestedTimeoutMs !== undefined && requestedTimeoutMs > 0 ? requestedTimeoutMs : undefined;
307
312
  const nativeOwnsTimeout = nativeTimeoutMs !== undefined;
308
313
  if (deadlineTimeoutMs !== undefined) {
314
+ const fallbackTimeoutMs = nativeOwnsTimeout
315
+ ? deadlineTimeoutMs + NATIVE_TIMEOUT_FALLBACK_GRACE_MS
316
+ : deadlineTimeoutMs;
309
317
  timeoutTimer = setTimeout(() => {
310
- // Explicit timeouts are already enforced inside pi-natives via
311
- // `timeoutMs`. Do not also abort the JS AbortSignal here: on Windows,
312
- // aborting that signal while a piped command is still forwarding output
313
- // can terminate the Bun host before the native timeout result resolves.
318
+ // Explicit timeouts are enforced inside pi-natives via `timeoutMs`.
319
+ // Give native cancellation time to flush pipeline output and drain the
320
+ // N-API bridge before this result-only watchdog quarantines the run.
314
321
  if (!nativeOwnsTimeout) {
315
322
  abortCurrentExecution();
316
323
  }
317
324
  timeoutDeferred.resolve("timeout");
318
- }, deadlineTimeoutMs);
325
+ }, fallbackTimeoutMs);
319
326
  }
320
327
 
321
328
  let resetSession = false;
@@ -357,6 +364,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
357
364
  return {
358
365
  exitCode: undefined,
359
366
  cancelled: true,
367
+ ...(winner.kind === "timeout" ? { timedOut: true } : {}),
360
368
  ...(await sink.dump(
361
369
  winner.kind === "timeout" && deadlineTimeoutMs !== undefined
362
370
  ? `Command timed out after ${Math.round(deadlineTimeoutMs / 1000)} seconds`
@@ -381,6 +389,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
381
389
  return {
382
390
  exitCode: undefined,
383
391
  cancelled: true,
392
+ timedOut: true,
384
393
  ...(await sink.dump(annotation)),
385
394
  };
386
395
  }
@@ -18,7 +18,7 @@ import { getAllPluginToolPaths } from "../../extensibility/plugins/loader";
18
18
  // Runtime self-reference: dereference this namespace only inside loader functions to keep the index.ts cycle safe.
19
19
  import * as PiCodingAgent from "../../index";
20
20
  import * as typebox from "../typebox";
21
- import { createNoOpUIContext, resolvePath, withExitGuard } from "../utils";
21
+ import { createNoOpUIContext, resolvePath, withHostGuard } from "../utils";
22
22
  import type { CustomToolAPI, CustomToolFactory, LoadedCustomTool, ToolLoadError } from "./types";
23
23
 
24
24
  interface LoadToolResult {
@@ -75,14 +75,14 @@ async function loadTool(
75
75
  }
76
76
 
77
77
  try {
78
- const module = await withExitGuard(() => import(resolvedPath));
78
+ const module = await withHostGuard(() => import(resolvedPath));
79
79
  const factory = (module.default ?? module) as CustomToolFactory;
80
80
 
81
81
  if (typeof factory !== "function") {
82
82
  return { tools: [], errors: [{ path: toolPath, error: "Tool must export a default function", source }] };
83
83
  }
84
84
 
85
- const toolResult: unknown = await withExitGuard(async () => factory(sharedApi));
85
+ const toolResult: unknown = await withHostGuard(async () => factory(sharedApi));
86
86
  const toolsArray = Array.isArray(toolResult) ? toolResult : [toolResult];
87
87
 
88
88
  const loadedTools: LoadedCustomTool[] = [];
@@ -4,6 +4,7 @@
4
4
  import type { AgentTool, AgentToolUpdateCallback, ToolLoadMode } from "@oh-my-pi/pi-agent-core";
5
5
  import type { Static, TSchema } from "@oh-my-pi/pi-ai";
6
6
  import type { Theme } from "../../modes/theme/theme";
7
+ import { defaultLoadModeForToolName } from "../../tools/essential-tools";
7
8
  import { applyToolProxy } from "../tool-proxy";
8
9
  import type { CustomTool, CustomToolContext } from "./types";
9
10
 
@@ -23,7 +24,7 @@ export class CustomToolAdapter<TParams extends TSchema = TSchema, TDetails = any
23
24
  ) {
24
25
  applyToolProxy(tool, this);
25
26
  this.strict = tool.strict;
26
- this.loadMode = tool.loadMode ?? "discoverable";
27
+ this.loadMode = defaultLoadModeForToolName(tool.name, tool.loadMode);
27
28
  }
28
29
 
29
30
  execute(
@@ -24,7 +24,7 @@ import { installLegacyPiSpecifierShim, loadLegacyPiModule } from "../plugins/leg
24
24
  import { getAllPluginExtensionPaths } from "../plugins/loader";
25
25
  import * as TypeBox from "../typebox";
26
26
 
27
- import { resolvePath, withExitGuard } from "../utils";
27
+ import { resolvePath, withHostGuard } from "../utils";
28
28
  import type {
29
29
  AssistantThinkingRenderer,
30
30
  Extension,
@@ -290,7 +290,7 @@ async function loadExtension(
290
290
  ): Promise<{ extension: Extension | null; error: string | null }> {
291
291
  const resolvedPath = resolvePath(extensionPath, cwd);
292
292
  try {
293
- const module = (await withExitGuard(() => loadLegacyPiModule(resolvedPath))) as LoadedExtensionModule;
293
+ const module = (await withHostGuard(() => loadLegacyPiModule(resolvedPath))) as LoadedExtensionModule;
294
294
  const factory = getExtensionFactory(module);
295
295
 
296
296
  if (typeof factory !== "function") {
@@ -302,7 +302,7 @@ async function loadExtension(
302
302
 
303
303
  const extension = createExtension(extensionPath, resolvedPath);
304
304
  const api = new ConcreteExtensionAPI(PiCodingAgent, extension, runtime, cwd, eventBus);
305
- await withExitGuard(async () => {
305
+ await withHostGuard(async () => {
306
306
  await factory(api);
307
307
  });
308
308
 
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Managed timers for extensions.
3
+ *
4
+ * Extensions scheduling their own background work through raw `setInterval` /
5
+ * `setTimeout` used to be able to take down the whole session: a throw inside
6
+ * the callback runs on a fresh stack outside the handler-dispatch try/catch,
7
+ * surfaces as a process-level `uncaughtException`, and the global postmortem
8
+ * handler treats that as fatal (issue #5664).
9
+ *
10
+ * {@link ManagedTimers} backs the sanctioned `ctx.setInterval` /
11
+ * `ctx.setTimeout` helpers. Each callback runs inside the same isolation the
12
+ * runner already applies to handler dispatch — a synchronous throw or a
13
+ * rejected promise is reported through `onError` and swallowed — and every
14
+ * outstanding handle is `unref`'d (never keeps the process alive) and cleared
15
+ * on session teardown via {@link clearAll}.
16
+ */
17
+ import { logger } from "@oh-my-pi/pi-utils";
18
+
19
+ /** Callback invoked when a managed timer's callback throws or rejects. */
20
+ export type ManagedTimerErrorHandler = (event: string, error: string, stack?: string) => void;
21
+
22
+ export class ManagedTimers {
23
+ readonly #timers = new Set<Timer>();
24
+
25
+ constructor(private readonly onError: ManagedTimerErrorHandler) {}
26
+
27
+ /** Schedule a repeating callback whose throws are contained. */
28
+ setInterval(callback: (...args: unknown[]) => void, ms?: number, ...args: unknown[]): Timer {
29
+ const timer = setInterval(() => this.#run("interval", callback, args), ms, ...args);
30
+ timer.unref?.();
31
+ this.#timers.add(timer);
32
+ return timer;
33
+ }
34
+
35
+ /** Schedule a one-shot callback whose throws are contained. Deregisters after it fires. */
36
+ setTimeout(callback: (...args: unknown[]) => void, ms?: number, ...args: unknown[]): Timer {
37
+ const timer = setTimeout(
38
+ () => {
39
+ this.#timers.delete(timer);
40
+ this.#run("timeout", callback, args);
41
+ },
42
+ ms,
43
+ ...args,
44
+ );
45
+ timer.unref?.();
46
+ this.#timers.add(timer);
47
+ return timer;
48
+ }
49
+
50
+ /** Clear one managed timer. Accepts an interval or timeout handle. */
51
+ clear(timer: Timer): void {
52
+ if (!this.#timers.delete(timer)) return;
53
+ clearInterval(timer);
54
+ clearTimeout(timer);
55
+ }
56
+
57
+ /** Clear every outstanding managed timer. Called on session teardown. */
58
+ clearAll(): void {
59
+ for (const timer of this.#timers) {
60
+ clearInterval(timer);
61
+ clearTimeout(timer);
62
+ }
63
+ this.#timers.clear();
64
+ }
65
+
66
+ #run(kind: "interval" | "timeout", callback: (...args: unknown[]) => void, args: unknown[]): void {
67
+ try {
68
+ const result = callback(...args) as unknown;
69
+ if (result instanceof Promise) {
70
+ result.catch((err: unknown) => this.#report(kind, err));
71
+ }
72
+ } catch (err) {
73
+ this.#report(kind, err);
74
+ }
75
+ }
76
+
77
+ #report(kind: "interval" | "timeout", err: unknown): void {
78
+ const message = err instanceof Error ? err.message : String(err);
79
+ const stack = err instanceof Error ? err.stack : undefined;
80
+ logger.warn("Extension timer callback threw", { event: `${kind}_callback`, error: message });
81
+ this.onError(`${kind}_callback`, message, stack);
82
+ }
83
+ }
@@ -12,6 +12,7 @@ import type { MemoryRuntimeContext } from "../../memory-backend";
12
12
  import { type Theme, theme } from "../../modes/theme/theme";
13
13
  import type { SessionManager } from "../../session/session-manager";
14
14
  import type { BranchHandler, NavigateTreeHandler, NewSessionHandler } from "../session-handler-types";
15
+ import { ManagedTimers } from "./managed-timers";
15
16
  import { createExtensionModelQuery } from "./model-api";
16
17
  import type {
17
18
  AfterProviderResponseEvent,
@@ -249,6 +250,18 @@ export class ExtensionRunner {
249
250
  */
250
251
  #pendingCredentialDisabled: CredentialDisabledEvent[] = [];
251
252
 
253
+ /**
254
+ * Timers scheduled by extensions through the sanctioned `ctx.setInterval` /
255
+ * `ctx.setTimeout` helpers. Callbacks run with the same isolation as handler
256
+ * dispatch — a throw is logged and routed through {@link onError} instead of
257
+ * escaping to the process `uncaughtException` handler and tearing down the
258
+ * whole session (issue #5664). Handles are `unref`'d and every outstanding
259
+ * timer is cleared on session teardown via {@link clearManagedTimers}.
260
+ */
261
+ #managedTimers = new ManagedTimers((event, error, stack) =>
262
+ this.emitError({ extensionPath: "<timer>", event, error, stack }),
263
+ );
264
+
252
265
  constructor(
253
266
  private readonly extensions: Extension[],
254
267
  private readonly runtime: ExtensionRuntime,
@@ -542,6 +555,9 @@ export class ExtensionRunner {
542
555
  getSystemPrompt: () => this.#getSystemPromptFn(),
543
556
  localProtocolOptions: this.localProtocolOptions,
544
557
  memory: this.#getMemoryFn?.(),
558
+ setInterval: (callback, ms, ...args) => this.#managedTimers.setInterval(callback, ms, ...args),
559
+ setTimeout: (callback, ms, ...args) => this.#managedTimers.setTimeout(callback, ms, ...args),
560
+ clearTimer: timer => this.#managedTimers.clear(timer),
545
561
  };
546
562
  }
547
563
 
@@ -552,6 +568,16 @@ export class ExtensionRunner {
552
568
  this.#shutdownHandler();
553
569
  }
554
570
 
571
+ /**
572
+ * Clear every timer scheduled through `ctx.setInterval` / `ctx.setTimeout`.
573
+ * Called during session teardown so extension background work does not
574
+ * outlive the session (a self-scheduling interval would otherwise keep
575
+ * firing against a disposed session).
576
+ */
577
+ clearManagedTimers(): void {
578
+ this.#managedTimers.clearAll();
579
+ }
580
+
555
581
  createCommandContext(): ExtensionCommandContext {
556
582
  return {
557
583
  ...this.createContext(),
@@ -440,6 +440,24 @@ export interface ExtensionContext {
440
440
  getSystemPrompt(): string[];
441
441
  /** Structured memory runtime for status/search/save across the configured backend. */
442
442
  memory?: MemoryRuntimeContext;
443
+ /**
444
+ * Schedule a repeating callback whose throws are contained. Unlike raw
445
+ * `setInterval`, a synchronous throw or rejected promise from `callback` is
446
+ * logged and surfaced through the extension error channel instead of
447
+ * escaping as a process-fatal `uncaughtException` — one misbehaving timer
448
+ * can no longer take down the whole session. The handle is `unref`'d and
449
+ * cleared automatically on `session_shutdown`. Prefer this over raw
450
+ * `setInterval` for any extension background work.
451
+ */
452
+ setInterval(callback: (...args: unknown[]) => void, ms?: number, ...args: unknown[]): Timer;
453
+ /**
454
+ * Schedule a one-shot callback whose throws are contained, mirroring
455
+ * {@link setInterval}. Cleared automatically on `session_shutdown` if it has
456
+ * not yet fired.
457
+ */
458
+ setTimeout(callback: (...args: unknown[]) => void, ms?: number, ...args: unknown[]): Timer;
459
+ /** Clear a timer scheduled via {@link setInterval} or {@link setTimeout}. */
460
+ clearTimer(timer: Timer): void;
443
461
  }
444
462
 
445
463
  /**
@@ -12,6 +12,7 @@ import type { ImageContent, Static, TextContent, TSchema } from "@oh-my-pi/pi-ai
12
12
  import type { Settings } from "../../config/settings";
13
13
  import type { Theme } from "../../modes/theme/theme";
14
14
  import { type ApprovalMode, formatApprovalPrompt, resolveApproval } from "../../tools/approval";
15
+ import { defaultLoadModeForToolName } from "../../tools/essential-tools";
15
16
  import { normalizeToolEventInput, resolveToolEventInput } from "../tool-event-input";
16
17
  import { applyToolProxy } from "../tool-proxy";
17
18
  import type { ExtensionRunner } from "./runner";
@@ -36,7 +37,7 @@ export class RegisteredToolAdapter implements AgentTool<any, any, any> {
36
37
  private runner: ExtensionRunner,
37
38
  ) {
38
39
  applyToolProxy(registeredTool.definition, this);
39
- this.loadMode = registeredTool.definition.loadMode ?? "discoverable";
40
+ this.loadMode = defaultLoadModeForToolName(registeredTool.definition.name, registeredTool.definition.loadMode);
40
41
 
41
42
  // Only define render methods when the underlying definition provides them.
42
43
  // If these exist unconditionally on the prototype, ToolExecutionComponent
@@ -12,7 +12,7 @@ import { loadCapability } from "../../discovery";
12
12
  import * as PiCodingAgent from "../../index";
13
13
  import type { CustomMessagePayload } from "../../session/messages";
14
14
  import * as typebox from "../typebox";
15
- import { resolvePath, withExitGuard } from "../utils";
15
+ import { resolvePath, withHostGuard } from "../utils";
16
16
  import { execCommand } from "./runner";
17
17
  import type { ExecOptions, HookAPI, HookFactory, HookMessageRenderer, RegisteredCommand } from "./types";
18
18
 
@@ -149,7 +149,7 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo
149
149
 
150
150
  try {
151
151
  // Import the module using native Bun import
152
- const module = await withExitGuard(() => import(resolvedPath));
152
+ const module = await withHostGuard(() => import(resolvedPath));
153
153
  const factory = module.default as HookFactory;
154
154
 
155
155
  if (typeof factory !== "function") {
@@ -164,7 +164,7 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo
164
164
  );
165
165
 
166
166
  // Call factory to register handlers
167
- await withExitGuard(async () => factory(api));
167
+ await withHostGuard(async () => factory(api));
168
168
 
169
169
  return {
170
170
  hook: {