@oh-my-pi/pi-coding-agent 17.2.7 → 17.2.9

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 (91) hide show
  1. package/CHANGELOG.md +57 -1
  2. package/dist/{CHANGELOG-1p8yvde7.md → CHANGELOG-8yy8h3bc.md} +57 -1
  3. package/dist/cli.js +4974 -4968
  4. package/dist/types/async/job-manager.d.ts +12 -0
  5. package/dist/types/discovery/agents.d.ts +11 -0
  6. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +10 -0
  7. package/dist/types/hindsight/state.d.ts +0 -1
  8. package/dist/types/lsp/utils.d.ts +8 -0
  9. package/dist/types/modes/session-observer-registry.d.ts +2 -0
  10. package/dist/types/registry/agent-lifecycle.d.ts +1 -1
  11. package/dist/types/session/agent-session-types.d.ts +7 -0
  12. package/dist/types/session/agent-session.d.ts +5 -3
  13. package/dist/types/session/turn-recovery.d.ts +6 -2
  14. package/dist/types/task/executor.d.ts +4 -0
  15. package/dist/types/tools/browser/launch.d.ts +2 -0
  16. package/dist/types/tools/path-utils.d.ts +7 -0
  17. package/dist/types/tools/read.d.ts +2 -0
  18. package/dist/types/tools/shell-tokenize.d.ts +21 -3
  19. package/dist/types/utils/changelog.d.ts +3 -2
  20. package/dist/types/utils/late-cleanup.d.ts +2 -0
  21. package/dist/types/vibe/runtime.d.ts +2 -0
  22. package/package.json +13 -13
  23. package/scripts/omp +11 -2
  24. package/src/async/job-manager.ts +26 -0
  25. package/src/cli/stats-cli.ts +3 -3
  26. package/src/cli/update-cli.ts +2 -56
  27. package/src/config/config-file.ts +2 -2
  28. package/src/config/keybindings.ts +3 -3
  29. package/src/config/model-discovery.ts +36 -2
  30. package/src/config/model-registry.ts +4 -1
  31. package/src/discovery/agents.ts +71 -3
  32. package/src/discovery/claude.ts +8 -5
  33. package/src/discovery/cursor.ts +8 -5
  34. package/src/discovery/gemini.ts +11 -8
  35. package/src/discovery/vscode.ts +1 -0
  36. package/src/discovery/windsurf.ts +4 -2
  37. package/src/edit/hashline/filesystem.ts +7 -7
  38. package/src/eval/agent-bridge.ts +1 -3
  39. package/src/eval/jl/prelude.jl +4 -4
  40. package/src/eval/js/shared/prelude.txt +2 -2
  41. package/src/eval/py/prelude.py +0 -3
  42. package/src/eval/rb/prelude.rb +1 -2
  43. package/src/extensibility/legacy-pi-coding-agent-shim.ts +23 -0
  44. package/src/hindsight/state.ts +2 -22
  45. package/src/internal-urls/memory-protocol.ts +29 -0
  46. package/src/lsp/client.ts +2 -2
  47. package/src/lsp/utils.ts +29 -0
  48. package/src/modes/acp/acp-agent.ts +2 -1
  49. package/src/modes/components/agent-hub.ts +36 -20
  50. package/src/modes/components/agent-transcript-viewer.ts +1 -3
  51. package/src/modes/components/bordered-loader.ts +1 -1
  52. package/src/modes/components/custom-editor.ts +2 -2
  53. package/src/modes/components/hook-input.ts +1 -1
  54. package/src/modes/components/hook-selector.ts +2 -2
  55. package/src/modes/components/session-selector.ts +2 -2
  56. package/src/modes/controllers/event-controller.ts +159 -2
  57. package/src/modes/controllers/extension-ui-controller.ts +2 -1
  58. package/src/modes/print-mode.ts +29 -12
  59. package/src/modes/session-observer-registry.ts +5 -0
  60. package/src/registry/agent-lifecycle.ts +21 -3
  61. package/src/sdk.ts +11 -5
  62. package/src/session/agent-session-types.ts +8 -0
  63. package/src/session/agent-session.ts +156 -186
  64. package/src/session/session-paths.ts +68 -121
  65. package/src/session/settings-stream-fn.ts +7 -3
  66. package/src/session/turn-recovery.ts +234 -16
  67. package/src/session/unexpected-stop-classifier.ts +12 -3
  68. package/src/slash-commands/helpers/stats-dashboard.ts +2 -1
  69. package/src/task/agents.ts +1 -1
  70. package/src/task/executor.ts +105 -29
  71. package/src/task/isolation-runner.ts +19 -1
  72. package/src/task/structured-subagent.ts +17 -2
  73. package/src/tiny/worker.ts +1 -1
  74. package/src/tools/bash-interceptor.ts +8 -4
  75. package/src/tools/browser/launch.ts +37 -9
  76. package/src/tools/glob.ts +4 -2
  77. package/src/tools/path-utils.ts +13 -3
  78. package/src/tools/read.ts +6 -0
  79. package/src/tools/shell-tokenize.ts +38 -9
  80. package/src/utils/changelog.ts +5 -4
  81. package/src/utils/late-cleanup.ts +17 -0
  82. package/src/vibe/runtime.ts +197 -53
  83. package/src/web/scrapers/hackage.ts +1 -12
  84. package/src/web/search/providers/duckduckgo.ts +1 -1
  85. package/src/web/search/providers/ecosia.ts +1 -1
  86. package/src/web/search/providers/exa.ts +1 -1
  87. package/src/web/search/providers/firecrawl.ts +1 -1
  88. package/src/web/search/providers/mojeek.ts +1 -1
  89. package/src/web/search/providers/perplexity.ts +1 -1
  90. package/src/web/search/providers/public.ts +1 -1
  91. package/src/web/search/providers/startpage.ts +1 -1
@@ -83,25 +83,47 @@ export function tokenizeShellSegments(command: string): string[][] {
83
83
  }
84
84
 
85
85
  /**
86
- * Returns the original text of flat shell command segments. Unlike
86
+ * A flat shell command segment with the context needed to decide interception.
87
+ *
88
+ * @see extractFlatShellCommandSegments
89
+ */
90
+ export interface FlatShellCommandSegment {
91
+ /** Original segment text with quoting and escaping preserved. */
92
+ text: string;
93
+ /**
94
+ * True when this segment consumes the previous stage's stdout via an
95
+ * unquoted `|` or `|&`. Blank and comment-only continuation lines preserve
96
+ * the pending pipe state. Such a stage reads piped stdin, so path-based
97
+ * dedicated tools (read/grep/glob) cannot replace it. `||`, `;`, `&`, and
98
+ * `&&` start an independent command and leave this false.
99
+ */
100
+ pipedStdin: boolean;
101
+ }
102
+
103
+ /**
104
+ * Returns the flat shell command segments with the original text of each. Unlike
87
105
  * `tokenizeShellSegments`, this preserves quoting and escaping so the results
88
- * are safe to match against user-configured regular expressions.
106
+ * are safe to match against user-configured regular expressions, and flags
107
+ * segments that receive piped stdin.
89
108
  *
90
109
  * The extractor deliberately declines to split syntax whose execution context
91
110
  * cannot be determined with this small scanner (heredocs, command substitution,
92
111
  * backticks, grouping, and malformed quoting). Callers must still check the
93
112
  * complete input in that case.
94
113
  */
95
- export function extractFlatShellCommandSegments(command: string): string[] {
96
- const segments: string[] = [];
114
+ export function extractFlatShellCommandSegments(command: string): FlatShellCommandSegment[] {
115
+ const segments: FlatShellCommandSegment[] = [];
97
116
  let segmentStart = 0;
98
117
  let inSingle = false;
99
118
  let inDouble = false;
100
119
  let atWordStart = true;
120
+ let currentPiped = false;
101
121
 
102
- const pushSegment = (end: number) => {
122
+ const pushSegment = (end: number): boolean => {
103
123
  const segment = command.slice(segmentStart, end).trim();
104
- if (segment.length > 0) segments.push(segment);
124
+ if (segment.length === 0) return false;
125
+ segments.push({ text: segment, pipedStdin: currentPiped });
126
+ return true;
105
127
  };
106
128
 
107
129
  for (let i = 0; i < command.length; i++) {
@@ -154,12 +176,14 @@ export function extractFlatShellCommandSegments(command: string): string[] {
154
176
  return [];
155
177
  }
156
178
  if (ch === "#" && atWordStart) {
157
- pushSegment(i);
179
+ const pushed = pushSegment(i);
158
180
  const newline = command.indexOf("\n", i + 1);
159
181
  if (newline === -1) return segments;
160
182
  i = newline;
161
183
  segmentStart = newline + 1;
162
184
  atWordStart = true;
185
+ // Preserve a pending pipe through a comment-only continuation.
186
+ if (pushed) currentPiped = false;
163
187
  continue;
164
188
  }
165
189
  const isRedirectionOperatorCharacter =
@@ -169,8 +193,13 @@ export function extractFlatShellCommandSegments(command: string): string[] {
169
193
  ? command[i - 1] === ">" || command[i - 1] === "<" || command[i + 1] === ">"
170
194
  : false;
171
195
  if ((ch === "\n" || ch === ";" || ch === "|" || ch === "&") && !isRedirectionOperatorCharacter) {
172
- pushSegment(i);
173
- if ((ch === "|" || ch === "&") && command[i + 1] === ch) i++;
196
+ const pushed = pushSegment(i);
197
+ const doubled = (ch === "|" || ch === "&") && command[i + 1] === ch;
198
+ const pipeStderr = ch === "|" && command[i + 1] === "&";
199
+ if (doubled || pipeStderr) i++;
200
+ // `|` and `|&` pipe into the next segment. Blank continuation
201
+ // lines preserve that pending state; all other operators reset it.
202
+ if (pushed || ch !== "\n") currentPiped = ch === "|" && !doubled;
174
203
  segmentStart = i + 1;
175
204
  atWordStart = true;
176
205
  continue;
@@ -198,9 +198,10 @@ function parseChangelogContent(content: string): ChangelogEntry[] {
198
198
  }
199
199
 
200
200
  /**
201
- * Compare versions. Returns: -1 if v1 < v2, 0 if v1 === v2, 1 if v1 > v2
201
+ * Compare changelog entries by their parsed version parts.
202
+ * Returns: -1 if v1 < v2, 0 if v1 === v2, 1 if v1 > v2
202
203
  */
203
- export function compareVersions(v1: ChangelogEntry, v2: ChangelogEntry): number {
204
+ export function compareChangelogEntries(v1: ChangelogEntry, v2: ChangelogEntry): number {
204
205
  if (v1.major !== v2.major) return v1.major - v2.major;
205
206
  if (v1.minor !== v2.minor) return v1.minor - v2.minor;
206
207
  return v1.patch - v2.patch;
@@ -232,7 +233,7 @@ export function getNewEntries(entries: ChangelogEntry[], lastVersion: string): C
232
233
  return [];
233
234
  }
234
235
 
235
- return entries.filter(entry => compareVersions(entry, parsedLastVersion) > 0);
236
+ return entries.filter(entry => compareChangelogEntries(entry, parsedLastVersion) > 0);
236
237
  }
237
238
 
238
239
  /**
@@ -328,7 +329,7 @@ export async function resolveStartupChangelogForDisplay(options: {
328
329
  }
329
330
  if (options.mode === "hidden") {
330
331
  const currentVersion = parseChangelogVersion(options.currentVersion);
331
- if (currentVersion && compareVersions(currentVersion, parsedLastVersion) > 0) {
332
+ if (currentVersion && compareChangelogEntries(currentVersion, parsedLastVersion) > 0) {
332
333
  await writeLastChangelogVersion(options.currentVersion, options.agentDir);
333
334
  }
334
335
  return undefined;
@@ -0,0 +1,17 @@
1
+ import { logger } from "@oh-my-pi/pi-utils";
2
+
3
+ const pendingCleanups = new Set<Promise<void>>();
4
+
5
+ /** Keep timed-out cleanup reachable until its resources really settle. */
6
+ export function trackLateCleanup(work: Promise<void>, context: Record<string, unknown>): void {
7
+ let tracked: Promise<void>;
8
+ tracked = work
9
+ .catch(error => {
10
+ logger.warn("Deferred cleanup failed", {
11
+ ...context,
12
+ error: error instanceof Error ? error.message : String(error),
13
+ });
14
+ })
15
+ .finally(() => pendingCleanups.delete(tracked));
16
+ pendingCleanups.add(tracked);
17
+ }
@@ -70,8 +70,8 @@ const TRACE_LINE_MAX = 120;
70
70
  const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
71
71
  /** Response text cap inside a delivered turn result; full output stays at agent://<id>. */
72
72
  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;
73
+ /** Grace period for Vibe cancellation/release cleanup before teardown detaches (ms). */
74
+ const VIBE_TEARDOWN_GRACE_MS = 5_000;
75
75
 
76
76
  const VIBE_LIFECYCLE_CUSTOM_TYPE = "vibe-session-lifecycle";
77
77
  const VIBE_LIFECYCLE_VERSION = 1;
@@ -252,6 +252,48 @@ export interface VibeWaitOutcome {
252
252
  timedOut: boolean;
253
253
  }
254
254
 
255
+ type VibeTeardownStatus = "pending" | "settled" | "failed";
256
+
257
+ interface TrackedVibeTeardown {
258
+ promise: Promise<void>;
259
+ status: () => VibeTeardownStatus;
260
+ }
261
+
262
+ /** Observe cleanup without propagating a detached late rejection. */
263
+ function trackVibeTeardown(promise: Promise<unknown>, onError: (error: unknown) => void): TrackedVibeTeardown {
264
+ let status: VibeTeardownStatus = "pending";
265
+ return {
266
+ promise: promise.then(
267
+ () => {
268
+ status = "settled";
269
+ },
270
+ error => {
271
+ status = "failed";
272
+ onError(error);
273
+ },
274
+ ),
275
+ status: () => status,
276
+ };
277
+ }
278
+
279
+ /** Wait for cleanup only until the caller's shared absolute deadline. */
280
+ async function waitForVibeTeardown(tasks: readonly TrackedVibeTeardown[], deadline: number): Promise<boolean> {
281
+ if (tasks.length === 0 || tasks.every(task => task.status() !== "pending")) return true;
282
+ const remainingMs = deadline - Date.now();
283
+ if (remainingMs <= 0) return false;
284
+ const timeout = Promise.withResolvers<void>();
285
+ const timer = setTimeout(timeout.resolve, remainingMs);
286
+ timer.unref?.();
287
+ try {
288
+ return await Promise.race([
289
+ Promise.allSettled(tasks.map(task => task.promise)).then(() => true),
290
+ timeout.promise.then(() => false),
291
+ ]);
292
+ } finally {
293
+ clearTimeout(timer);
294
+ }
295
+ }
296
+
255
297
  /** Normalize a text fragment to one bounded roster/trace line. */
256
298
  function firstLine(text: string, max = 100): string {
257
299
  return oneLineLabel(text, max);
@@ -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,
@@ -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);
@@ -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
 
@@ -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
 
@@ -210,7 +210,7 @@ export class MojeekProvider extends SearchProvider {
210
210
  return true;
211
211
  }
212
212
 
213
- isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
213
+ override isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
214
214
  return true;
215
215
  }
216
216
 
@@ -973,7 +973,7 @@ export class PerplexityProvider extends SearchProvider {
973
973
  * configured provider keeps priority over the anonymous/OpenRouter
974
974
  * fallbacks.
975
975
  */
976
- isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
976
+ override isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
977
977
  return true;
978
978
  }
979
979
 
@@ -189,7 +189,7 @@ export class PublicWebProvider extends SearchProvider {
189
189
  return false;
190
190
  }
191
191
 
192
- isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
192
+ override isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
193
193
  return true;
194
194
  }
195
195
 
@@ -215,7 +215,7 @@ export class StartpageProvider extends SearchProvider {
215
215
  return true;
216
216
  }
217
217
 
218
- isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
218
+ override isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
219
219
  return true;
220
220
  }
221
221