@oh-my-pi/pi-coding-agent 17.2.2 → 17.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/dist/{CHANGELOG-xfpkakrn.md → CHANGELOG-bpmhv26t.md} +55 -0
  3. package/dist/cli.js +3294 -3294
  4. package/dist/types/advisor/advise-tool.d.ts +6 -0
  5. package/dist/types/advisor/runtime.d.ts +4 -4
  6. package/dist/types/capability/mcp.d.ts +9 -0
  7. package/dist/types/cli/models-cli.d.ts +1 -1
  8. package/dist/types/config/keybindings.d.ts +1 -1
  9. package/dist/types/config/settings-schema.d.ts +1 -1
  10. package/dist/types/discovery/helpers.d.ts +6 -0
  11. package/dist/types/discovery/omp-extension-roots.d.ts +21 -2
  12. package/dist/types/extensibility/extensions/loader.d.ts +6 -2
  13. package/dist/types/extensibility/plugins/marketplace/registry.d.ts +2 -2
  14. package/dist/types/launch/paths.d.ts +2 -1
  15. package/dist/types/mcp/request-id.d.ts +19 -0
  16. package/dist/types/mcp/types.d.ts +14 -0
  17. package/dist/types/memory-backend/index.d.ts +1 -0
  18. package/dist/types/memory-backend/messages.d.ts +15 -0
  19. package/dist/types/modes/components/ask-dialog.d.ts +0 -1
  20. package/dist/types/modes/components/model-browser.d.ts +7 -5
  21. package/dist/types/modes/components/model-picker.d.ts +9 -3
  22. package/dist/types/modes/components/read-tool-group.d.ts +9 -0
  23. package/dist/types/modes/types.d.ts +1 -1
  24. package/dist/types/registry/agent-lifecycle.d.ts +14 -5
  25. package/dist/types/registry/agent-registry.d.ts +4 -3
  26. package/dist/types/sdk.d.ts +1 -1
  27. package/dist/types/secrets/index.d.ts +6 -8
  28. package/dist/types/session/agent-session.d.ts +6 -1
  29. package/dist/types/session/model-controls.d.ts +0 -1
  30. package/dist/types/session/session-advisors.d.ts +7 -0
  31. package/dist/types/session/session-maintenance.d.ts +1 -0
  32. package/dist/types/session/session-manager.d.ts +11 -5
  33. package/dist/types/system-prompt.d.ts +2 -0
  34. package/dist/types/tools/browser/launch.d.ts +30 -0
  35. package/dist/types/tools/browser/registry.d.ts +6 -1
  36. package/dist/types/tools/browser/shared-daemon.d.ts +26 -0
  37. package/dist/types/tools/default-renderer.d.ts +3 -0
  38. package/package.json +12 -12
  39. package/scripts/legacy-pi-virtual-module.ts +12 -2
  40. package/src/advisor/advise-tool.ts +18 -0
  41. package/src/advisor/runtime.ts +6 -6
  42. package/src/capability/mcp.ts +7 -0
  43. package/src/cli/models-cli.ts +13 -16
  44. package/src/collab/host.ts +1 -1
  45. package/src/config/keybindings.ts +14 -9
  46. package/src/config/mcp-schema.json +5 -0
  47. package/src/config/model-registry.ts +7 -1
  48. package/src/config/settings-schema.ts +1 -1
  49. package/src/discovery/builtin.ts +10 -0
  50. package/src/discovery/helpers.ts +10 -0
  51. package/src/discovery/mcp-json.ts +11 -1
  52. package/src/discovery/omp-extension-roots.ts +79 -20
  53. package/src/discovery/omp-plugins.ts +10 -1
  54. package/src/extensibility/extensions/loader.ts +66 -28
  55. package/src/extensibility/plugins/marketplace/registry.ts +4 -8
  56. package/src/launch/broker.ts +59 -41
  57. package/src/launch/paths.ts +2 -5
  58. package/src/main.ts +24 -22
  59. package/src/mcp/config.ts +1 -0
  60. package/src/mcp/request-id.ts +24 -0
  61. package/src/mcp/transports/http.ts +4 -2
  62. package/src/mcp/transports/sse.ts +4 -2
  63. package/src/mcp/transports/stdio.ts +4 -2
  64. package/src/mcp/types.ts +15 -0
  65. package/src/memory-backend/index.ts +1 -0
  66. package/src/memory-backend/messages.ts +19 -0
  67. package/src/modes/components/agent-hub.ts +1 -1
  68. package/src/modes/components/ask-dialog.ts +65 -23
  69. package/src/modes/components/custom-editor.ts +1 -1
  70. package/src/modes/components/model-browser.ts +25 -17
  71. package/src/modes/components/model-picker.ts +10 -6
  72. package/src/modes/components/read-tool-group.ts +26 -0
  73. package/src/modes/components/tool-execution.ts +77 -20
  74. package/src/modes/controllers/command-controller.ts +2 -2
  75. package/src/modes/controllers/event-controller.ts +182 -10
  76. package/src/modes/controllers/selector-controller.ts +27 -6
  77. package/src/modes/types.ts +5 -1
  78. package/src/prompts/tools/bash.md +1 -1
  79. package/src/registry/agent-lifecycle.ts +36 -11
  80. package/src/registry/agent-registry.ts +12 -5
  81. package/src/sdk.ts +68 -29
  82. package/src/secrets/index.ts +15 -17
  83. package/src/session/agent-session.ts +11 -2
  84. package/src/session/agent-storage.ts +6 -4
  85. package/src/session/history-storage.ts +4 -2
  86. package/src/session/messages.ts +31 -7
  87. package/src/session/model-controls.ts +0 -1
  88. package/src/session/session-advisors.ts +19 -1
  89. package/src/session/session-maintenance.ts +5 -2
  90. package/src/session/session-manager.ts +128 -76
  91. package/src/session/session-tools.ts +25 -23
  92. package/src/slash-commands/builtin-registry.ts +2 -2
  93. package/src/system-prompt.ts +6 -1
  94. package/src/task/executor.ts +8 -1
  95. package/src/tiny/text.ts +12 -0
  96. package/src/tools/bash.ts +11 -5
  97. package/src/tools/browser/launch.ts +54 -9
  98. package/src/tools/browser/registry.ts +123 -30
  99. package/src/tools/browser/shared-daemon.ts +190 -0
  100. package/src/tools/browser.ts +1 -1
  101. package/src/tools/default-renderer.ts +19 -4
  102. package/src/tools/index.ts +10 -6
  103. package/src/web/search/providers/anthropic.ts +3 -1
  104. package/src/web/search/providers/codex.ts +31 -6
@@ -91,6 +91,30 @@ export class EventController {
91
91
  #readToolCallArgs = new Map<string, Record<string, unknown>>();
92
92
  #readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
93
93
  #toolTimelineComponents = new Map<string, Component>();
94
+ // Stable identity for a streamed tool call while its assistant message is
95
+ // live: maps the tool-call block's position in the streaming message to the
96
+ // id last seen at that position. A streamed id can CHANGE across cumulative
97
+ // `message_update`s — some providers (GitHub Copilot's `call_id|id`
98
+ // transport, any stream that delivers name/args before the id) emit the
99
+ // block with an empty or partial id first and rewrite it in a later delta
100
+ // (openai-completions sets `id: toolCall.id || ""` then overwrites on the
101
+ // next chunk). Keyed only by id, the changed id reads as a brand-new call
102
+ // and a second card is created — the old-id card orphans as a pending
103
+ // preview while the new-id card takes the result (#6879). Reset per assistant
104
+ // message (indices are per-message).
105
+ #streamedToolCallIdByIndex = new Map<number, string>();
106
+ // A TTSR rewind retracts its uncommitted never-run cards at message_end;
107
+ // retain their ids until agent-loop's synthetic tool_execution_start/end for
108
+ // those calls arrive so the normal no-pending path cannot recreate the
109
+ // retracted card below the rewind's fresh blocks (#6879).
110
+ #retractedToolCallIds = new Set<string>();
111
+ // Cards settled by a synthetic aborted/error `tool_execution_end` (agent-loop
112
+ // emits one per never-run call on a terminal error/abort). They stay visible
113
+ // for a genuinely terminal failure, but if an auto-retry then supersedes the
114
+ // turn (`auto_retry_start`, decided AFTER message_end) they must be removed so
115
+ // the retry's fresh cards do not render the same call twice (Codex review on
116
+ // #6881). Keyed by call id; reset per turn.
117
+ #syntheticFailureCards = new Map<string, ToolExecutionHandle>();
94
118
  // Completions that arrived before any component existed for their call id.
95
119
  // Cursor's server-resolved tools (todo) emit `tool_execution_end` through a
96
120
  // synchronous callback fired mid-parse, while the `toolcall_start` for the
@@ -274,6 +298,71 @@ export class EventController {
274
298
  this.#readToolCallAssistantComponents.delete(toolCallId);
275
299
  }
276
300
 
301
+ #retractToolCardEntry(toolCallId: string, component: ToolExecutionHandle): void {
302
+ component.seal();
303
+ let removeComponent = true;
304
+ if (component instanceof ReadToolGroupComponent) {
305
+ removeComponent = component.removeEntry(toolCallId);
306
+ if (component === this.#lastReadGroup) this.#resetReadGroup();
307
+ }
308
+ if (removeComponent) this.ctx.chatContainer.removeChild(component);
309
+ this.ctx.pendingTools.delete(toolCallId);
310
+ this.#toolTimelineComponents.delete(toolCallId);
311
+ this.#clearReadToolCall(toolCallId);
312
+ }
313
+
314
+ /**
315
+ * Re-key a live streamed tool card whose id changed mid-stream (see
316
+ * {@link #streamedToolCallIdByIndex}). Moves every id-keyed tracker from the
317
+ * old id to the new one so the next cumulative `message_update` reuses the
318
+ * existing card instead of creating a duplicate (#6879). The card component
319
+ * itself is id-agnostic (routing is via `pendingTools`), so only the maps and
320
+ * the shared read group's entry need re-keying.
321
+ */
322
+ #migrateStreamedToolCallId(oldId: string, newId: string): void {
323
+ // `oldId` may be "" (the block streamed before its id): that empty key still
324
+ // owns a live card and must migrate. Skip only a no-op or an empty target.
325
+ if (oldId === newId || !newId) return;
326
+ const pending = this.ctx.pendingTools.get(oldId);
327
+ if (pending && !this.ctx.pendingTools.has(newId)) {
328
+ this.ctx.pendingTools.delete(oldId);
329
+ this.ctx.pendingTools.set(newId, pending);
330
+ }
331
+ const timeline = this.#toolTimelineComponents.get(oldId);
332
+ if (timeline && !this.#toolTimelineComponents.has(newId)) {
333
+ this.#toolTimelineComponents.delete(oldId);
334
+ this.#toolTimelineComponents.set(newId, timeline);
335
+ }
336
+ // The reveal controller is id-keyed; drop the stale target so the loop's
337
+ // setTarget/bind under the new id owns the paced reveal.
338
+ this.#toolArgsReveal.finish(oldId);
339
+ const readArgs = this.#readToolCallArgs.get(oldId);
340
+ if (readArgs !== undefined) {
341
+ this.#readToolCallArgs.delete(oldId);
342
+ this.#readToolCallArgs.set(newId, readArgs);
343
+ }
344
+ const readAssistant = this.#readToolCallAssistantComponents.get(oldId);
345
+ if (readAssistant !== undefined) {
346
+ this.#readToolCallAssistantComponents.delete(oldId);
347
+ this.#readToolCallAssistantComponents.set(newId, readAssistant);
348
+ }
349
+ // A collapsed read renders into a shared group keyed by id; rename its
350
+ // entry so the row isn't duplicated under the new id.
351
+ if (pending instanceof ReadToolGroupComponent) pending.renameEntry(oldId, newId);
352
+ // A server-resolved completion (Cursor/todo) can land under `newId` while
353
+ // the card was still keyed by `oldId`, so it was parked in
354
+ // `#orphanedToolCompletions` instead of settling. Now that the card owns
355
+ // `newId`, apply the held result — the normal creation path that consumes
356
+ // held completions is skipped on a re-key (Codex review on #6881).
357
+ if (pending) {
358
+ const orphan = this.#orphanedToolCompletions.get(newId);
359
+ if (orphan) {
360
+ this.#orphanedToolCompletions.delete(newId);
361
+ this.#settleHeldCompletion(pending, orphan);
362
+ }
363
+ }
364
+ }
365
+
277
366
  #inlineReadToolImages(
278
367
  toolCallId: string,
279
368
  result: { content: Array<{ type: string; data?: string; mimeType?: string }> },
@@ -351,6 +440,9 @@ export class EventController {
351
440
  this.#renderedCustomMessages.clear();
352
441
  this.#lastIntent = undefined;
353
442
  this.#toolTimelineComponents.clear();
443
+ this.#streamedToolCallIdByIndex.clear();
444
+ this.#retractedToolCallIds.clear();
445
+ this.#syntheticFailureCards.clear();
354
446
  this.#orphanedToolCompletions.clear();
355
447
  this.#postToolAssistantComponents.clear();
356
448
  this.#backgroundTaskCallIds.clear();
@@ -440,6 +532,9 @@ export class EventController {
440
532
 
441
533
  async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
442
534
  this.#toolTimelineComponents.clear();
535
+ this.#streamedToolCallIdByIndex.clear();
536
+ this.#retractedToolCallIds.clear();
537
+ this.#syntheticFailureCards.clear();
443
538
  this.#orphanedToolCompletions.clear();
444
539
  this.#postToolAssistantComponents.clear();
445
540
  this.#lastIntent = undefined;
@@ -539,6 +634,7 @@ export class EventController {
539
634
  this.ctx.ui.requestRender();
540
635
  } else if (event.message.role === "assistant") {
541
636
  this.#lastVisibleBlockCount = 0;
637
+ this.#streamedToolCallIdByIndex.clear();
542
638
  this.ctx.streamingComponent = createAssistantMessageComponent(this.ctx);
543
639
  this.ctx.streamingMessage = event.message;
544
640
  this.ctx.chatContainer.addChild(this.ctx.streamingComponent);
@@ -750,8 +846,17 @@ export class EventController {
750
846
  if (this.ctx.streamingMessage.content.some(content => content.type === "toolCall")) {
751
847
  this.ctx.streamingComponent.markTranscriptBlockFinalized();
752
848
  }
753
- for (const content of this.ctx.streamingMessage.content) {
849
+ for (let contentIndex = 0; contentIndex < this.ctx.streamingMessage.content.length; contentIndex++) {
850
+ const content = this.ctx.streamingMessage.content[contentIndex]!;
754
851
  if (content.type !== "toolCall") continue;
852
+ // Re-key the live card when a provider rewrites this block's id
853
+ // across deltas, so the changed id reuses the existing card
854
+ // instead of spawning a duplicate (#6879).
855
+ const priorId = this.#streamedToolCallIdByIndex.get(contentIndex);
856
+ if (priorId !== undefined && priorId !== content.id) {
857
+ this.#migrateStreamedToolCallId(priorId, content.id);
858
+ }
859
+ this.#streamedToolCallIdByIndex.set(contentIndex, content.id);
755
860
  if (content.name === "read") {
756
861
  if (!readArgsHaveTarget(content.arguments)) {
757
862
  // Args still streaming — defer until path is parseable so we can route to the
@@ -929,17 +1034,40 @@ export class EventController {
929
1034
  component.setArgsComplete(toolCallId);
930
1035
  }
931
1036
  } else {
932
- // The turn ended without running these calls (abort/error/TTSR rewind),
933
- // so they will never produce a result. Seal them so they stop animating
934
- // and freeze instead of pinning the transcript live region while a retry
935
- // streams fresh blocks below them. Background task calls keep updating.
936
- for (const [toolCallId, component] of this.ctx.pendingTools.entries()) {
937
- if (!this.#backgroundTaskCallIds.has(toolCallId) && component instanceof ToolExecutionComponent) {
938
- component.seal();
1037
+ // The turn ended without running these calls. What happens next
1038
+ // decides whether their cards should vanish or stay:
1039
+ // TTSR rewind known NOW via `isTtsrAbortPending` re-runs the
1040
+ // turn and re-streams fresh cards, so retract the uncommitted
1041
+ // never-run cards here and swallow the synthetic completions
1042
+ // agent-loop emits for them, else the call renders twice (#6879).
1043
+ // • A plain terminal error/abort, or an auto-retry (whose
1044
+ // supersession is only known later at `auto_retry_start`), must
1045
+ // NOT be retracted here: agent-loop emits a synthetic
1046
+ // `tool_execution_end` right after this that settles each card
1047
+ // into a visible aborted/error result. Leave them so the terminal
1048
+ // failure stays visible; `#handleAutoRetryStart` removes them only
1049
+ // if a retry actually supersedes them (Codex review on #6881).
1050
+ const supersededByRewind =
1051
+ this.ctx.streamingMessage.stopReason === "aborted" && this.ctx.viewSession.isTtsrAbortPending;
1052
+ if (supersededByRewind) {
1053
+ for (const [toolCallId, component] of Array.from(this.ctx.pendingTools.entries())) {
1054
+ if (this.#backgroundTaskCallIds.has(toolCallId)) continue;
1055
+ if (
1056
+ !(component instanceof ToolExecutionComponent) &&
1057
+ !(component instanceof ReadToolGroupComponent)
1058
+ ) {
1059
+ continue;
1060
+ }
1061
+ if (this.ctx.chatContainer.isBlockUncommitted(component)) {
1062
+ this.#retractToolCardEntry(toolCallId, component);
1063
+ this.#retractedToolCallIds.add(toolCallId);
1064
+ } else {
1065
+ component.seal();
1066
+ }
939
1067
  }
940
1068
  }
941
- // These calls will never produce a result either, so the tracked
942
- // waiting poll cannot be displaced anymore — freeze it in place.
1069
+ // These calls will never run this attempt, so the tracked waiting
1070
+ // poll cannot be displaced anymore — freeze it in place.
943
1071
  this.#resolveDisplaceablePoll();
944
1072
  }
945
1073
  // Surface a prompt-cache invalidation: if the previous turn cached a
@@ -1005,6 +1133,7 @@ export class EventController {
1005
1133
  }
1006
1134
 
1007
1135
  async #handleToolExecutionStart(event: Extract<AgentSessionEvent, { type: "tool_execution_start" }>): Promise<void> {
1136
+ if (this.#retractedToolCallIds.has(event.toolCallId)) return;
1008
1137
  this.#ensureWorkingLoaderWhileStreaming();
1009
1138
  this.#updateWorkingMessageFromIntent(event.intent);
1010
1139
  if (event.toolName === "ask" || this.#toolWillPromptForApproval(event.toolName, event.args)) {
@@ -1157,6 +1286,23 @@ export class EventController {
1157
1286
  }
1158
1287
 
1159
1288
  async #handleToolExecutionEnd(event: Extract<AgentSessionEvent, { type: "tool_execution_end" }>): Promise<void> {
1289
+ // `createAbortedToolResult` emits start/end after an error/aborted
1290
+ // assistant message. The matching card was deliberately retracted at
1291
+ // message_end; consume the completion instead of recreating/updating UI.
1292
+ if (this.#retractedToolCallIds.delete(event.toolCallId)) return;
1293
+ // A synthetic aborted/error completion (agent-loop's placeholder for a
1294
+ // never-run call on a terminal error/abort) settles the card in place so a
1295
+ // terminal failure stays visible. Remember it so `#handleAutoRetryStart`
1296
+ // can remove it if an auto-retry then supersedes the turn (Codex review on
1297
+ // #6881). Capture the live component now — the settle path below drops it
1298
+ // from `pendingTools` but leaves it in the transcript.
1299
+ const syntheticFailureDetails = event.result.details as { __synthetic?: boolean; source?: string } | undefined;
1300
+ const syntheticFailureCard =
1301
+ syntheticFailureDetails?.__synthetic === true &&
1302
+ (syntheticFailureDetails.source === "assistant_stop_error" ||
1303
+ syntheticFailureDetails.source === "assistant_stop_aborted")
1304
+ ? this.ctx.pendingTools.get(event.toolCallId)
1305
+ : undefined;
1160
1306
  // A transient overlay (auto-compaction / auto-retry / handoff) that ran
1161
1307
  // between this tool's start and end could have detached the working
1162
1308
  // loader. `tool_execution_update` already reconciles this so the spinner
@@ -1187,6 +1333,17 @@ export class EventController {
1187
1333
  } else {
1188
1334
  let component = this.ctx.pendingTools.get(event.toolCallId);
1189
1335
  if (!component) {
1336
+ // A persisted result can win a mid-stream transcript rebuild
1337
+ // before this live completion handler runs. Rebuild removes the
1338
+ // pending handle and replay owns the completed card, but the
1339
+ // original timeline entry remains as proof that a card already
1340
+ // existed. Do not create a fallback read group beside replay
1341
+ // (#6879); the fallback is only for a completion that genuinely
1342
+ // outran every streamed card.
1343
+ if (this.#toolTimelineComponents.has(event.toolCallId)) {
1344
+ this.#clearReadToolCall(event.toolCallId);
1345
+ return;
1346
+ }
1190
1347
  const group = this.#getReadGroup();
1191
1348
  const args = this.#readToolCallArgs.get(event.toolCallId);
1192
1349
  if (args) {
@@ -1243,6 +1400,7 @@ export class EventController {
1243
1400
  this.#orphanedToolCompletions.set(event.toolCallId, event);
1244
1401
  }
1245
1402
  }
1403
+ if (syntheticFailureCard) this.#syntheticFailureCards.set(event.toolCallId, syntheticFailureCard);
1246
1404
  // Update todo display when todo tool completes
1247
1405
  if (event.toolName === "todo" && !event.isError) {
1248
1406
  const details = event.result.details as { phases?: TodoPhase[] } | undefined;
@@ -1349,6 +1507,9 @@ export class EventController {
1349
1507
  this.#readToolCallArgs.clear();
1350
1508
  this.#readToolCallAssistantComponents.clear();
1351
1509
  this.#toolTimelineComponents.clear();
1510
+ this.#streamedToolCallIdByIndex.clear();
1511
+ this.#retractedToolCallIds.clear();
1512
+ this.#syntheticFailureCards.clear();
1352
1513
  this.#orphanedToolCompletions.clear();
1353
1514
  this.#postToolAssistantComponents.clear();
1354
1515
  this.#resetReadGroup();
@@ -1522,6 +1683,17 @@ export class EventController {
1522
1683
  async #handleAutoRetryStart(event: Extract<AgentSessionEvent, { type: "auto_retry_start" }>): Promise<void> {
1523
1684
  this.#retryPending = true;
1524
1685
  this.#trackRetrySupersededAssistantComponent(this.#lastAssistantComponent);
1686
+ // A retry supersedes the just-failed turn: its assistant + tool calls are
1687
+ // pruned from context and re-streamed. Remove the cards that a synthetic
1688
+ // aborted/error completion settled in place at message_end so the retry's
1689
+ // fresh cards don't render the same call twice (#6879). Only uncommitted
1690
+ // cards are removable; one already on the scrollback tape stays as history.
1691
+ for (const [toolCallId, component] of this.#syntheticFailureCards) {
1692
+ if (this.ctx.chatContainer.isBlockUncommitted(component)) {
1693
+ this.#retractToolCardEntry(toolCallId, component);
1694
+ }
1695
+ }
1696
+ this.#syntheticFailureCards.clear();
1525
1697
  this.#stopWorkingLoader();
1526
1698
  this.ctx.statusContainer.disposeChildren();
1527
1699
  if (AIError.is(event.errorId, AIError.Flag.ThinkingLoop)) {
@@ -1,4 +1,5 @@
1
1
  import { type AgentToolResult, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
+ import type { CompactionOutcome } from "@oh-my-pi/pi-agent-core/compaction";
2
3
  import { PASTE_CODE_LOGIN_PROVIDERS } from "@oh-my-pi/pi-ai";
3
4
  import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
4
5
  import type { OAuthProvider } from "@oh-my-pi/pi-ai/oauth/types";
@@ -696,15 +697,38 @@ export class SelectorController {
696
697
  this.ctx.session.modelRegistry,
697
698
  this.ctx.session.scopedModels,
698
699
  {
699
- onPick: async (model, selector) => {
700
- try {
701
- // Session-only: update agent state but don't persist the model to settings.
700
+ onPick: async (model, selector, { overContext }) => {
701
+ // Session-only: update agent state but don't persist the model to settings.
702
+ const applySessionModel = async () => {
702
703
  const roleThinkingLevel = this.ctx.session.resolveTemporaryModelThinkingLevel(model);
703
704
  await this.ctx.session.setModelTemporary(model, roleThinkingLevel);
704
705
  this.ctx.statusLine.invalidate();
705
706
  this.ctx.updateEditorBorderColor();
706
707
  const roleSelectorHint = this.ctx.keybindings.getKeys("app.model.select")[0] ?? "Alt+M";
707
708
  this.ctx.showStatus(`Session-only model: ${selector}. Use ${roleSelectorHint} or /model for roles.`);
709
+ };
710
+ try {
711
+ if (overContext) {
712
+ // Over-context pick: close the picker so the compaction loader is
713
+ // visible, compact with the current model, then switch. The switch
714
+ // runs in the before-flush hook so any prompt queued during
715
+ // compaction executes on the target model, not the old one; the
716
+ // early "nothing to compact" return skips the hook, so the
717
+ // idempotent post-return call covers it. A cancelled or failed
718
+ // compaction keeps the current model — the target still cannot
719
+ // fit the transcript.
720
+ done();
721
+ let switched = false;
722
+ const switchAfterCompaction = async (outcome: CompactionOutcome) => {
723
+ if (switched || outcome !== "ok") return;
724
+ switched = true;
725
+ await applySessionModel();
726
+ };
727
+ const outcome = await this.ctx.handleCompactCommand(undefined, undefined, switchAfterCompaction);
728
+ await switchAfterCompaction(outcome);
729
+ return;
730
+ }
731
+ await applySessionModel();
708
732
  done();
709
733
  } catch (error) {
710
734
  this.ctx.showError(error instanceof Error ? error.message : String(error));
@@ -753,7 +777,6 @@ export class SelectorController {
753
777
  * entry — used when reopening the hub after a /login round-trip.
754
778
  */
755
779
  #showModelHub(hubOptions: { initialProviderId?: string }): void {
756
- const currentContextTokens = this.ctx.session.getContextUsage()?.tokens ?? 0;
757
780
  let overlayHandle: OverlayHandle | undefined;
758
781
  let hub: ModelHubComponent | undefined;
759
782
  let closed = false;
@@ -821,7 +844,6 @@ export class SelectorController {
821
844
  selector,
822
845
  thinkingLevel: isAuto ? ThinkingLevel.Inherit : concreteThinking,
823
846
  persist: targetScope === "global",
824
- currentContextTokens,
825
847
  });
826
848
  if (!switched) return;
827
849
  if (targetScope === "project") {
@@ -922,7 +944,6 @@ export class SelectorController {
922
944
  thinkingLevel: effectiveIsAuto
923
945
  ? ThinkingLevel.Inherit
924
946
  : (concreteThinking ?? ThinkingLevel.Inherit),
925
- currentContextTokens,
926
947
  });
927
948
  if (!switched) return;
928
949
  if (effectiveIsAuto) {
@@ -355,7 +355,11 @@ export interface InteractiveModeContext {
355
355
  handlePythonCommand(code: string, excludeFromContext?: boolean): Promise<void>;
356
356
  handleMCPCommand(text: string): Promise<void>;
357
357
  handleSSHCommand(text: string): Promise<void>;
358
- handleCompactCommand(customInstructions?: string, mode?: CompactMode): Promise<CompactionOutcome>;
358
+ handleCompactCommand(
359
+ customInstructions?: string,
360
+ mode?: CompactMode,
361
+ beforeFlush?: (outcome: CompactionOutcome) => void | Promise<void>,
362
+ ): Promise<CompactionOutcome>;
359
363
  handleHandoffCommand(customInstructions?: string): Promise<void>;
360
364
  handleShakeCommand(mode: ShakeMode): Promise<void>;
361
365
  handleMoveCommand(targetPath?: string): Promise<void>;
@@ -8,7 +8,7 @@ Use ONLY for one binary or a short pipeline that computes a fact (`wc -l`, `sort
8
8
  - `pty: true` only for terminal interaction (`sudo`, `ssh`).
9
9
  - Order-dependent commands use `&&` in one call; independent calls may run concurrently.
10
10
  - Internal URIs (`skill://`, `agent://`, …) auto-resolve to paths.
11
- {{#if hasShellBuiltins}}- aux utils available: mkdir, wc, sort, comm, diff, uniq, base64, cmp, md5sum, sha{1,224,256,384,512}sum, b2sum, basename, dirname, readlink, realpath, touch, stat, date, mktemp, seq, yes, printenv, truncate, tac, nproc, uname, whoami, hostname, which, cut, tee, tr, paste, sed, xargs, jq, rm, mv, ln, ts, sponge, ifne, isutf8, combine{{#unless isWindows}}, errno{{/unless}}{{/if}}
11
+ {{#if hasShellBuiltins}}- aux utils available: mkdir, wc, sort, comm, diff, uniq, base64, cmp, md5sum, sha{1,224,256,384,512}sum, b2sum, basename, dirname, readlink, realpath, touch, stat, date, mktemp, seq, yes, printenv, truncate, tac, nproc, uname, whoami, hostname, which, pgrep, pkill, pidwait, top, cut, tee, tr, paste, sed, xargs, jq, rm, mv, ln, ts, sponge, ifne, isutf8, combine{{#unless isWindows}}, errno{{/unless}}{{/if}}
12
12
  {{#if asyncEnabled}}- `async: true` defers a finite command's result; it does not extend `timeout`.{{/if}}
13
13
  </instruction>
14
14
 
@@ -345,12 +345,19 @@ export class AgentLifecycleManager {
345
345
  }
346
346
 
347
347
  /**
348
- * Hard removal: dispose if live, unregister from registry, drop timers.
349
- * When `expected` is given, only a ref matching it is released; a stale
350
- * release can never take down a newer same-id ref. Returns true when a
351
- * matching ref was released.
348
+ * Dispose if live and drop timers. When `expected` is given, only a ref
349
+ * matching it is released; a stale release can never take down a newer
350
+ * same-id ref. Returns true when a matching ref was released.
351
+ *
352
+ * By default the ref is unregistered (teardown / one-shot removal). Pass
353
+ * `tombstone: true` for an explicit kill: the ref is kept registered as a
354
+ * terminal `aborted` row (session detached) instead of being removed, so a
355
+ * later persisted-subagent scan (e.g. Agent Hub reopen) skips it via its
356
+ * `if (!registry.get(id))` guard rather than re-adopting the surviving
357
+ * on-disk transcript as a fresh `parked` row. Mirrors
358
+ * `finalizeSubagentLifecycle`'s genuine-kill path.
352
359
  */
353
- async release(id: string, expected?: AgentRefExpectation): Promise<boolean> {
360
+ async release(id: string, expected?: AgentRefExpectation, options?: { tombstone?: boolean }): Promise<boolean> {
354
361
  const adopted = this.#adopted.get(id);
355
362
  const current = this.#registry.get(id);
356
363
  const currentMatches =
@@ -371,14 +378,26 @@ export class AgentLifecycleManager {
371
378
  await park.promise;
372
379
  }
373
380
 
374
- if (this.#registry.get(id) === ref && ref.session) {
381
+ if (options?.tombstone) {
382
+ // Explicit kill: mark the ref terminal `aborted` and detach the session
383
+ // BEFORE disposing. aborted refs must satisfy the AgentRef invariant
384
+ // (session === null) so ensureLive / hub focus can never route into a
385
+ // disposed session; setting the terminal status first also makes
386
+ // createAgentSession's dispose wrapper (unregisterUnlessParked) preserve
387
+ // the ref instead of removing it, so a later persisted-subagent rescan
388
+ // skips it. The transcript file is left intact (history://<id>).
389
+ this.#registry.setStatus(id, "aborted", ref);
390
+ }
391
+ const live = this.#registry.get(id) === ref ? ref.session : null;
392
+ if (options?.tombstone) this.#registry.detachSession(id, ref);
393
+ if (live) {
375
394
  try {
376
- await ref.session.dispose();
395
+ await live.dispose();
377
396
  } catch (error) {
378
397
  logger.warn("AgentLifecycleManager.release: session dispose failed", { id, error: String(error) });
379
398
  }
380
399
  }
381
- this.#registry.unregister(id, ref);
400
+ if (!options?.tombstone) this.#registry.unregister(id, ref);
382
401
  return true;
383
402
  }
384
403
 
@@ -396,21 +415,27 @@ export class AgentLifecycleManager {
396
415
  async #revive(id: string, revive: AgentReviver, ref: AgentRef, adopted: AdoptedAgent): Promise<AgentSession> {
397
416
  const session = await revive(ref);
398
417
  let liveRef = this.#registry.get(id);
399
- if (liveRef === ref) {
418
+ if (liveRef === ref && ref.status === "parked" && !ref.session) {
419
+ // A simple reviver returned a session without claiming the parked ref;
420
+ // attach it here while the exact ref is still revivable.
400
421
  if (!this.#registry.attachSession(id, session, ref.sessionFile, ref)) {
401
422
  await session.dispose();
402
423
  throw new Error(`Agent "${id}" changed before its persisted session could attach.`);
403
424
  }
404
425
  liveRef = ref;
405
426
  } else if (
406
- !liveRef ||
427
+ liveRef !== ref ||
428
+ liveRef.status !== "running" ||
407
429
  liveRef.session !== session ||
408
430
  liveRef.kind !== ref.kind ||
409
431
  liveRef.parentId !== ref.parentId ||
410
432
  liveRef.sessionFile !== ref.sessionFile
411
433
  ) {
434
+ // createAgentSession may have already claimed this exact parked ref and
435
+ // attached the returned session. Any other state — especially an
436
+ // `aborted` tombstone set while revive() was in flight — is stale.
412
437
  await session.dispose();
413
- throw new Error(`Agent "${id}" was replaced while its persisted session was reviving.`);
438
+ throw new Error(`Agent "${id}" was replaced or became terminal while its persisted session was reviving.`);
414
439
  }
415
440
  adopted.ref = liveRef;
416
441
  // Emits status_changed → "idle", which re-arms the TTL timer below.
@@ -105,19 +105,23 @@ export class AgentRegistry {
105
105
  }
106
106
 
107
107
  /**
108
- * Register a new id only when it is absent, or reuse the exact ref a parked
109
- * revival was authorized to revive. A missing expected ref is a failed CAS:
110
- * callers must never claim an id after its prior generation disappeared.
108
+ * Register a new id only when it is absent, or reuse the exact detached
109
+ * `parked` ref a revival was authorized to revive. A missing, replaced, or
110
+ * terminal expected ref is a failed CAS: delayed revivers must never claim an
111
+ * id after its prior generation disappeared or was hard-killed.
111
112
  */
112
113
  registerIfAvailable(input: RegisterInput, expected: AgentRef | null): AgentRef | undefined {
113
114
  const current = this.#refs.get(input.id);
114
115
  if (expected === null) return current ? undefined : this.register(input);
115
- return current === expected ? current : undefined;
116
+ return current === expected && current.status === "parked" && !current.session ? current : undefined;
116
117
  }
117
118
 
118
119
  setStatus(id: string, status: AgentStatus, expected?: AgentRefExpectation): boolean {
119
120
  const ref = this.#refs.get(id);
120
121
  if (!ref || !this.#matchesExpected(ref, expected)) return false;
122
+ // `aborted` is terminal: delayed progress/revival work from the killed
123
+ // generation must never transition the tombstone back to a live status.
124
+ if (ref.status === "aborted") return status === "aborted";
121
125
  if (ref.status === status) return true;
122
126
  ref.status = status;
123
127
  // Activity describes current work; it is meaningless once the agent
@@ -159,7 +163,10 @@ export class AgentRegistry {
159
163
  expected?: AgentRefExpectation,
160
164
  ): boolean {
161
165
  const ref = this.#refs.get(id);
162
- if (!ref || !this.#matchesExpected(ref, expected)) return false;
166
+ // Never attach a late-created session to a hard-killed tombstone. This
167
+ // closes the race between a parked reviver claiming the ref and finishing
168
+ // createAgentSession after an explicit kill.
169
+ if (!ref || ref.status === "aborted" || !this.#matchesExpected(ref, expected)) return false;
163
170
  ref.session = session;
164
171
  if (sessionFile !== undefined) ref.sessionFile = sessionFile;
165
172
  ref.lastActivity = Date.now();