@oh-my-pi/pi-coding-agent 16.3.0 → 16.3.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 (163) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/cli.js +3855 -3798
  3. package/dist/types/cli/update-cli.d.ts +1 -0
  4. package/dist/types/cli/update-cli.test.d.ts +1 -0
  5. package/dist/types/commands/update.d.ts +5 -0
  6. package/dist/types/config/config-file.d.ts +1 -1
  7. package/dist/types/config/settings-schema.d.ts +20 -0
  8. package/dist/types/dap/client.d.ts +9 -1
  9. package/dist/types/discovery/helpers.d.ts +2 -0
  10. package/dist/types/edit/file-snapshot-store.d.ts +9 -1
  11. package/dist/types/edit/hashline/execute.d.ts +1 -1
  12. package/dist/types/edit/hashline/params.d.ts +3 -6
  13. package/dist/types/edit/renderer.d.ts +1 -0
  14. package/dist/types/extensibility/plugins/parser.d.ts +2 -1
  15. package/dist/types/hindsight/client.d.ts +15 -11
  16. package/dist/types/hindsight/client.test.d.ts +1 -0
  17. package/dist/types/irc/bus.d.ts +5 -4
  18. package/dist/types/mcp/smithery-registry.d.ts +1 -0
  19. package/dist/types/mcp/smithery-registry.test.d.ts +1 -0
  20. package/dist/types/modes/components/advisor-message.d.ts +4 -2
  21. package/dist/types/modes/components/tool-execution.d.ts +9 -6
  22. package/dist/types/modes/components/transcript-container.d.ts +1 -0
  23. package/dist/types/modes/theme/theme.d.ts +1 -1
  24. package/dist/types/session/agent-session.d.ts +13 -13
  25. package/dist/types/session/indexed-session-storage.d.ts +2 -2
  26. package/dist/types/session/session-storage.d.ts +31 -3
  27. package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +1 -0
  28. package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +1 -0
  29. package/dist/types/ssh/connection-manager.d.ts +19 -0
  30. package/dist/types/ssh/sshfs-mount.d.ts +10 -1
  31. package/dist/types/subprocess/worker-client.d.ts +20 -6
  32. package/dist/types/task/discovery.d.ts +5 -2
  33. package/dist/types/task/renderer.d.ts +1 -0
  34. package/dist/types/tools/ast-grep.d.ts +4 -2
  35. package/dist/types/tools/bash.d.ts +27 -0
  36. package/dist/types/tools/browser/render.d.ts +2 -0
  37. package/dist/types/tools/debug.d.ts +1 -0
  38. package/dist/types/tools/eval-render.d.ts +2 -0
  39. package/dist/types/tools/glob.d.ts +4 -2
  40. package/dist/types/tools/grep.d.ts +4 -3
  41. package/dist/types/tools/path-utils.d.ts +7 -0
  42. package/dist/types/tools/renderers.d.ts +24 -0
  43. package/dist/types/tools/ssh.d.ts +4 -1
  44. package/dist/types/tts/index.d.ts +2 -0
  45. package/dist/types/tts/speakable.d.ts +47 -0
  46. package/dist/types/tts/speech-enhancer.d.ts +46 -0
  47. package/dist/types/tts/streaming-player.d.ts +1 -2
  48. package/dist/types/tts/tts-client.d.ts +11 -10
  49. package/dist/types/tts/tts-protocol.d.ts +7 -0
  50. package/dist/types/tts/vocalizer.d.ts +15 -8
  51. package/dist/types/utils/fetch-timeout.d.ts +4 -0
  52. package/dist/types/utils/git.d.ts +2 -0
  53. package/dist/types/web/search/providers/base.d.ts +1 -0
  54. package/dist/types/web/search/providers/gemini.d.ts +1 -0
  55. package/package.json +12 -12
  56. package/scripts/generate-legacy-pi-bundled-registry.ts +8 -2
  57. package/src/advisor/__tests__/advisor.test.ts +1 -1
  58. package/src/cli/gallery-fixtures/fs.ts +2 -2
  59. package/src/cli/gallery-fixtures/search.ts +2 -2
  60. package/src/cli/models-cli.ts +19 -0
  61. package/src/cli/update-cli.test.ts +28 -0
  62. package/src/cli/update-cli.ts +35 -8
  63. package/src/cli.ts +27 -5
  64. package/src/commands/update.ts +8 -2
  65. package/src/config/config-file.ts +6 -6
  66. package/src/config/model-resolver.ts +31 -10
  67. package/src/config/settings-schema.ts +21 -0
  68. package/src/cursor.ts +1 -1
  69. package/src/dap/client.ts +134 -36
  70. package/src/discovery/helpers.ts +8 -0
  71. package/src/edit/file-snapshot-store.ts +12 -1
  72. package/src/edit/hashline/diff.ts +4 -13
  73. package/src/edit/hashline/execute.ts +1 -1
  74. package/src/edit/hashline/params.ts +5 -12
  75. package/src/edit/renderer.ts +4 -2
  76. package/src/edit/streaming.ts +15 -5
  77. package/src/export/html/tool-views.generated.js +34 -34
  78. package/src/extensibility/custom-tools/loader.ts +3 -3
  79. package/src/extensibility/extensions/loader.ts +10 -3
  80. package/src/extensibility/legacy-pi-coding-agent-shim.ts +2 -2
  81. package/src/extensibility/plugins/installer.ts +12 -3
  82. package/src/extensibility/plugins/legacy-pi-compat.ts +87 -11
  83. package/src/extensibility/plugins/loader.ts +30 -1
  84. package/src/extensibility/plugins/manager.ts +32 -8
  85. package/src/extensibility/plugins/parser.ts +7 -5
  86. package/src/extensibility/tool-event-input.ts +1 -1
  87. package/src/hindsight/client.test.ts +33 -0
  88. package/src/hindsight/client.ts +42 -22
  89. package/src/internal-urls/docs-index.generated.txt +1 -1
  90. package/src/irc/bus.ts +5 -4
  91. package/src/main.ts +7 -1
  92. package/src/mcp/oauth-flow.ts +93 -4
  93. package/src/mcp/smithery-auth.ts +3 -0
  94. package/src/mcp/smithery-connect.ts +9 -0
  95. package/src/mcp/smithery-registry.test.ts +51 -0
  96. package/src/mcp/smithery-registry.ts +27 -4
  97. package/src/modes/components/__tests__/move-overlay.test.ts +72 -1
  98. package/src/modes/components/advisor-message.ts +13 -10
  99. package/src/modes/components/assistant-message.ts +1 -1
  100. package/src/modes/components/move-overlay.ts +35 -23
  101. package/src/modes/components/status-line/component.ts +11 -4
  102. package/src/modes/components/tool-execution.ts +119 -20
  103. package/src/modes/components/transcript-container.ts +26 -0
  104. package/src/modes/components/tree-selector.ts +10 -3
  105. package/src/modes/controllers/event-controller.ts +23 -3
  106. package/src/modes/controllers/tool-args-reveal.ts +1 -1
  107. package/src/modes/interactive-mode.ts +1 -0
  108. package/src/modes/rpc/rpc-client.ts +29 -16
  109. package/src/modes/theme/shimmer.ts +49 -15
  110. package/src/modes/theme/theme.ts +7 -0
  111. package/src/prompts/goals/goal-mode-context.md +4 -0
  112. package/src/prompts/goals/goal-todo-context.md +10 -2
  113. package/src/prompts/system/speech-rewrite.md +15 -0
  114. package/src/prompts/system/tiny-title-system.md +1 -1
  115. package/src/prompts/system/title-system-marker.md +2 -1
  116. package/src/prompts/system/title-system.md +2 -1
  117. package/src/prompts/tools/ast-grep.md +3 -3
  118. package/src/prompts/tools/glob.md +1 -1
  119. package/src/prompts/tools/grep.md +1 -1
  120. package/src/sdk.ts +8 -5
  121. package/src/session/agent-session.ts +329 -79
  122. package/src/session/indexed-session-storage.ts +40 -3
  123. package/src/session/session-history-format.ts +6 -2
  124. package/src/session/session-manager.ts +83 -14
  125. package/src/session/session-storage.ts +112 -26
  126. package/src/slash-commands/helpers/usage-report.ts +6 -1
  127. package/src/ssh/__tests__/connection-manager-timeout.test.ts +61 -0
  128. package/src/ssh/__tests__/sshfs-mount.test.ts +13 -0
  129. package/src/ssh/connection-manager.ts +44 -11
  130. package/src/ssh/sshfs-mount.ts +27 -4
  131. package/src/subprocess/worker-client.ts +161 -10
  132. package/src/task/discovery.ts +25 -2
  133. package/src/task/executor.ts +24 -4
  134. package/src/task/render.ts +26 -12
  135. package/src/task/renderer.ts +1 -0
  136. package/src/task/worktree.ts +144 -16
  137. package/src/tiny/text.ts +109 -17
  138. package/src/tools/ast-grep.ts +20 -17
  139. package/src/tools/bash.ts +46 -9
  140. package/src/tools/browser/render.ts +2 -0
  141. package/src/tools/debug.ts +1 -0
  142. package/src/tools/eval-render.ts +5 -2
  143. package/src/tools/gh-renderer.ts +3 -0
  144. package/src/tools/glob.ts +24 -20
  145. package/src/tools/grep.ts +39 -37
  146. package/src/tools/path-utils.ts +55 -10
  147. package/src/tools/read.ts +23 -6
  148. package/src/tools/renderers.ts +24 -0
  149. package/src/tools/ssh.ts +25 -7
  150. package/src/tts/index.ts +2 -0
  151. package/src/tts/speakable.ts +382 -0
  152. package/src/tts/speech-enhancer.ts +204 -0
  153. package/src/tts/streaming-player.ts +71 -16
  154. package/src/tts/tts-client.ts +11 -10
  155. package/src/tts/tts-protocol.ts +14 -5
  156. package/src/tts/tts-worker.ts +52 -49
  157. package/src/tts/vocalizer.ts +277 -46
  158. package/src/utils/clipboard.ts +49 -12
  159. package/src/utils/fetch-timeout.ts +10 -0
  160. package/src/utils/git.ts +8 -0
  161. package/src/web/search/index.ts +8 -0
  162. package/src/web/search/providers/base.ts +1 -0
  163. package/src/web/search/providers/gemini.ts +23 -6
@@ -121,6 +121,7 @@ import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
121
121
  import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
122
122
  import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
123
123
  import {
124
+ escapeXmlText,
124
125
  extractRetryHint,
125
126
  formatDuration,
126
127
  getAgentDbPath,
@@ -237,6 +238,7 @@ import { containsWorkflow, WORKFLOW_NOTICE } from "../modes/workflow";
237
238
  import { createPlanReadMatcher } from "../plan-mode/plan-protection";
238
239
  import type { PlanModeState } from "../plan-mode/state";
239
240
  import advisorSystemPrompt from "../prompts/advisor/system.md" with { type: "text" };
241
+ import goalModeContextPrompt from "../prompts/goals/goal-mode-context.md" with { type: "text" };
240
242
  import goalTodoContextPrompt from "../prompts/goals/goal-todo-context.md" with { type: "text" };
241
243
  import parentIrcSteerTemplate from "../prompts/steering/parent-irc.md" with { type: "text" };
242
244
  import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
@@ -358,6 +360,8 @@ import { classifyUnexpectedStop, isUnexpectedStopCandidate } from "./unexpected-
358
360
  import { YieldQueue } from "./yield-queue";
359
361
 
360
362
  const SESSION_STOP_CONTINUATION_CAP = 8;
363
+ const PLAN_MODE_REMINDER_MAX = 3;
364
+ const PLAN_DECISION_TOOLS = new Set(["ask", "resolve"]);
361
365
 
362
366
  /**
363
367
  * Mutating tool results (`bash`/`eval`/`edit`/`write`/`ast_edit`) without the
@@ -965,6 +969,18 @@ interface ActiveAdvisor {
965
969
  agentUnsubscribe?: () => void;
966
970
  model: Model;
967
971
  thinkingLevel: ThinkingLevel;
972
+ /** Stable key for the resolved runtime inputs that require a rebuild to change. */
973
+ signature: string;
974
+ }
975
+
976
+ /** Resolved advisor config ready to instantiate as an {@link ActiveAdvisor}. */
977
+ interface AdvisorRuntimeDescriptor {
978
+ config: AdvisorConfig;
979
+ name: string;
980
+ slug: string;
981
+ model: Model;
982
+ thinkingLevel: ThinkingLevel;
983
+ signature: string;
968
984
  }
969
985
 
970
986
  export interface FreshSessionResult {
@@ -1587,6 +1603,8 @@ export class AgentSession {
1587
1603
  /** Mid-run nudges fired this prompt cycle; capped by
1588
1604
  * {@link MID_RUN_TODO_NUDGE_MAX_PER_CYCLE}, reset with the counter above. */
1589
1605
  #midRunNudgeCount = 0;
1606
+ #planModeReminderCount = 0;
1607
+ #planModeReminderAwaitingProgress = false;
1590
1608
  #todoPhases: TodoPhase[] = [];
1591
1609
  #replanTitleRefreshInFlight: Promise<void> | undefined = undefined;
1592
1610
  /** Resolved TITLE_SYSTEM.md override applied to every automatic session-title
@@ -1861,6 +1879,21 @@ export class AgentSession {
1861
1879
  const records = [...this.#pendingIrcInterrupts, ...this.#pendingIrcAsides];
1862
1880
  this.#pendingIrcInterrupts = [];
1863
1881
  this.#pendingIrcAsides = [];
1882
+ if (this.#planModeState?.enabled) {
1883
+ // Plan mode: fold stranded IRC asides into context without waking an
1884
+ // autonomous turn. Convergence to ask/resolve stays user-driven.
1885
+ for (const record of records) {
1886
+ this.agent.appendMessage(record);
1887
+ this.sessionManager.appendCustomMessageEntry(
1888
+ record.customType,
1889
+ record.content,
1890
+ record.display,
1891
+ record.details,
1892
+ record.attribution ?? "agent",
1893
+ );
1894
+ }
1895
+ return;
1896
+ }
1864
1897
  this.#wakeForIrc(records);
1865
1898
  }
1866
1899
 
@@ -2247,32 +2280,10 @@ export class AgentSession {
2247
2280
  }
2248
2281
  }
2249
2282
 
2250
- #buildAdvisorRuntime(seedToCurrent = false): boolean {
2251
- if (this.#isDisposed) return false;
2252
- if (this.#advisors.length > 0) return true;
2253
- if (!this.#advisorEnabled) return false;
2254
- if (this.#agentKind !== "main" && !this.settings.get("advisor.subagents")) return false;
2255
-
2256
- // The lone implicit "default" advisor (slug "") reproduces the legacy
2257
- // single-advisor path: the `advisor` role model + `__advisor.jsonl`.
2283
+ #resolveAdvisorRuntimeDescriptors(emitWarnings: boolean): AdvisorRuntimeDescriptor[] {
2258
2284
  const legacy = !this.#advisorConfigs?.length;
2259
2285
  const roster: AdvisorConfig[] = legacy ? [{ name: "default" }] : this.#advisorConfigs!;
2260
-
2261
- // Advisor service tier (`tier.advisor`): "none" (default) runs the advisor
2262
- // on standard processing; "inherit" tracks the session's live per-family
2263
- // tiers per request (like the main agent, including /fast toggles); a
2264
- // concrete value is broadcast across families and applied to the advisor
2265
- // model's family. One value for all advisors.
2266
- const advisorTierSetting = this.settings.get("tier.advisor");
2267
- const advisorTierMap =
2268
- advisorTierSetting === "inherit"
2269
- ? undefined
2270
- : serviceTierForAllFamilies(serviceTierSettingToTier(advisorTierSetting));
2271
- const advisorServiceTierResolver = (model: Model): ServiceTier | undefined =>
2272
- advisorTierSetting === "inherit"
2273
- ? this.#effectiveServiceTier(model)
2274
- : resolveModelServiceTier(advisorTierMap, model);
2275
-
2286
+ const descriptors: AdvisorRuntimeDescriptor[] = [];
2276
2287
  const usedSlugs = new Set<string>();
2277
2288
  for (const config of roster) {
2278
2289
  let slug = legacy ? "" : slugifyAdvisorName(config.name);
@@ -2293,23 +2304,84 @@ export class AgentSession {
2293
2304
  model = resolved.model;
2294
2305
  thinkingLevel = concreteThinkingLevel(resolved.thinkingLevel);
2295
2306
  if (!model) {
2296
- this.emitNotice("warning", `Advisor "${config.name}": no model matched "${config.model}"`, "advisor");
2307
+ if (emitWarnings) {
2308
+ this.emitNotice("warning", `Advisor "${config.name}": no model matched "${config.model}"`, "advisor");
2309
+ }
2297
2310
  continue;
2298
2311
  }
2299
2312
  } else {
2300
2313
  const sel = resolveAdvisorRoleSelection(this.settings, this.#modelRegistry.getAvailable());
2301
2314
  if (!sel) {
2302
- logger.debug("advisor enabled but no model assigned to the 'advisor' role; advisor inactive", {
2303
- advisor: config.name,
2304
- });
2315
+ if (emitWarnings) {
2316
+ logger.debug("advisor enabled but no model assigned to the 'advisor' role; advisor inactive", {
2317
+ advisor: config.name,
2318
+ });
2319
+ }
2305
2320
  continue;
2306
2321
  }
2307
2322
  model = sel.model;
2308
2323
  thinkingLevel = concreteThinkingLevel(sel.thinkingLevel);
2309
2324
  }
2310
- const advisorModel = model;
2311
- const advisorName = config.name;
2312
2325
  const advisorThinkingLevel = thinkingLevel ?? ThinkingLevel.Medium;
2326
+ descriptors.push({
2327
+ config,
2328
+ name: config.name,
2329
+ slug,
2330
+ model,
2331
+ thinkingLevel: advisorThinkingLevel,
2332
+ signature: this.#advisorRuntimeSignature(config, slug, model, advisorThinkingLevel),
2333
+ });
2334
+ }
2335
+ return descriptors;
2336
+ }
2337
+
2338
+ #advisorRuntimeSignature(config: AdvisorConfig, slug: string, model: Model, thinkingLevel: ThinkingLevel): string {
2339
+ const tools = config.tools?.length ? config.tools.join("\u001e") : "";
2340
+ const instructions = config.instructions?.trim() ?? "";
2341
+ return [config.name, slug, model.provider, model.id, thinkingLevel, tools, instructions].join("\u001f");
2342
+ }
2343
+
2344
+ #advisorRuntimeMatchesCurrentConfig(): boolean {
2345
+ const descriptors = this.#resolveAdvisorRuntimeDescriptors(false);
2346
+ if (descriptors.length !== this.#advisors.length) return false;
2347
+ for (let i = 0; i < descriptors.length; i++) {
2348
+ if (descriptors[i].signature !== this.#advisors[i].signature) return false;
2349
+ }
2350
+ return true;
2351
+ }
2352
+
2353
+ #buildAdvisorRuntime(seedToCurrent = false): boolean {
2354
+ if (this.#isDisposed) return false;
2355
+ if (this.#advisors.length > 0) return true;
2356
+ if (!this.#advisorEnabled) return false;
2357
+ if (this.#agentKind !== "main" && !this.settings.get("advisor.subagents")) return false;
2358
+
2359
+ const descriptors = this.#resolveAdvisorRuntimeDescriptors(true);
2360
+
2361
+ // Advisor service tier (`tier.advisor`): "none" (default) runs the advisor
2362
+ // on standard processing; "inherit" tracks the session's live per-family
2363
+ // tiers per request (like the main agent, including /fast toggles); a
2364
+ // concrete value is broadcast across families and applied to the advisor
2365
+ // model's family. One value for all advisors.
2366
+ const advisorTierSetting = this.settings.get("tier.advisor");
2367
+ const advisorTierMap =
2368
+ advisorTierSetting === "inherit"
2369
+ ? undefined
2370
+ : serviceTierForAllFamilies(serviceTierSettingToTier(advisorTierSetting));
2371
+ const advisorServiceTierResolver = (model: Model): ServiceTier | undefined =>
2372
+ advisorTierSetting === "inherit"
2373
+ ? this.#effectiveServiceTier(model)
2374
+ : resolveModelServiceTier(advisorTierMap, model);
2375
+
2376
+ for (const descriptor of descriptors) {
2377
+ const {
2378
+ config,
2379
+ slug,
2380
+ model: advisorModel,
2381
+ name: advisorName,
2382
+ thinkingLevel: advisorThinkingLevel,
2383
+ signature,
2384
+ } = descriptor;
2313
2385
 
2314
2386
  const emissionGuard = new AdvisorEmissionGuard();
2315
2387
  const adviseTool = new AdviseTool((note, severity) => this.#routeAdvice(advisorRef, note, severity));
@@ -2435,6 +2507,7 @@ export class AgentSession {
2435
2507
  recorderClosed: Promise.resolve(),
2436
2508
  model: advisorModel,
2437
2509
  thinkingLevel: advisorThinkingLevel,
2510
+ signature,
2438
2511
  };
2439
2512
  this.#attachAdvisorRecorderFeed(advisorRef);
2440
2513
  if (seedToCurrent) runtime.seedTo(this.agent.state.messages.length);
@@ -2521,6 +2594,20 @@ export class AgentSession {
2521
2594
  return;
2522
2595
  }
2523
2596
  this.#recordAdvisorInterruptDelivered();
2597
+ if (this.#planModeState?.enabled) {
2598
+ // Plan mode: record advice visibly in context but never wake an
2599
+ // autonomous turn — only user-driven turns converge on ask/resolve.
2600
+ this.#preserveAdvisorCard({
2601
+ role: "custom",
2602
+ customType: "advisor",
2603
+ content,
2604
+ display: true,
2605
+ attribution: "agent",
2606
+ details,
2607
+ timestamp: Date.now(),
2608
+ });
2609
+ return;
2610
+ }
2524
2611
  void this.sendCustomMessage(
2525
2612
  { customType: "advisor", content, display: true, attribution: "agent", details },
2526
2613
  { deliverAs: "steer", triggerTurn: true },
@@ -2945,6 +3032,26 @@ export class AgentSession {
2945
3032
  manager?.cancelAll({ ownerId: this.#agentId });
2946
3033
  }
2947
3034
 
3035
+ /**
3036
+ * True when a background async job owned by this agent is still running with
3037
+ * an unsuppressed delivery, or a finished job's delivery is still queued or
3038
+ * in flight. Either way the async-result follow-up will re-wake the loop, so
3039
+ * a settle observed now is a scheduling pause rather than a terminal stop:
3040
+ * stop-time passes (todo reminder, session_stop hooks) defer to the settle
3041
+ * reached once the session is fully idle. Suppressed deliveries
3042
+ * (acknowledged, or watched by an in-flight `job` poll) never wake the loop,
3043
+ * so they don't count.
3044
+ */
3045
+ #hasPendingAsyncWake(): boolean {
3046
+ const manager = this.#asyncJobManager;
3047
+ if (!manager) return false;
3048
+ const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined;
3049
+ return (
3050
+ manager.getRunningJobs(ownerFilter).some(job => !manager.isDeliverySuppressed(job.id)) ||
3051
+ manager.hasPendingDeliveries(ownerFilter)
3052
+ );
3053
+ }
3054
+
2948
3055
  // =========================================================================
2949
3056
  // Event Subscription
2950
3057
  // =========================================================================
@@ -3421,6 +3528,11 @@ export class AgentSession {
3421
3528
  } else {
3422
3529
  await this.#goalRuntime.onToolCompleted(event.toolName);
3423
3530
  }
3531
+ this.#planModeReminderAwaitingProgress = false;
3532
+ if (this.#isPlanDecisionTool(event.toolName)) {
3533
+ this.#planModeReminderCount = 0;
3534
+ this.#planModeReminderAwaitingProgress = false;
3535
+ }
3424
3536
  }
3425
3537
  if (event.type === "tool_execution_end" && event.toolName === "yield" && !event.isError) {
3426
3538
  this.#lastSuccessfulYieldToolCallId = event.toolCallId;
@@ -3843,12 +3955,26 @@ export class AgentSession {
3843
3955
  await emitAgentEndNotification();
3844
3956
  return;
3845
3957
  }
3958
+ const planModeContinuationScheduled = await this.#enforcePlanModeDecisionAtSettle();
3959
+ if (planModeContinuationScheduled) {
3960
+ await emitAgentEndNotification();
3961
+ return;
3962
+ }
3846
3963
  const todoContinuationScheduled = await this.#checkTodoCompletion();
3847
3964
  if (todoContinuationScheduled) {
3848
3965
  await emitAgentEndNotification();
3849
3966
  return;
3850
3967
  }
3851
3968
  }
3969
+ // A pending async wake means this settle is a scheduling pause, not
3970
+ // the terminal stop: the async-result delivery continues the loop and
3971
+ // the real stop settles later. Defer the session_stop hook pass until
3972
+ // the session is fully idle (the todo reminder above defers the same
3973
+ // way inside #checkTodoCompletion).
3974
+ if (this.#hasPendingAsyncWake()) {
3975
+ await emitAgentEndNotification();
3976
+ return;
3977
+ }
3852
3978
  await this.#emitSessionStopEvent(settledMessages, msg);
3853
3979
  await emitAgentEndNotification();
3854
3980
  }
@@ -6684,6 +6810,12 @@ export class AgentSession {
6684
6810
  if (state?.enabled) {
6685
6811
  this.#planReferenceSent = false;
6686
6812
  this.#planReferencePath = state.planFilePath;
6813
+ } else {
6814
+ this.#planModeReminderCount = 0;
6815
+ this.#planModeReminderAwaitingProgress = false;
6816
+ // Drop any unconsumed forced decision so a post-plan execution turn
6817
+ // does not inherit a stale `required` tool choice.
6818
+ this.#toolChoiceQueue.removeByLabel("plan-mode-decision");
6687
6819
  }
6688
6820
  }
6689
6821
 
@@ -6948,15 +7080,30 @@ export class AgentSession {
6948
7080
  return {
6949
7081
  role: "custom",
6950
7082
  customType: "goal-mode-context",
6951
- content: todoContext ? `${content}\n\n${todoContext}` : content,
7083
+ content: prompt.render(goalModeContextPrompt, { goalContext: content, todoContext }),
6952
7084
  display: false,
6953
7085
  attribution: "agent",
6954
7086
  timestamp: Date.now(),
6955
7087
  };
6956
7088
  }
6957
7089
 
7090
+ #sanitizeGoalTodoText(text: string): string {
7091
+ return escapeXmlText(text)
7092
+ .replace(/\r\n/g, "\\n")
7093
+ .replace(/\r/g, "\\r")
7094
+ .replace(/\n/g, "\\n")
7095
+ .replace(/\t/g, "\\t")
7096
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u2028\u2029]/g, " ");
7097
+ }
7098
+
6958
7099
  #buildGoalTodoContext(): string | undefined {
6959
7100
  if (!this.settings.get("todo.enabled")) return undefined;
7101
+ const activeToolNames = this.getActiveToolNames();
7102
+ const canCallTodoTool = activeToolNames.includes("todo");
7103
+ const canDiscoverTodoTool =
7104
+ !canCallTodoTool && this.getDiscoverableTools({ source: "builtin" }).some(tool => tool.name === "todo");
7105
+ const canActivateTodoTool = canDiscoverTodoTool && activeToolNames.includes("search_tool_bm25");
7106
+ if (!canCallTodoTool && !canDiscoverTodoTool) return undefined;
6960
7107
  const phases = this.getTodoPhases().filter(phase => phase.tasks.length > 0);
6961
7108
  if (phases.length === 0) return undefined;
6962
7109
 
@@ -6964,7 +7111,7 @@ export class AgentSession {
6964
7111
  let closed = 0;
6965
7112
  let open = 0;
6966
7113
  const promptPhases = phases.map(phase => ({
6967
- name: phase.name,
7114
+ name: this.#sanitizeGoalTodoText(phase.name),
6968
7115
  tasks: phase.tasks.map(task => {
6969
7116
  total++;
6970
7117
  if (task.status === "completed" || task.status === "abandoned") {
@@ -6972,11 +7119,13 @@ export class AgentSession {
6972
7119
  } else {
6973
7120
  open++;
6974
7121
  }
6975
- return { content: task.content, status: task.status };
7122
+ return { content: this.#sanitizeGoalTodoText(task.content), status: task.status };
6976
7123
  }),
6977
7124
  }));
6978
7125
 
6979
7126
  return prompt.render(goalTodoContextPrompt, {
7127
+ canCallTodoTool,
7128
+ canActivateTodoTool,
6980
7129
  closed: String(closed),
6981
7130
  open: String(open),
6982
7131
  phases: promptPhases,
@@ -7162,6 +7311,11 @@ export class AgentSession {
7162
7311
  // Agent-initiated synthetic prompts (auto-continue, plan, reminders) do not.
7163
7312
  if (options?.userInitiated ?? !options?.synthetic) {
7164
7313
  this.#advisorAutoResumeSuppressed = false;
7314
+ this.#planModeReminderCount = 0;
7315
+ this.#planModeReminderAwaitingProgress = false;
7316
+ // A user turn owns the next decision; drop a queued forced choice from
7317
+ // a reminder continuation this prompt just preempted.
7318
+ this.#toolChoiceQueue.removeByLabel("plan-mode-decision");
7165
7319
  }
7166
7320
 
7167
7321
  // If streaming, queue via steer() or followUp() based on option
@@ -7232,9 +7386,6 @@ export class AgentSession {
7232
7386
  // (e.g., compaction aborted, validation failed).
7233
7387
  this.#toolChoiceQueue.removeByLabel("eager-todo");
7234
7388
  }
7235
- if (!options?.synthetic) {
7236
- await this.#enforcePlanModeToolDecision();
7237
- }
7238
7389
  return true;
7239
7390
  }
7240
7391
 
@@ -8483,12 +8634,10 @@ export class AgentSession {
8483
8634
  /**
8484
8635
  * Set model directly.
8485
8636
  * Validates that a credential source is configured (synchronously, without
8486
- * refreshing OAuth or running command-backed key programs). The active
8487
- * session switches by default; when `currentContextTokens` is provided and
8488
- * exceeds the refreshed candidate's context window, the live switch is
8489
- * skipped while role persistence still runs. Returns whether the active
8490
- * model actually switched, computed against the refreshed metadata so
8491
- * dynamic providers (e.g. llama.cpp) honor their post-load contextWindow.
8637
+ * refreshing OAuth or running command-backed key programs). Active switches
8638
+ * always take effect; if the current transcript is too large for the target
8639
+ * model, the next prompt's compaction/error path owns that recovery instead
8640
+ * of leaving the session pinned to the old model.
8492
8641
  * @throws Error if no API key available for the model
8493
8642
  */
8494
8643
  async setModel(
@@ -8508,28 +8657,15 @@ export class AgentSession {
8508
8657
 
8509
8658
  const targetModel = await this.#modelRegistry.refreshSelectedModelMetadata(model);
8510
8659
 
8511
- const currentContextTokens = options?.currentContextTokens ?? 0;
8512
- const targetContextWindow = targetModel.contextWindow ?? 0;
8513
- const switched = !(
8514
- currentContextTokens > 0 &&
8515
- targetContextWindow > 0 &&
8516
- currentContextTokens > targetContextWindow
8517
- );
8518
-
8519
- if (switched) {
8520
- this.#clearActiveRetryFallback();
8521
- this.#setModelWithProviderSessionReset(targetModel);
8522
- this.sessionManager.appendModelChange(`${targetModel.provider}/${targetModel.id}`, role);
8523
- }
8660
+ this.#clearActiveRetryFallback();
8661
+ this.#setModelWithProviderSessionReset(targetModel);
8662
+ this.sessionManager.appendModelChange(`${targetModel.provider}/${targetModel.id}`, role);
8524
8663
  if (options?.persist) {
8525
8664
  this.settings.setModelRole(
8526
8665
  role,
8527
8666
  this.#formatRoleModelValue(role, targetModel, options.selector, options.thinkingLevel),
8528
8667
  );
8529
8668
  }
8530
- if (!switched) {
8531
- return { switched: false };
8532
- }
8533
8669
  this.settings.getStorage()?.recordModelUsage(`${targetModel.provider}/${targetModel.id}`);
8534
8670
 
8535
8671
  // Re-apply thinking for the newly selected model. Prefer the model's
@@ -10062,6 +10198,48 @@ export class AgentSession {
10062
10198
  }
10063
10199
  return COMPACTION_CHECK_NONE;
10064
10200
  }
10201
+ // A context promotion can land while the failing call is already in
10202
+ // flight (or on a run whose loop predates the switch): the overflow
10203
+ // error then arrives stamped with the pre-promotion model while
10204
+ // `this.model` is already the promoted target. The sameModel guard
10205
+ // above deliberately ignores stale foreign-model errors, but this
10206
+ // state is not stale — recover exactly like the promotion path:
10207
+ // drop the dead turn and retry on the already-promoted model. Gated
10208
+ // narrowly on "current model IS the failed model's promotion target
10209
+ // with a strictly larger window" so genuinely stale errors from
10210
+ // old user-switched models keep surfacing untouched.
10211
+ if (
10212
+ !sameModel &&
10213
+ autoContinue &&
10214
+ !errorIsFromBeforeCompaction &&
10215
+ assistantMessage.stopReason === "error" &&
10216
+ this.model &&
10217
+ contextWindow > 0 &&
10218
+ this.settings.getGroup("contextPromotion").enabled
10219
+ ) {
10220
+ const failedModel = this.#modelRegistry.find(assistantMessage.provider, assistantMessage.model);
10221
+ const failedWindow = failedModel?.contextWindow ?? 0;
10222
+ const promotionTarget = failedModel
10223
+ ? this.#resolveContextPromotionConfiguredTarget(failedModel, this.#modelRegistry.getAvailable())
10224
+ : undefined;
10225
+ if (
10226
+ failedModel &&
10227
+ failedWindow > 0 &&
10228
+ contextWindow > failedWindow &&
10229
+ promotionTarget &&
10230
+ modelsAreEqual(promotionTarget, this.model) &&
10231
+ AIError.isContextOverflow(assistantMessage, failedWindow)
10232
+ ) {
10233
+ this.#removeAssistantMessageFromActiveContext(assistantMessage);
10234
+ await this.#dropPersistedAssistantTurn(assistantMessage);
10235
+ logger.debug("Overflow on pre-promotion model; retrying on promoted model", {
10236
+ failed: `${assistantMessage.provider}/${assistantMessage.model}`,
10237
+ current: `${this.model.provider}/${this.model.id}`,
10238
+ });
10239
+ this.#scheduleAgentContinue({ delayMs: 100, generation });
10240
+ return COMPACTION_CHECK_CONTINUATION;
10241
+ }
10242
+ }
10065
10243
 
10066
10244
  // Case 3: Output-side incomplete — `response.incomplete` from OpenAI Responses
10067
10245
  // (and Codex) maps to stopReason === "length". The model burned its
@@ -10548,41 +10726,72 @@ export class AgentSession {
10548
10726
  this.#checkpointState = undefined;
10549
10727
  this.#pendingRewindReport = undefined;
10550
10728
  }
10551
- async #enforcePlanModeToolDecision(): Promise<void> {
10729
+ #isPlanDecisionTool(name: string): boolean {
10730
+ return PLAN_DECISION_TOOLS.has(name);
10731
+ }
10732
+
10733
+ async #enforcePlanModeDecisionAtSettle(): Promise<boolean> {
10552
10734
  if (!this.#planModeState?.enabled) {
10553
- return;
10735
+ return false;
10554
10736
  }
10555
10737
  const assistantMessage = this.#findLastAssistantMessage();
10556
10738
  if (!assistantMessage) {
10557
- return;
10739
+ return false;
10558
10740
  }
10559
10741
  if (assistantMessage.stopReason === "error" || assistantMessage.stopReason === "aborted") {
10560
- return;
10742
+ return false;
10561
10743
  }
10562
10744
 
10563
- const calledRequiredTool = assistantMessage.content.some(
10564
- content => content.type === "toolCall" && (content.name === "ask" || content.name === "resolve"),
10745
+ const calledDecisionTool = assistantMessage.content.some(
10746
+ content => content.type === "toolCall" && this.#isPlanDecisionTool(content.name),
10565
10747
  );
10566
- if (calledRequiredTool) {
10567
- return;
10748
+ if (calledDecisionTool) {
10749
+ this.#planModeReminderCount = 0;
10750
+ this.#planModeReminderAwaitingProgress = false;
10751
+ return false;
10752
+ }
10753
+
10754
+ const hasToolCall = assistantMessage.content.some(content => content.type === "toolCall");
10755
+ if (hasToolCall) {
10756
+ return false;
10757
+ }
10758
+ if (this.#planModeReminderAwaitingProgress) {
10759
+ return false;
10760
+ }
10761
+ if (this.#planModeReminderCount >= PLAN_MODE_REMINDER_MAX) {
10762
+ logger.debug("Plan mode convergence: reminder cap reached; yielding to user");
10763
+ return false;
10568
10764
  }
10569
10765
  const hasRequiredTools = this.#toolRegistry.has("ask") && this.#toolRegistry.has("resolve");
10570
10766
  if (!hasRequiredTools) {
10571
10767
  logger.warn("Plan mode enforcement skipped because ask/resolve tools are unavailable", {
10572
10768
  activeToolNames: this.agent.state.tools.map(tool => tool.name),
10573
10769
  });
10574
- return;
10770
+ return false;
10575
10771
  }
10576
10772
 
10773
+ this.#planModeReminderCount++;
10774
+ this.#planModeReminderAwaitingProgress = true;
10775
+ this.#toolChoiceQueue.pushOnce("required", { label: "plan-mode-decision" });
10577
10776
  const reminder = prompt.render(planModeToolDecisionReminderPrompt, {
10578
10777
  askToolName: "ask",
10579
10778
  });
10779
+ const reminderMessage: Message = {
10780
+ role: "developer",
10781
+ content: [{ type: "text", text: reminder }],
10782
+ attribution: "agent",
10783
+ timestamp: Date.now(),
10784
+ };
10580
10785
 
10581
- await this.prompt(reminder, {
10582
- synthetic: true,
10583
- expandPromptTemplates: false,
10584
- toolChoice: "required",
10786
+ this.agent.appendMessage(reminderMessage);
10787
+ this.sessionManager.appendMessage(reminderMessage);
10788
+ this.#scheduleAgentContinue({
10789
+ generation: this.#promptGeneration,
10790
+ // If the continuation never runs (new prompt, dispose, compaction,
10791
+ // handoff), the forced choice must not leak onto an unrelated turn.
10792
+ onSkip: () => this.#toolChoiceQueue.removeByLabel("plan-mode-decision"),
10585
10793
  });
10794
+ return true;
10586
10795
  }
10587
10796
 
10588
10797
  /**
@@ -10731,6 +10940,13 @@ export class AgentSession {
10731
10940
  return false;
10732
10941
  }
10733
10942
 
10943
+ // Plan mode owns convergence via #enforcePlanModeDecisionAtSettle (remind →
10944
+ // cap → yield). Todo reminders must not re-wake a turn the cap intends to
10945
+ // yield to the user. The label is already consumed above, so no leak.
10946
+ if (this.#planModeState?.enabled) {
10947
+ return false;
10948
+ }
10949
+
10734
10950
  // Suppress within a self-continuation chain: if the agent's last turn was driven by a
10735
10951
  // prior reminder (and the agent took no tool-level action since), do not re-ping.
10736
10952
  // The agent has already acknowledged; further escalation just wastes context and
@@ -10781,6 +10997,18 @@ export class AgentSession {
10781
10997
  return false;
10782
10998
  }
10783
10999
 
11000
+ // Background async jobs (bash/task) owned by this agent re-wake the loop
11001
+ // when they complete: the result delivery enqueues an async-result
11002
+ // follow-up that continues the run, and todos are re-evaluated at that
11003
+ // settle. A stop with such a job in flight is a scheduling pause, not
11004
+ // abandonment — stay silent instead of nagging.
11005
+ if (this.#hasPendingAsyncWake()) {
11006
+ logger.debug("Todo completion: async jobs in flight will re-wake the loop; skipping reminder", {
11007
+ incomplete: incomplete.length,
11008
+ });
11009
+ return false;
11010
+ }
11011
+
10784
11012
  // Build reminder message
10785
11013
  this.#todoReminderCount++;
10786
11014
  const todoList = incompleteByPhase
@@ -13676,24 +13904,32 @@ export class AgentSession {
13676
13904
  *
13677
13905
  * - mid-turn → queued on the aside channel and folded in at the next step
13678
13906
  * boundary (non-interrupting, like async-result deliveries) → "injected";
13907
+ * - idle in plan mode → appended into context without waking an autonomous
13908
+ * turn (convergence stays user-driven) → "injected";
13679
13909
  * - idle → starts a real turn with the message so the recipient wakes
13680
13910
  * → "woken".
13681
13911
  *
13682
13912
  * Never blocks on the recipient's turn: the wake turn is fire-and-forget.
13683
13913
  *
13684
- * When the sender expects a reply (`send await:true`) and this session is
13685
- * mid-turn with async execution disabled, the next step boundary may be
13686
- * gated on the sender's own batch finishing (blocking task spawns), so a
13687
- * real reply turn can never happen in time. In that case an ephemeral
13688
- * side-channel auto-reply is generated from the current context (the old
13689
- * `respondAsBackground` path) and sent back over the bus on this agent's
13690
- * behalf.
13914
+ * When the sender expects a reply (`send await:true`) and this session
13915
+ * cannot produce a real reply turn in time mid-turn with async execution
13916
+ * disabled (the next step boundary may be gated on the sender's own batch
13917
+ * finishing), or idle in plan mode (wake turns are suppressed) an
13918
+ * ephemeral side-channel auto-reply is generated from the current context
13919
+ * (the old `respondAsBackground` path) and sent back over the bus on this
13920
+ * agent's behalf.
13691
13921
  */
13692
13922
  async deliverIrcMessage(msg: IrcMessage, opts?: { expectsReply?: boolean }): Promise<"injected" | "woken"> {
13693
13923
  if (this.#isDisposed) {
13694
13924
  throw new Error("Recipient session is disposed.");
13695
13925
  }
13696
- const autoReply = (opts?.expectsReply ?? false) && this.isStreaming && !this.settings.get("async.enabled");
13926
+ // Auto-reply eligibility: the sender is blocked on an answer and this
13927
+ // session cannot produce a real reply turn in time — either mid-turn with
13928
+ // async execution disabled (no step boundary until the sender's own batch
13929
+ // ends), or idle in plan mode (autonomous wake turns are suppressed).
13930
+ const planModeIdle = !this.isStreaming && this.#planModeState?.enabled === true;
13931
+ const autoReply =
13932
+ (opts?.expectsReply ?? false) && ((this.isStreaming && !this.settings.get("async.enabled")) || planModeIdle);
13697
13933
  const record: CustomMessage = {
13698
13934
  role: "custom",
13699
13935
  customType: "irc:incoming",
@@ -13726,6 +13962,19 @@ export class AgentSession {
13726
13962
  if (autoReply) void this.#runIrcAutoReply(msg);
13727
13963
  return "injected";
13728
13964
  }
13965
+ // Plan mode: record into context but do not wake an autonomous turn.
13966
+ if (this.#planModeState?.enabled) {
13967
+ this.agent.appendMessage(record);
13968
+ this.sessionManager.appendCustomMessageEntry(
13969
+ record.customType,
13970
+ record.content,
13971
+ record.display,
13972
+ record.details,
13973
+ record.attribution ?? "agent",
13974
+ );
13975
+ if (autoReply) void this.#runIrcAutoReply(msg);
13976
+ return "injected";
13977
+ }
13729
13978
  // Idle: wake a real turn so the recipient responds (shared with the stranded-aside resume).
13730
13979
  this.#wakeForIrc([record]);
13731
13980
  return "woken";
@@ -15187,6 +15436,7 @@ export class AgentSession {
15187
15436
  setAdvisorEnabled(enabled: boolean): boolean {
15188
15437
  this.#advisorEnabled = enabled;
15189
15438
  if (enabled) {
15439
+ if (this.#advisors.length > 0 && !this.#advisorRuntimeMatchesCurrentConfig()) this.#stopAdvisorRuntime();
15190
15440
  return this.#buildAdvisorRuntime(true);
15191
15441
  }
15192
15442
  this.#stopAdvisorRuntime();