@oh-my-pi/pi-coding-agent 17.3.4 → 17.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/dist/{CHANGELOG-trcc215s.md → CHANGELOG-tt9k4jpr.md} +68 -0
  3. package/dist/cli.js +3146 -3153
  4. package/dist/docs-index.generated.txt +1 -1
  5. package/dist/types/config/model-discovery.d.ts +2 -0
  6. package/dist/types/config/model-registry.d.ts +6 -1
  7. package/dist/types/config/settings-schema.d.ts +10 -0
  8. package/dist/types/debug/report-bundle.d.ts +7 -2
  9. package/dist/types/extensibility/extensions/loader.d.ts +2 -0
  10. package/dist/types/extensibility/extensions/runner.d.ts +3 -3
  11. package/dist/types/extensibility/extensions/types.d.ts +2 -0
  12. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
  13. package/dist/types/launch/presence.d.ts +11 -0
  14. package/dist/types/lsp/client.d.ts +22 -0
  15. package/dist/types/lsp/workspace-diagnostics.d.ts +2 -0
  16. package/dist/types/mcp/config.d.ts +3 -1
  17. package/dist/types/modes/components/tool-execution.d.ts +3 -1
  18. package/dist/types/registry/agent-lifecycle.d.ts +11 -0
  19. package/dist/types/registry/agent-registry.d.ts +4 -0
  20. package/dist/types/session/agent-session-types.d.ts +0 -2
  21. package/dist/types/session/agent-session.d.ts +20 -0
  22. package/dist/types/session/date-cwd-reminder.d.ts +22 -0
  23. package/dist/types/session/irc-bridge.d.ts +2 -0
  24. package/dist/types/session/messages.d.ts +4 -0
  25. package/dist/types/session/session-loader.d.ts +18 -11
  26. package/dist/types/session/session-maintenance.d.ts +6 -2
  27. package/dist/types/session/session-manager.d.ts +19 -3
  28. package/dist/types/session/session-tools.d.ts +0 -1
  29. package/dist/types/session/turn-recovery.d.ts +7 -7
  30. package/dist/types/tools/browser/attach.d.ts +16 -0
  31. package/dist/types/tools/browser/launch.d.ts +2 -2
  32. package/dist/types/tools/browser.d.ts +1 -1
  33. package/dist/types/tools/builtin-names.d.ts +1 -1
  34. package/dist/types/tools/hub/jobs.d.ts +6 -0
  35. package/dist/types/tools/hub/types.d.ts +6 -0
  36. package/dist/types/vibe/runtime.d.ts +3 -3
  37. package/package.json +13 -13
  38. package/src/auto-thinking/classifier.ts +37 -23
  39. package/src/cli/models-cli.ts +1 -1
  40. package/src/commit/analysis/conventional.ts +15 -9
  41. package/src/commit/analysis/summary.ts +15 -9
  42. package/src/commit/changelog/generate.ts +15 -9
  43. package/src/commit/map-reduce/map-phase.ts +3 -19
  44. package/src/commit/map-reduce/reduce-phase.ts +15 -9
  45. package/src/config/model-discovery.ts +3 -3
  46. package/src/config/model-registry.ts +29 -16
  47. package/src/config/settings-schema.ts +13 -0
  48. package/src/debug/report-bundle.ts +25 -59
  49. package/src/discovery/claude-plugins.ts +2 -1
  50. package/src/discovery/claude.ts +4 -2
  51. package/src/discovery/helpers.ts +5 -0
  52. package/src/edit/renderer.ts +48 -19
  53. package/src/extensibility/extensions/loader.ts +9 -5
  54. package/src/extensibility/extensions/runner.ts +124 -21
  55. package/src/extensibility/extensions/types.ts +2 -0
  56. package/src/extensibility/extensions/wrapper.ts +16 -10
  57. package/src/extensibility/legacy-pi-ai-shim.ts +4 -0
  58. package/src/extensibility/plugins/marketplace/manager.ts +15 -18
  59. package/src/irc/bus.ts +1 -1
  60. package/src/launch/broker.ts +8 -1
  61. package/src/launch/presence.ts +77 -1
  62. package/src/lsp/client.ts +35 -3
  63. package/src/lsp/clients/biome-client.ts +47 -93
  64. package/src/lsp/servers.ts +17 -11
  65. package/src/lsp/tool.ts +2 -4
  66. package/src/lsp/workspace-diagnostics.ts +24 -2
  67. package/src/mcp/config.ts +50 -6
  68. package/src/memories/index.ts +29 -25
  69. package/src/mnemopi/backend.ts +13 -11
  70. package/src/modes/components/ask-dialog.ts +25 -5
  71. package/src/modes/components/tool-execution.ts +10 -6
  72. package/src/modes/components/welcome.ts +4 -1
  73. package/src/modes/controllers/extension-ui-controller.ts +52 -32
  74. package/src/modes/controllers/selector-controller.ts +6 -0
  75. package/src/modes/controllers/tan-command-controller.ts +1 -0
  76. package/src/modes/interactive-mode.ts +49 -10
  77. package/src/modes/theme/defaults/birch.json +2 -2
  78. package/src/prompts/system/checkpoint-active-notice.md +5 -0
  79. package/src/prompts/system/date-cwd-reminder.md +3 -0
  80. package/src/prompts/system/project-prompt.md +0 -1
  81. package/src/prompts/system/rewind-report.md +1 -3
  82. package/src/prompts/tools/browser.md +1 -0
  83. package/src/prompts/tools/rewind.md +1 -13
  84. package/src/registry/agent-lifecycle.ts +34 -0
  85. package/src/registry/agent-registry.ts +27 -2
  86. package/src/registry/persisted-agents.ts +40 -20
  87. package/src/sdk.ts +32 -2
  88. package/src/session/agent-session-types.ts +0 -2
  89. package/src/session/agent-session.ts +178 -46
  90. package/src/session/date-cwd-reminder.ts +77 -0
  91. package/src/session/irc-bridge.ts +5 -0
  92. package/src/session/messages.ts +5 -1
  93. package/src/session/session-loader.ts +106 -64
  94. package/src/session/session-maintenance.ts +189 -115
  95. package/src/session/session-manager.ts +142 -35
  96. package/src/session/session-persistence.ts +4 -4
  97. package/src/session/session-storage.ts +18 -5
  98. package/src/session/session-tools.ts +5 -10
  99. package/src/session/turn-recovery.ts +102 -20
  100. package/src/session/unexpected-stop-classifier.ts +33 -21
  101. package/src/system-prompt.ts +0 -5
  102. package/src/task/executor.ts +4 -15
  103. package/src/task/persisted-revive.ts +3 -6
  104. package/src/tools/ask.ts +10 -3
  105. package/src/tools/browser/attach.ts +80 -25
  106. package/src/tools/browser/launch.ts +44 -15
  107. package/src/tools/browser/relay/daemon.ts +3 -7
  108. package/src/tools/browser/render.ts +1 -1
  109. package/src/tools/browser/shared-daemon.ts +3 -7
  110. package/src/tools/browser.ts +5 -5
  111. package/src/tools/builtin-names.ts +7 -3
  112. package/src/tools/checkpoint.ts +1 -7
  113. package/src/tools/hub/index.ts +1 -1
  114. package/src/tools/hub/jobs.ts +20 -3
  115. package/src/tools/hub/types.ts +6 -0
  116. package/src/tools/render-utils.ts +53 -21
  117. package/src/tools/think.ts +15 -3
  118. package/src/tts/speech-enhancer.ts +19 -14
  119. package/src/utils/commit-message-generator.ts +14 -12
  120. package/src/utils/title-generator.ts +23 -19
  121. package/src/vibe/runtime.ts +32 -17
package/src/sdk.ts CHANGED
@@ -136,6 +136,7 @@ import {
136
136
  import { AgentSession, type InitialRetryFallbackState, type PlanYolo, type Prewalk } from "./session/agent-session";
137
137
  import { discoverAuthStorage as discoverAuthStorageFromConfig } from "./session/auth-broker-config";
138
138
  import type { AuthStorage } from "./session/auth-storage";
139
+ import { withDateCwdReminder } from "./session/date-cwd-reminder";
139
140
  import { createInterruptedTurnAbortMessage } from "./session/exit-diagnostics";
140
141
  import {
141
142
  type CustomMessage,
@@ -222,6 +223,8 @@ import { ttsTool } from "./tools/tts";
222
223
  import { resolveActiveRepoContext } from "./utils/active-repo-context";
223
224
  import { EventBus } from "./utils/event-bus";
224
225
  import { normalizeProviderContextImagesForModel } from "./utils/image-loading";
226
+ import { formatLocalCalendarDate } from "./utils/local-date";
227
+ import { normalizePromptPath } from "./utils/prompt-path";
225
228
  import { buildNamedToolChoice } from "./utils/tool-choice";
226
229
  import { VibeSessionRegistry } from "./vibe/runtime";
227
230
  import { buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
@@ -3034,6 +3037,20 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
3034
3037
  options.expectedAgentRef === undefined
3035
3038
  ? agentRegistry.register(registrationInput)
3036
3039
  : agentRegistry.registerIfAvailable(registrationInput, options.expectedAgentRef);
3040
+ if (!registeredAgentRef && options.expectedAgentRef === null) {
3041
+ // A fresh spawn collided with an existing id. If that id is held by a
3042
+ // provably-dead parked corpse — no live session, no reviver — reclaim it
3043
+ // so this new generation can take the id instead of failing forever at
3044
+ // construction. Without this, one such corpse (isolated-run park,
3045
+ // interrupted construction) poisons the id for the whole process (#8490).
3046
+ // The reclaim is gated by the lifecycle owner and only touches the
3047
+ // registry it manages; the corpse's transcript stays at history://.
3048
+ const stale = agentRegistry.get(resolvedAgentId);
3049
+ const lifecycle = AgentLifecycleManager.global();
3050
+ if (stale && lifecycle.manages(agentRegistry) && (await lifecycle.reclaimDeadCorpse(resolvedAgentId, stale))) {
3051
+ registeredAgentRef = agentRegistry.registerIfAvailable(registrationInput, null);
3052
+ }
3053
+ }
3037
3054
  if (!registeredAgentRef) {
3038
3055
  throw new Error(`Agent "${resolvedAgentId}" is already owned by another session generation.`);
3039
3056
  }
@@ -3130,7 +3147,15 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
3130
3147
  let transformed = obfuscator ? obfuscateProviderContext(obfuscator, context) : context;
3131
3148
  if (snapcompactInline) transformed = await snapcompactInline.transform(transformed, transformModel);
3132
3149
  transformed = clampProviderContextImages(transformed, transformModel);
3133
- return await normalizeProviderContextImagesForModel(transformed, transformModel);
3150
+ transformed = await normalizeProviderContextImagesForModel(transformed, transformModel);
3151
+ // Keep per-request volatility out of the system prompt: the date/cwd
3152
+ // reminder rides on the first user turn so open-weight providers keep
3153
+ // their tool-schema prefix cache (#7404).
3154
+ return withDateCwdReminder(
3155
+ transformed,
3156
+ formatLocalCalendarDate(),
3157
+ normalizePromptPath(sessionManager.getCwd()),
3158
+ );
3134
3159
  };
3135
3160
  const onPayload = async (payload: unknown, model?: Model) => {
3136
3161
  return await extensionRunner.emitBeforeProviderRequest(payload, model);
@@ -3771,7 +3796,12 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
3771
3796
  transformProviderContext: async (context, transformModel) => {
3772
3797
  let transformed = obfuscator ? obfuscateProviderContext(obfuscator, context) : context;
3773
3798
  transformed = clampProviderContextImages(transformed, transformModel);
3774
- return await normalizeProviderContextImagesForModel(transformed, transformModel);
3799
+ transformed = await normalizeProviderContextImagesForModel(transformed, transformModel);
3800
+ return withDateCwdReminder(
3801
+ transformed,
3802
+ formatLocalCalendarDate(),
3803
+ normalizePromptPath(sessionManager.getCwd()),
3804
+ );
3775
3805
  },
3776
3806
  thinkingBudgets: agent.thinkingBudgets,
3777
3807
  temperature: agent.temperature,
@@ -211,8 +211,6 @@ export interface AgentSessionConfig {
211
211
  toolNames: string[],
212
212
  tools: Map<string, AgentTool>,
213
213
  ) => Promise<{ systemPrompt: string[]; xdevCatalogNames?: readonly string[] }>;
214
- /** Local calendar date provider used by prompt-cache invalidation. */
215
- getLocalCalendarDate?: () => string;
216
214
  /** Tools mounted under `xd://`, for `/tools` display. */
217
215
  getXdevToolEntries?: () => Array<{ name: string; summary: string }>;
218
216
  /** `xd://` presentation state backed by the canonical tool map. */
@@ -161,6 +161,7 @@ import type { PlanModeState } from "../plan-mode/state";
161
161
  import goalModeContextPrompt from "../prompts/goals/goal-mode-context.md" with { type: "text" };
162
162
  import goalTodoContextPrompt from "../prompts/goals/goal-todo-context.md" with { type: "text" };
163
163
  import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
164
+ import checkpointActiveNoticeTemplate from "../prompts/system/checkpoint-active-notice.md" with { type: "text" };
164
165
  import interruptedThinkingTemplate from "../prompts/system/interrupted-thinking.md" with { type: "text" };
165
166
  import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
166
167
  import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" with { type: "text" };
@@ -285,6 +286,7 @@ import {
285
286
  import {
286
287
  type BashExecutionMessage,
287
288
  buildReplanTitleContext,
289
+ CHECKPOINT_ACTIVE_REMINDER_TYPE,
288
290
  type CustomMessage,
289
291
  type CustomMessagePayload,
290
292
  convertToLlm,
@@ -297,6 +299,7 @@ import {
297
299
  type InterruptedThinkingDetails,
298
300
  isEmptyErrorTurn,
299
301
  isUserInterruptAbort,
302
+ isUserInvokedSkillPrompt,
300
303
  logProviderTurnError,
301
304
  normalizeCustomMessagePayload,
302
305
  type PythonExecutionMessage,
@@ -328,7 +331,7 @@ import {
328
331
  SessionMaintenance,
329
332
  type SessionMaintenanceHost,
330
333
  } from "./session-maintenance";
331
- import { cleanupEmptyMoveSession, type SessionManager } from "./session-manager";
334
+ import { cleanupEmptyMoveSession, copySessionArtifacts, type SessionManager } from "./session-manager";
332
335
  import { SessionMemory, type SessionMemoryHost } from "./session-memory";
333
336
  import { buildSessionMetadata } from "./session-metadata";
334
337
  import { SessionProviderBoundary, type SessionProviderBoundaryHost } from "./session-provider-boundary";
@@ -488,6 +491,7 @@ export class AgentSession {
488
491
  /** Last (enable, providerId) tuple resolved by `#syncAppendOnlyContext` — used to skip no-op invalidations. */
489
492
  #lastAppendOnlyResolution?: { enable: boolean; providerId: string | undefined };
490
493
  #eventListeners: AgentSessionEventListener[] = [];
494
+ #runStateListeners = new Set<(state: "running" | "idle") => void>();
491
495
  #commandMetadataChangedListeners: CommandMetadataChangedListener[] = [];
492
496
  #sessionChangeCallbacks = new Set<() => void>();
493
497
  #observedSessionId: string | undefined;
@@ -597,6 +601,7 @@ export class AgentSession {
597
601
  #usageFallbackConfirmer: UsageFallbackConfirmer | undefined;
598
602
  #usagePreflightAbortControllers = new Set<AbortController>();
599
603
  #queuedMessageDrainBlocked = false;
604
+ #modeExitDrainSuppressionDepth = 0;
600
605
  #usagePreflightReadyForNextModelCall = false;
601
606
  #usagePreflightReadyModel: Model | undefined;
602
607
  #detachUsageBeforeQueueDequeue: (() => void) | undefined;
@@ -771,7 +776,9 @@ export class AgentSession {
771
776
  * the agent responds to the peer. Skip only when a queued steer/follow-up will itself drive a
772
777
  * resume turn whose aside poll already consumes these (no double-wake). */
773
778
  #resumeStrandedIrcAsides(): void {
774
- if (this.#isDisposed || this.isStreaming || !this.#irc.hasPending()) return;
779
+ if (this.#modeExitDrainSuppressionDepth > 0 || this.#isDisposed || this.isStreaming || !this.#irc.hasPending()) {
780
+ return;
781
+ }
775
782
  if (this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages()) return;
776
783
  const records = this.#irc.drainPending();
777
784
  if (this.#planModeState?.enabled) {
@@ -800,6 +807,10 @@ export class AgentSession {
800
807
  * because #canAutoContinueForFollowUp suppresses follow-up auto-resume while a user interrupt is
801
808
  * in effect, even though the wake left a provider-valid tail. */
802
809
  #wakeForIrc(records: CustomMessage[]): void {
810
+ if (this.#modeExitDrainSuppressionDepth > 0) {
811
+ this.#irc.deferWake(records);
812
+ return;
813
+ }
803
814
  // Park only a *blocked* follow-up (one a user interrupt is intentionally holding); an
804
815
  // already-resumable follow-up can ride the wake turn normally without reordering.
805
816
  const parkedFollowUps =
@@ -1295,7 +1306,6 @@ export class AgentSession {
1295
1306
  presentationPinnedToolNames: config.presentationPinnedToolNames,
1296
1307
  ensureWriteRegistered: config.ensureWriteRegistered,
1297
1308
  rebuildSystemPrompt: config.rebuildSystemPrompt,
1298
- getLocalCalendarDate: config.getLocalCalendarDate,
1299
1309
  getMcpServerInstructions: config.getMcpServerInstructions,
1300
1310
  xdev: config.xdev,
1301
1311
  setActiveToolNames: config.setActiveToolNames,
@@ -1384,7 +1394,7 @@ export class AgentSession {
1384
1394
  // Pre-scheduling tool_call wiring: extension handlers run at arg-prep
1385
1395
  // time so a block/revision lands before concurrency resolution,
1386
1396
  // tool_execution_start, and the wrapper's approval gate.
1387
- this.agent.beforeToolCall = ctx => this.#beforeToolCall(ctx);
1397
+ this.agent.beforeToolCall = (ctx, signal) => this.#beforeToolCall(ctx, signal);
1388
1398
  this.agent.providerSessionState = this.#providerSessionState;
1389
1399
  this.#syncAgentSessionId();
1390
1400
  this.#todo.syncFromBranch();
@@ -1518,7 +1528,7 @@ export class AgentSession {
1518
1528
  planReferencePath: () => this.#planReferencePath,
1519
1529
  nonMessageTokenSource: () => this,
1520
1530
  memoryBackendSession: () => this,
1521
- emitSessionEvent: event => this.#emitSessionEvent(event),
1531
+ emitSessionEvent: (event, options) => this.#emitSessionEvent(event, options),
1522
1532
  emitNotice: (level, message, source) => this.emitNotice(level, message, source),
1523
1533
  schedulePostPromptTask: (task, options) => this.#schedulePostPromptTask(task, options),
1524
1534
  scheduleAgentContinue: options => this.#scheduleAgentContinue(options),
@@ -1968,6 +1978,18 @@ export class AgentSession {
1968
1978
  }
1969
1979
  }
1970
1980
 
1981
+ #emitRunState(state: "running" | "idle"): void {
1982
+ for (const listener of this.#runStateListeners) {
1983
+ try {
1984
+ listener(state);
1985
+ } catch (error) {
1986
+ logger.warn("AgentSession run-state listener threw", {
1987
+ error: error instanceof Error ? error.message : String(error),
1988
+ });
1989
+ }
1990
+ }
1991
+ }
1992
+
1971
1993
  /**
1972
1994
  * Emit a UI-only notice to the session. Surfaces in interactive mode as a
1973
1995
  * `showWarning` / `showError` / `showStatus` line; non-interactive modes
@@ -2063,7 +2085,7 @@ export class AgentSession {
2063
2085
  */
2064
2086
  #subscriberEmitGate: Promise<void> = Promise.resolve();
2065
2087
 
2066
- async #emitSessionEvent(event: AgentSessionEvent): Promise<void> {
2088
+ async #emitSessionEvent(event: AgentSessionEvent, options: { detachExtensions?: boolean } = {}): Promise<void> {
2067
2089
  if (event.type === "message_update") {
2068
2090
  this.#emit(event);
2069
2091
  void this.#queueExtensionEvent(event);
@@ -2076,7 +2098,17 @@ export class AgentSession {
2076
2098
  const { promise: gate, resolve: releaseGate } = Promise.withResolvers<void>();
2077
2099
  this.#subscriberEmitGate = gate;
2078
2100
  try {
2079
- await this.#emitExtensionEvent(event);
2101
+ const extensionEmit = this.#emitExtensionEvent(event);
2102
+ if (options.detachExtensions) {
2103
+ void extensionEmit.catch(error => {
2104
+ logger.warn("Detached session event extension emit failed", {
2105
+ type: event.type,
2106
+ error: error instanceof Error ? error.message : String(error),
2107
+ });
2108
+ });
2109
+ } else {
2110
+ await extensionEmit;
2111
+ }
2080
2112
  await previousGate;
2081
2113
  // Hold the wire-level agent_end until in-flight prompts unwind. Subscribers
2082
2114
  // (rpc-mode, ACP, Cursor) treat agent_end as the "session is idle" signal;
@@ -2341,6 +2373,34 @@ export class AgentSession {
2341
2373
  }
2342
2374
  }
2343
2375
 
2376
+ /**
2377
+ * Builds the transient checkpoint-active reminder for a successful
2378
+ * checkpoint tool result, or undefined otherwise. The reminder is queued as
2379
+ * steering synchronously in the message_end handler (before any await), so
2380
+ * the agent loop folds it into the next provider call and persists it through
2381
+ * its normal custom-message event. Because the entry sits after the checkpoint
2382
+ * entry, the rewind branch cut drops it from the active path.
2383
+ */
2384
+ #checkpointActiveReminderFor(
2385
+ message: AgentMessage,
2386
+ ): CustomMessage<{ goal?: string; startedAt?: string }> | undefined {
2387
+ if (message.role !== "toolResult" || message.isError) return undefined;
2388
+ const semanticResult = semanticToolResult(message.toolName, message);
2389
+ if (semanticResult?.toolName !== "checkpoint") return undefined;
2390
+ const details = isRecord(semanticResult.details) ? semanticResult.details : undefined;
2391
+ const goal = details ? stringProperty(details, "goal") : undefined;
2392
+ const startedAt = details ? stringProperty(details, "startedAt") : undefined;
2393
+ return {
2394
+ role: "custom",
2395
+ customType: CHECKPOINT_ACTIVE_REMINDER_TYPE,
2396
+ content: prompt.render(checkpointActiveNoticeTemplate),
2397
+ display: false,
2398
+ details: { goal, startedAt },
2399
+ attribution: "agent",
2400
+ timestamp: Date.now(),
2401
+ };
2402
+ }
2403
+
2344
2404
  #persistMessageEnd(message: AgentMessage): void {
2345
2405
  if (message.role === "hookMessage" || message.role === "custom") {
2346
2406
  // Prewalk's plan nudge is a one-run steering instruction. Persisting it would
@@ -2438,6 +2498,7 @@ export class AgentSession {
2438
2498
  // turn: state-based lookups take over again.
2439
2499
  if (event.type === "agent_start") {
2440
2500
  this.#prunedTerminalRefusal = undefined;
2501
+ this.#emitRunState("running");
2441
2502
  }
2442
2503
  // Step the mid-run todo counter synchronously, BEFORE any await in this
2443
2504
  // handler. The agent loop's next-turn `getAsideMessages` poll can run
@@ -2498,6 +2559,35 @@ export class AgentSession {
2498
2559
  this.agent.appendMessage(interruptedThinkingMessage);
2499
2560
  }
2500
2561
 
2562
+ // message_end listeners are fire-and-forget, and the agent loop runs against
2563
+ // a context cloned at prompt start. Appending only to agent.state would not
2564
+ // reach the next provider call in this tool loop. Queue the reminder as
2565
+ // steering before any await so the loop drains it at the next step boundary;
2566
+ // its normal custom-message event persists it after the checkpoint entry,
2567
+ // allowing the rewind branch cut to drop it from the active path.
2568
+ const checkpointReminder =
2569
+ event.type === "message_end" && event.message.role === "toolResult"
2570
+ ? this.#checkpointActiveReminderFor(event.message)
2571
+ : undefined;
2572
+ if (checkpointReminder) {
2573
+ // Set #checkpointState synchronously too: the reminder is now visible to
2574
+ // the very next provider call, so a model that immediately calls `rewind`
2575
+ // must find an active checkpoint in RewindTool.execute(). The entry id is
2576
+ // backfilled post-await (see the toolResult handler) once the checkpoint
2577
+ // toolResult entry is persisted; #applyRewind runs on a later rewind turn,
2578
+ // never before that backfill.
2579
+ this.#checkpointState = {
2580
+ checkpointMessageCount: this.agent.state.messages.length,
2581
+ checkpointEntryId: null,
2582
+ startedAt:
2583
+ (checkpointReminder.details && stringProperty(checkpointReminder.details, "startedAt")) ??
2584
+ new Date().toISOString(),
2585
+ };
2586
+ this.#pendingRewindReport = undefined;
2587
+ this.#lastCompletedRewind = undefined;
2588
+ this.agent.steer(checkpointReminder);
2589
+ }
2590
+
2501
2591
  const messageEndPersistence =
2502
2592
  event.type === "message_end" ? this.#createMessageEndPersistenceSlot(event.message) : undefined;
2503
2593
 
@@ -2704,15 +2794,24 @@ export class AgentSession {
2704
2794
  );
2705
2795
  }
2706
2796
  if (semanticResult?.toolName === "checkpoint" && !isError) {
2707
- const checkpointEntryId = this.sessionManager.getEntries().at(-1)?.id ?? null;
2708
- this.#checkpointState = {
2709
- checkpointMessageCount: this.agent.state.messages.length,
2710
- checkpointEntryId,
2711
- startedAt:
2712
- (semanticDetails && stringProperty(semanticDetails, "startedAt")) ?? new Date().toISOString(),
2713
- };
2714
- this.#pendingRewindReport = undefined;
2715
- this.#lastCompletedRewind = undefined;
2797
+ // Backfill the checkpoint entry id now that the toolResult entry is
2798
+ // persisted. #checkpointState was set synchronously pre-await (with a
2799
+ // null entry id) so an immediate `rewind` still finds an active
2800
+ // checkpoint; locate the toolResult's own entry by identity since the
2801
+ // transient reminder entry follows it (last-entry would branch-cut the
2802
+ // reminder instead of leaving it on the active path).
2803
+ const entries = this.sessionManager.getEntries();
2804
+ let checkpointEntryId: string | null = null;
2805
+ for (let i = entries.length - 1; i >= 0; i--) {
2806
+ const entry = entries[i];
2807
+ if (entry.type === "message" && entry.message === event.message) {
2808
+ checkpointEntryId = entry.id;
2809
+ break;
2810
+ }
2811
+ }
2812
+ if (this.#checkpointState) {
2813
+ this.#checkpointState.checkpointEntryId = checkpointEntryId;
2814
+ }
2716
2815
  }
2717
2816
  if (semanticResult?.toolName === "rewind" && !isError && this.#checkpointState) {
2718
2817
  const detailReport = semanticDetails ? (stringProperty(semanticDetails, "report")?.trim() ?? "") : "";
@@ -2733,6 +2832,7 @@ export class AgentSession {
2733
2832
  // maintenance can emit agent_end, so preserve the state at settle entry.
2734
2833
  const ttsrAbortPendingAtAgentEnd = this.#ttsr.abortPending;
2735
2834
  const emitAgentEndNotification = async (options?: { willContinue?: boolean }) => {
2835
+ this.#emitRunState("idle");
2736
2836
  // Public agent_end is held out of the eager display pass and emitted
2737
2837
  // here after maintenance routing, tagged isTerminal so subscribers can
2738
2838
  // tell final settles from scheduled continuations.
@@ -3297,7 +3397,7 @@ export class AgentSession {
3297
3397
  * emit a second event (nested xd:// device dispatches and direct non-loop
3298
3398
  * execution still emit there).
3299
3399
  */
3300
- async #beforeToolCall(ctx: BeforeToolCallContext): Promise<BeforeToolCallResult | undefined> {
3400
+ async #beforeToolCall(ctx: BeforeToolCallContext, signal?: AbortSignal): Promise<BeforeToolCallResult | undefined> {
3301
3401
  const runner = this.#extensionRunner;
3302
3402
  if (!runner?.hasHandlers("tool_call")) return undefined;
3303
3403
  const metadata = ctx.toolCall.providerMetadata;
@@ -3315,12 +3415,15 @@ export class AgentSession {
3315
3415
  ? { actions: computer.actions, pendingSafetyChecks: computer.pendingSafetyChecks }
3316
3416
  : ctx.args;
3317
3417
  runner.markToolCallEmitted(ctx.toolCall.id, ctx.tool.name);
3318
- const callResult = await runner.emitToolCall({
3319
- type: "tool_call",
3320
- toolName: ctx.tool.name,
3321
- toolCallId: ctx.toolCall.id,
3322
- input: normalizeToolEventInput(ctx.tool.name, resolveToolEventInput(ctx.tool, eventArgs)),
3323
- });
3418
+ const callResult = await runner.emitToolCall(
3419
+ {
3420
+ type: "tool_call",
3421
+ toolName: ctx.tool.name,
3422
+ toolCallId: ctx.toolCall.id,
3423
+ input: normalizeToolEventInput(ctx.tool.name, resolveToolEventInput(ctx.tool, eventArgs)),
3424
+ },
3425
+ signal,
3426
+ );
3324
3427
  if (callResult?.block) {
3325
3428
  return { block: true, reason: callResult.reason || "Tool execution was blocked by an extension" };
3326
3429
  }
@@ -3608,6 +3711,15 @@ export class AgentSession {
3608
3711
  };
3609
3712
  }
3610
3713
 
3714
+ /**
3715
+ * Observe authoritative run-state transitions before public `agent_end`
3716
+ * deferral, for lifecycle owners that must not remain stale while prompts unwind.
3717
+ */
3718
+ subscribeRunState(listener: (state: "running" | "idle") => void): () => void {
3719
+ this.#runStateListeners.add(listener);
3720
+ return () => this.#runStateListeners.delete(listener);
3721
+ }
3722
+
3611
3723
  /** Register cleanup that runs when this AgentSession adopts a different session ID. */
3612
3724
  registerSessionChangeCallback(callback: () => void): () => void {
3613
3725
  this.#sessionChangeCallbacks.add(callback);
@@ -3970,6 +4082,7 @@ export class AgentSession {
3970
4082
  this.#unsubscribeModelRoles = undefined;
3971
4083
  }
3972
4084
  this.#eventListeners = [];
4085
+ this.#runStateListeners.clear();
3973
4086
  this.#sessionChangeCallbacks.clear();
3974
4087
 
3975
4088
  // A dispose triggered mid-turn (Ctrl-C / timeout / hard-killed subagent)
@@ -5568,10 +5681,14 @@ export class AgentSession {
5568
5681
  }
5569
5682
 
5570
5683
  // Auto thinking: classify this real user turn and set the effective level
5571
- // before the model request. Synthetic/tool-continuation turns (developer/
5572
- // custom roles) and non-auto sessions are skipped. Never blocks the turn —
5573
- // failures fall back to a concrete level inside the helper.
5574
- if (this.isAutoThinking && message.role === "user") {
5684
+ // before the model request. A user-invoked `/skill:<name>` arrives as a
5685
+ // user-attributed skill custom message whose expanded body is the task
5686
+ // prompt, so it counts as a user turn. Synthetic/tool-continuation turns
5687
+ // (developer roles), agent-originated or autoloaded skill injections, and
5688
+ // non-auto sessions are skipped. Never blocks the turn — failures fall
5689
+ // back to a concrete level inside the helper.
5690
+ const isUserTurn = message.role === "user" || (message.role === "custom" && isUserInvokedSkillPrompt(message));
5691
+ if (this.isAutoThinking && isUserTurn) {
5575
5692
  await this.#models.applyAutoThinkingLevel(expandedText, generation);
5576
5693
  if (this.#promptGeneration !== generation) {
5577
5694
  return;
@@ -5836,6 +5953,33 @@ export class AgentSession {
5836
5953
  this.#scheduleIdleQueueDrain();
5837
5954
  }
5838
5955
 
5956
+ /**
5957
+ * Run a mode-exit `teardown` (abort the active turn, swap the toolset, clear
5958
+ * mode state) with queued-message auto-resume suppressed, then re-arm the
5959
+ * drain so a queued user turn resumes cleanly once the previous toolset is
5960
+ * back.
5961
+ *
5962
+ * `abort()`'s stranded-queue drain runs from its own `finally`; without this
5963
+ * guard a queued steer/follow-up behind the aborted turn would start a fresh
5964
+ * `agent.continue()` during the teardown's `await`s — while the exiting mode's
5965
+ * tools/context are still live — and then have those tools removed underneath
5966
+ * it, reintroducing the mode's stale-tool failure on the restarted turn
5967
+ * (issue #8326). Suppressing the drain across teardown guarantees the queued
5968
+ * turn resumes only after teardown, so it runs as a clean non-mode turn.
5969
+ */
5970
+ async runModeExitTeardown(teardown: () => Promise<void>): Promise<void> {
5971
+ this.#modeExitDrainSuppressionDepth++;
5972
+ try {
5973
+ await teardown();
5974
+ } finally {
5975
+ this.#modeExitDrainSuppressionDepth--;
5976
+ if (this.#modeExitDrainSuppressionDepth === 0) {
5977
+ this.#scheduleIdleQueueDrain();
5978
+ this.#resumeStrandedIrcAsides();
5979
+ }
5980
+ }
5981
+ }
5982
+
5839
5983
  async #queueUserMessage(
5840
5984
  text: string,
5841
5985
  images: ImageContent[] | undefined,
@@ -5884,6 +6028,7 @@ export class AgentSession {
5884
6028
  #scheduleQueuedMessageDrain(): void {
5885
6029
  if (
5886
6030
  this.#queuedMessageDrainScheduled ||
6031
+ this.#modeExitDrainSuppressionDepth > 0 ||
5887
6032
  this.#queuedMessageDrainBlocked ||
5888
6033
  !this.#canAutoContinueForFollowUp() ||
5889
6034
  !this.agent.hasQueuedMessages()
@@ -5894,7 +6039,11 @@ export class AgentSession {
5894
6039
  this.#scheduleAgentContinue({
5895
6040
  shouldContinue: () => {
5896
6041
  this.#queuedMessageDrainScheduled = false;
5897
- return this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages();
6042
+ return (
6043
+ this.#modeExitDrainSuppressionDepth === 0 &&
6044
+ this.#canAutoContinueForFollowUp() &&
6045
+ this.agent.hasQueuedMessages()
6046
+ );
5898
6047
  },
5899
6048
  onSkip: () => {
5900
6049
  this.#queuedMessageDrainScheduled = false;
@@ -6701,24 +6850,7 @@ export class AgentSession {
6701
6850
  // under a fresh id, so the work already produced is still this session's.
6702
6851
  this.#recovery.reanchorServedAttribution(previousSessionId);
6703
6852
 
6704
- // Copy artifacts directory if it exists
6705
- const oldArtifactDir = forkResult.oldSessionFile.slice(0, -6);
6706
- const newArtifactDir = forkResult.newSessionFile.slice(0, -6);
6707
-
6708
- try {
6709
- const oldDirStat = await fs.promises.stat(oldArtifactDir);
6710
- if (oldDirStat.isDirectory()) {
6711
- await fs.promises.cp(oldArtifactDir, newArtifactDir, { recursive: true });
6712
- }
6713
- } catch (err) {
6714
- if (!isEnoent(err)) {
6715
- logger.warn("Failed to copy artifacts during fork", {
6716
- oldArtifactDir,
6717
- newArtifactDir,
6718
- error: err instanceof Error ? err.message : String(err),
6719
- });
6720
- }
6721
- }
6853
+ await copySessionArtifacts(forkResult.oldSessionFile, forkResult.newSessionFile);
6722
6854
 
6723
6855
  // Update agent session ID
6724
6856
  this.#freshProviderSessionId = undefined;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Date/cwd reminder injection.
3
+ *
4
+ * The system prompt must stay byte-stable so open-weight chat templates that
5
+ * render tool schemas *after* the system content keep their prefix cache
6
+ * (#7404). The per-request date/cwd line used to live at the tail of the
7
+ * system prompt (`project-prompt.md`), which invalidated the whole tool array
8
+ * on every directory change or day rollover. It now rides on the first user
9
+ * turn of each provider request instead: built at request time (never stored
10
+ * in the session), deterministic per `(date, cwd)`, so the bytes are stable
11
+ * for the lifetime of a session/day and refresh automatically at midnight.
12
+ */
13
+ import type { Context, Message } from "@oh-my-pi/pi-ai";
14
+ import { prompt } from "@oh-my-pi/pi-utils";
15
+ import dateCwdReminderTemplate from "../prompts/system/date-cwd-reminder.md" with { type: "text" };
16
+
17
+ /** Renders the reminder text for the given local calendar date and cwd. */
18
+ export function renderDateCwdReminder(date: string, cwd: string): string {
19
+ return prompt.render(dateCwdReminderTemplate, { date, cwd }).trim();
20
+ }
21
+
22
+ /**
23
+ * Prepends `reminder` to the content of the first user message in `messages`,
24
+ * returning a new array. The input is never mutated. Returns the input
25
+ * unchanged when there is no user message to attach to, when the first user
26
+ * message already carries the exact reminder, or when an identical input was
27
+ * injected before with the same reminder.
28
+ *
29
+ * The memo is required by the append-only context path: it syncs the converted
30
+ * message objects into its log and reuses them across requests, and callers
31
+ * assert that identity is preserved for the stable prefix. Re-injecting the
32
+ * same pristine first user message with the same reminder must hand back the
33
+ * same injected message object, not a fresh clone. Keyed on the pristine
34
+ * message object (the append-only log hands back fresh array copies every
35
+ * turn, so array identity is not stable), with entries garbage-collected
36
+ * alongside the messages they belong to.
37
+ */
38
+ const injectCache = new WeakMap<Message, { reminder: string; injected: Message }>();
39
+
40
+ export function injectDateCwdReminder(messages: Message[], reminder: string): Message[] {
41
+ const index = messages.findIndex(message => message.role === "user");
42
+ if (index < 0) return messages;
43
+ const first = messages[index]!;
44
+ if (typeof first.content === "string") {
45
+ if (first.content.startsWith(reminder)) return messages;
46
+ } else if (first.content[0]?.type === "text" && first.content[0].text === reminder) {
47
+ return messages;
48
+ }
49
+ const cached = injectCache.get(first);
50
+ if (cached !== undefined && cached.reminder === reminder) {
51
+ const out = messages.slice();
52
+ out[index] = cached.injected;
53
+ return out;
54
+ }
55
+ const content =
56
+ typeof first.content === "string"
57
+ ? `${reminder}\n\n${first.content}`
58
+ : ([{ type: "text", text: reminder }, ...first.content] as Message["content"]);
59
+ const injected = { ...first, content } as Message;
60
+ injectCache.set(first, { reminder, injected });
61
+ const out = messages.slice();
62
+ out[index] = injected;
63
+ return out;
64
+ }
65
+
66
+ /**
67
+ * Applies the date/cwd reminder to a provider `Context`, keeping the system
68
+ * prompt byte-stable for prompt caching. Skips NULL_PROMPT-style contexts
69
+ * (empty system prompt) so a no-prompt session stays byte-for-byte unchanged.
70
+ */
71
+ export function withDateCwdReminder(context: Context, date: string, cwd: string): Context {
72
+ if (!context.systemPrompt || context.systemPrompt.length === 0) return context;
73
+ if (context.messages.length === 0) return context;
74
+ const reminder = renderDateCwdReminder(date, cwd);
75
+ const messages = injectDateCwdReminder(context.messages, reminder);
76
+ return messages === context.messages ? context : { ...context, messages };
77
+ }
@@ -51,6 +51,11 @@ export class IrcBridge {
51
51
  return records;
52
52
  }
53
53
 
54
+ /** Queues records whose idle wake must wait for a session transition to finish. */
55
+ deferWake(records: CustomMessage[]): void {
56
+ this.#asides.push(...records);
57
+ }
58
+
54
59
  /** Surfaces and consumes queued incoming records before automatic injection. */
55
60
  drainInboxMessages(agentId: string, opts?: { from?: string; limit?: number }): IrcMessage[] {
56
61
  const messages: IrcMessage[] = [];
@@ -328,6 +328,9 @@ export type NormalizedCustomMessagePayload<T = unknown> = Pick<
328
328
  /** Custom message type for hidden interrupted-thinking continuity context. */
329
329
  export const INTERRUPTED_THINKING_MESSAGE_TYPE = "interrupted-thinking";
330
330
 
331
+ /** Custom message type for the transient checkpoint-active reminder. */
332
+ export const CHECKPOINT_ACTIVE_REMINDER_TYPE = "checkpoint-active-reminder";
333
+
331
334
  /** Metadata persisted with a hidden interrupted-thinking continuity message. */
332
335
  export interface InterruptedThinkingDetails {
333
336
  interruptedAt: number;
@@ -1085,7 +1088,8 @@ function customMessageContentToLlmContent(content: CustomMessage["content"]): (T
1085
1088
  return typeof content === "string" ? [{ type: "text", text: content }] : content;
1086
1089
  }
1087
1090
 
1088
- function isUserInvokedSkillPrompt(message: CustomMessage): boolean {
1091
+ /** True for a `/skill:<name>` prompt the user invoked directly (attribution `user`), as opposed to an agent/autoload injection. */
1092
+ export function isUserInvokedSkillPrompt(message: CustomMessage): boolean {
1089
1093
  return message.customType === SKILL_PROMPT_MESSAGE_TYPE && message.attribution === "user";
1090
1094
  }
1091
1095