@oh-my-pi/pi-coding-agent 17.2.1 → 17.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/{CHANGELOG-dj46zzrm.md → CHANGELOG-c5hpqt9r.md} +61 -0
  3. package/dist/cli.js +3856 -3850
  4. package/dist/types/config/keybindings.d.ts +6 -1
  5. package/dist/types/config/settings-schema.d.ts +16 -5
  6. package/dist/types/edit/hashline/block-resolver.d.ts +5 -5
  7. package/dist/types/edit/hashline/diff.d.ts +4 -4
  8. package/dist/types/eval/executor-base.d.ts +27 -0
  9. package/dist/types/eval/js/context-manager.d.ts +7 -0
  10. package/dist/types/eval/js/executor.d.ts +2 -0
  11. package/dist/types/extensibility/extensions/runner.d.ts +64 -5
  12. package/dist/types/extensibility/extensions/types.d.ts +15 -0
  13. package/dist/types/extensibility/extensions/wrapper.d.ts +1 -1
  14. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -1
  15. package/dist/types/live/visualizer.d.ts +3 -1
  16. package/dist/types/modes/acp/acp-agent.d.ts +3 -3
  17. package/dist/types/modes/components/ask-dialog.d.ts +0 -1
  18. package/dist/types/modes/components/keybinding-hints.d.ts +1 -1
  19. package/dist/types/modes/controllers/mcp-command-controller.d.ts +13 -0
  20. package/dist/types/modes/interactive-mode.d.ts +1 -0
  21. package/dist/types/modes/theme/theme.d.ts +1 -1
  22. package/dist/types/modes/types.d.ts +2 -0
  23. package/dist/types/secrets/index.d.ts +22 -0
  24. package/dist/types/secrets/obfuscator.d.ts +1 -1
  25. package/dist/types/session/agent-session-types.d.ts +4 -0
  26. package/dist/types/session/agent-session.d.ts +9 -0
  27. package/dist/types/session/codex-auto-reset.d.ts +182 -64
  28. package/dist/types/session/session-storage.d.ts +5 -2
  29. package/dist/types/session/session-tools.d.ts +2 -1
  30. package/dist/types/session/turn-recovery.d.ts +7 -1
  31. package/dist/types/system-prompt.d.ts +8 -0
  32. package/dist/types/task/executor.d.ts +27 -0
  33. package/dist/types/task/persisted-revive.d.ts +7 -0
  34. package/dist/types/tools/bash-interceptor.d.ts +1 -1
  35. package/dist/types/tools/browser/attach.d.ts +9 -1
  36. package/dist/types/tools/browser/launch.d.ts +50 -1
  37. package/dist/types/tools/browser/registry.d.ts +7 -0
  38. package/dist/types/tools/browser/shared-daemon.d.ts +26 -0
  39. package/dist/types/tools/browser/tab-protocol.d.ts +8 -0
  40. package/dist/types/tools/browser/tab-supervisor.d.ts +1 -0
  41. package/dist/types/tools/browser/tab-worker.d.ts +3 -1
  42. package/dist/types/tools/shell-tokenize.d.ts +11 -0
  43. package/dist/types/utils/image-loading.d.ts +2 -0
  44. package/dist/types/web/search/providers/duckduckgo.d.ts +9 -0
  45. package/dist/types/web/search/types.d.ts +2 -2
  46. package/package.json +12 -12
  47. package/src/commit/agentic/index.ts +2 -1
  48. package/src/commit/pipeline.ts +3 -2
  49. package/src/config/keybindings.ts +6 -1
  50. package/src/config/settings-schema.ts +16 -3
  51. package/src/edit/hashline/block-resolver.ts +5 -5
  52. package/src/edit/hashline/diff.ts +22 -15
  53. package/src/edit/hashline/execute.ts +14 -13
  54. package/src/edit/renderer.ts +3 -3
  55. package/src/eval/executor-base.ts +39 -0
  56. package/src/eval/jl/executor.ts +13 -2
  57. package/src/eval/js/context-manager.ts +97 -14
  58. package/src/eval/js/executor.ts +3 -0
  59. package/src/eval/js/index.ts +1 -0
  60. package/src/eval/py/executor.ts +55 -15
  61. package/src/eval/rb/executor.ts +8 -1
  62. package/src/extensibility/extensions/runner.ts +121 -4
  63. package/src/extensibility/extensions/types.ts +15 -0
  64. package/src/extensibility/extensions/wrapper.ts +19 -2
  65. package/src/extensibility/legacy-pi-coding-agent-shim.ts +5 -5
  66. package/src/launch/broker.ts +59 -41
  67. package/src/live/visualizer.ts +8 -1
  68. package/src/lsp/index.ts +51 -11
  69. package/src/main.ts +3 -0
  70. package/src/modes/acp/acp-agent.ts +16 -5
  71. package/src/modes/components/ask-dialog.ts +65 -23
  72. package/src/modes/components/assistant-message.ts +5 -10
  73. package/src/modes/components/custom-editor.ts +1 -1
  74. package/src/modes/components/keybinding-hints.ts +4 -13
  75. package/src/modes/components/status-line/segments.ts +4 -5
  76. package/src/modes/components/tool-execution.ts +4 -5
  77. package/src/modes/controllers/input-controller.ts +18 -40
  78. package/src/modes/controllers/live-command-controller.ts +1 -0
  79. package/src/modes/controllers/mcp-command-controller.ts +28 -11
  80. package/src/modes/interactive-mode.ts +59 -2
  81. package/src/modes/theme/theme.ts +13 -6
  82. package/src/modes/types.ts +2 -0
  83. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  84. package/src/prompts/system/plan-mode-active.md +5 -5
  85. package/src/sdk.ts +47 -16
  86. package/src/secrets/index.ts +83 -3
  87. package/src/secrets/obfuscator.ts +70 -15
  88. package/src/session/agent-session-types.ts +7 -1
  89. package/src/session/agent-session.ts +394 -113
  90. package/src/session/codex-auto-reset.ts +594 -123
  91. package/src/session/eval-runner.ts +2 -0
  92. package/src/session/messages.ts +1 -1
  93. package/src/session/session-maintenance.ts +50 -3
  94. package/src/session/session-manager.ts +4 -3
  95. package/src/session/session-stats.ts +14 -3
  96. package/src/session/session-storage.ts +47 -15
  97. package/src/session/session-tools.ts +30 -2
  98. package/src/session/turn-recovery.ts +15 -2
  99. package/src/slash-commands/builtin-registry.ts +21 -15
  100. package/src/slash-commands/helpers/reset-usage.ts +2 -0
  101. package/src/system-prompt.ts +13 -1
  102. package/src/task/executor.ts +168 -15
  103. package/src/task/persisted-revive.ts +26 -1
  104. package/src/telemetry-export.ts +8 -4
  105. package/src/tools/bash-interceptor.ts +85 -8
  106. package/src/tools/bash.ts +12 -6
  107. package/src/tools/browser/attach.ts +35 -10
  108. package/src/tools/browser/launch.ts +110 -16
  109. package/src/tools/browser/registry.ts +140 -31
  110. package/src/tools/browser/shared-daemon.ts +190 -0
  111. package/src/tools/browser/tab-protocol.ts +8 -0
  112. package/src/tools/browser/tab-supervisor.ts +31 -4
  113. package/src/tools/browser/tab-worker.ts +32 -2
  114. package/src/tools/browser.ts +1 -1
  115. package/src/tools/output-meta.ts +16 -1
  116. package/src/tools/shell-tokenize.ts +101 -0
  117. package/src/utils/image-loading.ts +8 -3
  118. package/src/utils/shell-snapshot.ts +31 -21
  119. package/src/web/search/providers/codex.ts +31 -6
  120. package/src/web/search/providers/duckduckgo.ts +183 -15
  121. package/src/web/search/types.ts +6 -1
@@ -2,6 +2,7 @@ import type { Agent } from "@oh-my-pi/pi-agent-core";
2
2
  import { logger } from "@oh-my-pi/pi-utils";
3
3
  import type { Settings } from "../config/settings";
4
4
  import { disposeJuliaKernelSessionsByOwner } from "../eval/jl/executor";
5
+ import { disposeVmContextsByOwner } from "../eval/js/context-manager";
5
6
  import { namespaceSessionId as namespacePythonSessionId } from "../eval/py";
6
7
  import {
7
8
  disposeKernelSessionsByOwner,
@@ -181,6 +182,7 @@ export class EvalRunner {
181
182
  disposeKernelSessionsByOwner(this.#kernelOwnerId),
182
183
  disposeRubyKernelSessionsByOwner(this.#kernelOwnerId),
183
184
  disposeJuliaKernelSessionsByOwner(this.#kernelOwnerId),
185
+ disposeVmContextsByOwner(this.#kernelOwnerId),
184
186
  ]);
185
187
  const errors: unknown[] = [];
186
188
  for (const result of results) if (result.status === "rejected") errors.push(result.reason);
@@ -1170,7 +1170,7 @@ function convertOne(m: AgentMessage, interruptedNext: boolean): Message[] {
1170
1170
  }
1171
1171
  case "assistant": {
1172
1172
  // A user-interrupted turn keeps its trailing thinking run on the
1173
- // persisted/displayed message so reload and Ctrl+L rebuilds still
1173
+ // persisted/displayed message so reload and display-reset rebuilds still
1174
1174
  // show it. That run is incomplete/unsigned and gets rejected on
1175
1175
  // resend, so strip it here — LLM path only — when the hidden
1176
1176
  // interrupted-thinking continuity message follows.
@@ -268,6 +268,19 @@ export interface SessionMaintenanceHost {
268
268
  export class SessionMaintenance {
269
269
  #compactionAbortController: AbortController | undefined;
270
270
  #autoCompactionAbortController: AbortController | undefined;
271
+ /**
272
+ * Live tool-loop contexts parked after mid-turn maintenance hit a no-progress
273
+ * dead end. Membership suppresses the repeated rescue + warning while no cut
274
+ * point exists; {@link maintainContextMidRun} re-arms the entry once a later
275
+ * tool result makes `prepareCompaction` viable again.
276
+ */
277
+ readonly #midTurnCompactionDeadEnds = new WeakSet<AgentMessage[]>();
278
+ /**
279
+ * Carries a mid-turn dead end across the loop's final answer to the next
280
+ * pre-prompt check. That check must not warn again for the same oversized
281
+ * persisted turn, but a new agent loop still gets its own live-array guard.
282
+ */
283
+ #midTurnDeadEndPendingPrePrompt = false;
271
284
  #skipPostTurnMaintenanceAssistantTimestamp: number | undefined;
272
285
  readonly #host: SessionMaintenanceHost;
273
286
 
@@ -968,7 +981,18 @@ export class SessionMaintenance {
968
981
  if (contextWindow <= 0) return;
969
982
  const compactionSettings = this.#host.settings.getGroup("compaction");
970
983
  const contextTokens = this.#estimatePrePromptContextTokens(messages, contextWindow);
984
+ const pendingMidTurnDeadEnd = this.#midTurnDeadEndPendingPrePrompt;
985
+ this.#midTurnDeadEndPendingPrePrompt = false;
971
986
  if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) return;
987
+ if (
988
+ pendingMidTurnDeadEnd &&
989
+ prepareCompaction(this.#host.sessionManager.getBranch(), compactionSettings, model) === undefined
990
+ ) {
991
+ // The prior tool loop already attempted the rescue and warned for this
992
+ // persisted oversized turn. Only a later persisted cut point makes a
993
+ // pre-prompt retry useful; the new agent loop may warn for its own turn.
994
+ return;
995
+ }
972
996
 
973
997
  // Auto-promote first: switching to a larger-context model avoids compacting
974
998
  // the history at all. The post-turn threshold path already promotes before
@@ -1039,6 +1063,25 @@ export class SessionMaintenance {
1039
1063
  if (!lastAssistant || lastAssistant.stopReason === "aborted" || lastAssistant.stopReason === "error") return;
1040
1064
 
1041
1065
  if (!(await this.#host.persistTurnMessagesForMidRunCompaction(context))) return;
1066
+ if (this.#midTurnCompactionDeadEnds.has(activeMessages)) {
1067
+ // A prior boundary already ran the dead-end rescue and could not reduce
1068
+ // this turn. Re-running the rescue and re-emitting its warning on every
1069
+ // following tool boundary is wasted work while nothing summarizable
1070
+ // exists. But the tool loop keeps appending turns: once a later
1071
+ // (smaller) tool result gives prepareCompaction a cut point before the
1072
+ // now-older oversized turn, compaction can finally make progress and
1073
+ // MUST run rather than stay suppressed until provider overflow (#7153
1074
+ // review). Stay parked only while no cut point is available; re-arm as
1075
+ // soon as one appears.
1076
+ if (
1077
+ !model ||
1078
+ prepareCompaction(this.#host.sessionManager.getBranch(), compactionSettings, model) === undefined
1079
+ ) {
1080
+ return;
1081
+ }
1082
+ this.#midTurnCompactionDeadEnds.delete(activeMessages);
1083
+ this.#midTurnDeadEndPendingPrePrompt = false;
1084
+ }
1042
1085
 
1043
1086
  const billedContextTokens = calculateContextTokens(lastAssistant.usage);
1044
1087
  const storedContextTokens = this.#estimateStoredContextTokens();
@@ -1061,13 +1104,17 @@ export class SessionMaintenance {
1061
1104
  }
1062
1105
 
1063
1106
  const messagesBefore = activeMessages.length;
1064
- await this.runAutoCompaction("threshold", false, false, false, {
1107
+ const result = await this.runAutoCompaction("threshold", false, false, false, {
1065
1108
  autoContinue: false,
1066
1109
  suppressContinuation: true,
1067
1110
  suppressHandoff: true,
1068
1111
  triggerContextTokens: contextTokens,
1069
1112
  phase: "mid_turn",
1070
1113
  });
1114
+ if (result.automaticContinuationBlocked) {
1115
+ this.#midTurnCompactionDeadEnds.add(activeMessages);
1116
+ this.#midTurnDeadEndPendingPrePrompt = true;
1117
+ }
1071
1118
 
1072
1119
  if (signal?.aborted) return;
1073
1120
  const compactedMessages = this.#host.agent.state.messages;
@@ -1492,7 +1539,7 @@ export class SessionMaintenance {
1492
1539
  thinkingLevel: this.#host.thinkingLevel(),
1493
1540
  tools: this.#host.agent.state.tools,
1494
1541
  sessionId: this.#host.sessionId(),
1495
- promptCacheKey: this.#host.sessionId(),
1542
+ promptCacheKey: this.#host.agent.promptCacheKey ?? this.#host.agent.sessionId,
1496
1543
  providerSessionState: this.#host.providerSessionState,
1497
1544
  // Route every summarization HTTP request through the
1498
1545
  // session's side-stream transport so the provider
@@ -2538,7 +2585,7 @@ export class SessionMaintenance {
2538
2585
  thinkingLevel: this.#host.thinkingLevel(),
2539
2586
  tools: this.#host.agent.state.tools,
2540
2587
  sessionId: this.#host.sessionId(),
2541
- promptCacheKey: this.#host.sessionId(),
2588
+ promptCacheKey: this.#host.agent.promptCacheKey ?? this.#host.agent.sessionId,
2542
2589
  providerSessionState: this.#host.providerSessionState,
2543
2590
  codexCompaction,
2544
2591
  },
@@ -870,9 +870,9 @@ export class SessionManager {
870
870
  return;
871
871
  }
872
872
 
873
- // Hot path: append synchronously so the entry is durable the instant this
874
- // returns (file/memory writers perform the write in-body). Never routed
875
- // through the async disk chain durability must hold without a flush().
873
+ // Hot path: queue the entry directly on the writer, outside the async disk
874
+ // chain. File writers batch same-turn appends until their microtask
875
+ // boundary; flush(), flushSync(), and close() drain that batch explicitly.
876
876
  // A mid-close writer leaves `#writer` undefined, so `#appendWriter` simply
877
877
  // opens a fresh append handle and the entry still lands.
878
878
  try {
@@ -1555,6 +1555,7 @@ export class SessionManager {
1555
1555
  if (this.#atomicEntryBatch) throw new Error("Cannot synchronously flush during an atomic session batch.");
1556
1556
  if (this.#diskFailure) throw this.#diskFailure;
1557
1557
  if (this.#fileIsCurrent && !this.#rewriteRequired) {
1558
+ this.#writer?.flushSync?.();
1558
1559
  const writerError = this.#writer?.getError();
1559
1560
  if (writerError) throw writerError;
1560
1561
  return;
@@ -1,5 +1,10 @@
1
1
  import type { Agent, AgentMessage } from "@oh-my-pi/pi-agent-core";
2
- import { calculatePromptTokens, estimateTokens, type SessionMessageEntry } from "@oh-my-pi/pi-agent-core/compaction";
2
+ import {
3
+ calculatePromptTokens,
4
+ estimateTokens,
5
+ hasContextTokenUsage,
6
+ type SessionMessageEntry,
7
+ } from "@oh-my-pi/pi-agent-core/compaction";
3
8
  import type { AssistantMessage, Model, ProviderResponseMetadata, Usage } from "@oh-my-pi/pi-ai";
4
9
  import { isRecord } from "@oh-my-pi/pi-utils";
5
10
  import type { ModelRegistry } from "../config/model-registry";
@@ -127,7 +132,12 @@ export class SessionStatsTracker {
127
132
  const entry = branchEntries[index];
128
133
  if (entry.type !== "message" || entry.message.role !== "assistant") continue;
129
134
  const assistant = entry.message;
130
- if (assistant.stopReason !== "aborted" && assistant.stopReason !== "error" && assistant.usage) {
135
+ if (
136
+ assistant.stopReason !== "aborted" &&
137
+ assistant.stopReason !== "error" &&
138
+ assistant.usage &&
139
+ hasContextTokenUsage(assistant.usage)
140
+ ) {
131
141
  anchorEntry = entry;
132
142
  break;
133
143
  }
@@ -184,7 +194,8 @@ export class SessionStatsTracker {
184
194
  message.role !== "assistant" ||
185
195
  message.stopReason === "aborted" ||
186
196
  message.stopReason === "error" ||
187
- !message.usage
197
+ !message.usage ||
198
+ !hasContextTokenUsage(message.usage)
188
199
  ) {
189
200
  continue;
190
201
  }
@@ -14,14 +14,17 @@ export interface SessionStorageStat {
14
14
 
15
15
  export interface SessionStorageWriter {
16
16
  /**
17
- * Append one newline-terminated line. File and memory storage perform the
18
- * write synchronously in-body; indexed backends queue in call order.
17
+ * Append one newline-terminated line. File storage batches same-turn appends
18
+ * until its microtask boundary; memory storage updates in-body; indexed
19
+ * backends queue in call order.
19
20
  *
20
21
  * `line` MUST include the trailing newline.
21
22
  */
22
23
  append(line: string): Promise<void>;
23
24
  /** Resolve once all queued appends complete. No fsync. */
24
25
  flush(): Promise<void>;
26
+ /** Drain synchronously flushable queued work when the backend supports it. No fsync. */
27
+ flushSync?(): void;
25
28
  /** False once close() has begun/finished. */
26
29
  isOpen(): boolean;
27
30
  close(): Promise<void>;
@@ -90,6 +93,8 @@ class FileSessionStorageWriter implements SessionStorageWriter {
90
93
  #closed = false;
91
94
  #error: Error | undefined;
92
95
  #onError: ((err: Error) => void) | undefined;
96
+ #pending = "";
97
+ #flushScheduled = false;
93
98
 
94
99
  constructor(fpath: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }) {
95
100
  this.#onError = options?.onError;
@@ -112,25 +117,46 @@ class FileSessionStorageWriter implements SessionStorageWriter {
112
117
  return error;
113
118
  }
114
119
 
115
- async append(line: string): Promise<void> {
116
- if (this.#closed) throw new Error("Writer closed");
117
- if (this.#error) throw this.#error;
118
- try {
119
- const buf = Buffer.from(line, "utf-8");
120
- let offset = 0;
121
- while (offset < buf.length) {
122
- const written = fs.writeSync(this.#fd, buf, offset, buf.length - offset);
123
- if (written === 0) {
124
- throw new Error("Short write");
125
- }
126
- offset += written;
120
+ #writeNow(line: string): void {
121
+ const buf = Buffer.from(line, "utf-8");
122
+ let offset = 0;
123
+ while (offset < buf.length) {
124
+ const written = fs.writeSync(this.#fd, buf, offset, buf.length - offset);
125
+ if (written === 0) {
126
+ throw new Error("Short write");
127
127
  }
128
+ offset += written;
129
+ }
130
+ }
131
+
132
+ #flushPendingNow(): void {
133
+ this.#flushScheduled = false;
134
+ if (this.#pending.length === 0) return;
135
+ const pending = this.#pending;
136
+ this.#pending = "";
137
+ try {
138
+ this.#writeNow(pending);
128
139
  } catch (err) {
129
- throw this.#recordError(err);
140
+ this.#recordError(err);
130
141
  }
131
142
  }
132
143
 
144
+ async append(line: string): Promise<void> {
145
+ if (this.#closed) throw new Error("Writer closed");
146
+ if (this.#error) throw this.#error;
147
+ this.#pending += line;
148
+ if (this.#flushScheduled) return;
149
+ this.#flushScheduled = true;
150
+ queueMicrotask(() => this.#flushPendingNow());
151
+ }
152
+
133
153
  async flush(): Promise<void> {
154
+ this.#flushPendingNow();
155
+ if (this.#error) throw this.#error;
156
+ }
157
+
158
+ flushSync(): void {
159
+ this.#flushPendingNow();
134
160
  if (this.#error) throw this.#error;
135
161
  }
136
162
 
@@ -140,6 +166,7 @@ class FileSessionStorageWriter implements SessionStorageWriter {
140
166
 
141
167
  async close(): Promise<void> {
142
168
  if (this.#closed) return;
169
+ this.#flushPendingNow();
143
170
  this.#closed = true;
144
171
  // Unregister from finalization - we're closing properly
145
172
  writerRegistry.unregister(this);
@@ -148,6 +175,7 @@ class FileSessionStorageWriter implements SessionStorageWriter {
148
175
  } catch {
149
176
  // Ignore close errors
150
177
  }
178
+ if (this.#error) throw this.#error;
151
179
  }
152
180
 
153
181
  getError(): Error | undefined {
@@ -474,6 +502,10 @@ class MemorySessionStorageWriter implements SessionStorageWriter {
474
502
  if (this.#error) throw this.#error;
475
503
  }
476
504
 
505
+ flushSync(): void {
506
+ if (this.#error) throw this.#error;
507
+ }
508
+
477
509
  isOpen(): boolean {
478
510
  return !this.#closed;
479
511
  }
@@ -72,7 +72,10 @@ interface SessionToolsOptions {
72
72
  builtInToolNames?: Iterable<string>;
73
73
  presentationPinnedToolNames?: ReadonlySet<string>;
74
74
  ensureWriteRegistered?: () => Promise<boolean>;
75
- rebuildSystemPrompt?: (toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>;
75
+ rebuildSystemPrompt?: (
76
+ toolNames: string[],
77
+ tools: Map<string, AgentTool>,
78
+ ) => Promise<{ systemPrompt: string[]; xdevCatalogNames?: readonly string[] }>;
76
79
  getLocalCalendarDate?: () => string;
77
80
  getMcpServerInstructions?: () => Map<string, string> | undefined;
78
81
  xdev?: XdevState;
@@ -196,6 +199,14 @@ export class SessionTools {
196
199
  #runtimeSelectedToolNames: ReadonlySet<string> | undefined;
197
200
  #baseSystemPrompt: string[];
198
201
  #lastAppliedToolSignature: string | undefined;
202
+ /**
203
+ * `xd://` device names the current base system prompt renders in its catalog
204
+ * (the last rebuild's {@link BuildSystemPromptResult.xdevCatalogNames}). Consulted
205
+ * when a pending mount notice is delivered: a device the outgoing prompt already
206
+ * lists is recorded as announced without a redundant notice line. Empty when the
207
+ * prompt carries no catalog (no mounts, or a custom prompt that omits the section).
208
+ */
209
+ #basePromptXdevNames: ReadonlySet<string> = new Set();
199
210
  #mcpRefreshTail: Promise<void> = Promise.resolve();
200
211
  #promptModelKey: string | undefined;
201
212
  #rebuildSystemPrompt: SessionToolsOptions["rebuildSystemPrompt"];
@@ -619,6 +630,7 @@ export class SessionTools {
619
630
 
620
631
  let rebuiltSystemPrompt: string[] | undefined;
621
632
  let rebuiltSignature: string | undefined;
633
+ let rebuiltXdevCatalogNames: readonly string[] | undefined;
622
634
  try {
623
635
  if (this.#rebuildSystemPrompt) {
624
636
  const signature = this.#computeAppliedToolSignature(validToolNames, tools);
@@ -626,6 +638,7 @@ export class SessionTools {
626
638
  const built = await this.#rebuildSystemPrompt(validToolNames, this.#toolRegistry);
627
639
  rebuiltSystemPrompt = built.systemPrompt;
628
640
  rebuiltSignature = signature;
641
+ rebuiltXdevCatalogNames = built.xdevCatalogNames;
629
642
  }
630
643
  }
631
644
  } catch (error) {
@@ -649,6 +662,7 @@ export class SessionTools {
649
662
  this.#host.agent.setSystemPrompt(this.#baseSystemPrompt);
650
663
  this.#lastAppliedToolSignature = rebuiltSignature;
651
664
  this.#promptModelKey = this.#currentPromptModelKey();
665
+ this.#basePromptXdevNames = new Set(rebuiltXdevCatalogNames);
652
666
  }
653
667
  }
654
668
 
@@ -765,11 +779,24 @@ export class SessionTools {
765
779
  }
766
780
 
767
781
  /** Consumes the hidden notice for unannounced `xd://` mount changes. */
768
- takePendingXdevMountNotice(): CustomMessage<XdevMountNoticeDetails> | undefined {
782
+ takePendingXdevMountNotice(baseCatalogDelivered: boolean): CustomMessage<XdevMountNoticeDetails> | undefined {
769
783
  const pending = this.#pendingXdevMountDelta;
770
784
  if (!pending) return undefined;
771
785
  this.#pendingXdevMountDelta = undefined;
772
786
  this.#ensureAnnouncedMountsSeeded();
787
+ // A pending add for a device the outgoing base prompt already lists in its
788
+ // catalog needs no notice line — but only when the final provider prompt
789
+ // still carries that base catalog. A `before_agent_start` replacement drops
790
+ // it, so its additions must remain in the notice. Record prompt-carried
791
+ // devices announced here, after the final prompt is known and immediately
792
+ // before delivery. The pending delta remains untouched by rebuilds, letting
793
+ // {@link #notifyXdevMountDelta} cancel a mount followed by an unmount before
794
+ // any request is sent (issue #7139 reviews).
795
+ if (baseCatalogDelivered) {
796
+ for (const name of pending.added) {
797
+ if (this.#basePromptXdevNames.has(name)) this.#announcedMounts.add(name);
798
+ }
799
+ }
773
800
  // Only announce a net change relative to what the model already knows (from
774
801
  // this session and persisted history): a re-mount of an already-announced
775
802
  // device — the common resume/reconnect case — and an unmount for a device
@@ -1066,6 +1093,7 @@ export class SessionTools {
1066
1093
  const built = await this.#rebuildSystemPrompt(activeToolNames, this.#toolRegistry);
1067
1094
  if (this.#host.isDisposed()) return;
1068
1095
  this.#baseSystemPrompt = built.systemPrompt;
1096
+ this.#basePromptXdevNames = new Set(built.xdevCatalogNames);
1069
1097
  this.#host.clearMemoryPromotionSnapshot();
1070
1098
  if (
1071
1099
  previousBaseSystemPrompt.length !== this.#baseSystemPrompt.length ||
@@ -121,7 +121,13 @@ export interface TurnRecoveryHost {
121
121
  sessionMessageAlreadyPersisted(message: AssistantMessage): boolean;
122
122
  setModelWithProviderSessionReset(model: Model): Promise<void>;
123
123
  resetCurrentResponsesProviderSession(reason: string): void;
124
- maybeAutoRedeemCodexReset(): Promise<boolean>;
124
+ /**
125
+ * Spend a saved Codex reset for the blocked pool, if eligible.
126
+ * `activeBlockUnblockAtMs` is the absolute unblock time parsed from the
127
+ * live usage-limit error — authoritative for the active account when the
128
+ * usage report still shows a pre-block snapshot.
129
+ */
130
+ maybeAutoRedeemCodexReset(activeBlockUnblockAtMs?: number): Promise<boolean>;
125
131
  runAutoCompaction(
126
132
  reason: "overflow" | "threshold" | "idle" | "incomplete",
127
133
  willRetry: boolean,
@@ -1390,7 +1396,14 @@ export class TurnRecovery {
1390
1396
  }
1391
1397
 
1392
1398
  if (!retryBudgetExhausted && !staleOpenAIResponsesReplayError && recordedUsageLimitOutcome) {
1393
- if (recordedUsageLimitOutcome.switchedCredential || (await this.#host.maybeAutoRedeemCodexReset())) {
1399
+ if (
1400
+ recordedUsageLimitOutcome.switchedCredential ||
1401
+ // Convert the parsed hint to an absolute timestamp NOW, before the
1402
+ // hook's usage IO — a duration re-anchored after slow fetches drifts.
1403
+ (await this.#host.maybeAutoRedeemCodexReset(
1404
+ parsedRetryAfterMs === undefined ? undefined : Date.now() + parsedRetryAfterMs,
1405
+ ))
1406
+ ) {
1394
1407
  switchedCredential = true;
1395
1408
  delayMs = 0;
1396
1409
  } else {
@@ -34,7 +34,7 @@ import {
34
34
  import { readMCPConfigFile } from "../mcp/config-writer";
35
35
  import { resolveMemoryBackend } from "../memory-backend";
36
36
  import { runPauseScreen } from "../modes/components/pause-screen";
37
- import { collectMcpServerNames } from "../modes/controllers/mcp-command-controller";
37
+ import { collectMcpServerNames, MCPCommandController } from "../modes/controllers/mcp-command-controller";
38
38
  import { describeLoopLimitRuntime } from "../modes/loop-limit";
39
39
  import { theme } from "../modes/theme/theme";
40
40
  import type { InteractiveModeContext } from "../modes/types";
@@ -2628,13 +2628,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
2628
2628
  return commandConsumed();
2629
2629
  },
2630
2630
  handleTui: async (_command, runtime) => {
2631
- // Invalidate registry fs caches and the plugin roots cache so
2632
- // listClaudePluginRoots re-reads from disk on next access.
2633
- const projectPath = await resolveActiveProjectRegistryPath(runtime.ctx.sessionManager.getCwd());
2634
- clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined);
2635
- await runtime.ctx.refreshSkillState();
2636
- await runtime.ctx.refreshSlashCommandState();
2637
- resetCapabilities();
2631
+ await reloadTuiPluginState(runtime.ctx);
2638
2632
  runtime.ctx.showStatus("Plugins reloaded.");
2639
2633
  runtime.ctx.editor.setText("");
2640
2634
  },
@@ -3059,6 +3053,24 @@ export function buildTuiBuiltinSlashCommands(runtime: TuiSlashCommandRuntime): R
3059
3053
  */
3060
3054
  export const BUILTIN_SLASH_COMMANDS_INTERNAL: ReadonlyArray<SlashCommandSpec> = BUILTIN_SLASH_COMMAND_REGISTRY;
3061
3055
 
3056
+ /**
3057
+ * Reload the interactive session's plugin runtime: invalidate fs/plugin-root
3058
+ * caches, rediscover skills and file slash commands, reset the capability
3059
+ * cache, and reconnect MCP servers (rebinding the session's MCP tools). Shared
3060
+ * by `/reload-plugins`'s TUI handler and the `handle`-adapter's `reloadPlugins`
3061
+ * hook so both honor the command's documented MCP reload scope (#7189).
3062
+ */
3063
+ async function reloadTuiPluginState(ctx: InteractiveModeContext): Promise<void> {
3064
+ const projectPath = await resolveActiveProjectRegistryPath(ctx.sessionManager.getCwd());
3065
+ clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined);
3066
+ await ctx.refreshSkillState();
3067
+ await ctx.refreshSlashCommandState();
3068
+ resetCapabilities();
3069
+ if (ctx.mcpManager) {
3070
+ await new MCPCommandController(ctx).reloadServers();
3071
+ }
3072
+ }
3073
+
3062
3074
  /**
3063
3075
  * Execute a builtin slash command in the interactive TUI.
3064
3076
  *
@@ -3107,13 +3119,7 @@ export async function executeBuiltinSlashCommand(
3107
3119
  ctx.showStatus(text);
3108
3120
  },
3109
3121
  refreshCommands: () => ctx.refreshSlashCommandState(),
3110
- reloadPlugins: async () => {
3111
- const projectPath = await resolveActiveProjectRegistryPath(ctx.sessionManager.getCwd());
3112
- clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined);
3113
- await ctx.refreshSkillState();
3114
- await ctx.refreshSlashCommandState();
3115
- resetCapabilities();
3116
- },
3122
+ reloadPlugins: () => reloadTuiPluginState(ctx),
3117
3123
  };
3118
3124
  const result = await command.handle(parsed, adapted);
3119
3125
  ctx.editor.setText("");
@@ -54,6 +54,8 @@ export function describeRedeemOutcome(outcome: ResetCreditRedeemOutcome, label:
54
54
  return `${label}: that reset was already redeemed.`;
55
55
  case "no_credit":
56
56
  return `${label}: no saved resets available to spend.`;
57
+ case "credit_list_failed":
58
+ return `${label}: couldn't load this account's saved resets (network/auth) — nothing was spent, try again.`;
57
59
  case "nothing_to_reset":
58
60
  return `${label}: nothing to reset right now — your limits aren't constrained, so no credit was spent.`;
59
61
  case "no_account":
@@ -554,6 +554,14 @@ export interface BuildSystemPromptOptions {
554
554
  export interface BuildSystemPromptResult {
555
555
  /** Ordered system prompt blocks. Providers should preserve entries as distinct messages/blocks. */
556
556
  systemPrompt: string[];
557
+ /**
558
+ * Names of `xd://` devices whose catalog/protocol section this prompt renders.
559
+ * Empty/undefined when no catalog was emitted (no mounted devices, or a custom
560
+ * prompt template that omits the section). Lets the session fold these devices
561
+ * into its announced-mount baseline so a same-turn mount notice does not re-list
562
+ * a catalog the prompt already carries (issue #7139).
563
+ */
564
+ xdevCatalogNames?: readonly string[];
557
565
  }
558
566
 
559
567
  /** Build the system prompt with tools, guidelines, and context */
@@ -895,5 +903,9 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
895
903
  systemPrompt.push(activeRepoContextPrompt);
896
904
  }
897
905
 
898
- return { systemPrompt };
906
+ // The xd:// protocol section (with its device catalog) is only rendered by the
907
+ // default template; a resolved custom prompt uses a template that omits it.
908
+ const xdevCatalogNames =
909
+ !resolvedCustomPrompt && xdevTools.length > 0 ? xdevTools.map(mounted => mounted.name) : undefined;
910
+ return { systemPrompt, xdevCatalogNames };
899
911
  }