@oh-my-pi/pi-coding-agent 17.2.8 → 17.2.10

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 (246) hide show
  1. package/CHANGELOG.md +94 -1
  2. package/dist/{CHANGELOG-0b0w17se.md → CHANGELOG-rhwzpexa.md} +94 -1
  3. package/dist/cli.js +13216 -15614
  4. package/dist/types/async/job-manager.d.ts +12 -0
  5. package/dist/types/cli/args.d.ts +1 -0
  6. package/dist/types/config/model-resolver.d.ts +13 -0
  7. package/dist/types/discovery/agents.d.ts +11 -0
  8. package/dist/types/extensibility/custom-commands/types.d.ts +5 -5
  9. package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
  10. package/dist/types/extensibility/extensions/loader.d.ts +2 -2
  11. package/dist/types/extensibility/extensions/types.d.ts +34 -8
  12. package/dist/types/extensibility/hooks/types.d.ts +5 -5
  13. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +10 -0
  14. package/dist/types/hindsight/state.d.ts +0 -1
  15. package/dist/types/index.d.ts +2 -2
  16. package/dist/types/lsp/utils.d.ts +8 -0
  17. package/dist/types/main.d.ts +1 -1
  18. package/dist/types/modes/acp/acp-agent.d.ts +1 -1
  19. package/dist/types/modes/acp/acp-client-bridge.d.ts +1 -1
  20. package/dist/types/modes/acp/acp-event-mapper.d.ts +1 -1
  21. package/dist/types/modes/acp/acp-mode.d.ts +1 -1
  22. package/dist/types/modes/components/agent-hub-projection.d.ts +38 -0
  23. package/dist/types/modes/components/agent-hub-renderer.d.ts +41 -0
  24. package/dist/types/modes/components/agent-hub.d.ts +12 -5
  25. package/dist/types/modes/components/model-browser.d.ts +9 -1
  26. package/dist/types/modes/interactive-mode.d.ts +8 -1
  27. package/dist/types/modes/session-observer-registry.d.ts +2 -0
  28. package/dist/types/registry/agent-lifecycle.d.ts +1 -1
  29. package/dist/types/registry/agent-registry.d.ts +47 -0
  30. package/dist/types/registry/persisted-agents.d.ts +3 -1
  31. package/dist/types/session/agent-session-types.d.ts +7 -0
  32. package/dist/types/session/agent-session.d.ts +8 -4
  33. package/dist/types/session/session-entries.d.ts +10 -0
  34. package/dist/types/session/session-loader.d.ts +11 -1
  35. package/dist/types/session/session-manager.d.ts +9 -1
  36. package/dist/types/session/session-tools.d.ts +10 -1
  37. package/dist/types/session/turn-recovery.d.ts +6 -2
  38. package/dist/types/slash-commands/acp-builtins.d.ts +1 -1
  39. package/dist/types/slash-commands/available-commands.d.ts +1 -1
  40. package/dist/types/task/executor.d.ts +10 -0
  41. package/dist/types/task/index.d.ts +2 -3
  42. package/dist/types/task/read-only-policy.d.ts +3 -0
  43. package/dist/types/task/structured-subagent.d.ts +2 -0
  44. package/dist/types/task/types.d.ts +4 -0
  45. package/dist/types/tools/bash.d.ts +3 -3
  46. package/dist/types/tools/browser/launch.d.ts +3 -0
  47. package/dist/types/tools/browser/tab-worker.d.ts +2 -2
  48. package/dist/types/tools/path-utils.d.ts +8 -0
  49. package/dist/types/tools/read.d.ts +3 -5
  50. package/dist/types/tools/run-scope.d.ts +27 -1
  51. package/dist/types/tools/shell-tokenize.d.ts +21 -3
  52. package/dist/types/tools/terminal-output.d.ts +1 -1
  53. package/dist/types/utils/changelog.d.ts +3 -2
  54. package/dist/types/utils/late-cleanup.d.ts +2 -0
  55. package/dist/types/utils/turndown.d.ts +1 -1
  56. package/dist/types/vibe/runtime.d.ts +4 -3
  57. package/dist/types/web/scrapers/readthedocs.d.ts +0 -3
  58. package/dist/types/web/search/providers/browser-headers.d.ts +2 -3
  59. package/examples/custom-tools/README.md +1 -1
  60. package/examples/extensions/README.md +1 -1
  61. package/examples/extensions/api-demo.ts +5 -6
  62. package/examples/extensions/chalk-logger.ts +1 -1
  63. package/examples/extensions/hello.ts +2 -2
  64. package/examples/extensions/reload-runtime.ts +1 -1
  65. package/examples/extensions/tools.ts +2 -2
  66. package/examples/extensions/with-deps/index.ts +1 -1
  67. package/examples/sdk/06-extensions.ts +1 -1
  68. package/package.json +15 -31
  69. package/scripts/bundle-dist.ts +1 -11
  70. package/scripts/omp +11 -2
  71. package/src/advisor/runtime.ts +54 -0
  72. package/src/async/job-manager.ts +26 -0
  73. package/src/cli/agents-cli.ts +1 -1
  74. package/src/cli/args.ts +24 -2
  75. package/src/cli/auth-broker-cli.ts +1 -1
  76. package/src/cli/auth-gateway-cli.ts +1 -1
  77. package/src/cli/bench-cli.ts +1 -1
  78. package/src/cli/claude-trace-cli.ts +1 -1
  79. package/src/cli/config-cli.ts +1 -1
  80. package/src/cli/dry-balance-cli.ts +1 -1
  81. package/src/cli/file-processor.ts +1 -1
  82. package/src/cli/flag-tables.ts +4 -0
  83. package/src/cli/grep-cli.ts +1 -1
  84. package/src/cli/grievances-cli.ts +1 -1
  85. package/src/cli/help-extra.ts +1 -1
  86. package/src/cli/models-cli.ts +1 -1
  87. package/src/cli/plugin-cli.ts +1 -1
  88. package/src/cli/read-cli.ts +1 -1
  89. package/src/cli/setup-cli.ts +1 -1
  90. package/src/cli/shell-cli.ts +1 -1
  91. package/src/cli/ssh-cli.ts +1 -1
  92. package/src/cli/stats-cli.ts +4 -4
  93. package/src/cli/tiny-models-cli.ts +1 -1
  94. package/src/cli/ttsr-cli.ts +1 -1
  95. package/src/cli/update-cli.ts +3 -57
  96. package/src/cli/usage-cli.ts +1 -1
  97. package/src/cli/web-search-cli.ts +1 -1
  98. package/src/cli/worktree-cli.ts +1 -1
  99. package/src/commands/say.ts +1 -1
  100. package/src/commands/token.ts +1 -1
  101. package/src/commit/agentic/agent.ts +1 -1
  102. package/src/commit/cli.ts +1 -1
  103. package/src/config/config-file.ts +2 -2
  104. package/src/config/keybindings.ts +3 -3
  105. package/src/config/model-discovery.ts +36 -2
  106. package/src/config/model-registry.ts +4 -1
  107. package/src/config/model-resolver.ts +54 -7
  108. package/src/discovery/agents.ts +73 -3
  109. package/src/discovery/builtin-rules/ts-no-inline-cast-access.md +5 -4
  110. package/src/discovery/builtin-rules/ts-no-tiny-functions.md +1 -1
  111. package/src/discovery/claude.ts +8 -5
  112. package/src/discovery/cursor.ts +8 -5
  113. package/src/discovery/gemini.ts +11 -8
  114. package/src/discovery/vscode.ts +1 -0
  115. package/src/discovery/windsurf.ts +4 -2
  116. package/src/edit/hashline/filesystem.ts +7 -7
  117. package/src/eval/agent-bridge.ts +1 -3
  118. package/src/eval/jl/prelude.jl +4 -4
  119. package/src/eval/js/shared/prelude.txt +2 -2
  120. package/src/eval/py/prelude.py +0 -3
  121. package/src/eval/py/runner.py +38 -1
  122. package/src/eval/rb/prelude.rb +1 -2
  123. package/src/extensibility/custom-commands/loader.ts +4 -4
  124. package/src/extensibility/custom-commands/types.ts +5 -5
  125. package/src/extensibility/custom-tools/loader.ts +4 -4
  126. package/src/extensibility/custom-tools/types.ts +1 -1
  127. package/src/extensibility/extensions/loader.ts +7 -6
  128. package/src/extensibility/extensions/types.ts +39 -8
  129. package/src/extensibility/hooks/loader.ts +4 -5
  130. package/src/extensibility/hooks/types.ts +5 -5
  131. package/src/extensibility/legacy-pi-coding-agent-shim.ts +23 -0
  132. package/src/extensibility/plugins/legacy-pi-compat.ts +53 -0
  133. package/src/hindsight/state.ts +2 -22
  134. package/src/index.ts +2 -2
  135. package/src/internal-urls/memory-protocol.ts +33 -0
  136. package/src/launch/terminal-output.ts +1 -1
  137. package/src/lsp/client.ts +2 -2
  138. package/src/lsp/utils.ts +29 -0
  139. package/src/main.ts +85 -21
  140. package/src/markit/converters/docx.ts +1 -1
  141. package/src/markit/converters/epub.ts +1 -1
  142. package/src/markit/converters/pptx.ts +1 -1
  143. package/src/markit/converters/xlsx.ts +1 -1
  144. package/src/modes/acp/acp-agent.ts +7 -6
  145. package/src/modes/acp/acp-client-bridge.ts +1 -1
  146. package/src/modes/acp/acp-event-mapper.ts +1 -1
  147. package/src/modes/acp/acp-mode.ts +1 -1
  148. package/src/modes/components/agent-hub-projection.ts +248 -0
  149. package/src/modes/components/agent-hub-renderer.ts +194 -0
  150. package/src/modes/components/agent-hub.ts +670 -192
  151. package/src/modes/components/agent-transcript-viewer.ts +1 -3
  152. package/src/modes/components/assistant-message.ts +1 -1
  153. package/src/modes/components/bordered-loader.ts +1 -1
  154. package/src/modes/components/custom-editor.ts +2 -2
  155. package/src/modes/components/extensions/inspector-panel.ts +11 -6
  156. package/src/modes/components/footer.ts +1 -3
  157. package/src/modes/components/hook-input.ts +1 -1
  158. package/src/modes/components/hook-selector.ts +2 -2
  159. package/src/modes/components/model-browser.ts +11 -3
  160. package/src/modes/components/model-hub.ts +4 -1
  161. package/src/modes/components/session-selector.ts +2 -2
  162. package/src/modes/components/status-line/segments.ts +4 -3
  163. package/src/modes/controllers/event-controller.ts +188 -7
  164. package/src/modes/controllers/extension-ui-controller.ts +4 -3
  165. package/src/modes/controllers/mcp-command-controller.ts +81 -20
  166. package/src/modes/controllers/selector-controller.ts +40 -49
  167. package/src/modes/controllers/streaming-reveal.ts +1 -1
  168. package/src/modes/interactive-mode.ts +13 -2
  169. package/src/modes/print-mode.ts +29 -12
  170. package/src/modes/rpc/rpc-mode.ts +2 -2
  171. package/src/modes/runtime-init.ts +1 -1
  172. package/src/modes/session-observer-registry.ts +5 -0
  173. package/src/modes/theme/theme.ts +2 -2
  174. package/src/prompts/tools/computer.md +1 -1
  175. package/src/registry/agent-lifecycle.ts +35 -10
  176. package/src/registry/agent-registry.ts +65 -2
  177. package/src/registry/persisted-agents.ts +341 -16
  178. package/src/sdk.ts +11 -5
  179. package/src/session/agent-session-types.ts +8 -0
  180. package/src/session/agent-session.ts +177 -188
  181. package/src/session/session-entries.ts +10 -0
  182. package/src/session/session-listing.ts +1 -1
  183. package/src/session/session-loader.ts +74 -8
  184. package/src/session/session-manager.ts +22 -2
  185. package/src/session/session-paths.ts +105 -110
  186. package/src/session/session-tools.ts +28 -1
  187. package/src/session/settings-stream-fn.ts +7 -3
  188. package/src/session/turn-recovery.ts +236 -18
  189. package/src/session/unexpected-stop-classifier.ts +12 -3
  190. package/src/slash-commands/acp-builtins.ts +1 -1
  191. package/src/slash-commands/available-commands.ts +1 -1
  192. package/src/slash-commands/helpers/stats-dashboard.ts +2 -1
  193. package/src/task/agents.ts +1 -1
  194. package/src/task/executor.ts +173 -47
  195. package/src/task/index.ts +9 -28
  196. package/src/task/isolation-runner.ts +43 -12
  197. package/src/task/persisted-revive.ts +12 -5
  198. package/src/task/read-only-policy.ts +27 -0
  199. package/src/task/structured-subagent.ts +31 -6
  200. package/src/task/types.ts +4 -0
  201. package/src/tiny/worker.ts +1 -1
  202. package/src/tools/auto-generated-guard.ts +1 -1
  203. package/src/tools/bash-interactive.ts +4 -4
  204. package/src/tools/bash-interceptor.ts +8 -4
  205. package/src/tools/bash.ts +6 -6
  206. package/src/tools/browser/cmux/cmux-tab.ts +66 -18
  207. package/src/tools/browser/launch.ts +76 -19
  208. package/src/tools/browser/readable.ts +9 -9
  209. package/src/tools/browser/tab-supervisor.ts +12 -3
  210. package/src/tools/browser/tab-worker-entry.ts +1 -1
  211. package/src/tools/browser/tab-worker.ts +101 -12
  212. package/src/tools/fetch.ts +1 -1
  213. package/src/tools/glob.ts +4 -2
  214. package/src/tools/path-utils.ts +22 -5
  215. package/src/tools/read.ts +22 -5
  216. package/src/tools/run-scope.ts +290 -4
  217. package/src/tools/shell-tokenize.ts +38 -9
  218. package/src/tools/terminal-output.ts +1 -1
  219. package/src/tools/todo.ts +1 -1
  220. package/src/utils/changelog.ts +5 -4
  221. package/src/utils/clipboard.ts +10 -2
  222. package/src/utils/external-editor.ts +4 -2
  223. package/src/utils/jj.ts +1 -1
  224. package/src/utils/late-cleanup.ts +17 -0
  225. package/src/utils/turndown.ts +1 -2
  226. package/src/vibe/runtime.ts +202 -58
  227. package/src/web/scrapers/arxiv.ts +1 -1
  228. package/src/web/scrapers/go-pkg.ts +1 -1
  229. package/src/web/scrapers/hackage.ts +1 -12
  230. package/src/web/scrapers/iacr.ts +1 -1
  231. package/src/web/scrapers/readthedocs.ts +2 -1
  232. package/src/web/scrapers/twitter.ts +2 -2
  233. package/src/web/scrapers/types.ts +1 -1
  234. package/src/web/scrapers/wikipedia.ts +1 -1
  235. package/src/web/search/providers/browser-headers.ts +12 -39
  236. package/src/web/search/providers/codex.ts +3 -26
  237. package/src/web/search/providers/duckduckgo.ts +1 -1
  238. package/src/web/search/providers/ecosia.ts +2 -2
  239. package/src/web/search/providers/exa.ts +1 -1
  240. package/src/web/search/providers/firecrawl.ts +1 -1
  241. package/src/web/search/providers/google.ts +1 -1
  242. package/src/web/search/providers/mojeek.ts +2 -2
  243. package/src/web/search/providers/perplexity.ts +1 -1
  244. package/src/web/search/providers/public.ts +1 -1
  245. package/src/web/search/providers/startpage.ts +2 -2
  246. package/src/markit/converters/mammoth.d.ts +0 -24
@@ -25,7 +25,6 @@ import { MCPManager } from "../mcp/manager";
25
25
  import vibeTurnResultTemplate from "../prompts/tools/vibe-turn-result.md" with { type: "text" };
26
26
  import { AgentLifecycleManager } from "../registry/agent-lifecycle";
27
27
  import { type AgentRef, AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
28
- import type { SessionEntry } from "../session/session-entries";
29
28
  import { SessionManager, SessionPersistenceIndeterminateError } from "../session/session-manager";
30
29
  import { getBundledAgent } from "../task/agents";
31
30
  import { type ExecutorOptions, runSubagentFollowUpTurn, runSubprocess } from "../task/executor";
@@ -70,8 +69,8 @@ const TRACE_LINE_MAX = 120;
70
69
  const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
71
70
  /** Response text cap inside a delivered turn result; full output stays at agent://<id>. */
72
71
  const RESPONSE_PREVIEW_MAX = 6000;
73
- /** Grace period for abort-aware turns before teardown detaches a stuck provider/tool call. */
74
- const CANCELLED_TURN_SETTLE_GRACE_MS = 250;
72
+ /** Grace period for Vibe cancellation/release cleanup before teardown detaches (ms). */
73
+ const VIBE_TEARDOWN_GRACE_MS = 5_000;
75
74
 
76
75
  const VIBE_LIFECYCLE_CUSTOM_TYPE = "vibe-session-lifecycle";
77
76
  const VIBE_LIFECYCLE_VERSION = 1;
@@ -252,6 +251,48 @@ export interface VibeWaitOutcome {
252
251
  timedOut: boolean;
253
252
  }
254
253
 
254
+ type VibeTeardownStatus = "pending" | "settled" | "failed";
255
+
256
+ interface TrackedVibeTeardown {
257
+ promise: Promise<void>;
258
+ status: () => VibeTeardownStatus;
259
+ }
260
+
261
+ /** Observe cleanup without propagating a detached late rejection. */
262
+ function trackVibeTeardown(promise: Promise<unknown>, onError: (error: unknown) => void): TrackedVibeTeardown {
263
+ let status: VibeTeardownStatus = "pending";
264
+ return {
265
+ promise: promise.then(
266
+ () => {
267
+ status = "settled";
268
+ },
269
+ error => {
270
+ status = "failed";
271
+ onError(error);
272
+ },
273
+ ),
274
+ status: () => status,
275
+ };
276
+ }
277
+
278
+ /** Wait for cleanup only until the caller's shared absolute deadline. */
279
+ async function waitForVibeTeardown(tasks: readonly TrackedVibeTeardown[], deadline: number): Promise<boolean> {
280
+ if (tasks.length === 0 || tasks.every(task => task.status() !== "pending")) return true;
281
+ const remainingMs = deadline - Date.now();
282
+ if (remainingMs <= 0) return false;
283
+ const timeout = Promise.withResolvers<void>();
284
+ const timer = setTimeout(timeout.resolve, remainingMs);
285
+ timer.unref?.();
286
+ try {
287
+ return await Promise.race([
288
+ Promise.allSettled(tasks.map(task => task.promise)).then(() => true),
289
+ timeout.promise.then(() => false),
290
+ ]);
291
+ } finally {
292
+ clearTimeout(timer);
293
+ }
294
+ }
295
+
255
296
  /** Normalize a text fragment to one bounded roster/trace line. */
256
297
  function firstLine(text: string, max = 100): string {
257
298
  return oneLineLabel(text, max);
@@ -321,11 +362,12 @@ function parseLifecycleEvent(value: unknown): VibeLifecycleEvent | undefined {
321
362
  return undefined;
322
363
  }
323
364
 
324
- /** Child ids claimed by any valid Vibe spawn event, independent of current parent scope. */
325
- export function persistedVibeChildIds(entries: Iterable<SessionEntry>): Set<string> {
365
+ /** Child ids claimed by valid Vibe spawn records from untrusted persisted JSON. */
366
+ export function persistedVibeChildIds(entries: Iterable<unknown>): Set<string> {
326
367
  const ids = new Set<string>();
327
- for (const entry of entries) {
328
- if (entry.type !== "custom" || entry.customType !== VIBE_LIFECYCLE_CUSTOM_TYPE) continue;
368
+ for (const value of entries) {
369
+ const entry = objectRecord(value);
370
+ if (entry?.type !== "custom" || entry.customType !== VIBE_LIFECYCLE_CUSTOM_TYPE) continue;
329
371
  const event = parseLifecycleEvent(entry.data);
330
372
  if (
331
373
  event?.action === "spawn" &&
@@ -354,24 +396,6 @@ function mergeTrace(turn: VibeTurn, progress: AgentProgress): void {
354
396
  /** Thrown from a turn job body so the job manager marks the job failed while carrying the formatted result. */
355
397
  export class VibeTurnError extends Error {}
356
398
 
357
- async function awaitCancelledTurnJobs(jobs: ReadonlySet<AsyncJob>): Promise<void> {
358
- if (jobs.size === 0) return;
359
- const settled = Promise.allSettled([...jobs].map(job => job.promise)).then(() => true);
360
- const timeout = Promise.withResolvers<false>();
361
- const timer = setTimeout(() => timeout.resolve(false), CANCELLED_TURN_SETTLE_GRACE_MS);
362
- timer.unref();
363
- try {
364
- if (!(await Promise.race([settled, timeout.promise]))) {
365
- logger.warn("vibe: detached cancelled turn that did not settle within teardown grace period", {
366
- jobCount: jobs.size,
367
- graceMs: CANCELLED_TURN_SETTLE_GRACE_MS,
368
- });
369
- }
370
- } finally {
371
- clearTimeout(timer);
372
- }
373
- }
374
-
375
399
  /**
376
400
  * Process-global registry of vibe worker sessions, scoped by both owner agent
377
401
  * id and stable parent session id. Persisted lifecycle events rebuild idle
@@ -419,6 +443,12 @@ export class VibeSessionRegistry {
419
443
  readonly #records = new Map<string, VibeRecord>();
420
444
  readonly #terminationTails = new Map<string, Promise<void>>();
421
445
  readonly #terminatedScopes = new Set<string>();
446
+ #teardownGraceMs = VIBE_TEARDOWN_GRACE_MS;
447
+
448
+ /** Override the teardown grace period for deterministic lifecycle tests. */
449
+ setTeardownGraceForTesting(timeoutMs: number): void {
450
+ this.#teardownGraceMs = Math.max(1, timeoutMs);
451
+ }
422
452
 
423
453
  ownerScope(session: VibeParentSession): VibeOwnerScope {
424
454
  const parentSessionId = session.getSessionId?.();
@@ -709,11 +739,50 @@ export class VibeSessionRegistry {
709
739
  }
710
740
  }
711
741
 
742
+ #trackAgentRelease(id: string, ref: AgentRef, action: "detach" | "release"): TrackedVibeTeardown {
743
+ return trackVibeTeardown(AgentLifecycleManager.global().release(id, ref), error => {
744
+ logger.warn(`vibe: failed to ${action} worker session`, {
745
+ id,
746
+ error: error instanceof Error ? error.message : String(error),
747
+ });
748
+ });
749
+ }
750
+
751
+ #finishAgentRelease(id: string, ref: AgentRef, task: TrackedVibeTeardown, action: "detach" | "release"): void {
752
+ if (task.status() === "settled") return;
753
+ if (task.status() === "pending") {
754
+ logger.warn(`vibe: timed out waiting to ${action} worker session; detaching registry ref`, { id });
755
+ }
756
+ AgentRegistry.global().unregister(id, ref);
757
+ }
758
+
759
+ async #releaseRefWithinDeadline(
760
+ id: string,
761
+ ref: AgentRef,
762
+ deadline: number,
763
+ action: "detach" | "release",
764
+ ): Promise<void> {
765
+ const task = this.#trackAgentRelease(id, ref, action);
766
+ await waitForVibeTeardown([task], deadline);
767
+ this.#finishAgentRelease(id, ref, task, action);
768
+ }
769
+
770
+ #trackJobSettlement(record: VibeRecord, job: AsyncJob): TrackedVibeTeardown {
771
+ return trackVibeTeardown(job.promise, error => {
772
+ logger.warn("vibe: cancelled worker turn cleanup failed", {
773
+ id: record.id,
774
+ jobId: job.id,
775
+ error: error instanceof Error ? error.message : String(error),
776
+ });
777
+ });
778
+ }
779
+
712
780
  async #markTerminalRef(
713
781
  id: string,
714
782
  ownerId: string,
715
783
  childSessionFile: string,
716
784
  expected?: AgentRef | null,
785
+ teardownDeadline?: number,
717
786
  ): Promise<void> {
718
787
  const registry = AgentRegistry.global();
719
788
  const existing = registry.get(id);
@@ -726,7 +795,9 @@ export class VibeSessionRegistry {
726
795
  }
727
796
  if (existing?.status === "aborted" && !existing.session) return;
728
797
  if (existing && !registry.setStatus(id, "aborted", existing)) return;
729
- if (existing && AgentLifecycleManager.global().has(id, existing)) {
798
+ if (existing && teardownDeadline !== undefined) {
799
+ await this.#releaseRefWithinDeadline(id, existing, teardownDeadline, "release");
800
+ } else if (existing && AgentLifecycleManager.global().has(id, existing)) {
730
801
  await AgentLifecycleManager.global().release(id, existing);
731
802
  } else if (existing?.session) {
732
803
  await existing.session.dispose();
@@ -1086,21 +1157,50 @@ export class VibeSessionRegistry {
1086
1157
  this.#records.delete(scopeKey(scope, record.id));
1087
1158
  if (record.turn && manager) manager.cancel(record.turn.jobId, { ownerId: record.ownerId });
1088
1159
  }
1089
- for (const { record, ref } of teardown) {
1090
- if (!ref) continue;
1091
- try {
1092
- await AgentLifecycleManager.global().release(record.id, ref);
1093
- } catch (error) {
1094
- logger.warn("vibe: failed to detach worker session", {
1160
+ const deadline = Date.now() + this.#teardownGraceMs;
1161
+ const cleanup = teardown.map(entry => ({
1162
+ ...entry,
1163
+ releaseTask: entry.ref ? this.#trackAgentRelease(entry.record.id, entry.ref, "detach") : undefined,
1164
+ jobTask: entry.job ? this.#trackJobSettlement(entry.record, entry.job) : undefined,
1165
+ }));
1166
+ await waitForVibeTeardown(
1167
+ cleanup.flatMap(entry => [entry.releaseTask, entry.jobTask].filter(task => task !== undefined)),
1168
+ deadline,
1169
+ );
1170
+ for (const { record, ref, releaseTask, job, jobTask } of cleanup) {
1171
+ if (ref && releaseTask) this.#finishAgentRelease(record.id, ref, releaseTask, "detach");
1172
+ if (job && jobTask?.status() === "pending") {
1173
+ logger.warn("vibe: timed out waiting for cancelled worker turn; cleanup continues in the background", {
1095
1174
  id: record.id,
1096
- error: error instanceof Error ? error.message : String(error),
1175
+ jobId: job.id,
1097
1176
  });
1177
+ this.#continueSuspendedCleanup(scope, record, jobTask);
1178
+ }
1179
+ if (this.#records.has(scopeKey(scope, record.id))) continue;
1180
+ const lateRef = this.#registeredAgent(record);
1181
+ if (lateRef && lateRef !== ref) {
1182
+ await this.#releaseRefWithinDeadline(record.id, lateRef, deadline, "detach");
1098
1183
  }
1099
1184
  }
1100
- await awaitCancelledTurnJobs(new Set(teardown.flatMap(entry => (entry.job ? [entry.job] : []))));
1101
1185
  return records.length;
1102
1186
  }
1103
1187
 
1188
+ #continueSuspendedCleanup(scope: VibeOwnerScope, record: VibeRecord, jobTask: TrackedVibeTeardown): void {
1189
+ void jobTask.promise
1190
+ .then(async () => {
1191
+ if (this.#records.has(scopeKey(scope, record.id))) return;
1192
+ const lateRef = this.#registeredAgent(record);
1193
+ if (!lateRef) return;
1194
+ await this.#releaseRefWithinDeadline(record.id, lateRef, Date.now() + this.#teardownGraceMs, "detach");
1195
+ })
1196
+ .catch(error => {
1197
+ logger.warn("vibe: failed to finish suspended worker cleanup", {
1198
+ id: record.id,
1199
+ error: error instanceof Error ? error.message : String(error),
1200
+ });
1201
+ });
1202
+ }
1203
+
1104
1204
  /** Terminate one worker; a tombstone failure still tears it down before reconciliation and error delivery. */
1105
1205
  async kill(session: ToolSession, id: string): Promise<VibeKillOutcome> {
1106
1206
  const scope = this.ownerScope(session);
@@ -1144,15 +1244,21 @@ export class VibeSessionRegistry {
1144
1244
  await this.#persistModeExit(session, scope, records);
1145
1245
  } catch (error) {
1146
1246
  if (error instanceof SessionPersistenceIndeterminateError) {
1147
- for (const record of records) {
1148
- await this.#killRecord(record, session.asyncJobManager, session, "mode-exit", false);
1149
- }
1247
+ const teardownDeadline = Date.now() + this.#teardownGraceMs;
1248
+ await Promise.all(
1249
+ records.map(record =>
1250
+ this.#killRecord(record, session.asyncJobManager, session, "mode-exit", false, teardownDeadline),
1251
+ ),
1252
+ );
1150
1253
  }
1151
1254
  throw error;
1152
1255
  }
1153
- for (const record of records) {
1154
- await this.#killRecord(record, session.asyncJobManager, session, "mode-exit");
1155
- }
1256
+ const teardownDeadline = Date.now() + this.#teardownGraceMs;
1257
+ await Promise.all(
1258
+ records.map(record =>
1259
+ this.#killRecord(record, session.asyncJobManager, session, "mode-exit", true, teardownDeadline),
1260
+ ),
1261
+ );
1156
1262
  return records.length;
1157
1263
  }
1158
1264
 
@@ -1162,6 +1268,7 @@ export class VibeSessionRegistry {
1162
1268
  session: VibeParentSession,
1163
1269
  reason: VibeTombstoneReason,
1164
1270
  persistTerminal = true,
1271
+ teardownDeadline?: number,
1165
1272
  ): Promise<VibeKillOutcome> {
1166
1273
  const registered = this.#registeredAgent(record);
1167
1274
  const settlingJobs = new Set<AsyncJob>();
@@ -1198,26 +1305,29 @@ export class VibeSessionRegistry {
1198
1305
  record.state = "dead";
1199
1306
  record.lastActivityAt = Date.now();
1200
1307
  record.lastActivity = "killed";
1201
- if (registered) {
1202
- try {
1203
- await AgentLifecycleManager.global().release(record.id, registered);
1204
- } catch (error) {
1205
- logger.warn("vibe: failed to release worker session", {
1206
- id: record.id,
1207
- error: error instanceof Error ? error.message : String(error),
1208
- });
1209
- }
1308
+ const deadline = teardownDeadline ?? Date.now() + this.#teardownGraceMs;
1309
+ const releaseTask = registered ? this.#trackAgentRelease(record.id, registered, "release") : undefined;
1310
+ const jobCleanup = [...settlingJobs].map(job => ({ job, task: this.#trackJobSettlement(record, job) }));
1311
+ await waitForVibeTeardown(
1312
+ [releaseTask, ...jobCleanup.map(entry => entry.task)].filter(task => task !== undefined),
1313
+ deadline,
1314
+ );
1315
+ if (registered && releaseTask) this.#finishAgentRelease(record.id, registered, releaseTask, "release");
1316
+ const pendingJobs = jobCleanup.filter(entry => entry.task.status() === "pending");
1317
+ for (const { job } of pendingJobs) {
1318
+ logger.warn("vibe: timed out waiting for cancelled worker turn; cleanup continues in the background", {
1319
+ id: record.id,
1320
+ jobId: job.id,
1321
+ });
1210
1322
  }
1211
- await awaitCancelledTurnJobs(settlingJobs);
1212
1323
  const terminalRef = registered ?? this.#registeredAgent(record) ?? null;
1213
- if (record.childSessionFile) {
1214
- try {
1215
- const persisted = await SessionManager.peekSessionInit(record.childSessionFile);
1216
- if (persisted?.init)
1217
- await this.#markTerminalRef(record.id, record.ownerId, record.childSessionFile, terminalRef);
1218
- } catch {
1219
- // A turn killed before session initialization has no readable history to retain.
1220
- }
1324
+ await this.#markTerminalRecord(record, terminalRef, deadline);
1325
+ if (pendingJobs.length > 0) {
1326
+ this.#continueKilledCleanup(
1327
+ record,
1328
+ pendingJobs.map(entry => entry.task),
1329
+ registered,
1330
+ );
1221
1331
  }
1222
1332
  if (persistenceError) {
1223
1333
  let finalPersistenceError = persistenceError;
@@ -1246,6 +1356,40 @@ export class VibeSessionRegistry {
1246
1356
  return { id: record.id, cancelledTurn };
1247
1357
  }
1248
1358
 
1359
+ async #markTerminalRecord(
1360
+ record: VibeRecord,
1361
+ expected: AgentRef | null | undefined,
1362
+ teardownDeadline: number,
1363
+ ): Promise<void> {
1364
+ if (!record.childSessionFile) return;
1365
+ try {
1366
+ const persisted = await SessionManager.peekSessionInit(record.childSessionFile);
1367
+ if (persisted?.init) {
1368
+ await this.#markTerminalRef(record.id, record.ownerId, record.childSessionFile, expected, teardownDeadline);
1369
+ }
1370
+ } catch (error) {
1371
+ logger.warn("vibe: failed to retain terminal worker transcript", {
1372
+ id: record.id,
1373
+ error: error instanceof Error ? error.message : String(error),
1374
+ });
1375
+ }
1376
+ }
1377
+
1378
+ #continueKilledCleanup(
1379
+ record: VibeRecord,
1380
+ jobTasks: readonly TrackedVibeTeardown[],
1381
+ expected: AgentRef | undefined,
1382
+ ): void {
1383
+ void Promise.allSettled(jobTasks.map(task => task.promise))
1384
+ .then(() => this.#markTerminalRecord(record, expected, Date.now() + this.#teardownGraceMs))
1385
+ .catch(error => {
1386
+ logger.warn("vibe: failed to finish killed worker cleanup", {
1387
+ id: record.id,
1388
+ error: error instanceof Error ? error.message : String(error),
1389
+ });
1390
+ });
1391
+ }
1392
+
1249
1393
  /** Build the ExecutorOptions for a first spawn, mirroring the `task`/eval-bridge plumbing. */
1250
1394
  async #buildSpawnOptions(
1251
1395
  session: ToolSession,
@@ -30,7 +30,7 @@ export const handleArxiv: SpecialHandler = async (
30
30
  if (!result.ok) return null;
31
31
 
32
32
  // Parse the Atom feed response
33
- const { parseHTML } = await import("linkedom");
33
+ const { parseHTML } = await import("@oh-my-pi/pi-utils/dom");
34
34
  const doc = parseHTML(result.content).document;
35
35
  const entry = doc.querySelector("entry");
36
36
 
@@ -96,7 +96,7 @@ export const handleGoPkg: SpecialHandler = async (
96
96
  });
97
97
  }
98
98
 
99
- const { parseHTML } = await import("linkedom");
99
+ const { parseHTML } = await import("@oh-my-pi/pi-utils/dom");
100
100
  const doc = parseHTML(pageResult.content).document;
101
101
 
102
102
  // Extract actual module path from breadcrumb or header
@@ -1,4 +1,4 @@
1
- import { tryParseJson } from "@oh-my-pi/pi-utils";
1
+ import { compareVersions, tryParseJson } from "@oh-my-pi/pi-utils";
2
2
  import type { RenderResult, SpecialHandler } from "./types";
3
3
  import { buildResult, loadPage } from "./types";
4
4
 
@@ -20,17 +20,6 @@ interface ParsedCabal {
20
20
  stability?: string;
21
21
  }
22
22
 
23
- function compareVersions(a: string, b: string): number {
24
- const aParts = a.split(".").map(part => Number.parseInt(part, 10) || 0);
25
- const bParts = b.split(".").map(part => Number.parseInt(part, 10) || 0);
26
- const max = Math.max(aParts.length, bParts.length);
27
- for (let i = 0; i < max; i++) {
28
- const delta = (aParts[i] || 0) - (bParts[i] || 0);
29
- if (delta !== 0) return delta;
30
- }
31
- return 0;
32
- }
33
-
34
23
  function extractCabalField(content: string, fieldName: string): string | undefined {
35
24
  const pattern = new RegExp(`^${fieldName}:\\s*(.*)$`, "im");
36
25
  const match = content.match(pattern);
@@ -29,7 +29,7 @@ export const handleIacr: SpecialHandler = async (
29
29
 
30
30
  if (!result.ok) return null;
31
31
 
32
- const { parseHTML } = await import("linkedom");
32
+ const { parseHTML } = await import("@oh-my-pi/pi-utils/dom");
33
33
  const doc = parseHTML(result.content).document;
34
34
 
35
35
  // Extract metadata from the page
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Read the Docs handler for web-fetch
3
3
  */
4
+ import type { Element } from "@oh-my-pi/pi-utils/dom";
4
5
  import { buildResult, htmlToBasicMarkdown, loadPage, type RenderResult, type SpecialHandler } from "./types";
5
6
 
6
7
  export const handleReadTheDocs: SpecialHandler = async (
@@ -38,7 +39,7 @@ export const handleReadTheDocs: SpecialHandler = async (
38
39
  }
39
40
 
40
41
  // Parse HTML
41
- const { parseHTML } = await import("linkedom");
42
+ const { parseHTML } = await import("@oh-my-pi/pi-utils/dom");
42
43
  const root = parseHTML(result.content).document;
43
44
 
44
45
  // Extract main content from common Read the Docs selectors
@@ -1,4 +1,4 @@
1
- import type { HTMLElement } from "linkedom";
1
+ import type { HTMLElement } from "@oh-my-pi/pi-utils/dom";
2
2
  import { ToolAbortError } from "../../tools/tool-errors";
3
3
  import type { RenderResult, SpecialHandler } from "./types";
4
4
  import { buildResult, loadPage } from "./types";
@@ -33,7 +33,7 @@ export const handleTwitter: SpecialHandler = async (
33
33
 
34
34
  if (result.ok && result.content.length > 500) {
35
35
  // Parse the Nitter HTML
36
- const { parseHTML } = await import("linkedom");
36
+ const { parseHTML } = await import("@oh-my-pi/pi-utils/dom");
37
37
  const doc = parseHTML(result.content).document;
38
38
 
39
39
  // Extract tweet content
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { scheduler } from "node:timers/promises";
5
5
  import { ptree } from "@oh-my-pi/pi-utils";
6
- import type TurndownService from "turndown";
6
+ import type TurndownService from "@oh-my-pi/pi-utils/turndown";
7
7
 
8
8
  import type { AgentStorage } from "../../session/agent-storage";
9
9
  import { ToolAbortError } from "../../tools/tool-errors";
@@ -44,7 +44,7 @@ export const handleWikipedia: SpecialHandler = async (
44
44
  const contentResult = await loadPage(contentUrl, { timeout, signal });
45
45
 
46
46
  if (contentResult.ok) {
47
- const { parseHTML } = await import("linkedom");
47
+ const { parseHTML } = await import("@oh-my-pi/pi-utils/dom");
48
48
  const doc = parseHTML(contentResult.content).document;
49
49
 
50
50
  // Extract main content sections
@@ -1,28 +1,13 @@
1
- import { HeaderGenerator } from "header-generator";
1
+ import { HeaderGenerator } from "@oh-my-pi/pi-utils/headers";
2
2
 
3
- // Lazily instantiate the singleton header generator. Bun single-file binaries do not
4
- // bundle header-generator's fs-loaded data_files, so construction may throw when the
5
- // original build-time node_modules path is absent.
6
- let generator: HeaderGenerator | undefined;
7
- let generatorUnavailable = false;
8
-
9
- function getHeaderGenerator(): HeaderGenerator | undefined {
10
- if (generatorUnavailable) return undefined;
11
- try {
12
- generator ??= new HeaderGenerator({
13
- browserListQuery: "last 3 versions",
14
- devices: ["desktop"],
15
- operatingSystems: ["windows", "macos", "linux"],
16
- locales: ["en-US", "en"],
17
- httpVersion: "2",
18
- strict: false,
19
- });
20
- return generator;
21
- } catch {
22
- generatorUnavailable = true;
23
- return undefined;
24
- }
25
- }
3
+ const generator = new HeaderGenerator({
4
+ browserListQuery: "last 3 versions",
5
+ devices: ["desktop"],
6
+ operatingSystems: ["windows", "macos", "linux"],
7
+ locales: ["en-US", "en"],
8
+ httpVersion: "2",
9
+ strict: false,
10
+ });
26
11
 
27
12
  // A fallback desktop Mac Chrome navigation fingerprint matching
28
13
  // the previous static default setup for deterministic or non-randomized calls.
@@ -84,9 +69,8 @@ function canonicalizeHeaderNames(headers: Record<string, string>): Record<string
84
69
 
85
70
  /**
86
71
  * Build a fresh, internally consistent desktop navigation fingerprint for one HTTP request.
87
- * By default, this randomizes across valid modern versions of Chrome, Firefox, Edge, and Safari
88
- * using real-world traffic data. Set `randomized` to `false` when a fetch must preserve a
89
- * stable Mac Chrome identity.
72
+ * By default, this randomizes across coherent modern Chrome, Firefox, and Safari profiles.
73
+ * Set `randomized` to `false` when a fetch must preserve a stable Mac Chrome identity.
90
74
  */
91
75
  export function buildBrowserNavigationHeaders(options?: { randomized?: boolean }): Record<string, string> {
92
76
  const randomized = options?.randomized !== false;
@@ -94,16 +78,5 @@ export function buildBrowserNavigationHeaders(options?: { randomized?: boolean }
94
78
  return { ...CHROME_FALLBACK_HEADERS };
95
79
  }
96
80
 
97
- const generator = getHeaderGenerator();
98
- if (!generator) {
99
- return { ...CHROME_FALLBACK_HEADERS };
100
- }
101
-
102
- try {
103
- // Generate realistic, consistent headers with the Bayesian generator
104
- return canonicalizeHeaderNames(generator.getHeaders());
105
- } catch {
106
- // Gracefully recover to the robust default profile on unexpected generator errors
107
- return { ...CHROME_FALLBACK_HEADERS };
108
- }
81
+ return canonicalizeHeaderNames(generator.getHeaders());
109
82
  }
@@ -13,11 +13,7 @@ import {
13
13
  withAuth,
14
14
  withOAuthAccess,
15
15
  } from "@oh-my-pi/pi-ai";
16
- import { applyCodexResponsesLiteShape } from "@oh-my-pi/pi-ai/providers/openai-codex/request-transformer";
17
- import {
18
- createOpenAICodexCompatibilityMetadata,
19
- resolveCodexResponsesUrl,
20
- } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
16
+ import { resolveCodexResponsesUrl } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
21
17
  import { getBundledModels } from "@oh-my-pi/pi-catalog/models";
22
18
  import {
23
19
  CODEX_BASE_URL,
@@ -447,7 +443,6 @@ async function callCodexSearch(
447
443
  systemPrompt?: string;
448
444
  searchContextSize?: "low" | "medium" | "high";
449
445
  model: CodexModelCandidate;
450
- sessionId?: string;
451
446
  fetch?: FetchImpl;
452
447
  transport: CodexSearchTransport;
453
448
  },
@@ -455,7 +450,6 @@ async function callCodexSearch(
455
450
  const headers = buildCodexHeaders(auth.accessToken, auth.accountId, options.transport.headers);
456
451
 
457
452
  const requestedModel = options.model.modelId;
458
- const usesResponsesLite = options.model.catalogModel?.useResponsesLite === true;
459
453
 
460
454
  const body: Record<string, unknown> = {
461
455
  model: requestedModel,
@@ -477,21 +471,6 @@ async function callCodexSearch(
477
471
  tool_choice: { type: "web_search" },
478
472
  instructions: options.systemPrompt ?? DEFAULT_INSTRUCTIONS,
479
473
  };
480
- if (usesResponsesLite) {
481
- const metadata = createOpenAICodexCompatibilityMetadata({
482
- sessionId: options.sessionId,
483
- requestKind: "turn",
484
- startNewTurn: true,
485
- });
486
- for (const name in metadata.headers) {
487
- const value = metadata.headers[name];
488
- if (value !== undefined) headers.set(name, value);
489
- }
490
- headers.set(OPENAI_HEADERS.RESPONSES_LITE, "true");
491
- body.client_metadata = metadata.clientMetadata;
492
- body.reasoning = { context: "all_turns" };
493
- applyCodexResponsesLiteShape(body);
494
- }
495
474
 
496
475
  const fetchImpl = options.fetch ?? fetch;
497
476
  const response = await fetchImpl(options.transport.url, {
@@ -519,9 +498,8 @@ async function callCodexSearch(
519
498
  let model = requestedModel;
520
499
  let requestId = "";
521
500
  let usage: { inputTokens: number; outputTokens: number; totalTokens: number } | undefined;
522
- // Evidence that the hosted web_search tool actually ran. Lite models get
523
- // `tool_choice: "auto"` and may answer without searching (#6988); a search
524
- // command must reject that rather than return a non-search completion.
501
+ // A search command must reject a completion that did not invoke the hosted
502
+ // tool rather than returning an answer from the model's own knowledge (#6988).
525
503
  let webSearchInvoked = false;
526
504
 
527
505
  for await (const rawEvent of readSseJson<Record<string, unknown>>(response.body, options.signal)) {
@@ -651,7 +629,6 @@ async function runCodexSearchCandidates(options: {
651
629
  systemPrompt: options.params.systemPrompt,
652
630
  searchContextSize: "high",
653
631
  model: candidate,
654
- sessionId: options.params.sessionId,
655
632
  fetch: options.params.fetch,
656
633
  transport: options.transport,
657
634
  });
@@ -372,7 +372,7 @@ export class DuckDuckGoProvider extends SearchProvider {
372
372
  return true;
373
373
  }
374
374
 
375
- isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
375
+ override isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
376
376
  return true;
377
377
  }
378
378
 
@@ -1,5 +1,5 @@
1
1
  import type { AuthStorage } from "@oh-my-pi/pi-ai";
2
- import { parseHTML } from "linkedom";
2
+ import { parseHTML } from "@oh-my-pi/pi-utils/dom";
3
3
  import type { SearchResponse, SearchSource } from "../../../web/search/types";
4
4
  import { SearchProviderError } from "../../../web/search/types";
5
5
  import { formatScraperQuery } from "../query";
@@ -173,7 +173,7 @@ export class EcosiaProvider extends SearchProvider {
173
173
  return true;
174
174
  }
175
175
 
176
- isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
176
+ override isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
177
177
  return true;
178
178
  }
179
179
 
@@ -458,7 +458,7 @@ export class ExaProvider extends SearchProvider {
458
458
  * still uses {@link isAvailable} so an unrelated configured provider
459
459
  * keeps priority over the public fallback.
460
460
  */
461
- isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
461
+ override isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
462
462
  return this.#settingsAllowSearch();
463
463
  }
464
464
 
@@ -203,7 +203,7 @@ export class FirecrawlProvider extends SearchProvider {
203
203
  * Firecrawl supports keyless mode, so an explicit user selection
204
204
  * (`webSearch: firecrawl`) works without any credential configured.
205
205
  */
206
- isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
206
+ override isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
207
207
  return true;
208
208
  }
209
209
 
@@ -1,5 +1,5 @@
1
1
  import type { AuthStorage } from "@oh-my-pi/pi-ai";
2
- import { parseHTML } from "linkedom";
2
+ import { type Element, parseHTML } from "@oh-my-pi/pi-utils/dom";
3
3
  import type { SearchResponse, SearchSource } from "../../../web/search/types";
4
4
  import { SearchProviderError } from "../../../web/search/types";
5
5
  import { formatScraperQuery } from "../query";