@oh-my-pi/pi-coding-agent 16.2.13 → 16.3.0

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 (166) hide show
  1. package/CHANGELOG.md +108 -7
  2. package/dist/cli.js +6033 -5982
  3. package/dist/types/advisor/config.d.ts +4 -2
  4. package/dist/types/collab/host.d.ts +16 -0
  5. package/dist/types/collab/protocol.d.ts +9 -3
  6. package/dist/types/commit/model-selection.d.ts +5 -0
  7. package/dist/types/config/model-resolver.d.ts +12 -10
  8. package/dist/types/config/settings-schema.d.ts +28 -5
  9. package/dist/types/edit/modes/patch.d.ts +11 -0
  10. package/dist/types/eval/agent-bridge.d.ts +5 -1
  11. package/dist/types/exec/bash-executor.d.ts +1 -0
  12. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +86 -2
  13. package/dist/types/extensibility/skills.d.ts +2 -1
  14. package/dist/types/mnemopi/state.d.ts +12 -0
  15. package/dist/types/modes/components/agent-hub.d.ts +9 -5
  16. package/dist/types/modes/components/status-line/component.test.d.ts +1 -0
  17. package/dist/types/modes/components/usage-row.d.ts +1 -1
  18. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  19. package/dist/types/modes/controllers/extension-ui-controller.d.ts +6 -0
  20. package/dist/types/modes/controllers/streaming-reveal.d.ts +17 -1
  21. package/dist/types/modes/controllers/tool-args-reveal.d.ts +30 -3
  22. package/dist/types/modes/interactive-mode.d.ts +12 -7
  23. package/dist/types/modes/rpc/rpc-client.d.ts +5 -0
  24. package/dist/types/modes/rpc/rpc-mode.d.ts +64 -1
  25. package/dist/types/modes/session-teardown.d.ts +63 -0
  26. package/dist/types/modes/session-teardown.test.d.ts +1 -0
  27. package/dist/types/modes/theme/theme.d.ts +9 -4
  28. package/dist/types/modes/types.d.ts +2 -3
  29. package/dist/types/modes/utils/copy-targets.d.ts +2 -0
  30. package/dist/types/plan-mode/approved-plan-prompt.test.d.ts +1 -0
  31. package/dist/types/sdk.d.ts +9 -0
  32. package/dist/types/session/agent-session.d.ts +46 -13
  33. package/dist/types/session/exit-diagnostics.d.ts +48 -0
  34. package/dist/types/session/session-loader.d.ts +5 -0
  35. package/dist/types/task/executor.d.ts +12 -5
  36. package/dist/types/task/parallel.d.ts +1 -0
  37. package/dist/types/task/render.test.d.ts +1 -0
  38. package/dist/types/thinking.d.ts +2 -0
  39. package/dist/types/tools/checkpoint.d.ts +8 -0
  40. package/dist/types/tools/eval-render.d.ts +1 -0
  41. package/dist/types/tools/fetch.d.ts +11 -1
  42. package/dist/types/tools/index.d.ts +3 -1
  43. package/dist/types/tools/irc.d.ts +1 -0
  44. package/dist/types/tools/output-meta.d.ts +7 -0
  45. package/dist/types/tools/output-schema-validator.d.ts +7 -4
  46. package/dist/types/tools/path-utils.d.ts +16 -0
  47. package/dist/types/tools/render-utils.d.ts +4 -1
  48. package/dist/types/utils/git.d.ts +47 -2
  49. package/dist/types/web/search/provider.d.ts +7 -0
  50. package/dist/types/web/search/types.d.ts +1 -1
  51. package/package.json +12 -12
  52. package/src/advisor/config.ts +4 -2
  53. package/src/cli/bench-cli.ts +7 -2
  54. package/src/collab/guest.ts +94 -5
  55. package/src/collab/host.ts +80 -3
  56. package/src/collab/protocol.ts +9 -2
  57. package/src/commit/model-selection.ts +8 -2
  58. package/src/config/keybindings.ts +3 -1
  59. package/src/config/model-discovery.ts +66 -2
  60. package/src/config/model-registry.ts +7 -3
  61. package/src/config/model-resolver.ts +51 -23
  62. package/src/config/settings-schema.ts +44 -14
  63. package/src/edit/hashline/diff.ts +13 -2
  64. package/src/edit/index.ts +58 -8
  65. package/src/edit/modes/patch.ts +53 -18
  66. package/src/edit/streaming.ts +7 -6
  67. package/src/eval/__tests__/agent-bridge.test.ts +57 -1
  68. package/src/eval/agent-bridge.ts +15 -5
  69. package/src/exec/bash-executor.ts +7 -12
  70. package/src/extensibility/legacy-pi-coding-agent-shim.ts +534 -16
  71. package/src/extensibility/skills.ts +29 -6
  72. package/src/goals/guided-setup.ts +4 -3
  73. package/src/internal-urls/docs-index.generated.txt +1 -1
  74. package/src/lsp/client.ts +11 -14
  75. package/src/main.ts +38 -10
  76. package/src/mnemopi/state.ts +43 -7
  77. package/src/modes/acp/acp-agent.ts +1 -1
  78. package/src/modes/components/agent-hub.ts +10 -2
  79. package/src/modes/components/agent-transcript-viewer.ts +39 -7
  80. package/src/modes/components/assistant-message.ts +16 -10
  81. package/src/modes/components/chat-transcript-builder.ts +11 -1
  82. package/src/modes/components/custom-editor.test.ts +20 -0
  83. package/src/modes/components/hook-selector.ts +44 -25
  84. package/src/modes/components/model-selector.ts +1 -1
  85. package/src/modes/components/status-line/component.test.ts +44 -0
  86. package/src/modes/components/status-line/component.ts +9 -1
  87. package/src/modes/components/status-line/segments.ts +3 -3
  88. package/src/modes/components/usage-row.ts +16 -2
  89. package/src/modes/controllers/command-controller.ts +6 -21
  90. package/src/modes/controllers/event-controller.ts +11 -9
  91. package/src/modes/controllers/extension-ui-controller.ts +84 -3
  92. package/src/modes/controllers/input-controller.ts +16 -13
  93. package/src/modes/controllers/selector-controller.ts +3 -8
  94. package/src/modes/controllers/streaming-reveal.ts +75 -53
  95. package/src/modes/controllers/tool-args-reveal.ts +340 -10
  96. package/src/modes/interactive-mode.ts +122 -79
  97. package/src/modes/rpc/rpc-client.ts +21 -0
  98. package/src/modes/rpc/rpc-mode.ts +197 -46
  99. package/src/modes/session-teardown.test.ts +219 -0
  100. package/src/modes/session-teardown.ts +82 -0
  101. package/src/modes/setup-wizard/scenes/theme.ts +2 -2
  102. package/src/modes/skill-command.ts +7 -20
  103. package/src/modes/theme/theme.ts +29 -15
  104. package/src/modes/types.ts +2 -3
  105. package/src/modes/utils/copy-targets.ts +12 -0
  106. package/src/modes/utils/ui-helpers.ts +19 -2
  107. package/src/plan-mode/approved-plan-prompt.test.ts +36 -0
  108. package/src/prompts/advisor/system.md +1 -1
  109. package/src/prompts/agents/tester.md +6 -2
  110. package/src/prompts/skills/autoload.md +8 -0
  111. package/src/prompts/skills/user-invocation.md +11 -0
  112. package/src/prompts/steering/parent-irc.md +5 -0
  113. package/src/prompts/system/irc-incoming.md +2 -0
  114. package/src/prompts/system/mid-run-todo-nudge.md +3 -0
  115. package/src/prompts/system/plan-mode-approved.md +7 -10
  116. package/src/prompts/system/plan-mode-compact-instructions.md +2 -1
  117. package/src/prompts/system/plan-mode-reference.md +3 -4
  118. package/src/prompts/system/rewind-report.md +6 -0
  119. package/src/prompts/system/subagent-system-prompt.md +3 -0
  120. package/src/prompts/tools/irc.md +2 -1
  121. package/src/prompts/tools/job.md +2 -2
  122. package/src/prompts/tools/rewind.md +3 -2
  123. package/src/prompts/tools/task.md +4 -0
  124. package/src/sdk.ts +18 -4
  125. package/src/session/agent-session.ts +660 -114
  126. package/src/session/exit-diagnostics.ts +202 -0
  127. package/src/session/session-context.ts +25 -13
  128. package/src/session/session-loader.ts +58 -25
  129. package/src/session/session-manager.ts +0 -1
  130. package/src/session/session-persistence.ts +30 -4
  131. package/src/session/settings-stream-fn.ts +14 -0
  132. package/src/slash-commands/builtin-registry.ts +35 -3
  133. package/src/system-prompt.ts +15 -1
  134. package/src/task/executor.ts +31 -8
  135. package/src/task/index.ts +31 -9
  136. package/src/task/isolation-runner.ts +18 -2
  137. package/src/task/parallel.ts +7 -2
  138. package/src/task/render.test.ts +121 -0
  139. package/src/task/render.ts +48 -2
  140. package/src/task/worktree.ts +12 -13
  141. package/src/task/yield-assembly.ts +8 -5
  142. package/src/thinking.ts +5 -0
  143. package/src/tools/ask.ts +188 -9
  144. package/src/tools/ast-edit.ts +7 -0
  145. package/src/tools/ast-grep.ts +11 -0
  146. package/src/tools/bash.ts +6 -30
  147. package/src/tools/checkpoint.ts +15 -1
  148. package/src/tools/eval-render.ts +15 -0
  149. package/src/tools/fetch.ts +82 -18
  150. package/src/tools/gh.ts +1 -1
  151. package/src/tools/grep.ts +45 -27
  152. package/src/tools/index.ts +3 -1
  153. package/src/tools/irc.ts +24 -9
  154. package/src/tools/output-meta.ts +50 -0
  155. package/src/tools/output-schema-validator.ts +152 -15
  156. package/src/tools/path-utils.ts +55 -10
  157. package/src/tools/read.ts +1 -1
  158. package/src/tools/render-utils.ts +5 -3
  159. package/src/tools/yield.ts +41 -1
  160. package/src/utils/commit-message-generator.ts +2 -2
  161. package/src/utils/git.ts +271 -29
  162. package/src/utils/thinking-display.ts +13 -0
  163. package/src/web/search/index.ts +9 -28
  164. package/src/web/search/provider.ts +26 -1
  165. package/src/web/search/providers/duckduckgo.ts +1 -1
  166. package/src/web/search/types.ts +5 -1
@@ -62,8 +62,8 @@ import {
62
62
  generateHandoffFromContext,
63
63
  prepareCompaction,
64
64
  renderHandoffPrompt,
65
+ resolveBudgetReserveTokens,
65
66
  resolveThresholdTokens,
66
- type SessionEntry,
67
67
  type SessionMessageEntry,
68
68
  type ShakeConfig,
69
69
  type ShakeRegion,
@@ -128,6 +128,7 @@ import {
128
128
  isBunTestRuntime,
129
129
  isEnoent,
130
130
  logger,
131
+ postmortem,
131
132
  prompt,
132
133
  relativePathWithinRoot,
133
134
  Snowflake,
@@ -237,6 +238,7 @@ import { createPlanReadMatcher } from "../plan-mode/plan-protection";
237
238
  import type { PlanModeState } from "../plan-mode/state";
238
239
  import advisorSystemPrompt from "../prompts/advisor/system.md" with { type: "text" };
239
240
  import goalTodoContextPrompt from "../prompts/goals/goal-todo-context.md" with { type: "text" };
241
+ import parentIrcSteerTemplate from "../prompts/steering/parent-irc.md" with { type: "text" };
240
242
  import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
241
243
  import eagerTaskPrompt from "../prompts/system/eager-task.md" with { type: "text" };
242
244
  import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
@@ -245,17 +247,20 @@ import geminiToolReminderTemplate from "../prompts/system/gemini-tool-call-remin
245
247
  import interruptedThinkingTemplate from "../prompts/system/interrupted-thinking.md" with { type: "text" };
246
248
  import ircAutoReplyTemplate from "../prompts/system/irc-autoreply.md" with { type: "text" };
247
249
  import ircIncomingTemplate from "../prompts/system/irc-incoming.md" with { type: "text" };
250
+ import midRunTodoNudgePrompt from "../prompts/system/mid-run-todo-nudge.md" with { type: "text" };
248
251
  import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
249
252
  import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" with { type: "text" };
250
253
  import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool-decision-reminder.md" with {
251
254
  type: "text",
252
255
  };
256
+ import rewindReportTemplate from "../prompts/system/rewind-report.md" with { type: "text" };
253
257
  import sideChannelNoToolsReminder from "../prompts/system/side-channel-no-tools.md" with { type: "text" };
254
258
  import thinkingLoopRedirectTemplate from "../prompts/system/thinking-loop-redirect.md" with { type: "text" };
255
259
  import toolCallLoopRedirectTemplate from "../prompts/system/tool-call-loop-redirect.md" with { type: "text" };
256
260
  import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
257
261
  import ttsrToolReminderTemplate from "../prompts/system/ttsr-tool-reminder.md" with { type: "text" };
258
262
  import unexpectedStopRetryTemplate from "../prompts/system/unexpected-stop-retry.md" with { type: "text" };
263
+ import { AgentRegistry } from "../registry/agent-registry";
259
264
  import {
260
265
  deobfuscateAssistantContent,
261
266
  deobfuscateSessionContext,
@@ -268,6 +273,7 @@ import {
268
273
  AUTO_THINKING,
269
274
  type ConfiguredThinkingLevel,
270
275
  clampAutoThinkingEffort,
276
+ concreteThinkingLevel,
271
277
  parseConfiguredThinkingLevel,
272
278
  resolveProvisionalAutoLevel,
273
279
  resolveThinkingLevelForModel,
@@ -289,7 +295,7 @@ import {
289
295
  import { assertEditableFile } from "../tools/auto-generated-guard";
290
296
  import { releaseTabsForOwner } from "../tools/browser/tab-supervisor";
291
297
  import { normalizeToolNames } from "../tools/builtin-names";
292
- import type { CheckpointState } from "../tools/checkpoint";
298
+ import type { CheckpointState, CompletedRewindState } from "../tools/checkpoint";
293
299
  import { outputMeta, wrapToolWithMetaNotice } from "../tools/output-meta";
294
300
  import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
295
301
  import { isAutoQaEnabled } from "../tools/report-tool-issue";
@@ -315,6 +321,14 @@ import {
315
321
  shouldPromptCodexAutoRedeem,
316
322
  } from "./codex-auto-reset";
317
323
  import { findCompactMode } from "./compact-modes";
324
+ import {
325
+ collectPendingToolCalls,
326
+ SESSION_EXIT_CUSTOM_TYPE,
327
+ type SessionExitData,
328
+ summarizeToolArguments,
329
+ TOOL_EXECUTION_START_CUSTOM_TYPE,
330
+ type ToolExecutionStartData,
331
+ } from "./exit-diagnostics";
318
332
  import {
319
333
  type BashExecutionMessage,
320
334
  type CustomMessage,
@@ -333,7 +347,7 @@ import {
333
347
  import type { BuildSessionContextOptions, SessionContext } from "./session-context";
334
348
  import { getLatestCompactionEntry, getRestorableSessionModels } from "./session-context";
335
349
  import { formatSessionDumpText } from "./session-dump-format";
336
- import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions } from "./session-entries";
350
+ import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionEntry } from "./session-entries";
337
351
  import { EPHEMERAL_MODEL_CHANGE_ROLE } from "./session-entries";
338
352
  import { formatSessionHistoryMarkdown } from "./session-history-format";
339
353
  import { cleanupEmptyMoveSession, type SessionManager } from "./session-manager";
@@ -345,6 +359,33 @@ import { YieldQueue } from "./yield-queue";
345
359
 
346
360
  const SESSION_STOP_CONTINUATION_CAP = 8;
347
361
 
362
+ /**
363
+ * Mutating tool results (`bash`/`eval`/`edit`/`write`/`ast_edit`) without the
364
+ * agent touching the `todo` tool that trip the mid-run reconciliation nudge.
365
+ * Read-only exploration (grep/read/glob/lsp) never ticks this: an agent
366
+ * researching for a long stretch has nothing to flip. Picked so a normal
367
+ * fix-verify loop (~3-6 mutations) never sees the nudge, but a sustained run
368
+ * of landed work without flipping any todos does. Without this nudge, long
369
+ * runs drive the live todo HUD to `0/N` until the final stop, then batch-flip
370
+ * to `N/N` (issue #3651).
371
+ */
372
+ const MID_RUN_TODO_NUDGE_MUTATION_THRESHOLD = 12;
373
+ /** Mid-run nudges per prompt cycle. Deliberately tighter than
374
+ * `todo.reminders.max` (the stop-time budget): this is a gentle hidden hint,
375
+ * not an escalation ladder. */
376
+ const MID_RUN_TODO_NUDGE_MAX_PER_CYCLE = 2;
377
+ /** Tool results that count as landed work for the mid-run todo nudge. */
378
+ const MID_RUN_TODO_NUDGE_MUTATING_TOOLS: Record<string, true> = {
379
+ bash: true,
380
+ eval: true,
381
+ edit: true,
382
+ write: true,
383
+ ast_edit: true,
384
+ };
385
+ /** `customType` for the hidden mid-run todo nudge; `display: false`, so it reaches
386
+ * the model but never renders in the TUI or transcript. */
387
+ const MID_RUN_TODO_NUDGE_MESSAGE_TYPE = "mid-run-todo-nudge";
388
+
348
389
  /** Abort reason for the Gemini reasoning-header runaway interrupt. Surfaced on the
349
390
  * discarded assistant turn only; never reaches the model. */
350
391
  const GEMINI_HEADER_INTERRUPT_REASON = "Interrupted: emit a tool call instead of more planning";
@@ -355,6 +396,61 @@ const GEMINI_TOOL_REMINDER_TYPE = "gemini-tool-call-reminder";
355
396
  const THINKING_LOOP_REDIRECT_TYPE = "thinking-loop-redirect";
356
397
  const TOOL_CALL_LOOP_REDIRECT_TYPE = "tool-call-loop-redirect";
357
398
 
399
+ function customMessageContentText(content: string | (TextContent | ImageContent)[]): string {
400
+ if (typeof content === "string") return content;
401
+ const parts: string[] = [];
402
+ for (const part of content) {
403
+ if (part.type === "text") parts.push(part.text);
404
+ }
405
+ return parts.join("\n");
406
+ }
407
+
408
+ function stringProperty(value: object, key: string): string | undefined {
409
+ const field = Object.getOwnPropertyDescriptor(value, key)?.value;
410
+ return typeof field === "string" ? field : undefined;
411
+ }
412
+
413
+ function reportFromRewindReportContent(content: string): string {
414
+ const marker = "\nReport:\n";
415
+ const index = content.lastIndexOf(marker);
416
+ const report = index >= 0 ? content.slice(index + marker.length) : content;
417
+ return report.trim();
418
+ }
419
+
420
+ function completedRewindFromEntry(entry: SessionEntry): CompletedRewindState | undefined {
421
+ if (entry.type !== "custom_message" || entry.customType !== "rewind-report") return undefined;
422
+ const details = entry.details;
423
+ if (!details || typeof details !== "object") return undefined;
424
+ const startedAt = stringProperty(details, "startedAt");
425
+ const rewoundAt = stringProperty(details, "rewoundAt");
426
+ if (!startedAt || !rewoundAt) return undefined;
427
+ const report =
428
+ stringProperty(details, "report")?.trim() ||
429
+ reportFromRewindReportContent(customMessageContentText(entry.content));
430
+ return report.length > 0 ? { report, startedAt, rewoundAt } : undefined;
431
+ }
432
+
433
+ function isSuccessfulCheckpointEntry(entry: SessionEntry): entry is SessionMessageEntry & {
434
+ message: { role: "toolResult"; toolName: "checkpoint"; isError?: false };
435
+ } {
436
+ return (
437
+ entry.type === "message" &&
438
+ entry.message.role === "toolResult" &&
439
+ entry.message.toolName === "checkpoint" &&
440
+ entry.message.isError !== true
441
+ );
442
+ }
443
+
444
+ function checkpointStartedAtFromEntry(entry: SessionEntry): string | undefined {
445
+ if (!isSuccessfulCheckpointEntry(entry)) return undefined;
446
+ const details = entry.message.details;
447
+ if (details && typeof details === "object") {
448
+ const startedAt = stringProperty(details, "startedAt");
449
+ if (startedAt) return startedAt;
450
+ }
451
+ return entry.timestamp;
452
+ }
453
+
358
454
  // A side-channel assistant response is signed for the hidden prompt/history that
359
455
  // produced it. If we persist that response under a different user turn, native
360
456
  // replay anchors become invalid; keep only visible, non-cryptographic content.
@@ -421,11 +517,32 @@ const UNEXPECTED_STOP_MAX_RETRIES = 3;
421
517
  const UNEXPECTED_STOP_TIMEOUT_MS = 4000;
422
518
  const EMPTY_STOP_MAX_RETRIES = 3;
423
519
  const RETRY_BACKOFF_MAX_DELAY_MS = 8_000;
520
+ /**
521
+ * Budget for callers on the user-visible `/quit` / `/exit` shutdown path that
522
+ * want to cap how long they wait for `MnemopiSessionState.dispose()` to finish
523
+ * its consolidate pass. Consolidate fires fresh LLM fact extractions, each a
524
+ * 1–3 s round-trip, so interactive shutdown passes this budget to keep the
525
+ * UI responsive. Callers that keep the process/session host alive must omit it
526
+ * so dispose still awaits the full consolidate-then-close pipeline.
527
+ */
528
+ export const SHUTDOWN_CONSOLIDATE_BUDGET_MS = 1_500;
529
+
530
+ export interface AgentSessionDisposeOptions {
531
+ mnemopiConsolidateTimeoutMs?: number;
532
+ /**
533
+ * Postmortem reason that triggered this dispose (signal/fatal teardown
534
+ * paths). When set, the persisted `session_exit` diagnostic records it
535
+ * instead of the generic `"dispose"` used for normal programmatic disposal
536
+ * (`/quit`, test teardown, subagent completion).
537
+ */
538
+ reason?: postmortem.Reason;
539
+ }
424
540
 
425
541
  type CompactionCheckResult = Readonly<{
426
542
  deferredHandoff: boolean;
427
543
  continuationScheduled: boolean;
428
544
  automaticContinuationBlocked?: boolean;
545
+ historyRewritten?: boolean;
429
546
  }>;
430
547
 
431
548
  const COMPACTION_CHECK_NONE: CompactionCheckResult = {
@@ -446,6 +563,18 @@ const COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION: CompactionCheckResult = {
446
563
  automaticContinuationBlocked: true,
447
564
  };
448
565
 
566
+ /**
567
+ * User-facing notice for a compaction dead end: maintenance freed too little
568
+ * to retry safely. `remedies` names the recovery actions available on the
569
+ * emitting path (the shake-rescue path can additionally offer `/shake images`).
570
+ */
571
+ function compactionDeadEndWarning(remedies: string): string {
572
+ return (
573
+ "Compaction freed too little context to make progress — pausing automatic maintenance to avoid a compaction loop. " +
574
+ `The most recent turn alone is too large to reduce further; ${remedies} or switch to a larger-context model.`
575
+ );
576
+ }
577
+
449
578
  /**
450
579
  * Per-turn prune cache window. A tool result whose all-message suffix exceeds
451
580
  * this is in the warm, already-sent prompt-cache prefix: re-writing it costs the
@@ -670,6 +799,14 @@ export interface AgentSessionConfig {
670
799
  * teardown never tears down the shared servers.
671
800
  */
672
801
  disconnectOwnedMcpManager?: () => Promise<void>;
802
+ /**
803
+ * Override the bundled system prompt used by automatic session-title
804
+ * generation paths (initial title + replan refresh). Source-of-truth is
805
+ * `TITLE_SYSTEM.md` discovered via {@link discoverTitleSystemPromptFile} and
806
+ * resolved through {@link resolvePromptInput}; refresh after a `/move`-style
807
+ * cwd change via {@link AgentSession.setTitleSystemPrompt}.
808
+ */
809
+ titleSystemPrompt?: string;
673
810
  }
674
811
 
675
812
  /** Options for AgentSession.prompt() */
@@ -727,7 +864,7 @@ export interface RoleModelCycleResult {
727
864
  export interface ResolvedRoleModel {
728
865
  role: string;
729
866
  model: Model;
730
- thinkingLevel?: ThinkingLevel;
867
+ thinkingLevel?: ConfiguredThinkingLevel;
731
868
  explicitThinkingLevel: boolean;
732
869
  }
733
870
 
@@ -870,6 +1007,7 @@ function parseRetryFallbackSelector(
870
1007
  if (!trimmed) return undefined;
871
1008
  const parsed = parseModelString(trimmed, {
872
1009
  allowMaxAlias: true,
1010
+ allowAutoAlias: true,
873
1011
  isLiteralModelId: (provider, id) => modelLookup?.find(provider, id) !== undefined,
874
1012
  });
875
1013
  if (!parsed) return undefined;
@@ -877,7 +1015,7 @@ function parseRetryFallbackSelector(
877
1015
  raw: trimmed,
878
1016
  provider: parsed.provider,
879
1017
  id: parsed.id,
880
- thinkingLevel: parsed.thinkingLevel,
1018
+ thinkingLevel: concreteThinkingLevel(parsed.thinkingLevel),
881
1019
  };
882
1020
  }
883
1021
 
@@ -1272,6 +1410,22 @@ type MessageEndPersistenceSlot = {
1272
1410
  release: () => void;
1273
1411
  };
1274
1412
 
1413
+ type PostPromptSkipReason = "aborted" | "stale-generation";
1414
+
1415
+ type AgentContinueSkipReason =
1416
+ | PostPromptSkipReason
1417
+ | "session-unavailable"
1418
+ | "should-continue-false"
1419
+ | "post-restore-unavailable";
1420
+
1421
+ type ScheduledAgentContinueOptions = {
1422
+ delayMs?: number;
1423
+ generation?: number;
1424
+ shouldContinue?: () => boolean;
1425
+ onSkip?: (reason: AgentContinueSkipReason) => void;
1426
+ onError?: () => void;
1427
+ };
1428
+
1275
1429
  const REPLAN_TITLE_CONTEXT_TURN_LIMIT = 6;
1276
1430
 
1277
1431
  type SessionTitleSource = "auto" | "user";
@@ -1350,6 +1504,8 @@ export class AgentSession {
1350
1504
 
1351
1505
  // Event subscription state
1352
1506
  #unsubscribeAgent?: () => void;
1507
+ #cancelExitRecorder?: () => void;
1508
+ #exitRecorded = false;
1353
1509
  #unsubscribeAppendOnly?: () => void;
1354
1510
  /** Last (enable, providerId) tuple resolved by `#syncAppendOnlyContext` — used to skip no-op invalidations. */
1355
1511
  #lastAppendOnlyResolution?: { enable: boolean; providerId: string | undefined };
@@ -1418,8 +1574,25 @@ export class AgentSession {
1418
1574
  * instruction") does not drive 1/3 → 2/3 → 3/3 without user input.
1419
1575
  */
1420
1576
  #todoReminderAwaitingProgress = false;
1577
+ /**
1578
+ * Successful mutating tool results (bash/eval/edit/write/ast_edit) since the
1579
+ * agent last touched the `todo` tool. Drives {@link #takeMidRunTodoNudge} so
1580
+ * the live HUD stays in sync with actual progress instead of flipping
1581
+ * `0/N -> N/N` only at the very end of a long run (issue #3651). Read-only
1582
+ * tools and errored results never tick it. Reset to 0 on any `todo` tool
1583
+ * result, on a nudge fire (cooldown), on a stop-time reminder, and at every
1584
+ * new-prompt / clear / handoff lifecycle boundary.
1585
+ */
1586
+ #mutationsSinceLastTodoTouch = 0;
1587
+ /** Mid-run nudges fired this prompt cycle; capped by
1588
+ * {@link MID_RUN_TODO_NUDGE_MAX_PER_CYCLE}, reset with the counter above. */
1589
+ #midRunNudgeCount = 0;
1421
1590
  #todoPhases: TodoPhase[] = [];
1422
1591
  #replanTitleRefreshInFlight: Promise<void> | undefined = undefined;
1592
+ /** Resolved TITLE_SYSTEM.md override applied to every automatic session-title
1593
+ * generation path. Refresh via {@link AgentSession.setTitleSystemPrompt} when
1594
+ * the session cwd changes. */
1595
+ #titleSystemPrompt: string | undefined;
1423
1596
  #toolChoiceQueue = new ToolChoiceQueue();
1424
1597
 
1425
1598
  // Bash execution state
@@ -1447,8 +1620,10 @@ export class AgentSession {
1447
1620
  #activeEvalExecutions = new Set<Promise<unknown>>();
1448
1621
  #evalExecutionDisposing = false;
1449
1622
 
1450
- // Incoming IRC messages received while a turn was streaming; drained as
1451
- // non-interrupting asides at the next step boundary (see the aside provider).
1623
+ // Incoming IRC messages received while a turn was streaming. Parent IRCs
1624
+ // enter the steering queue; peer IRCs enter the interrupt queue and drain as
1625
+ // asides at the next boundary; passive IRC records stay in the aside queue.
1626
+ #pendingIrcInterrupts: CustomMessage[] = [];
1452
1627
  #pendingIrcAsides: CustomMessage[] = [];
1453
1628
  // Agent identity (registry id) used for IRC routing and job ownership.
1454
1629
  #agentId: string | undefined;
@@ -1591,6 +1766,7 @@ export class AgentSession {
1591
1766
  #pruneToolDescriptions = false;
1592
1767
  #checkpointState: CheckpointState | undefined = undefined;
1593
1768
  #pendingRewindReport: string | undefined = undefined;
1769
+ #lastCompletedRewind: CompletedRewindState | undefined = undefined;
1594
1770
  #rewoundToolResultIds = new Set<string>();
1595
1771
  #lastSuccessfulYieldToolCallId: string | undefined = undefined;
1596
1772
  /**
@@ -1673,16 +1849,17 @@ export class AgentSession {
1673
1849
  this.#resumeStrandedIrcAsides();
1674
1850
  }
1675
1851
 
1676
- /** IRC asides that arrive after the loop's final aside poll — or while an abort skipped that
1677
- * poll — land in #pendingIrcAsides with no loop left to drain them; the queued-message drain's
1678
- * gate (agent.hasQueuedMessages()) does not count them. Once idle, wake a turn so the agent
1679
- * responds to the peer. Skip only when a queued steer/follow-up will itself drive a resume turn
1680
- * whose aside poll already consumes these (no double-wake). */
1852
+ /** IRC records that arrive after the loop's final aside poll — or while an abort skipped that
1853
+ * poll — land in pending IRC queues with no loop left to drain them; the queued-message drain's
1854
+ * gate (agent.hasQueuedMessages()) does not count peer IRC interrupts. Once idle, wake a turn so
1855
+ * the agent responds to the peer. Skip only when a queued steer/follow-up will itself drive a
1856
+ * resume turn whose aside poll already consumes these (no double-wake). */
1681
1857
  #resumeStrandedIrcAsides(): void {
1682
1858
  if (this.#isDisposed || this.isStreaming) return;
1683
- if (this.#pendingIrcAsides.length === 0) return;
1859
+ if (this.#pendingIrcInterrupts.length === 0 && this.#pendingIrcAsides.length === 0) return;
1684
1860
  if (this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages()) return;
1685
- const records = this.#pendingIrcAsides;
1861
+ const records = [...this.#pendingIrcInterrupts, ...this.#pendingIrcAsides];
1862
+ this.#pendingIrcInterrupts = [];
1686
1863
  this.#pendingIrcAsides = [];
1687
1864
  this.#wakeForIrc(records);
1688
1865
  }
@@ -1809,6 +1986,7 @@ export class AgentSession {
1809
1986
  this.#advisorSharedInstructions = config.advisorSharedInstructions;
1810
1987
  this.#advisorContextPrompt = config.advisorContextPrompt;
1811
1988
  this.#advisorConfigs = config.advisorConfigs;
1989
+ this.#titleSystemPrompt = config.titleSystemPrompt;
1812
1990
  this.#pruneToolDescriptions = config.pruneToolDescriptions === true;
1813
1991
  this.#validateRetryFallbackChains();
1814
1992
  this.#toolRegistry = config.toolRegistry ?? new Map();
@@ -1892,13 +2070,19 @@ export class AgentSession {
1892
2070
  },
1893
2071
  });
1894
2072
  // Background-job completions / late diagnostics are pulled into the run at
1895
- // each step boundary as non-interrupting asides (see Agent.getAsideMessages),
1896
- // so they reach the model between requests without waiting for a yield.
2073
+ // each step boundary as non-interrupting asides. Peer IRCs share the aside
2074
+ // injection boundary, but also expose a non-consuming interrupt peek so
2075
+ // `job poll` / `irc wait` can return early before the boundary drains them.
2076
+ this.agent.hasIrcInterrupts = () => this.#pendingIrcInterrupts.length > 0;
1897
2077
  this.agent.setAsideMessageProvider(() => {
1898
- const pendingIrc = this.#pendingIrcAsides;
2078
+ const pendingIrc = [...this.#pendingIrcInterrupts, ...this.#pendingIrcAsides];
2079
+ this.#pendingIrcInterrupts = [];
1899
2080
  this.#pendingIrcAsides = [];
1900
2081
  const thunks: AsideMessage[] = pendingIrc.map(record => () => record);
1901
2082
  thunks.push(...this.yieldQueue.drainLazy());
2083
+ // Mid-run todo reconciliation — evaluated at injection time so a turn
2084
+ // that flips a todo just before this poll suppresses the nudge.
2085
+ thunks.push(() => this.#takeMidRunTodoNudge());
1902
2086
  return thunks;
1903
2087
  });
1904
2088
  this.#convertToLlm = config.convertToLlm ?? convertToLlm;
@@ -1987,10 +2171,15 @@ export class AgentSession {
1987
2171
  );
1988
2172
  },
1989
2173
  });
2174
+ this.#cancelExitRecorder = postmortem.register(`agent-session:${this.sessionManager.getSessionId()}`, reason => {
2175
+ this.#recordSessionExit(reason);
2176
+ });
1990
2177
 
1991
2178
  this.#advisorEnabled = this.settings.get("advisor.enabled") as boolean;
1992
2179
  if (this.#advisorEnabled) this.#buildAdvisorRuntime();
1993
2180
 
2181
+ this.#rehydrateCheckpointRewindState();
2182
+
1994
2183
  // Always subscribe to agent events for internal handling
1995
2184
  // (session persistence, hooks, auto-compaction, retry logic)
1996
2185
  this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent);
@@ -2102,7 +2291,7 @@ export class AgentSession {
2102
2291
  if (config.model) {
2103
2292
  const resolved = resolveModelOverride([config.model], this.#modelRegistry, this.settings);
2104
2293
  model = resolved.model;
2105
- thinkingLevel = resolved.thinkingLevel;
2294
+ thinkingLevel = concreteThinkingLevel(resolved.thinkingLevel);
2106
2295
  if (!model) {
2107
2296
  this.emitNotice("warning", `Advisor "${config.name}": no model matched "${config.model}"`, "advisor");
2108
2297
  continue;
@@ -2116,7 +2305,7 @@ export class AgentSession {
2116
2305
  continue;
2117
2306
  }
2118
2307
  model = sel.model;
2119
- thinkingLevel = sel.thinkingLevel;
2308
+ thinkingLevel = concreteThinkingLevel(sel.thinkingLevel);
2120
2309
  }
2121
2310
  const advisorModel = model;
2122
2311
  const advisorName = config.name;
@@ -2157,6 +2346,12 @@ export class AgentSession {
2157
2346
  // Mirror the SDK's provider-shaping options (streamFn/onPayload/...,
2158
2347
  // providerSessionState, promptCacheKey, transformProviderContext) so each
2159
2348
  // advisor's requests cache, route, and obfuscate like the main turn.
2349
+ // `promptCacheKey` preserves an explicitly pinned provider cache key
2350
+ // unchanged so tan/shared-session advisor calls read the exact shard the
2351
+ // parent turn populated, while keeping only `sessionId` advisor-scoped;
2352
+ // sessions without a pinned key fall back to the advisor session id for
2353
+ // stable advisor-local caching (see can1357/oh-my-pi#3639).
2354
+ const advisorPromptCacheKey = this.agent.promptCacheKey ?? advisorSessionId;
2160
2355
  const advisorAgent = new Agent({
2161
2356
  initialState: {
2162
2357
  systemPrompt,
@@ -2166,7 +2361,7 @@ export class AgentSession {
2166
2361
  },
2167
2362
  appendOnlyContext,
2168
2363
  sessionId: advisorSessionId,
2169
- promptCacheKey: advisorSessionId,
2364
+ promptCacheKey: advisorPromptCacheKey,
2170
2365
  providerSessionState: this.#providerSessionState,
2171
2366
  preferWebsockets: this.#preferWebsockets,
2172
2367
  getApiKey: requestModel => this.#modelRegistry.resolver(requestModel, advisorSessionId),
@@ -2791,6 +2986,67 @@ export class AgentSession {
2791
2986
  this.#emit({ type: "notice", level, message, source });
2792
2987
  }
2793
2988
 
2989
+ #recordToolExecutionStart(event: Extract<AgentEvent, { type: "tool_execution_start" }>): void {
2990
+ const data: ToolExecutionStartData = {
2991
+ toolCallId: event.toolCallId,
2992
+ toolName: event.toolName,
2993
+ startedAt: new Date().toISOString(),
2994
+ };
2995
+ // The assistant message already persists the full arguments; store only
2996
+ // the command/path projection the resume warning renders.
2997
+ const args = summarizeToolArguments(event.args);
2998
+ if (args) data.args = args;
2999
+ if (event.intent) data.intent = event.intent;
3000
+ this.sessionManager.appendCustomEntry(TOOL_EXECUTION_START_CUSTOM_TYPE, data);
3001
+ }
3002
+
3003
+ #recordSessionExit(reason: postmortem.Reason | "dispose"): void {
3004
+ if (this.#exitRecorded) return;
3005
+ this.#exitRecorded = true;
3006
+ const pendingToolCalls = collectPendingToolCalls(this.sessionManager.getBranch());
3007
+ if (
3008
+ pendingToolCalls.length === 0 &&
3009
+ !this.sessionManager.getEntries().some(entry => entry.type === "message" && entry.message.role === "assistant")
3010
+ ) {
3011
+ return;
3012
+ }
3013
+ const kind: SessionExitData["kind"] =
3014
+ reason === "dispose" || reason === postmortem.Reason.MANUAL
3015
+ ? "normal"
3016
+ : reason === postmortem.Reason.UNCAUGHT_EXCEPTION || reason === postmortem.Reason.UNHANDLED_REJECTION
3017
+ ? "fatal"
3018
+ : reason === postmortem.Reason.EXIT
3019
+ ? "process_exit"
3020
+ : "signal";
3021
+ const data: SessionExitData = {
3022
+ reason,
3023
+ kind,
3024
+ recordedAt: new Date().toISOString(),
3025
+ };
3026
+ if (pendingToolCalls.length > 0) data.pendingToolCalls = pendingToolCalls;
3027
+ try {
3028
+ this.sessionManager.appendCustomEntry(SESSION_EXIT_CUSTOM_TYPE, data);
3029
+ this.sessionManager.flushSync();
3030
+ // Only pending tool calls or an abnormal teardown are noteworthy; a
3031
+ // clean dispose logs at debug so routine exits don't read as problems.
3032
+ const exitLog = pendingToolCalls.length > 0 || kind !== "normal" ? logger.warn : logger.debug;
3033
+ exitLog("Session exit recorded", {
3034
+ sessionId: this.sessionManager.getSessionId(),
3035
+ sessionFile: this.sessionManager.getSessionFile(),
3036
+ reason,
3037
+ kind,
3038
+ pendingToolCalls: pendingToolCalls.length,
3039
+ });
3040
+ } catch (error) {
3041
+ logger.error("Failed to record session exit", {
3042
+ sessionId: this.sessionManager.getSessionId(),
3043
+ sessionFile: this.sessionManager.getSessionFile(),
3044
+ reason,
3045
+ error: error instanceof Error ? error.message : String(error),
3046
+ });
3047
+ }
3048
+ }
3049
+
2794
3050
  #queuedExtensionEvents: Promise<void> = Promise.resolve();
2795
3051
 
2796
3052
  #queueExtensionEvent(event: AgentSessionEvent): Promise<void> {
@@ -3043,6 +3299,25 @@ export class AgentSession {
3043
3299
  }
3044
3300
 
3045
3301
  #processAgentEvent = async (event: AgentEvent): Promise<void> => {
3302
+ // Step the mid-run todo counter synchronously, BEFORE any await in this
3303
+ // handler. The agent loop's next-turn `getAsideMessages` poll can run
3304
+ // before queued microtasks drain, so `#takeMidRunTodoNudge` MUST see the
3305
+ // freshest counter — otherwise a turn that just invoked `todo` could
3306
+ // trip a spurious nudge against stale state, and a turn that just hit
3307
+ // the threshold could fail to nudge until a later turn (issue #3651).
3308
+ // Pure in-memory math — no ordering requirement vs persistence or
3309
+ // session-event fan-out. Keyed on toolResult (not the assistant toolCall
3310
+ // turn) so planned-but-aborted or permission-denied calls never count,
3311
+ // and only successful mutating tools tick — read-only exploration is
3312
+ // not progress an agent could mark done.
3313
+ if (event.type === "message_end" && event.message.role === "toolResult") {
3314
+ const { toolName, isError } = event.message;
3315
+ if (toolName === "todo") {
3316
+ this.#mutationsSinceLastTodoTouch = 0;
3317
+ } else if (!isError && MID_RUN_TODO_NUDGE_MUTATING_TOOLS[toolName]) {
3318
+ this.#mutationsSinceLastTodoTouch++;
3319
+ }
3320
+ }
3046
3321
  // Plan-mode internal transition: stamp `SILENT_ABORT_MARKER` on the
3047
3322
  // persisted message BEFORE the obfuscator's display-side copy below.
3048
3323
  // Invariant (must hold across refactors): this branch precedes the
@@ -3108,6 +3383,10 @@ export class AgentSession {
3108
3383
  });
3109
3384
  }
3110
3385
 
3386
+ if (event.type === "tool_execution_start") {
3387
+ this.#recordToolExecutionStart(event);
3388
+ }
3389
+
3111
3390
  try {
3112
3391
  await this.#emitSessionEvent(displayEvent);
3113
3392
  } catch (error) {
@@ -3336,6 +3615,7 @@ export class AgentSession {
3336
3615
  startedAt: details?.startedAt ?? new Date().toISOString(),
3337
3616
  };
3338
3617
  this.#pendingRewindReport = undefined;
3618
+ this.#lastCompletedRewind = undefined;
3339
3619
  }
3340
3620
  if (toolName === "rewind" && !isError && this.#checkpointState) {
3341
3621
  const detailReport = typeof details?.report === "string" ? details.report.trim() : "";
@@ -3536,7 +3816,11 @@ export class AgentSession {
3536
3816
  this.#trackPostPromptTask(compactionTask);
3537
3817
  compactionResult = await compactionTask;
3538
3818
  }
3539
- // Check for incomplete todos only after a final assistant stop, not intermediate tool-use turns.
3819
+ // Stop-time todo reconciliation only fires at a text-only final stop. A run
3820
+ // that ends still mid-tool-use (deadline hit, context full, etc.) skips the
3821
+ // reminder so we don't pile a follow-up onto an already in-flight turn.
3822
+ // Mid-run sync is handled separately via #takeMidRunTodoNudge so a long
3823
+ // tool-use loop still gets prodded to keep the live HUD honest (issue #3651).
3540
3824
  const hasToolCalls = msg.content.some(content => content.type === "toolCall");
3541
3825
  if (hasToolCalls) {
3542
3826
  await emitAgentEndNotification();
@@ -3624,7 +3908,7 @@ export class AgentSession {
3624
3908
 
3625
3909
  #schedulePostPromptTask(
3626
3910
  task: (signal: AbortSignal) => Promise<void>,
3627
- options?: { delayMs?: number; generation?: number; onSkip?: () => void },
3911
+ options?: { delayMs?: number; generation?: number; onSkip?: (reason: PostPromptSkipReason) => void },
3628
3912
  ): void {
3629
3913
  const delayMs = options?.delayMs ?? 0;
3630
3914
  const signal = this.#postPromptTasksAbortController.signal;
@@ -3637,11 +3921,11 @@ export class AgentSession {
3637
3921
  }
3638
3922
  }
3639
3923
  if (signal.aborted) {
3640
- options?.onSkip?.();
3924
+ options?.onSkip?.("aborted");
3641
3925
  return;
3642
3926
  }
3643
3927
  if (options?.generation !== undefined && this.#promptGeneration !== options.generation) {
3644
- options.onSkip?.();
3928
+ options.onSkip?.("stale-generation");
3645
3929
  return;
3646
3930
  }
3647
3931
  await task(signal);
@@ -3649,13 +3933,12 @@ export class AgentSession {
3649
3933
  this.#trackPostPromptTask(scheduled);
3650
3934
  }
3651
3935
 
3652
- #scheduleAgentContinue(options?: {
3653
- delayMs?: number;
3654
- generation?: number;
3655
- shouldContinue?: () => boolean;
3656
- onSkip?: () => void;
3657
- onError?: () => void;
3658
- }): void {
3936
+ #skipAgentContinue(reason: AgentContinueSkipReason, options: ScheduledAgentContinueOptions | undefined): void {
3937
+ logger.debug("agent.continue skipped after scheduling", { reason });
3938
+ options?.onSkip?.(reason);
3939
+ }
3940
+
3941
+ #scheduleAgentContinue(options?: ScheduledAgentContinueOptions): void {
3659
3942
  this.#schedulePostPromptTask(
3660
3943
  async signal => {
3661
3944
  // Defense in depth: if compaction/handoff slipped onto the post-prompt queue
@@ -3664,24 +3947,25 @@ export class AgentSession {
3664
3947
  // reset. The first-class fix is in #checkCompaction/the agent_end handler,
3665
3948
  // but this guard catches anything that bypasses that path.
3666
3949
  if (signal.aborted || this.#isDisposed || this.isCompacting || this.isGeneratingHandoff) {
3667
- options?.onSkip?.();
3950
+ this.#skipAgentContinue("session-unavailable", options);
3668
3951
  return;
3669
3952
  }
3670
3953
  if (options?.shouldContinue && !options.shouldContinue()) {
3671
- options?.onSkip?.();
3954
+ this.#skipAgentContinue("should-continue-false", options);
3672
3955
  return;
3673
3956
  }
3674
3957
  this.#beginInFlight();
3675
3958
  try {
3676
3959
  await this.#maybeRestoreRetryFallbackPrimary();
3677
3960
  if (signal.aborted || this.#isDisposed) {
3678
- options?.onSkip?.();
3961
+ this.#skipAgentContinue("post-restore-unavailable", options);
3679
3962
  return;
3680
3963
  }
3681
3964
  await this.agent.continue();
3682
3965
  } catch (error) {
3683
3966
  logger.warn("agent.continue failed after scheduling", {
3684
3967
  error: error instanceof Error ? error.message : String(error),
3968
+ stack: error instanceof Error ? error.stack : undefined,
3685
3969
  });
3686
3970
  options?.onError?.();
3687
3971
  } finally {
@@ -3691,7 +3975,7 @@ export class AgentSession {
3691
3975
  {
3692
3976
  delayMs: options?.delayMs,
3693
3977
  generation: options?.generation,
3694
- onSkip: options?.onSkip,
3978
+ onSkip: reason => this.#skipAgentContinue(reason, options),
3695
3979
  },
3696
3980
  );
3697
3981
  }
@@ -5097,6 +5381,7 @@ export class AgentSession {
5097
5381
  this.#flushPendingIrcAsides();
5098
5382
  this.yieldQueue.clear();
5099
5383
  this.agent.setAsideMessageProvider(undefined);
5384
+ this.agent.hasIrcInterrupts = undefined;
5100
5385
  this.#stopAdvisorRuntime();
5101
5386
  this.#evalExecutionDisposing = true;
5102
5387
  }
@@ -5104,9 +5389,24 @@ export class AgentSession {
5104
5389
  /**
5105
5390
  * Remove all listeners, flush pending writes, and disconnect from agent.
5106
5391
  * Call this when completely done with the session.
5392
+ *
5393
+ * Idempotent: concurrent or repeated calls share one settled promise. The
5394
+ * keypress `InteractiveMode.shutdown()` path and the postmortem
5395
+ * `SIGTERM`/`SIGHUP`/`uncaughtException` callback can both target this
5396
+ * method, so a second invocation must never re-emit `session_shutdown` or
5397
+ * double-drain the owned `AsyncJobManager` (issue #4080).
5107
5398
  */
5108
- async dispose(): Promise<void> {
5399
+ #disposeCall?: Promise<void>;
5400
+ dispose(options: AgentSessionDisposeOptions = {}): Promise<void> {
5401
+ if (!this.#disposeCall) this.#disposeCall = this.#doDispose(options);
5402
+ return this.#disposeCall;
5403
+ }
5404
+
5405
+ async #doDispose(options: AgentSessionDisposeOptions = {}): Promise<void> {
5109
5406
  this.beginDispose();
5407
+ this.#recordSessionExit(options.reason ?? "dispose");
5408
+ this.#cancelExitRecorder?.();
5409
+ this.#cancelExitRecorder = undefined;
5110
5410
  try {
5111
5411
  if (this.#extensionRunner?.hasHandlers("session_shutdown")) {
5112
5412
  await this.#extensionRunner.emit({ type: "session_shutdown" });
@@ -5219,7 +5519,7 @@ export class AgentSession {
5219
5519
  this.setHindsightSessionState(undefined);
5220
5520
  hindsightState?.dispose();
5221
5521
  const mnemopiState = setMnemopiSessionState(this, undefined);
5222
- await mnemopiState?.dispose();
5522
+ await mnemopiState?.dispose({ timeoutMs: options.mnemopiConsolidateTimeoutMs });
5223
5523
  // Tear down the embeddings subprocess AFTER mnemopi state.dispose:
5224
5524
  // consolidate-on-dispose may still call `embed()` to store the final
5225
5525
  // memories, and that round-trips through the worker we are about to
@@ -6426,13 +6726,71 @@ export class AgentSession {
6426
6726
  this.agent.setTools(activeTools);
6427
6727
  }
6428
6728
 
6729
+ #clearCheckpointRuntimeState(): void {
6730
+ this.#checkpointState = undefined;
6731
+ this.#pendingRewindReport = undefined;
6732
+ this.#lastCompletedRewind = undefined;
6733
+ this.#rewoundToolResultIds.clear();
6734
+ }
6735
+
6736
+ /**
6737
+ * Rebuild checkpoint/rewind runtime state from the current branch. Handles two
6738
+ * cases surfaced by session resume, `switchSession()` reloading the same file,
6739
+ * and tree navigation:
6740
+ * - The branch's most recent checkpoint has already been rewound → restore
6741
+ * `#lastCompletedRewind` so a repeat `rewind` call receives the
6742
+ * "checkpoint already completed" recovery guidance.
6743
+ * - The branch's most recent checkpoint has NOT been rewound (e.g. the run
6744
+ * was aborted between `checkpoint` and `rewind`) → restore
6745
+ * `#checkpointState` so the next `rewind` call can complete the
6746
+ * checkpoint instead of failing with "No active checkpoint".
6747
+ */
6748
+ #rehydrateCheckpointRewindState(): void {
6749
+ this.#clearCheckpointRuntimeState();
6750
+ let completed: CompletedRewindState | undefined;
6751
+ let pending: { entryId: string; startedAt: string; messageCount: number } | undefined;
6752
+ let messageCount = 0;
6753
+ for (const entry of this.sessionManager.getBranch()) {
6754
+ if (entry.type === "message") messageCount++;
6755
+ if (isSuccessfulCheckpointEntry(entry)) {
6756
+ completed = undefined;
6757
+ pending = {
6758
+ entryId: entry.id,
6759
+ startedAt: checkpointStartedAtFromEntry(entry) ?? entry.timestamp,
6760
+ messageCount,
6761
+ };
6762
+ continue;
6763
+ }
6764
+ const completedFromEntry = completedRewindFromEntry(entry);
6765
+ if (completedFromEntry) {
6766
+ completed = completedFromEntry;
6767
+ pending = undefined;
6768
+ }
6769
+ }
6770
+ if (pending) {
6771
+ this.#checkpointState = {
6772
+ checkpointEntryId: pending.entryId,
6773
+ startedAt: pending.startedAt,
6774
+ checkpointMessageCount: pending.messageCount,
6775
+ };
6776
+ return;
6777
+ }
6778
+ this.#lastCompletedRewind = completed;
6779
+ }
6780
+
6429
6781
  getCheckpointState(): CheckpointState | undefined {
6430
6782
  return this.#checkpointState;
6431
6783
  }
6432
6784
 
6785
+ getLastCompletedRewind(): CompletedRewindState | undefined {
6786
+ return this.#lastCompletedRewind;
6787
+ }
6788
+
6433
6789
  setCheckpointState(state: CheckpointState | undefined): void {
6434
6790
  this.#checkpointState = state;
6435
- if (!state) {
6791
+ if (state) {
6792
+ this.#lastCompletedRewind = undefined;
6793
+ } else {
6436
6794
  this.#pendingRewindReport = undefined;
6437
6795
  }
6438
6796
  }
@@ -6523,9 +6881,8 @@ export class AgentSession {
6523
6881
 
6524
6882
  const planFilePath = this.#planReferencePath;
6525
6883
  const resolvedPlanPath = resolveLocalUrlToPath(planFilePath, this.#localProtocolOptions());
6526
- let planContent: string;
6527
6884
  try {
6528
- planContent = await Bun.file(resolvedPlanPath).text();
6885
+ await fs.promises.access(resolvedPlanPath, fs.constants.R_OK);
6529
6886
  } catch (error) {
6530
6887
  if (isEnoent(error)) {
6531
6888
  return null;
@@ -6535,7 +6892,6 @@ export class AgentSession {
6535
6892
 
6536
6893
  const content = prompt.render(planModeReferencePrompt, {
6537
6894
  planFilePath,
6538
- planContent,
6539
6895
  });
6540
6896
 
6541
6897
  this.#planReferenceSent = true;
@@ -6966,6 +7322,8 @@ export class AgentSession {
6966
7322
  // Reset todo reminder count on new user prompt
6967
7323
  this.#todoReminderCount = 0;
6968
7324
  this.#todoReminderAwaitingProgress = false;
7325
+ this.#mutationsSinceLastTodoTouch = 0;
7326
+ this.#midRunNudgeCount = 0;
6969
7327
  this.#emptyStopRetryCount = 0;
6970
7328
  this.#unexpectedStopRetryCount = 0;
6971
7329
  // A new prompt cycle starts: drop any sticky yield-termination from the
@@ -7817,6 +8175,7 @@ export class AgentSession {
7817
8175
  sessionId,
7818
8176
  this.model,
7819
8177
  provider => this.agent.metadataForProvider(provider),
8178
+ this.#titleSystemPrompt,
7820
8179
  );
7821
8180
  if (!title) return;
7822
8181
  if (this.sessionManager.getSessionId() !== sessionId) return;
@@ -7826,6 +8185,21 @@ export class AgentSession {
7826
8185
  await setSessionName.call(this.sessionManager, title, "auto", "replan");
7827
8186
  }
7828
8187
 
8188
+ /** Currently-applied {@link TITLE_SYSTEM.md} override, or undefined when the
8189
+ * bundled prompt is in effect. Consumed by {@link InteractiveMode} so the
8190
+ * first-input title path and the replan refresh share one source. */
8191
+ get titleSystemPrompt(): string | undefined {
8192
+ return this.#titleSystemPrompt;
8193
+ }
8194
+
8195
+ /** Replace the title-generation system prompt override. Called by
8196
+ * {@link InteractiveMode.refreshTitleSystemPrompt} after the session cwd
8197
+ * changes (e.g. `/move` relocation) so the next replan refresh resolves
8198
+ * against the destination project's override. */
8199
+ setTitleSystemPrompt(prompt: string | undefined): void {
8200
+ this.#titleSystemPrompt = prompt;
8201
+ }
8202
+
7829
8203
  #syncTodoPhasesFromBranch(): void {
7830
8204
  const phases = getLatestTodoPhasesFromEntries(this.sessionManager.getBranch());
7831
8205
  // Strip completed/abandoned tasks — they were done in a previous run,
@@ -7982,6 +8356,7 @@ export class AgentSession {
7982
8356
  await this.sessionManager.flush();
7983
8357
  }
7984
8358
  await this.sessionManager.newSession(options);
8359
+ this.#clearCheckpointRuntimeState();
7985
8360
  this.setTodoPhases([]);
7986
8361
  this.#freshProviderSessionId = undefined;
7987
8362
  this.#syncAgentSessionId();
@@ -8006,6 +8381,8 @@ export class AgentSession {
8006
8381
 
8007
8382
  this.#todoReminderCount = 0;
8008
8383
  this.#todoReminderAwaitingProgress = false;
8384
+ this.#mutationsSinceLastTodoTouch = 0;
8385
+ this.#midRunNudgeCount = 0;
8009
8386
  this.#planReferenceSent = false;
8010
8387
  this.#planReferencePath = "local://PLAN.md";
8011
8388
  this.#resetAdvisorSessionState();
@@ -8254,8 +8631,16 @@ export class AgentSession {
8254
8631
 
8255
8632
  if (models.length === 0) return undefined;
8256
8633
 
8634
+ // Trust the recorded role only while its resolved model still IS the
8635
+ // active model. A model switch through another surface (alt+m, retry
8636
+ // fallback, /model) or a role re-configuration leaves the recorded role
8637
+ // pointing at a model the session no longer runs; cycling from that
8638
+ // stale slot lands on the wrong neighbor and reads as a skipped entry.
8257
8639
  const lastRole = this.sessionManager.getLastModelChangeRole();
8258
8640
  let currentIndex = lastRole ? models.findIndex(entry => entry.role === lastRole) : -1;
8641
+ if (currentIndex !== -1 && !modelsAreEqual(models[currentIndex].model, currentModel)) {
8642
+ currentIndex = -1;
8643
+ }
8259
8644
  if (currentIndex === -1) {
8260
8645
  currentIndex = models.findIndex(entry => modelsAreEqual(entry.model, currentModel));
8261
8646
  }
@@ -9383,6 +9768,7 @@ export class AgentSession {
9383
9768
  await this.sessionManager.flush();
9384
9769
  this.#cancelOwnAsyncJobs();
9385
9770
  await this.sessionManager.newSession(previousSessionFile ? { parentSession: previousSessionFile } : undefined);
9771
+ this.#clearCheckpointRuntimeState();
9386
9772
  // agent.reset() clears the core steering/follow-up queues. Preserve any queued
9387
9773
  // steers/follow-ups (RPC/SDK steer()/followUp() issued during the handoff, or a
9388
9774
  // pre-loader TUI steer) so they survive into the post-handoff session instead of
@@ -9403,6 +9789,8 @@ export class AgentSession {
9403
9789
  this.#scheduledHiddenNextTurnGeneration = undefined;
9404
9790
  this.#todoReminderCount = 0;
9405
9791
  this.#todoReminderAwaitingProgress = false;
9792
+ this.#mutationsSinceLastTodoTouch = 0;
9793
+ this.#midRunNudgeCount = 0;
9406
9794
 
9407
9795
  // Inject the handoff document as a custom message
9408
9796
  const handoffContent = createHandoffContext(handoffText);
@@ -9957,15 +10345,27 @@ export class AgentSession {
9957
10345
  });
9958
10346
  }
9959
10347
 
9960
- #removeAssistantMessageFromActiveContext(assistantMessage: AssistantMessage): void {
10348
+ #removeAssistantMessageFromActiveContext(
10349
+ assistantMessage: AssistantMessage,
10350
+ reason = "assistant-context-cleanup",
10351
+ ): void {
9961
10352
  const messages = this.agent.state.messages;
9962
10353
  const lastMessage = messages[messages.length - 1];
9963
- if (
9964
- lastMessage?.role === "assistant" &&
9965
- this.#isSameAssistantMessage(lastMessage as AssistantMessage, assistantMessage)
9966
- ) {
10354
+ const lastAssistant: AssistantMessage | undefined = lastMessage?.role === "assistant" ? lastMessage : undefined;
10355
+ if (lastAssistant !== undefined && this.#isSameAssistantMessage(lastAssistant, assistantMessage)) {
9967
10356
  this.agent.replaceMessages(messages.slice(0, -1));
10357
+ return;
9968
10358
  }
10359
+ // A miss means the failed turn is still in active context (or was never
10360
+ // there); log just enough to explain why the identity check failed.
10361
+ logger.debug("agent active context assistant removal missed", {
10362
+ reason,
10363
+ lastRole: lastMessage?.role,
10364
+ candidateTimestamp: assistantMessage.timestamp,
10365
+ lastTimestamp: lastAssistant?.timestamp,
10366
+ candidateStopReason: assistantMessage.stopReason,
10367
+ lastStopReason: lastAssistant?.stopReason,
10368
+ });
9969
10369
  }
9970
10370
 
9971
10371
  /**
@@ -9986,15 +10386,17 @@ export class AgentSession {
9986
10386
  * Drop the failed assistant turn from persisted history, run
9987
10387
  * {@link #runAutoCompaction} for an `overflow` / `incomplete` recovery, and
9988
10388
  * restore the assistant entry if compaction did not actually commit
9989
- * anything (no usable model/preparation, hook cancel, or compaction error).
10389
+ * anything (no usable model/preparation, hook cancel, compaction error,
10390
+ * or a no-progress automatic-continuation block before any summary was
10391
+ * written).
9990
10392
  *
9991
10393
  * Compaction has to see a clean branch — otherwise its `prepareCompaction`
9992
10394
  * pass would keep the failed turn in the kept region and the retry would
9993
- * replay it. But a NONE return that was not paired with a fresh compaction
9994
- * summary means no recovery is in progress, and leaving the branch
9995
- * reparented would erase the only user-visible explanation for why the turn
9996
- * stopped. Reverting the drop in that case preserves the transcript while
9997
- * still letting a real recovery path own the rewrite.
10395
+ * replay it. But a return that was not paired with a fresh compaction
10396
+ * summary or a successful history rewrite means no recovery is in progress,
10397
+ * even if queued user input gets drained next. Restoring the failed turn
10398
+ * before that continuation preserves the visible stop reason and rebuilds the
10399
+ * active assistant tail that `Agent.continue()` needs to dequeue follow-ups.
9998
10400
  */
9999
10401
  async #runRecoveryCompactionWithRollback(
10000
10402
  reason: "overflow" | "incomplete",
@@ -10005,17 +10407,25 @@ export class AgentSession {
10005
10407
  const compactionEntryBefore = getLatestCompactionEntry(this.sessionManager.getBranch());
10006
10408
  await this.#dropPersistedAssistantTurn(assistantMessage);
10007
10409
  const result = await this.#runAutoCompaction(reason, true, false, allowDefer, options);
10008
- const recoveryCommitted =
10009
- result.continuationScheduled || result.deferredHandoff || result.automaticContinuationBlocked === true;
10010
- if (!recoveryCommitted) {
10011
- const compactionEntryAfter = getLatestCompactionEntry(this.sessionManager.getBranch());
10012
- if (compactionEntryAfter === compactionEntryBefore) {
10013
- this.sessionManager.appendMessage(assistantMessage);
10014
- }
10410
+ const compactionEntryAfter = getLatestCompactionEntry(this.sessionManager.getBranch());
10411
+ if (result.historyRewritten !== true && compactionEntryAfter === compactionEntryBefore) {
10412
+ this.#restoreFailedAssistantTurn(assistantMessage);
10015
10413
  }
10016
10414
  return result;
10017
10415
  }
10018
10416
 
10417
+ #restoreFailedAssistantTurn(assistantMessage: AssistantMessage): void {
10418
+ this.sessionManager.appendMessage(assistantMessage);
10419
+ const lastMessage = this.agent.state.messages.at(-1);
10420
+ if (
10421
+ lastMessage?.role === "assistant" &&
10422
+ this.#isSameAssistantMessage(lastMessage as AssistantMessage, assistantMessage)
10423
+ ) {
10424
+ return;
10425
+ }
10426
+ this.agent.appendMessage(assistantMessage);
10427
+ }
10428
+
10019
10429
  /**
10020
10430
  * Drop an assistant turn from BOTH the live agent context and the persisted
10021
10431
  * session branch (reparenting the leaf to the turn's parent), so a discarded
@@ -10108,8 +10518,16 @@ export class AgentSession {
10108
10518
  });
10109
10519
  this.sessionManager.branchWithSummary(null, report, { startedAt: checkpointState.startedAt });
10110
10520
  }
10111
- const details = { startedAt: checkpointState.startedAt, rewoundAt: new Date().toISOString() };
10112
- this.sessionManager.appendCustomMessageEntry("rewind-report", report, false, details, "agent");
10521
+ const rewoundAt = new Date().toISOString();
10522
+ const details = { report, startedAt: checkpointState.startedAt, rewoundAt };
10523
+ this.sessionManager.appendCustomMessageEntry(
10524
+ "rewind-report",
10525
+ prompt.render(rewindReportTemplate, { report }),
10526
+ false,
10527
+ details,
10528
+ "agent",
10529
+ );
10530
+ this.#lastCompletedRewind = { report, startedAt: checkpointState.startedAt, rewoundAt };
10113
10531
 
10114
10532
  if (activeMessages) {
10115
10533
  for (const message of activeMessages) {
@@ -10395,6 +10813,11 @@ export class AgentSession {
10395
10813
  timestamp: Date.now(),
10396
10814
  };
10397
10815
 
10816
+ // A stop-time reminder starts a fresh reminder runway. Without resetting
10817
+ // the mid-run counter here, a run that stopped just below the threshold
10818
+ // would spend its stale pre-reminder count and fire "Mid-run reminder 2/3"
10819
+ // after only a little post-reminder work.
10820
+ this.#mutationsSinceLastTodoTouch = 0;
10398
10821
  this.#todoReminderAwaitingProgress = true;
10399
10822
  // Inject reminder and persist it so the JSONL transcript matches model context.
10400
10823
  this.agent.appendMessage(reminderMessage);
@@ -10403,6 +10826,68 @@ export class AgentSession {
10403
10826
  return true;
10404
10827
  }
10405
10828
 
10829
+ /**
10830
+ * Build the next mid-run todo reconciliation nudge when the agent has landed
10831
+ * {@link MID_RUN_TODO_NUDGE_MUTATION_THRESHOLD} mutating tool results without
10832
+ * invoking the `todo` tool and incomplete items remain. Returns the hidden
10833
+ * (`display: false`) custom message when it should fire, or `null` to skip.
10834
+ * Called once per turn via the aside provider; mutates internal counters when
10835
+ * it fires so the caller does not need to track delivery state.
10836
+ *
10837
+ * Deliberately a SEPARATE concept from {@link #checkTodoCompletion}'s
10838
+ * stop-time reminder: this is a gentle model-only hint (no `todo_reminder`
10839
+ * event, no TUI render, no escalation counter, own per-cycle budget), while
10840
+ * the stop-time reminder is the user-visible escalation ladder. Without this
10841
+ * nudge, long runs drive the live HUD to `0/N` until the final stop, then
10842
+ * batch-flip to `N/N` (issue #3651).
10843
+ */
10844
+ #takeMidRunTodoNudge(): AgentMessage | null {
10845
+ if (this.#mutationsSinceLastTodoTouch < MID_RUN_TODO_NUDGE_MUTATION_THRESHOLD) return null;
10846
+ if (this.#midRunNudgeCount >= MID_RUN_TODO_NUDGE_MAX_PER_CYCLE) return null;
10847
+ if (!this.settings.get("todo.enabled")) return null;
10848
+ if (!this.settings.get("todo.reminders")) return null;
10849
+ // Plan-mode runs are authoring a plan file, not implementing it; todos
10850
+ // don't apply, mirroring {@link #createEagerTodoPrelude}.
10851
+ if (this.#planModeState?.enabled) return null;
10852
+ // Tool discovery / explicit active-tool lists can hide `todo` from this
10853
+ // run while `todo.enabled` remains true (e.g. `setActiveToolsByName`
10854
+ // restricting the slate). Mirror {@link #createEagerTodoPrelude}'s
10855
+ // guard so we never ask the model to call a tool that is not in its
10856
+ // schema — the request would fabricate an unknown tool call.
10857
+ if (!this.getActiveToolNames().includes("todo")) return null;
10858
+
10859
+ const incomplete = this.getTodoPhases()
10860
+ .flatMap(phase => phase.tasks)
10861
+ .filter(task => task.status === "pending" || task.status === "in_progress");
10862
+ if (incomplete.length === 0) return null;
10863
+
10864
+ // Reset the mutation counter so the nudge has another full runway before
10865
+ // the next fire; #midRunNudgeCount caps total nudges per prompt cycle.
10866
+ this.#mutationsSinceLastTodoTouch = 0;
10867
+ this.#midRunNudgeCount++;
10868
+
10869
+ const { toolRefs } = this.#buildEagerPreludeContext();
10870
+ const reminder = prompt.render(midRunTodoNudgePrompt, {
10871
+ toolRefs,
10872
+ incompleteCount: incomplete.length,
10873
+ plural: incomplete.length !== 1,
10874
+ });
10875
+
10876
+ logger.debug("Mid-run todo nudge fired", {
10877
+ incomplete: incomplete.length,
10878
+ nudge: this.#midRunNudgeCount,
10879
+ });
10880
+
10881
+ return {
10882
+ role: "custom",
10883
+ customType: MID_RUN_TODO_NUDGE_MESSAGE_TYPE,
10884
+ content: reminder,
10885
+ display: false,
10886
+ attribution: "agent",
10887
+ timestamp: Date.now(),
10888
+ };
10889
+ }
10890
+
10406
10891
  /**
10407
10892
  * Attempt context promotion to a larger model.
10408
10893
  * Returns true if promotion succeeded (caller should retry without compacting).
@@ -10758,6 +11243,7 @@ export class AgentSession {
10758
11243
 
10759
11244
  const parsed = parseModelString(trimmedTarget, {
10760
11245
  allowMaxAlias: true,
11246
+ allowAutoAlias: true,
10761
11247
  isLiteralModelId: (provider, id) =>
10762
11248
  availableModels.some(model => model.provider === provider && model.id === id),
10763
11249
  });
@@ -11157,12 +11643,14 @@ export class AgentSession {
11157
11643
  * `0.8 × 170k = 136k` and was wrongly refused (PR #3412 review).
11158
11644
  *
11159
11645
  * Measures residual context against the usable budget (`contextWindow - reserve`).
11160
- * The default absolute reserve can exceed bundled small-context windows, so
11161
- * the retry path clamps that reserve back to the proportional 15% default
11162
- * before comparing; otherwise a prompt that fits the model window would still
11163
- * see a negative budget and dead-end. Callers MUST invoke this AFTER dropping
11164
- * the failed assistant from `this.messages`, so the just-failed turn (which
11165
- * the retry prompt will not include) is excluded from the estimate.
11646
+ * The default absolute reserve can exceed bundled small-context windows, or
11647
+ * nearly consume a 16k-class window; those known-impossible defaults fall
11648
+ * back to the proportional 15% reserve. Explicit valid reserves still define
11649
+ * the usable prompt budget so retries do not enter headroom the user
11650
+ * intentionally reserved. Callers MUST
11651
+ * invoke this AFTER dropping the failed assistant from `this.messages`, so
11652
+ * the just-failed turn (which the retry prompt will not include) is excluded
11653
+ * from the estimate.
11166
11654
  *
11167
11655
  * When the model/window is unknown we cannot evaluate the budget, so we
11168
11656
  * optimistically allow the retry (preserving prior behavior).
@@ -11175,10 +11663,7 @@ export class AgentSession {
11175
11663
  this.getContextUsage({ contextWindow })?.tokens ?? 0,
11176
11664
  this.#estimateStoredContextTokens(),
11177
11665
  );
11178
- const reserveTokens = effectiveReserveTokens(contextWindow, compactionSettings);
11179
- const defaultReserveTokens = Math.floor(contextWindow * 0.15);
11180
- const fitReserveTokens = Math.min(reserveTokens, defaultReserveTokens);
11181
- const fitBudget = Math.max(0, contextWindow - fitReserveTokens);
11666
+ const fitBudget = Math.max(0, contextWindow - resolveBudgetReserveTokens(contextWindow, compactionSettings));
11182
11667
  return residualTokens <= fitBudget;
11183
11668
  }
11184
11669
 
@@ -11364,7 +11849,10 @@ export class AgentSession {
11364
11849
  if (continuationScheduled) {
11365
11850
  this.#scheduleAutoContinuePrompt(generation);
11366
11851
  }
11367
- return continuationScheduled ? COMPACTION_CHECK_CONTINUATION : COMPACTION_CHECK_NONE;
11852
+ return {
11853
+ ...(continuationScheduled ? COMPACTION_CHECK_CONTINUATION : COMPACTION_CHECK_NONE),
11854
+ historyRewritten: true,
11855
+ };
11368
11856
  }
11369
11857
  }
11370
11858
 
@@ -11409,15 +11897,25 @@ export class AgentSession {
11409
11897
  willRetry: false,
11410
11898
  skipped: true,
11411
11899
  });
11412
- if (!willRetry && this.agent.hasQueuedMessages()) {
11900
+ const noProgressDeadEnd = reason !== "idle";
11901
+ let continuationScheduled = false;
11902
+ if (!suppressContinuation && this.agent.hasQueuedMessages()) {
11413
11903
  this.#scheduleAgentContinue({
11414
11904
  delayMs: 100,
11415
11905
  generation,
11416
11906
  shouldContinue: () => this.agent.hasQueuedMessages(),
11417
11907
  });
11418
- return COMPACTION_CHECK_CONTINUATION;
11908
+ continuationScheduled = true;
11419
11909
  }
11420
- return COMPACTION_CHECK_NONE;
11910
+ if (noProgressDeadEnd) {
11911
+ this.emitNotice(
11912
+ "warning",
11913
+ compactionDeadEndWarning("shrink it (e.g. clear large tool output)"),
11914
+ "compaction",
11915
+ );
11916
+ }
11917
+ if (continuationScheduled) return COMPACTION_CHECK_CONTINUATION;
11918
+ return noProgressDeadEnd ? COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION : COMPACTION_CHECK_NONE;
11421
11919
  }
11422
11920
 
11423
11921
  let hookCompaction: CompactionResult | undefined;
@@ -11828,7 +12326,7 @@ export class AgentSession {
11828
12326
  if (noProgressDeadEnd) {
11829
12327
  this.emitNotice(
11830
12328
  "warning",
11831
- "Compaction freed too little context to make progress — pausing automatic maintenance to avoid a compaction loop. The most recent turn alone is too large to reduce further; clear large tool output, run `/shake images` to drop attached images, or switch to a larger-context model.",
12329
+ compactionDeadEndWarning("clear large tool output, run `/shake images` to drop attached images,"),
11832
12330
  "compaction",
11833
12331
  );
11834
12332
  }
@@ -11991,7 +12489,17 @@ export class AgentSession {
11991
12489
  });
11992
12490
  continuationScheduled = true;
11993
12491
  }
11994
- return continuationScheduled ? COMPACTION_CHECK_CONTINUATION : COMPACTION_CHECK_NONE;
12492
+ if (!reclaimed) {
12493
+ return willRetry && continuationScheduled
12494
+ ? { ...COMPACTION_CHECK_CONTINUATION, historyRewritten: true }
12495
+ : continuationScheduled
12496
+ ? COMPACTION_CHECK_CONTINUATION
12497
+ : COMPACTION_CHECK_NONE;
12498
+ }
12499
+ return {
12500
+ ...(continuationScheduled ? COMPACTION_CHECK_CONTINUATION : COMPACTION_CHECK_NONE),
12501
+ historyRewritten: true,
12502
+ };
11995
12503
  } catch (error) {
11996
12504
  if (signal.aborted) {
11997
12505
  await this.#emitSessionEvent({
@@ -12662,7 +13170,7 @@ export class AgentSession {
12662
13170
  });
12663
13171
 
12664
13172
  // Remove the failed assistant message from active context before retrying.
12665
- this.#removeAssistantMessageFromActiveContext(message);
13173
+ this.#removeAssistantMessageFromActiveContext(message, "auto-retry");
12666
13174
 
12667
13175
  // A thinking/response loop retried into identical context loops again. Inject a
12668
13176
  // hidden redirect so the retried turn sees a directive to break the repeated
@@ -12850,6 +13358,7 @@ export class AgentSession {
12850
13358
  onChunk,
12851
13359
  signal: abortController.signal,
12852
13360
  sessionKey: this.sessionId,
13361
+ cwd,
12853
13362
  timeout: clampTimeout("bash") * 1000,
12854
13363
  onMinimizedSave: originalText => this.#saveBashOriginalArtifact(originalText),
12855
13364
  useUserShell: options?.useUserShell,
@@ -13109,46 +13618,53 @@ export class AgentSession {
13109
13618
  // =========================================================================
13110
13619
 
13111
13620
  /**
13112
- * Surfaces (and consumes) IRC incoming asides that have reached this running
13113
- * session but have not yet been folded into the next model step.
13621
+ * Surfaces (and consumes) pending IRC incoming records before the next model
13622
+ * step can inject them automatically.
13114
13623
  *
13115
13624
  * The inbox tool injects the formatted body into the tool result, so the
13116
- * model sees it once via the result. Leaving the record in
13117
- * {@link #pendingIrcAsides} would let the aside provider deliver it a second
13118
- * time at the next step boundary — including on `peek`, which is why peek
13119
- * also drains here.
13625
+ * model sees it once via the result. Leaving the record in either pending
13626
+ * IRC queue would deliver it a second time at the next step boundary —
13627
+ * including on `peek`, which is why peek also drains here.
13120
13628
  */
13121
13629
  drainPendingIrcInboxMessages(agentId: string): IrcMessage[] {
13122
13630
  const messages: IrcMessage[] = [];
13123
- const remaining: CustomMessage[] = [];
13124
- for (const record of this.#pendingIrcAsides) {
13125
- if (record.customType !== "irc:incoming") {
13126
- remaining.push(record);
13127
- continue;
13128
- }
13129
- const details = record.details;
13130
- if (!details || typeof details !== "object") {
13131
- remaining.push(record);
13132
- continue;
13133
- }
13134
- const id = Reflect.get(details, "id");
13135
- const from = Reflect.get(details, "from");
13136
- const body = Reflect.get(details, "message");
13137
- const replyTo = Reflect.get(details, "replyTo");
13138
- if (typeof id !== "string" || typeof from !== "string" || typeof body !== "string") {
13139
- remaining.push(record);
13140
- continue;
13631
+ const remainingInterrupts: CustomMessage[] = [];
13632
+ const remainingAsides: CustomMessage[] = [];
13633
+ const queues = [
13634
+ { records: this.#pendingIrcInterrupts, remaining: remainingInterrupts },
13635
+ { records: this.#pendingIrcAsides, remaining: remainingAsides },
13636
+ ];
13637
+ for (const queue of queues) {
13638
+ for (const record of queue.records) {
13639
+ if (record.customType !== "irc:incoming") {
13640
+ queue.remaining.push(record);
13641
+ continue;
13642
+ }
13643
+ const details = record.details;
13644
+ if (!details || typeof details !== "object") {
13645
+ queue.remaining.push(record);
13646
+ continue;
13647
+ }
13648
+ const id = Reflect.get(details, "id");
13649
+ const from = Reflect.get(details, "from");
13650
+ const body = Reflect.get(details, "message");
13651
+ const replyTo = Reflect.get(details, "replyTo");
13652
+ if (typeof id !== "string" || typeof from !== "string" || typeof body !== "string") {
13653
+ queue.remaining.push(record);
13654
+ continue;
13655
+ }
13656
+ messages.push({
13657
+ id,
13658
+ from,
13659
+ to: agentId,
13660
+ body,
13661
+ ts: record.timestamp,
13662
+ ...(typeof replyTo === "string" ? { replyTo } : {}),
13663
+ });
13141
13664
  }
13142
- messages.push({
13143
- id,
13144
- from,
13145
- to: agentId,
13146
- body,
13147
- ts: record.timestamp,
13148
- ...(typeof replyTo === "string" ? { replyTo } : {}),
13149
- });
13150
13665
  }
13151
- this.#pendingIrcAsides = remaining;
13666
+ this.#pendingIrcInterrupts = remainingInterrupts;
13667
+ this.#pendingIrcAsides = remainingAsides;
13152
13668
  return messages;
13153
13669
  }
13154
13670
 
@@ -13186,6 +13702,7 @@ export class AgentSession {
13186
13702
  message: msg.body,
13187
13703
  replyTo: msg.replyTo ?? "",
13188
13704
  autoReplied: autoReply,
13705
+ interrupting: this.isStreaming,
13189
13706
  }),
13190
13707
  display: true,
13191
13708
  details: { id: msg.id, from: msg.from, message: msg.body, ...(msg.replyTo ? { replyTo: msg.replyTo } : {}) },
@@ -13194,7 +13711,18 @@ export class AgentSession {
13194
13711
  };
13195
13712
  void this.#emitSessionEvent({ type: "irc_message", message: record });
13196
13713
  if (this.isStreaming) {
13197
- this.#pendingIrcAsides.push(record);
13714
+ const recipientParentId = AgentRegistry.global().get(msg.to)?.parentId;
13715
+ if (recipientParentId === msg.from) {
13716
+ this.agent.steer({
13717
+ role: "user",
13718
+ content: prompt.render(parentIrcSteerTemplate, { from: msg.from, message: msg.body }),
13719
+ attribution: "agent",
13720
+ timestamp: msg.ts,
13721
+ steering: true,
13722
+ });
13723
+ } else {
13724
+ this.#pendingIrcInterrupts.push(record);
13725
+ }
13198
13726
  if (autoReply) void this.#runIrcAutoReply(msg);
13199
13727
  return "injected";
13200
13728
  }
@@ -13405,8 +13933,9 @@ export class AgentSession {
13405
13933
  * of the next prompt so the model still sees them.
13406
13934
  */
13407
13935
  #flushPendingIrcAsides(): void {
13408
- if (this.#pendingIrcAsides.length === 0) return;
13409
- const records = this.#pendingIrcAsides;
13936
+ if (this.#pendingIrcInterrupts.length === 0 && this.#pendingIrcAsides.length === 0) return;
13937
+ const records = [...this.#pendingIrcInterrupts, ...this.#pendingIrcAsides];
13938
+ this.#pendingIrcInterrupts = [];
13410
13939
  this.#pendingIrcAsides = [];
13411
13940
  for (const record of records) {
13412
13941
  // emitExternalEvent on message_end appends to agent state and dispatches
@@ -13495,6 +14024,15 @@ export class AgentSession {
13495
14024
  ? this.#getSessionDefaultSelectedMCPToolNames(previousSessionFile)
13496
14025
  : undefined;
13497
14026
 
14027
+ // Snapshot the full checkpoint runtime state: the success path calls
14028
+ // #rehydrateCheckpointRewindState(), which clears and rebuilds all four
14029
+ // fields from the target branch. On rollback every one must be restored,
14030
+ // or a failed switch leaks the target session's checkpoint state.
14031
+ const previousCheckpointState = this.#checkpointState;
14032
+ const previousPendingRewindReport = this.#pendingRewindReport;
14033
+ const previousLastCompletedRewind = this.#lastCompletedRewind;
14034
+ const previousRewoundToolResultIds = new Set(this.#rewoundToolResultIds);
14035
+
13498
14036
  this.agent.clearAllQueues();
13499
14037
  this.#pendingNextTurnMessages = [];
13500
14038
  this.#scheduledHiddenNextTurnGeneration = undefined;
@@ -13514,6 +14052,7 @@ export class AgentSession {
13514
14052
  this.#didSessionMessagesChange(previousSessionContext.messages, sessionContext.messages);
13515
14053
  const fallbackSelectedMCPToolNames = this.#getSessionDefaultSelectedMCPToolNames(sessionPath);
13516
14054
  await this.#restoreMCPSelectionsForSessionContext(sessionContext, { fallbackSelectedMCPToolNames });
14055
+ this.#rehydrateCheckpointRewindState();
13517
14056
 
13518
14057
  // Emit session_switch event to hooks
13519
14058
  if (this.#extensionRunner) {
@@ -13655,6 +14194,10 @@ export class AgentSession {
13655
14194
  this.agent.replaceQueues(previousSteeringMessages, previousFollowUpMessages);
13656
14195
  this.#pendingNextTurnMessages = previousPendingNextTurnMessages;
13657
14196
  this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration;
14197
+ this.#checkpointState = previousCheckpointState;
14198
+ this.#pendingRewindReport = previousPendingRewindReport;
14199
+ this.#lastCompletedRewind = previousLastCompletedRewind;
14200
+ this.#rewoundToolResultIds = previousRewoundToolResultIds;
13658
14201
  if (previousModel) {
13659
14202
  this.agent.setModel(previousModel);
13660
14203
  }
@@ -13723,6 +14266,7 @@ export class AgentSession {
13723
14266
  } else {
13724
14267
  this.sessionManager.createBranchedSession(selectedEntry.parentId);
13725
14268
  }
14269
+ this.#rehydrateCheckpointRewindState();
13726
14270
  this.#syncTodoPhasesFromBranch();
13727
14271
  this.#freshProviderSessionId = undefined;
13728
14272
  this.#syncAgentSessionId();
@@ -13809,6 +14353,7 @@ export class AgentSession {
13809
14353
  this.#cancelOwnAsyncJobs();
13810
14354
 
13811
14355
  this.sessionManager.createBranchedSession(leafId);
14356
+ this.#rehydrateCheckpointRewindState();
13812
14357
  this.sessionManager.appendMessage({
13813
14358
  role: "user",
13814
14359
  content: [{ type: "text", text: question }],
@@ -14005,6 +14550,7 @@ export class AgentSession {
14005
14550
  const displayContext = deobfuscateSessionContext(stateContext, this.#obfuscator);
14006
14551
  await this.#restoreMCPSelectionsForSessionContext(displayContext);
14007
14552
  this.agent.replaceMessages(displayContext.messages);
14553
+ this.#rehydrateCheckpointRewindState();
14008
14554
  this.#resetAdvisorSessionState();
14009
14555
  this.#syncTodoPhasesFromBranch();
14010
14556
  this.#closeCodexProviderSessionsForHistoryRewrite();