@oh-my-pi/pi-coding-agent 17.3.7 → 17.3.8

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 (109) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/{CHANGELOG-1hmwwt45.md → CHANGELOG-vr9cckb4.md} +60 -0
  3. package/dist/cli.js +2993 -3001
  4. package/dist/docs-index.generated.txt +1 -1
  5. package/dist/{tool-views.generated-jdfmzwmn.js → tool-views.generated-dd2km5r2.js} +19 -19
  6. package/dist/types/advisor/advise-tool.d.ts +4 -2
  7. package/dist/types/cli/auth-broker-cli.d.ts +15 -0
  8. package/dist/types/cli/update-cli.d.ts +8 -0
  9. package/dist/types/cli-commands.d.ts +10 -2
  10. package/dist/types/config/settings-schema.d.ts +28 -0
  11. package/dist/types/config/settings.d.ts +9 -0
  12. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  13. package/dist/types/launch/presence.d.ts +4 -1
  14. package/dist/types/mcp/oauth-credentials.d.ts +23 -0
  15. package/dist/types/mcp/oauth-flow.d.ts +11 -0
  16. package/dist/types/mnemopi/backend.d.ts +12 -0
  17. package/dist/types/modes/components/tool-execution.d.ts +12 -0
  18. package/dist/types/modes/controllers/input-controller.d.ts +2 -0
  19. package/dist/types/modes/interactive-mode.d.ts +25 -3
  20. package/dist/types/modes/types.d.ts +10 -0
  21. package/dist/types/session/agent-session.d.ts +3 -0
  22. package/dist/types/session/prewalk.d.ts +4 -0
  23. package/dist/types/session/session-entries.d.ts +0 -1
  24. package/dist/types/session/session-manager.d.ts +12 -0
  25. package/dist/types/session/session-stats.d.ts +13 -1
  26. package/dist/types/session/skill-title-input.d.ts +13 -0
  27. package/dist/types/subprocess/worker-client.d.ts +7 -4
  28. package/dist/types/task/label.d.ts +2 -0
  29. package/dist/types/task/render.d.ts +2 -0
  30. package/dist/types/tiny/completion-prompt.d.ts +2 -0
  31. package/dist/types/tiny/title-client.d.ts +6 -4
  32. package/dist/types/tiny/title-protocol.d.ts +1 -0
  33. package/dist/types/tiny/worker.d.ts +27 -0
  34. package/dist/types/tools/bash.d.ts +1 -1
  35. package/dist/types/tools/read-format.d.ts +6 -0
  36. package/dist/types/tools/read-summary.d.ts +7 -1
  37. package/dist/types/utils/block-context.d.ts +14 -0
  38. package/dist/types/utils/fetch-timeout.d.ts +15 -0
  39. package/dist/types/utils/git.d.ts +25 -1
  40. package/dist/types/web/search/providers/tinyfish.d.ts +4 -0
  41. package/package.json +13 -13
  42. package/src/advisor/advise-tool.ts +5 -3
  43. package/src/cli/auth-broker-cli.ts +36 -1
  44. package/src/cli/profile-bootstrap.ts +2 -6
  45. package/src/cli/update-cli.ts +63 -11
  46. package/src/cli-commands.ts +61 -7
  47. package/src/commands/completions.ts +2 -1
  48. package/src/commit/agentic/index.ts +15 -2
  49. package/src/commit/git/diff.ts +6 -2
  50. package/src/config/model-resolver.ts +52 -6
  51. package/src/config/models-config.ts +2 -2
  52. package/src/config/settings-schema.ts +33 -0
  53. package/src/config/settings.ts +159 -30
  54. package/src/discovery/helpers.ts +45 -2
  55. package/src/discovery/omp-plugins.ts +2 -1
  56. package/src/discovery/opencode.ts +56 -3
  57. package/src/eval/js/process-entry.ts +4 -4
  58. package/src/export/html/tool-views.generated.js +19 -19
  59. package/src/extensibility/extensions/runner.ts +3 -2
  60. package/src/extensibility/plugins/legacy-pi-compat.ts +47 -0
  61. package/src/launch/client.ts +9 -4
  62. package/src/launch/presence.ts +19 -4
  63. package/src/lsp/defaults.json +1 -1
  64. package/src/mcp/manager.ts +41 -20
  65. package/src/mcp/oauth-credentials.ts +38 -0
  66. package/src/mcp/oauth-flow.ts +21 -0
  67. package/src/mcp/tool-bridge.ts +32 -16
  68. package/src/mnemopi/backend.ts +35 -3
  69. package/src/modes/components/model-hub.ts +37 -4
  70. package/src/modes/components/settings-selector.ts +17 -11
  71. package/src/modes/components/tool-execution.ts +97 -29
  72. package/src/modes/components/tree-selector.ts +7 -2
  73. package/src/modes/controllers/event-controller.ts +12 -2
  74. package/src/modes/controllers/input-controller.ts +64 -27
  75. package/src/modes/controllers/mcp-command-controller.ts +13 -4
  76. package/src/modes/interactive-mode.ts +79 -11
  77. package/src/modes/types.ts +11 -0
  78. package/src/prompts/system/memory-extraction-system.md +5 -22
  79. package/src/prompts/system/system-prompt.md +1 -1
  80. package/src/session/agent-session.ts +52 -6
  81. package/src/session/messages.ts +6 -0
  82. package/src/session/prewalk.ts +25 -7
  83. package/src/session/session-entries.ts +0 -1
  84. package/src/session/session-maintenance.ts +10 -1
  85. package/src/session/session-manager.ts +15 -0
  86. package/src/session/session-stats.ts +24 -3
  87. package/src/session/settings-stream-fn.ts +7 -0
  88. package/src/session/skill-title-input.ts +32 -0
  89. package/src/session/turn-recovery.ts +23 -18
  90. package/src/subprocess/worker-client.ts +8 -5
  91. package/src/task/executor.ts +11 -0
  92. package/src/task/index.ts +2 -0
  93. package/src/task/label.ts +14 -1
  94. package/src/task/persisted-revive.ts +13 -0
  95. package/src/task/render.ts +1 -1
  96. package/src/task/structured-subagent.ts +5 -2
  97. package/src/tiny/completion-prompt.ts +16 -0
  98. package/src/tiny/title-client.ts +15 -6
  99. package/src/tiny/title-protocol.ts +8 -1
  100. package/src/tiny/worker.ts +21 -19
  101. package/src/tools/bash.ts +7 -1
  102. package/src/tools/read-format.ts +16 -2
  103. package/src/tools/read-summary.ts +9 -4
  104. package/src/tools/read.ts +306 -72
  105. package/src/utils/block-context.ts +15 -1
  106. package/src/utils/fetch-timeout.ts +33 -0
  107. package/src/utils/git.ts +54 -11
  108. package/src/web/search/providers/browser-page.ts +21 -3
  109. package/src/web/search/providers/tinyfish.ts +26 -0
@@ -338,6 +338,7 @@ import { SessionProviderBoundary, type SessionProviderBoundaryHost } from "./ses
338
338
  import { SessionStatsTracker, type SessionStatsTrackerHost } from "./session-stats";
339
339
  import { SessionTools, type SessionToolsHost } from "./session-tools";
340
340
  import type { ShakeMode, ShakeResult } from "./shake-types";
341
+ import { skillPromptTitleInput } from "./skill-title-input";
341
342
  import { ToolChoiceQueue } from "./tool-choice-queue";
342
343
  import { planTurnPersistence, sameMessageContent, sessionMessagePersistenceKey } from "./turn-persistence";
343
344
  import { TurnRecovery, type TurnRecoveryHost } from "./turn-recovery";
@@ -533,6 +534,8 @@ export class AgentSession {
533
534
  * generation path. Refresh via {@link AgentSession.setTitleSystemPrompt} when
534
535
  * the session cwd changes. */
535
536
  #titleSystemPrompt: string | undefined;
537
+ #titleGenerationStart: (() => void) | undefined;
538
+ #titleGenerationInFlightFor: string | undefined;
536
539
  #titleGenerationAbortController = new AbortController();
537
540
  #toolChoiceQueue = new ToolChoiceQueue();
538
541
 
@@ -1011,8 +1014,13 @@ export class AgentSession {
1011
1014
  emitNotice: (level, message, source) => this.emitNotice(level, message, source),
1012
1015
  setModelTemporary: (model, thinkingLevel, options) => this.setModelTemporary(model, thinkingLevel, options),
1013
1016
  setActiveToolsByName: names => this.setActiveToolsByName(names),
1017
+ setActiveToolPresentation: (toolNames, mountedToolNames) =>
1018
+ this.setActiveToolPresentation(toolNames, mountedToolNames),
1019
+ runToolRegistryMutation: mutation => this.runToolRegistryMutation(mutation),
1014
1020
  getActiveToolNames: () => this.getActiveToolNames(),
1015
1021
  getEnabledToolNames: () => this.getEnabledToolNames(),
1022
+ getSelectedMCPToolNames: () => this.getSelectedMCPToolNames(),
1023
+ getMountedXdevToolNames: () => this.getMountedXdevToolNames(),
1016
1024
  hasBuiltInTool: name => this.hasBuiltInTool(name),
1017
1025
  getPlanModeState: () => this.getPlanModeState(),
1018
1026
  setPlanModeState: state => this.setPlanModeState(state),
@@ -2361,6 +2369,7 @@ export class AgentSession {
2361
2369
  assistantMsg.contextSnapshot = {
2362
2370
  promptTokens: calculatePromptTokens(assistantMsg.usage),
2363
2371
  nonMessageTokens: this.#stats.pendingNonMessageTokens ?? computeNonMessageTokens(this),
2372
+ compactionEpoch: this.#stats.compactionEpoch,
2364
2373
  };
2365
2374
  }
2366
2375
  }
@@ -5466,11 +5475,20 @@ export class AgentSession {
5466
5475
  let keywordNotices: CustomMessage[] = [];
5467
5476
  if (message.customType === SKILL_PROMPT_MESSAGE_TYPE && message.attribution === "user") {
5468
5477
  const details = message.details;
5478
+ let skillName: string | undefined;
5469
5479
  let skillArgs = "";
5470
- if (details && typeof details === "object" && "args" in details && typeof details.args === "string") {
5471
- skillArgs = details.args;
5480
+ if (details && typeof details === "object") {
5481
+ if ("name" in details && typeof details.name === "string") skillName = details.name;
5482
+ if ("args" in details && typeof details.args === "string") skillArgs = details.args;
5472
5483
  }
5473
5484
  keywordNotices = this.#createMagicKeywordNotices(skillArgs);
5485
+ this.maybeStartTitleGeneration(
5486
+ skillPromptTitleInput({
5487
+ name: skillName,
5488
+ args: skillArgs,
5489
+ queueChipText: options?.queueChipText,
5490
+ }),
5491
+ );
5474
5492
  }
5475
5493
 
5476
5494
  if (options?.queueOnly) {
@@ -6538,14 +6556,31 @@ export class AgentSession {
6538
6556
  this.#extensionRunner?.getCommand(
6539
6557
  extensionCommandSpace === -1 ? firstMessage.slice(1) : firstMessage.slice(1, extensionCommandSpace),
6540
6558
  ) !== undefined;
6541
- if (isLocalExtensionCommand || this.sessionName || $env.PI_NO_TITLE || isLowSignalTitleInput(firstMessage)) {
6559
+ const sessionId = this.sessionManager.getSessionId();
6560
+ if (
6561
+ isLocalExtensionCommand ||
6562
+ this.sessionName ||
6563
+ this.#titleGenerationInFlightFor === sessionId ||
6564
+ $env.PI_NO_TITLE ||
6565
+ isLowSignalTitleInput(firstMessage)
6566
+ ) {
6542
6567
  return;
6543
6568
  }
6544
- onStart?.();
6569
+ this.#titleGenerationInFlightFor = sessionId;
6570
+ try {
6571
+ (onStart ?? this.#titleGenerationStart)?.();
6572
+ } catch (error) {
6573
+ if (this.#titleGenerationInFlightFor === sessionId) {
6574
+ this.#titleGenerationInFlightFor = undefined;
6575
+ }
6576
+ throw error;
6577
+ }
6545
6578
  this.generateTitle(firstMessage)
6546
6579
  .then(async title => {
6547
- // Re-check after generation so concurrent attempts cannot replace
6548
- // the first title that completed.
6580
+ // Re-check after generation so a later completion cannot replace
6581
+ // the first title, and a request from a replaced session cannot
6582
+ // name the current one.
6583
+ if (this.sessionManager.getSessionId() !== sessionId) return;
6549
6584
  if (title && !this.sessionName) {
6550
6585
  await this.sessionManager.setSessionName(title, "auto");
6551
6586
  }
@@ -6556,6 +6591,11 @@ export class AgentSession {
6556
6591
  reason: "uncaught-auto-title-error",
6557
6592
  error: err instanceof Error ? err.message : String(err),
6558
6593
  });
6594
+ })
6595
+ .finally(() => {
6596
+ if (this.#titleGenerationInFlightFor === sessionId) {
6597
+ this.#titleGenerationInFlightFor = undefined;
6598
+ }
6559
6599
  });
6560
6600
  }
6561
6601
 
@@ -6602,6 +6642,12 @@ export class AgentSession {
6602
6642
  this.#titleSystemPrompt = prompt;
6603
6643
  }
6604
6644
 
6645
+ /** Install the interactive title-download UI hook. Used when `/skill:` starts
6646
+ * titling from {@link promptCustomMessage} without the input-controller callback. */
6647
+ setTitleGenerationStart(handler: (() => void) | undefined): void {
6648
+ this.#titleGenerationStart = handler;
6649
+ }
6650
+
6605
6651
  /**
6606
6652
  * Abort current operation and wait for agent to become idle.
6607
6653
  *
@@ -38,6 +38,7 @@ export {
38
38
 
39
39
  import type { OutputMeta } from "../tools/output-meta";
40
40
  import { formatOutputNotice } from "../tools/output-meta";
41
+ import { titleTextFromSkillPrompt } from "./skill-title-input";
41
42
 
42
43
  export const SKILL_PROMPT_MESSAGE_TYPE = "skill-prompt";
43
44
  export const LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE = "lsp-late-diagnostic";
@@ -163,6 +164,11 @@ function thinkingFromContent(content: unknown): string {
163
164
  }
164
165
 
165
166
  function titleConversationTurnFromMessage(message: AgentMessage): TitleConversationTurn | undefined {
167
+ if (message.role === "custom") {
168
+ const text = titleTextFromSkillPrompt(message);
169
+ if (!text) return undefined;
170
+ return { role: "user", text };
171
+ }
166
172
  if (message.role !== "user" && message.role !== "assistant") return undefined;
167
173
  const text = textFromContent(message.content);
168
174
  const thinking = message.role === "assistant" ? thinkingFromContent(message.content) : undefined;
@@ -11,6 +11,7 @@ import prewalkChecklistPrompt from "../prompts/system/prewalk-checklist.md" with
11
11
  import prewalkContinuePrompt from "../prompts/system/prewalk-continue.md" with { type: "text" };
12
12
  import prewalkPlanPrompt from "../prompts/system/prewalk-plan.md" with { type: "text" };
13
13
  import { type ConfiguredThinkingLevel, prewalkWouldBeNoop } from "../thinking";
14
+ import { isMCPToolName } from "../tools/builtin-names";
14
15
  import type { PlanProposalHandler } from "../tools/resolve";
15
16
  import { ToolError } from "../tools/tool-errors";
16
17
  import type { PlanYolo, Prewalk } from "./agent-session-types";
@@ -65,8 +66,12 @@ export interface PrewalkCoordinatorHost {
65
66
  options?: { ephemeral?: boolean },
66
67
  ): Promise<void>;
67
68
  setActiveToolsByName(names: string[]): Promise<void>;
69
+ setActiveToolPresentation(toolNames: string[], mountedToolNames: string[]): Promise<void>;
70
+ runToolRegistryMutation<T>(mutation: () => Promise<T>): Promise<T>;
68
71
  getActiveToolNames(): string[];
69
72
  getEnabledToolNames(): string[];
73
+ getSelectedMCPToolNames(): string[];
74
+ getMountedXdevToolNames(): string[];
70
75
  hasBuiltInTool(name: string): boolean;
71
76
  getPlanModeState(): PlanModeState | undefined;
72
77
  setPlanModeState(state: PlanModeState | undefined): void;
@@ -90,7 +95,7 @@ export class PrewalkCoordinator {
90
95
  #continuePending = false;
91
96
  #todoSeen = false;
92
97
  #planYolo: PlanYolo | undefined;
93
- #planYoloPreviousTools: string[] | undefined;
98
+ #planYoloPreviousNonMCPPresentation: { enabled: string[]; mounted: string[] } | undefined;
94
99
  #planYoloArmed = false;
95
100
 
96
101
  constructor(host: PrewalkCoordinatorHost, options: PrewalkCoordinatorOptions = {}) {
@@ -247,10 +252,14 @@ export class PrewalkCoordinator {
247
252
  async armPlanYoloIfNeeded(): Promise<void> {
248
253
  if (!this.#planYolo || this.#planYoloArmed) return;
249
254
  this.#planYoloArmed = true;
250
- const previousTools = this.#host.getEnabledToolNames();
255
+ const previousEnabledTools = this.#host.getEnabledToolNames();
256
+ const previousMountedTools = this.#host.getMountedXdevToolNames();
251
257
  const augmentations = this.#host.hasBuiltInTool("write") ? ["write"] : [];
252
- await this.#host.setActiveToolsByName([...new Set([...previousTools, ...augmentations])]);
253
- this.#planYoloPreviousTools = previousTools;
258
+ await this.#host.setActiveToolsByName([...new Set([...previousEnabledTools, ...augmentations])]);
259
+ this.#planYoloPreviousNonMCPPresentation = {
260
+ enabled: previousEnabledTools.filter(name => !isMCPToolName(name)),
261
+ mounted: previousMountedTools.filter(name => !isMCPToolName(name)),
262
+ };
254
263
  this.#host.setPlanModeState({
255
264
  enabled: true,
256
265
  planFilePath: this.#host.getPlanReferencePath() || "local://PLAN.md",
@@ -287,16 +296,25 @@ export class PrewalkCoordinator {
287
296
  listPlanFiles: () => listPlanFiles({ localProtocolOptions: this.#host.localProtocolOptions() }),
288
297
  });
289
298
  this.#host.setPlanModeState(undefined);
290
- const previousTools = this.#planYoloPreviousTools;
299
+ const previousPresentation = this.#planYoloPreviousNonMCPPresentation;
291
300
  try {
292
- if (previousTools) await this.#host.setActiveToolsByName(previousTools);
301
+ if (previousPresentation) {
302
+ await this.#host.runToolRegistryMutation(async () => {
303
+ const liveMCP = this.#host.getSelectedMCPToolNames();
304
+ const liveMountedMCP = this.#host.getMountedXdevToolNames().filter(isMCPToolName);
305
+ await this.#host.setActiveToolPresentation(
306
+ [...new Set([...previousPresentation.enabled, ...liveMCP])],
307
+ [...new Set([...previousPresentation.mounted, ...liveMountedMCP])],
308
+ );
309
+ });
310
+ }
293
311
  } catch (error) {
294
312
  this.#host.setPlanModeState(state);
295
313
  throw error;
296
314
  }
297
315
  this.#host.setPlanProposalHandler(null);
298
316
  this.#planYolo = undefined;
299
- this.#planYoloPreviousTools = undefined;
317
+ this.#planYoloPreviousNonMCPPresentation = undefined;
300
318
  await this.#host.setModelTemporary(planYolo.target, planYolo.thinkingLevel, { ephemeral: true });
301
319
  this.#host.emitNotice(
302
320
  "info",
@@ -171,7 +171,6 @@ declare module "@oh-my-pi/pi-agent-core/compaction/entries" {
171
171
  interface CustomCompactionSessionEntries {
172
172
  titleChange: TitleChangeEntry;
173
173
  credentialPin: CredentialPinEntry;
174
- resetBoundary: ResetBoundaryEntry;
175
174
  }
176
175
  }
177
176
 
@@ -477,7 +477,9 @@ export class SessionMaintenance {
477
477
  const config = this.#withPlanProtection({
478
478
  ...(opts.config ?? AGGRESSIVE_SHAKE_CONFIG),
479
479
  // Skip entries summarized away by the latest compaction — shaking them
480
- // only churns persisted history with no prompt/cache effect.
480
+ // only churns persisted history with no prompt/cache effect. The cut is
481
+ // unconditional on the wire (see `buildSessionContext`), so a compaction
482
+ // the active model cannot replay still hides its prefix from the prompt.
481
483
  keepBoundaryId: latestCompaction?.firstKeptEntryId,
482
484
  });
483
485
  const regions = collectShakeRegions(branchEntries, config);
@@ -2729,9 +2731,16 @@ export class SessionMaintenance {
2729
2731
  }
2730
2732
 
2731
2733
  const retryAfterMs = this.#host.parseRetryAfterMsFromError(message);
2734
+ // An input the summarizer cannot fit is deterministic: the same
2735
+ // prompt fails identically every attempt, so the retry budget is
2736
+ // pure latency and the next candidate (a larger window) is the
2737
+ // only move that can succeed. Overflow therefore vetoes the
2738
+ // transient/usage-limit arms, which a provider blob can trip on
2739
+ // coincidence alone.
2732
2740
  const shouldRetry =
2733
2741
  retrySettings.enabled &&
2734
2742
  attempt < retrySettings.maxRetries &&
2743
+ !AIError.is(id, AIError.Flag.ContextOverflow) &&
2735
2744
  (retryAfterMs !== undefined ||
2736
2745
  AIError.is(id, AIError.Flag.Transient) ||
2737
2746
  AIError.is(id, AIError.Flag.UsageLimit));
@@ -1950,6 +1950,21 @@ export class SessionManager {
1950
1950
  return this.#sessionFile;
1951
1951
  }
1952
1952
 
1953
+ /**
1954
+ * Whether the current session has actually been materialized to durable
1955
+ * storage (the JSONL exists on disk / in the active storage backend).
1956
+ *
1957
+ * Session persistence is lazy: the file is only written once the history
1958
+ * contains an assistant message (or an explicit {@link ensureOnDisk}
1959
+ * caller forces it). Until then {@link getSessionFile} returns an allocated
1960
+ * path that leads nowhere, so a `--resume <id>` hint built from it would
1961
+ * always fail. Consumers that advertise a resume command must gate on this
1962
+ * (issue #8860).
1963
+ */
1964
+ isSessionOnDisk(): boolean {
1965
+ return !!this.#sessionFile && this.#storage.existsSync(this.#sessionFile);
1966
+ }
1967
+
1953
1968
  getArtifactsDir(): string | null {
1954
1969
  if (this.#adoptedArtifactManager) return this.#adoptedArtifactManager.dir;
1955
1970
  return artifactsDirectoryFor(this.#sessionFile);
@@ -22,6 +22,12 @@ interface PendingContextSnapshot {
22
22
  promptTokens: number;
23
23
  nonMessageTokens: number;
24
24
  cutoffCount: number;
25
+ /**
26
+ * Compaction epoch at rebase time. Distinguishes a genuinely fresh in-turn
27
+ * anchor (same epoch) from a post-cutoff anchor that predates a mid-run
28
+ * compaction (older epoch) so the latter never out-ranks this snapshot.
29
+ */
30
+ epoch: number;
25
31
  }
26
32
 
27
33
  /** Capabilities the stats tracker borrows from its owning session. */
@@ -44,6 +50,7 @@ export class SessionStatsTracker {
44
50
  readonly #host: SessionStatsTrackerHost;
45
51
  #pendingContextSnapshot: PendingContextSnapshot | undefined;
46
52
  #contextUsageRevision = 0;
53
+ #compactionEpoch = 0;
47
54
 
48
55
  constructor(host: SessionStatsTrackerHost) {
49
56
  this.#host = host;
@@ -162,8 +169,11 @@ export class SessionStatsTracker {
162
169
  }
163
170
  }
164
171
 
172
+ const anchorEpoch = anchorAssistant?.contextSnapshot?.compactionEpoch ?? 0;
165
173
  const useAnchor =
166
- anchorAssistant !== undefined && anchorIndex !== -1 && (!pending || anchorIndex >= pending.cutoffCount);
174
+ anchorAssistant !== undefined &&
175
+ anchorIndex !== -1 &&
176
+ (!pending || (anchorIndex >= pending.cutoffCount && anchorEpoch >= pending.epoch));
167
177
  if (useAnchor && anchorAssistant) {
168
178
  const promptTokens = correctedPromptTokens(anchorAssistant);
169
179
  const nonMessageTokens =
@@ -255,6 +265,15 @@ export class SessionStatsTracker {
255
265
  return this.#contextUsageRevision;
256
266
  }
257
267
 
268
+ /**
269
+ * Monotonic compaction epoch, bumped whenever history is compacted. Stamped
270
+ * onto each assistant snapshot at record time so {@link getContextBreakdown}
271
+ * can reject a post-cutoff anchor whose usage predates the last compaction.
272
+ */
273
+ get compactionEpoch(): number {
274
+ return this.#compactionEpoch;
275
+ }
276
+
258
277
  /** Non-message token count captured for the active provider request. */
259
278
  get pendingNonMessageTokens(): number | undefined {
260
279
  return this.#pendingContextSnapshot?.nonMessageTokens;
@@ -292,6 +311,7 @@ export class SessionStatsTracker {
292
311
  assistant.contextSnapshot = {
293
312
  promptTokens: calculatePromptTokens(assistant.usage),
294
313
  nonMessageTokens: computeNonMessageTokens(this.#host.session),
314
+ compactionEpoch: this.#compactionEpoch,
295
315
  };
296
316
  }
297
317
  const snapshot = assistant.contextSnapshot;
@@ -302,13 +322,14 @@ export class SessionStatsTracker {
302
322
  }
303
323
 
304
324
  /** Sets or clears the in-flight context snapshot. */
305
- setPendingSnapshot(snapshot: PendingContextSnapshot | undefined): void {
306
- this.#pendingContextSnapshot = snapshot;
325
+ setPendingSnapshot(snapshot: Omit<PendingContextSnapshot, "epoch"> | undefined): void {
326
+ this.#pendingContextSnapshot = snapshot ? { ...snapshot, epoch: this.#compactionEpoch } : undefined;
307
327
  this.#contextUsageRevision++;
308
328
  }
309
329
 
310
330
  /** Recomputes an in-flight snapshot after history is compacted or rewritten. */
311
331
  rebaseAfterCompaction(): void {
332
+ this.#compactionEpoch++;
312
333
  if (!this.#pendingContextSnapshot) return;
313
334
  const nonMessageTokens = computeNonMessageTokens(this.#host.session);
314
335
  const messages = this.#host.agent.state.messages;
@@ -41,6 +41,12 @@ export function createSettingsAwareStreamFn(settings: Settings, base: StreamFn =
41
41
  : model.api === "openai-responses"
42
42
  ? settings.get("textVerbosity")
43
43
  : undefined;
44
+ // "auto" leaves the option unset so provider defaults and the
45
+ // PI_CACHE_RETENTION env override keep working; anything else is an
46
+ // explicit per-request retention (long restores 1h Anthropic TTLs and
47
+ // implicitly disables the short-entry keep-alive refresh loop).
48
+ const cacheRetentionSetting = settings.get("providers.cacheRetention");
49
+ const cacheRetention = cacheRetentionSetting === "auto" ? undefined : cacheRetentionSetting;
44
50
  const streamFirstEventTimeoutMs = timeoutSecondsToMs(settings.get("providers.streamFirstEventTimeoutSeconds"));
45
51
  const streamIdleTimeoutMs = timeoutSecondsToMs(settings.get("providers.streamIdleTimeoutSeconds"));
46
52
  // Server-side fallback (opt-in): when the user enables it AND the
@@ -60,6 +66,7 @@ export function createSettingsAwareStreamFn(settings: Settings, base: StreamFn =
60
66
  openrouterVariant: streamOptions?.openrouterVariant ?? openrouterVariant,
61
67
  antigravityEndpointMode: streamOptions?.antigravityEndpointMode ?? antigravityEndpointMode,
62
68
  textVerbosity: streamOptions?.textVerbosity ?? textVerbosity,
69
+ cacheRetention: streamOptions?.cacheRetention ?? cacheRetention,
63
70
  streamFirstEventTimeoutMs: streamOptions?.streamFirstEventTimeoutMs ?? streamFirstEventTimeoutMs,
64
71
  streamIdleTimeoutMs: streamOptions?.streamIdleTimeoutMs ?? streamIdleTimeoutMs,
65
72
  maxRetryDelayMs: streamOptions?.maxRetryDelayMs ?? settings.get("retry.maxDelayMs"),
@@ -0,0 +1,32 @@
1
+ /** Compact title-model input for a user-invoked `/skill:<name>` prompt. */
2
+ export function skillPromptTitleInput(input: { name?: string; args?: string; queueChipText?: string }): string {
3
+ const chip = input.queueChipText?.trim();
4
+ if (chip) return chip;
5
+ const name = input.name?.trim();
6
+ const args = input.args?.trim();
7
+ if (name && args) return `/skill:${name} ${args}`;
8
+ if (name) return `/skill:${name}`;
9
+ return args ?? "";
10
+ }
11
+
12
+ /** Title text for a persisted skill-prompt custom message. Never the expanded SKILL.md body. */
13
+ export function titleTextFromSkillPrompt(message: {
14
+ role: string;
15
+ customType?: string;
16
+ attribution?: string;
17
+ details?: unknown;
18
+ }): string | undefined {
19
+ if (message.role !== "custom" || message.customType !== "skill-prompt" || message.attribution !== "user") {
20
+ return undefined;
21
+ }
22
+ let name: string | undefined;
23
+ let args: string | undefined;
24
+ let queueChipText: string | undefined;
25
+ if (message.details && typeof message.details === "object") {
26
+ const details = message.details as Record<string, unknown>;
27
+ if (typeof details.name === "string") name = details.name;
28
+ if (typeof details.args === "string") args = details.args;
29
+ if (typeof details.__queueChipText === "string") queueChipText = details.__queueChipText;
30
+ }
31
+ return skillPromptTitleInput({ name, args, queueChipText }) || undefined;
32
+ }
@@ -20,7 +20,6 @@ import type {
20
20
  } from "@oh-my-pi/pi-ai";
21
21
  import { calculateRateLimitBackoffMs, parseRateLimitReason } from "@oh-my-pi/pi-ai";
22
22
  import * as AIError from "@oh-my-pi/pi-ai/error";
23
- import { kCursorExecResolved } from "@oh-my-pi/pi-ai/utils/block-symbols";
24
23
  import { isFireworksFastModelId, toFireworksBaseModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id";
25
24
  import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
26
25
  import { extractRetryHint, logger, prompt } from "@oh-my-pi/pi-utils";
@@ -1177,25 +1176,15 @@ export class TurnRecovery {
1177
1176
  if (!reasonlessAbort && !streamStall && !transportReset) return undefined;
1178
1177
  if (reasonlessAbort && genericAbort) message.errorId = AIError.create(AIError.Flag.Abort);
1179
1178
 
1180
- // The Cursor server-execution marker gate applies only to the idle stream-stall
1181
- // path: an unmarked/unresolved Cursor block there means the server has not
1182
- // finished executing, so resuming would race it. A reasonless abort instead
1183
- // ends the turn and the agent loop pairs every un-run call (Cursor's unmarked
1184
- // `todo`/MCP blocks included) with a synthetic `executed: false` result, so
1185
- // the tool-result reconciliation below is the safety gate and the marker is
1186
- // irrelevant. An HTTP/2 RST_STREAM / NGHTTP2_* close also ends the Connect
1187
- // stream, so there is no in-flight server exec to race — unmarked MCP/todo
1188
- // blocks are safe to continue once every emitted call has a result.
1179
+ // Idle stall and HTTP/2 RST both close the Cursor Connect stream:
1180
+ // the lazy watchdog aborts the request signal, and cursor.ts then
1181
+ // calls `h2Request.close()`. There is no in-flight server exec to
1182
+ // race, so unmarked MCP/todo blocks can continue once every emitted
1183
+ // call has a matching result. A reasonless abort ends the turn and
1184
+ // the agent loop pairs leftover calls with `executed: false`.
1189
1185
  const resolvedToolCallIds: string[] = [];
1190
1186
  for (const block of message.content) {
1191
1187
  if (block.type !== "toolCall") continue;
1192
- if (
1193
- streamStall &&
1194
- message.provider === "cursor" &&
1195
- (!(kCursorExecResolved in block) || block[kCursorExecResolved] !== true)
1196
- ) {
1197
- return undefined;
1198
- }
1199
1188
  resolvedToolCallIds.push(block.id);
1200
1189
  }
1201
1190
  if (resolvedToolCallIds.length === 0) return undefined;
@@ -1660,6 +1649,9 @@ export class TurnRecovery {
1660
1649
  if (AIError.isContextOverflow(message, model.contextWindow ?? 0)) return false;
1661
1650
  if (AIError.is(id, AIError.Flag.UsageLimit)) return false;
1662
1651
  if (AIError.is(id, AIError.Flag.AuthFailed)) return false;
1652
+ // A thinking loop is a same-model resample signal, not a router fault, so a
1653
+ // base-model swap would abandon the loop-guard redirect (issue #8760).
1654
+ if (AIError.is(id, AIError.Flag.ThinkingLoop)) return false;
1663
1655
  return this.#host.modelRegistry.find("fireworks", toFireworksBaseModelId(model.id)) !== undefined;
1664
1656
  }
1665
1657
 
@@ -1969,10 +1961,23 @@ export class TurnRecovery {
1969
1961
  );
1970
1962
  if (switchedCredential) delayMs = 0;
1971
1963
  }
1964
+ // A thinking-loop abort is not a provider failure — it is the loop guard
1965
+ // asking for a same-model resample, paired with a hidden
1966
+ // `thinking-loop-redirect` notice that only makes sense on the model that
1967
+ // looped. Walking `fallbackChains` (or parking the selector on a cooldown)
1968
+ // would swap a healthy planning turn to another family based on chain
1969
+ // contents, not model health (issue #8760). Keep it on the same model; the
1970
+ // retry budget still bounds a genuinely stuck stream.
1971
+ const thinkingLoop = AIError.is(id, AIError.Flag.ThinkingLoop);
1972
1972
  if (!staleOpenAIResponsesReplayError && !switchedCredential && currentSelector) {
1973
1973
  // A refusal chain stops at the retry budget: the exhausted-attempt
1974
1974
  // last resort is for provider failures, not classifier decisions.
1975
- if (allowModelFallback && retrySettings.modelFallback && !(retryBudgetExhausted && classifierRefusal)) {
1975
+ if (
1976
+ allowModelFallback &&
1977
+ retrySettings.modelFallback &&
1978
+ !thinkingLoop &&
1979
+ !(retryBudgetExhausted && classifierRefusal)
1980
+ ) {
1976
1981
  if (!classifierRefusal) {
1977
1982
  this.noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
1978
1983
  }
@@ -108,17 +108,20 @@ export const SMOKE_TEST_TIMEOUT_MS = 30_000;
108
108
  /**
109
109
  * Resolve the command used to relaunch the agent CLI into worker mode. In a
110
110
  * compiled binary the entry point is the binary itself; otherwise re-enter the
111
- * declared worker-host entry with a cwd-relative script path (Bun's subprocess
112
- * IPC is more reliable that way under `bun test`), falling back to this
113
- * package's own `src/cli.ts` when no host entry is declared (bun test, SDK
114
- * embedding).
111
+ * declared worker-host entry by absolute path. Workers deliberately spawn
112
+ * without a pinned cwd there: they share the parent's foreground process
113
+ * group, and terminal cwd heuristics (kitty's new_tab_with_cwd) read the
114
+ * newest process in that group, so anchoring them to the install dir leaks
115
+ * into newly opened terminal tabs. With no declared host entry (bun test, SDK
116
+ * embedding) fall back to a cwd-relative `src/cli.ts`, which Bun subprocess
117
+ * IPC handles more reliably under `bun test`.
115
118
  */
116
119
  export function resolveWorkerSpawnCmd(workerArg: string): WorkerSpawnCommand {
117
120
  const executable = stripWindowsExtendedLengthPathPrefix(process.execPath);
118
121
  if (isCompiledBinary()) return { cmd: [executable, workerArg] };
119
122
  const hostEntry = workerHostEntry();
120
123
  if (hostEntry) {
121
- return { cmd: [executable, path.basename(hostEntry), workerArg], cwd: path.dirname(hostEntry) };
124
+ return { cmd: [executable, hostEntry, workerArg] };
122
125
  }
123
126
  const packageRoot = path.resolve(import.meta.dir, "..", "..");
124
127
  return { cmd: [executable, "src/cli.ts", workerArg], cwd: packageRoot };
@@ -35,6 +35,7 @@ import type { HindsightSessionState } from "../hindsight/state";
35
35
  import type { LocalProtocolOptions } from "../internal-urls";
36
36
  import type { MCPManager } from "../mcp/manager";
37
37
  import type { MnemopiSessionState } from "../mnemopi/state";
38
+ import { initializeExtensions } from "../modes/runtime-init";
38
39
  import subagentAsyncPendingTemplate from "../prompts/system/subagent-async-pending.md" with { type: "text" };
39
40
  import subagentSystemPromptTemplate from "../prompts/system/subagent-system-prompt.md" with { type: "text" };
40
41
  import submitReminderTemplate from "../prompts/system/subagent-yield-reminder.md" with { type: "text" };
@@ -3167,6 +3168,16 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
3167
3168
  const { session: revived } = await createAgentSession(
3168
3169
  buildSubagentSessionOptions(reopened, expectedAgentRef),
3169
3170
  );
3171
+ // Re-run the executor's extension wiring on the rebuilt session.
3172
+ // Skipping it leaves the runner pre-init, so a `tool_call` handler
3173
+ // touching a runtime action trips the fail-closed gate and blocks
3174
+ // every tool (including `yield`) in the revived agent (issue #8824).
3175
+ await initializeExtensions(revived, {
3176
+ reportSendError: (action, err) =>
3177
+ logger.error("Extension send failed", { action, error: err.message }),
3178
+ reportRuntimeError: err =>
3179
+ logger.error("Extension error", { path: err.extensionPath, error: err.error }),
3180
+ });
3170
3181
  AgentRegistry.global().syncSessionStatus(id, revived);
3171
3182
  installIrcWakeTurnMonitor(revived);
3172
3183
  return revived;
package/src/task/index.ts CHANGED
@@ -1424,6 +1424,8 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1424
1424
  ...(Object.hasOwn(params, "outputSchema") ? { outputSchema: params.outputSchema } : {}),
1425
1425
  ...(Object.hasOwn(params, "schemaMode") ? { schemaMode: params.schemaMode } : {}),
1426
1426
  ...(params.effort !== undefined ? { effort: params.effort } : {}),
1427
+ // `name` is the spawn handle: keep it for id allocation when this
1428
+ // path did not pre-reserve one. Do not treat it as a HUD description.
1427
1429
  identity: { id: preAllocatedId, label: params.name },
1428
1430
  index: spawnIndex,
1429
1431
  parentToolCallId: toolCallId,
package/src/task/label.ts CHANGED
@@ -9,6 +9,17 @@ import { generateSessionTitle } from "../utils/title-generator";
9
9
 
10
10
  const TASK_LABEL_SYSTEM_PROMPT = prompt.render(taskLabelSystemPrompt);
11
11
 
12
+ /** True when a generated label is just the spawn handle, including `Name-2`. */
13
+ export function labelEchoesHandle(handle: string | undefined, label: string): boolean {
14
+ if (!handle) return false;
15
+ if (label.localeCompare(handle, undefined, { sensitivity: "accent" }) === 0) return true;
16
+ const separator = handle.lastIndexOf("-");
17
+ if (separator <= 0) return false;
18
+ const prefix = handle.slice(0, separator);
19
+ const suffix = handle.slice(separator + 1);
20
+ return /^\d+$/.test(suffix) && prefix.localeCompare(label, undefined, { sensitivity: "accent" }) === 0;
21
+ }
22
+
12
23
  /** Compresses a delegated assignment into a one-sentence UI label via the tiny title model — fired by the executor spawn path because the task wire schema no longer carries a `description`; null on empty input or failure. */
13
24
  export async function generateTaskLabel(
14
25
  assignment: string,
@@ -20,7 +31,7 @@ export async function generateTaskLabel(
20
31
  const text = assignment.trim();
21
32
  if (!text) return null;
22
33
  try {
23
- return await generateSessionTitle(
34
+ const label = await generateSessionTitle(
24
35
  text,
25
36
  registry,
26
37
  settings,
@@ -30,6 +41,8 @@ export async function generateTaskLabel(
30
41
  TASK_LABEL_SYSTEM_PROMPT,
31
42
  signal,
32
43
  );
44
+ if (!label || labelEchoesHandle(sessionId, label)) return null;
45
+ return label;
33
46
  } catch (err) {
34
47
  logger.debug("task-label: generation failed", {
35
48
  sessionId,
@@ -1,8 +1,10 @@
1
1
  import * as fs from "node:fs/promises";
2
+ import { logger } from "@oh-my-pi/pi-utils";
2
3
  import type { ModelRegistry } from "../config/model-registry";
3
4
  import { formatModelRoleAlias } from "../config/model-roles";
4
5
  import type { Settings } from "../config/settings";
5
6
  import { MCPManager } from "../mcp/manager";
7
+ import { initializeExtensions } from "../modes/runtime-init";
6
8
  import type { PersistedSubagentReviverFactory } from "../registry/agent-lifecycle";
7
9
  import { AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
8
10
  import { createAgentSession } from "../sdk";
@@ -153,6 +155,17 @@ export function createPersistedSubagentReviverFactory(
153
155
  // `alwaysInclude` can re-add non-defaultInactive extension/custom tools
154
156
  // the original run didn't carry. Unknown/missing names are ignored.
155
157
  await session.setActiveToolsByName([...init.tools, ...session.getMountedXdevToolNames()]);
158
+ // Wire the extension runtime exactly as the live executor does. Without
159
+ // this the runner stays pre-init, every action method throws
160
+ // `ExtensionRuntimeNotInitializedError`, and a `tool_call` handler that
161
+ // touches a runtime action trips the fail-closed gate in `emitToolCall`,
162
+ // blocking every tool — including the hidden `yield` — in the revived
163
+ // agent. `session_start` also re-runs so extensions restore per-session
164
+ // state (issue #8824).
165
+ await initializeExtensions(session, {
166
+ reportSendError: (action, err) => logger.error("Extension send failed", { action, error: err.message }),
167
+ reportRuntimeError: err => logger.error("Extension error", { path: err.extensionPath, error: err.error }),
168
+ });
156
169
  // Cold revives must drive registry status themselves — createAgentSession
157
170
  // doesn't wire this generically (the live path does it in the executor).
158
171
  // The internal run-state signal precedes deferrable public `agent_end`,
@@ -710,7 +710,7 @@ function formatAgentHeaderLabel(args: Partial<TaskParams> | undefined): string |
710
710
  }
711
711
 
712
712
  /** Dim `⟨agent⟩` badge for a non-default agent type; empty for the generic worker. */
713
- function agentTypeBadge(agent: string | undefined, theme: Theme): string {
713
+ export function agentTypeBadge(agent: string | undefined, theme: Theme): string {
714
714
  const trimmed = agent?.trim();
715
715
  if (!trimmed || trimmed === "task") return "";
716
716
  return ` ${theme.fg("dim", `${theme.format.bracketLeft}${trimmed}${theme.format.bracketRight}`)}`;
@@ -245,6 +245,7 @@ function assertDepthAndSpawnAllowed(request: StructuredSubagentRequest, agentNam
245
245
  export async function resolveEffectiveSubagentPolicy(
246
246
  request: StructuredSubagentRequest,
247
247
  ): Promise<EffectiveSubagentPolicy> {
248
+ await request.session.settings.reloadFromDisk();
248
249
  const spawnPolicy = resolveSpawnPolicy(request.session.getSessionSpawns());
249
250
  const agentName = request.agent?.trim() || spawnPolicy.defaultAgent;
250
251
  const planMode = request.session.getPlanModeState?.()?.enabled === true;
@@ -393,7 +394,9 @@ function buildExecutorOptions(
393
394
  assignment: request.assignment.trim(),
394
395
  context: request.context?.trim() || undefined,
395
396
  planReference: undefined,
396
- description: trimToUndefined(request.identity?.label),
397
+ // Task `name` is the spawn handle (id allocation). Eval `label` is a
398
+ // real UI description. Copy it only for eval so generateTaskLabel can run.
399
+ description: request.invocationKind === "eval" ? trimToUndefined(request.identity?.label) : undefined,
397
400
  index: request.index ?? 0,
398
401
  parentToolCallId: request.parentToolCallId,
399
402
  detached: request.detached,
@@ -476,7 +479,7 @@ function buildFailureResult(
476
479
  agentSource: policy.agent.source,
477
480
  task: renderSubagentPrompt(request.assignment),
478
481
  assignment: request.assignment.trim(),
479
- description: trimToUndefined(request.identity?.label),
482
+ description: request.invocationKind === "eval" ? trimToUndefined(request.identity?.label) : undefined,
480
483
  exitCode: 1,
481
484
  output: "",
482
485
  stderr: message,