@oh-my-pi/pi-coding-agent 17.1.2 → 17.1.4

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 (110) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/cli.js +3806 -3798
  3. package/dist/types/cli/__tests__/auth-gateway-catalog.test.d.ts +1 -0
  4. package/dist/types/cli/auth-gateway-cli.d.ts +8 -0
  5. package/dist/types/cli/update-cli.d.ts +41 -0
  6. package/dist/types/cli/usage-cli.d.ts +4 -2
  7. package/dist/types/commands/update.d.ts +1 -0
  8. package/dist/types/config/model-registry.d.ts +19 -0
  9. package/dist/types/config/settings-schema.d.ts +30 -7
  10. package/dist/types/cursor.d.ts +47 -1
  11. package/dist/types/eval/js/process-entry.d.ts +2 -1
  12. package/dist/types/eval/js/worker-core.d.ts +4 -1
  13. package/dist/types/extensibility/extensions/runner.d.ts +1 -0
  14. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
  15. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +4 -0
  16. package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +15 -7
  17. package/dist/types/extensibility/shared-events.d.ts +2 -0
  18. package/dist/types/modes/components/assistant-message.d.ts +9 -0
  19. package/dist/types/modes/components/custom-editor.d.ts +12 -8
  20. package/dist/types/plan-mode/approved-plan.d.ts +3 -2
  21. package/dist/types/secrets/obfuscator.d.ts +7 -31
  22. package/dist/types/session/agent-session.d.ts +19 -4
  23. package/dist/types/session/prewalk.d.ts +2 -1
  24. package/dist/types/session/session-advisors.d.ts +8 -0
  25. package/dist/types/session/session-metadata.d.ts +29 -0
  26. package/dist/types/session/turn-recovery.d.ts +5 -5
  27. package/dist/types/stt/sherpa-runtime.d.ts +38 -0
  28. package/dist/types/thinking.d.ts +24 -0
  29. package/dist/types/tools/auto-generated-guard.d.ts +5 -2
  30. package/dist/types/tools/bash.d.ts +5 -4
  31. package/dist/types/tools/computer/exposure.d.ts +8 -0
  32. package/dist/types/tools/computer/supervisor.d.ts +1 -1
  33. package/dist/types/tools/computer/worker-entry.d.ts +1 -1
  34. package/dist/types/tools/computer/worker.d.ts +1 -1
  35. package/dist/types/tools/computer.d.ts +6 -0
  36. package/dist/types/tools/memory-render.d.ts +1 -4
  37. package/dist/types/tools/shell-tokenize.d.ts +14 -0
  38. package/dist/types/tools/todo.d.ts +7 -1
  39. package/package.json +12 -12
  40. package/src/advisor/__tests__/advisor.test.ts +80 -10
  41. package/src/advisor/runtime.ts +27 -1
  42. package/src/cli/__tests__/auth-gateway-catalog.test.ts +111 -0
  43. package/src/cli/auth-gateway-cli.ts +63 -16
  44. package/src/cli/config-cli.ts +25 -7
  45. package/src/cli/update-cli.ts +229 -29
  46. package/src/cli/usage-cli.ts +144 -15
  47. package/src/cli.ts +21 -15
  48. package/src/commands/update.ts +6 -0
  49. package/src/config/__tests__/model-registry.test.ts +42 -7
  50. package/src/config/model-registry.ts +67 -4
  51. package/src/config/settings-schema.ts +39 -8
  52. package/src/config/settings.ts +24 -2
  53. package/src/cursor.ts +153 -0
  54. package/src/dap/session.ts +70 -11
  55. package/src/edit/hashline/filesystem.ts +1 -1
  56. package/src/edit/modes/patch.ts +1 -1
  57. package/src/eval/__tests__/js-context-manager.test.ts +9 -1
  58. package/src/eval/__tests__/process-entry-import.test.ts +111 -1
  59. package/src/eval/js/process-entry.ts +9 -5
  60. package/src/eval/js/worker-core.ts +6 -2
  61. package/src/exec/bash-executor.ts +4 -2
  62. package/src/extensibility/extensions/runner.ts +23 -8
  63. package/src/extensibility/legacy-pi-ai-shim.ts +9 -0
  64. package/src/extensibility/legacy-pi-coding-agent-shim.ts +14 -2
  65. package/src/extensibility/legacy-pi-tui-shim.ts +33 -0
  66. package/src/extensibility/shared-events.ts +2 -0
  67. package/src/main.ts +22 -1
  68. package/src/modes/acp/acp-agent.ts +6 -0
  69. package/src/modes/components/assistant-message.ts +88 -11
  70. package/src/modes/components/chat-transcript-builder.ts +2 -0
  71. package/src/modes/components/custom-editor.test.ts +170 -0
  72. package/src/modes/components/custom-editor.ts +79 -29
  73. package/src/modes/components/settings-defs.ts +5 -1
  74. package/src/modes/controllers/event-controller.ts +96 -4
  75. package/src/modes/controllers/selector-controller.ts +14 -0
  76. package/src/modes/interactive-mode.ts +34 -8
  77. package/src/modes/utils/context-usage.ts +19 -2
  78. package/src/modes/utils/interactive-context-helpers.ts +1 -0
  79. package/src/plan-mode/approved-plan.ts +18 -10
  80. package/src/prompts/system/custom-system-prompt.md +1 -1
  81. package/src/prompts/system/system-prompt.md +10 -1
  82. package/src/prompts/tools/ast-edit.md +1 -1
  83. package/src/sdk.ts +4 -0
  84. package/src/secrets/obfuscator.ts +36 -126
  85. package/src/session/agent-session.ts +69 -60
  86. package/src/session/prewalk.ts +8 -3
  87. package/src/session/session-advisors.ts +67 -3
  88. package/src/session/session-metadata.ts +53 -0
  89. package/src/session/session-provider-boundary.ts +0 -1
  90. package/src/session/session-tools.ts +35 -1
  91. package/src/session/stream-guards.ts +1 -1
  92. package/src/session/turn-recovery.ts +36 -19
  93. package/src/slash-commands/builtin-registry.ts +49 -7
  94. package/src/stt/asr-worker.ts +2 -37
  95. package/src/stt/sherpa-runtime.ts +71 -0
  96. package/src/task/executor.ts +6 -5
  97. package/src/thinking.ts +39 -0
  98. package/src/tools/auto-generated-guard.ts +18 -5
  99. package/src/tools/bash.ts +43 -15
  100. package/src/tools/computer/exposure.ts +38 -0
  101. package/src/tools/computer/supervisor.ts +61 -13
  102. package/src/tools/computer/worker-entry.ts +28 -19
  103. package/src/tools/computer/worker.ts +3 -7
  104. package/src/tools/computer.ts +65 -10
  105. package/src/tools/gh-cache-invalidation.ts +2 -82
  106. package/src/tools/memory-render.ts +11 -2
  107. package/src/tools/render-utils.ts +8 -3
  108. package/src/tools/shell-tokenize.ts +83 -0
  109. package/src/tools/todo.ts +44 -17
  110. package/src/tools/write.ts +1 -1
@@ -71,7 +71,7 @@ import type {
71
71
  ToolResultMessage,
72
72
  UsageReport,
73
73
  } from "@oh-my-pi/pi-ai";
74
- import { deriveClaudeDeviceId, type Effort, streamSimple } from "@oh-my-pi/pi-ai";
74
+ import { type Effort, streamSimple } from "@oh-my-pi/pi-ai";
75
75
  import * as AIError from "@oh-my-pi/pi-ai/error";
76
76
  import { resetOpenAICodexHistoryAfterCompaction } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
77
77
  import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
@@ -81,7 +81,6 @@ import {
81
81
  escapeXmlText,
82
82
  formatDuration,
83
83
  getAgentDbPath,
84
- getInstallId,
85
84
  isBunTestRuntime,
86
85
  isEnoent,
87
86
  isInteractiveHost,
@@ -229,7 +228,6 @@ import {
229
228
  type AsyncResultEntry,
230
229
  buildAsyncResultBatchMessage,
231
230
  } from "./async-job-delivery";
232
- import type { AuthStorage } from "./auth-storage";
233
231
  import { BashRunner, type BashRunnerHost } from "./bash-runner";
234
232
  import {
235
233
  checkpointStartedAtFromEntry,
@@ -304,6 +302,7 @@ import {
304
302
  } from "./session-maintenance";
305
303
  import { cleanupEmptyMoveSession, type SessionManager } from "./session-manager";
306
304
  import { SessionMemory, type SessionMemoryHost } from "./session-memory";
305
+ import { buildSessionMetadata } from "./session-metadata";
307
306
  import { SessionProviderBoundary, type SessionProviderBoundaryHost } from "./session-provider-boundary";
308
307
  import { SessionStatsTracker, type SessionStatsTrackerHost } from "./session-stats";
309
308
  import { SessionTools, type SessionToolsHost } from "./session-tools";
@@ -330,53 +329,6 @@ const PLAN_MODE_REMINDER_MAX = 3;
330
329
  // Constants
331
330
  // ============================================================================
332
331
 
333
- /** Standard thinking levels */
334
-
335
- /**
336
- * Build the per-request `metadata` payload for the Anthropic provider, shaped
337
- * like real Claude Code's `getAPIMetadata` output (`{ session_id, account_uuid,
338
- * device_id }`) so the backend buckets requests under one session and attributes
339
- * them to the authenticated OAuth account when available. Resolved at request
340
- * time so token refreshes and login/logout transitions don't strand a stale
341
- * account UUID in memory. `account_uuid` and `device_id` are omitted for
342
- * non-Anthropic providers to avoid leaking the user's Claude identity to
343
- * third-party APIs (including Anthropic-format-compatible proxies such as
344
- * cloudflare-ai-gateway or gitlab-duo).
345
- *
346
- * `provider` is the target provider string (e.g. `"anthropic"`) and gates the
347
- * `account_uuid` and `device_id` lookups — only `"anthropic"` requests carry them.
348
- *
349
- * `sessionId` is forwarded to the auth-storage session-sticky lookup so that
350
- * multi-credential setups attribute to the same OAuth account used for the
351
- * actual API request rather than always picking the first credential.
352
- *
353
- * `authStorage` is treated as optional so test fixtures that stub `modelRegistry`
354
- * without a real storage layer still work; the resolver simply skips the lookup
355
- * and emits `{ session_id }` alone, matching the no-OAuth-credential path.
356
- */
357
- function buildSessionMetadata(
358
- sessionId: string,
359
- provider: string,
360
- authStorage: AuthStorage | undefined,
361
- ): Record<string, unknown> {
362
- const userId: Record<string, string> = { session_id: sessionId };
363
- // Only look up account_uuid when the request is going to Anthropic. Injecting
364
- // a Claude OAuth account_uuid into requests bound for other providers (including
365
- // Anthropic-format-compatible proxies like cloudflare-ai-gateway or gitlab-duo)
366
- // would leak the user's Anthropic identity to unrelated third-party APIs.
367
- if (provider === "anthropic") {
368
- const accountUuid = authStorage?.getOAuthAccountId("anthropic", sessionId);
369
- if (typeof accountUuid === "string" && accountUuid.length > 0) {
370
- userId.account_uuid = accountUuid;
371
- // Claude Code's `device_id` is a stable 64-hex account-scoped install
372
- // identifier. Include both omp's persistent install id and the Claude
373
- // account UUID so two accounts on the same install do not share a device.
374
- userId.device_id = deriveClaudeDeviceId(getInstallId(), accountUuid);
375
- }
376
- }
377
- return { user_id: JSON.stringify(userId) };
378
- }
379
-
380
332
  const noOpUIContext: ExtensionUIContext = {
381
333
  select: async (_title, _options, _dialogOptions) => undefined,
382
334
  confirm: async (_title, _message, _dialogOptions) => false,
@@ -901,6 +853,7 @@ export class AgentSession {
901
853
  agent: this.agent,
902
854
  sessionManager: this.sessionManager,
903
855
  model: () => this.model,
856
+ configuredThinkingLevel: () => this.configuredThinkingLevel(),
904
857
  emitNotice: (level, message, source) => this.emitNotice(level, message, source),
905
858
  setModelTemporary: (model, thinkingLevel, options) => this.setModelTemporary(model, thinkingLevel, options),
906
859
  setActiveToolsByName: names => this.setActiveToolsByName(names),
@@ -2249,7 +2202,7 @@ export class AgentSession {
2249
2202
  // Deobfuscate assistant message content for display emission — the LLM echoes back
2250
2203
  // obfuscated placeholders, but listeners (TUI, extensions, exporters) must see real
2251
2204
  // values. The original event.message stays obfuscated so the persistence path below
2252
- // writes `#HASH#` tokens to the session file; convertToLlm re-obfuscates outbound
2205
+ // writes `$$HASH$$` tokens to the session file; convertToLlm re-obfuscates outbound
2253
2206
  // traffic on the next turn. Walks text, thinking, and toolCall arguments/intent.
2254
2207
  let displayEvent: AgentEvent = event;
2255
2208
  const obfuscator = this.#obfuscator;
@@ -2632,8 +2585,14 @@ export class AgentSession {
2632
2585
  return;
2633
2586
  }
2634
2587
 
2635
- if (this.#recovery.isRetryableReasonlessAbort(msg)) {
2636
- const didRetry = await this.#recovery.handleRetryableError(msg, { allowModelFallback: false });
2588
+ const resolvedInterruptedToolTurn = this.#recovery.classifyResolvedInterruptedToolTurn(msg);
2589
+ if (this.#recovery.isRetryableReasonlessAbort(msg) || resolvedInterruptedToolTurn === "reasonless-abort") {
2590
+ const didRetry = await this.#recovery.handleRetryableError(
2591
+ msg,
2592
+ resolvedInterruptedToolTurn === "reasonless-abort"
2593
+ ? { allowModelFallback: false, preserveFailedTurn: true }
2594
+ : { allowModelFallback: false },
2595
+ );
2637
2596
  if (didRetry) {
2638
2597
  await emitAgentEndNotification({ willContinue: true });
2639
2598
  return;
@@ -2659,7 +2618,7 @@ export class AgentSession {
2659
2618
  return;
2660
2619
  }
2661
2620
  }
2662
- const resumeResolvedStreamStall = this.#recovery.canResumeResolvedStreamStall(msg);
2621
+ const resumeResolvedStreamStall = resolvedInterruptedToolTurn === "stream-stall";
2663
2622
  if (resumeResolvedStreamStall || this.#recovery.isRetryableError(msg)) {
2664
2623
  const didRetry = await this.#recovery.handleRetryableError(
2665
2624
  msg,
@@ -3078,6 +3037,7 @@ export class AgentSession {
3078
3037
  session_id: this.sessionId,
3079
3038
  session_file: this.sessionFile,
3080
3039
  stop_hook_active: this.#sessionStopHookActive,
3040
+ signal: this.#postPromptTasksAbortController.signal,
3081
3041
  });
3082
3042
  if (this.#promptGeneration !== generation || this.#abortInProgress || this.#isDisposed) {
3083
3043
  this.#resetSessionStopContinuationState();
@@ -3336,6 +3296,12 @@ export class AgentSession {
3336
3296
  this.agent.setMetadataResolver((provider: string) =>
3337
3297
  buildSessionMetadata(sid, provider, this.#modelRegistry.authStorage),
3338
3298
  );
3299
+ // Keep every live advisor's provider identity in lockstep with the primary's
3300
+ // across every session-boundary transition — including branch paths that
3301
+ // skip conversation restore — so advisors never emit the previous
3302
+ // conversation's session id/metadata (issue #6625). Guarded because this
3303
+ // runs once during construction before the advisor controller exists.
3304
+ if (this.#advisors) this.#advisors.refreshProviderIdentity();
3339
3305
  }
3340
3306
 
3341
3307
  /** Run one abortable auto-learn capture outside the primary agent loop. */
@@ -4097,10 +4063,7 @@ export class AgentSession {
4097
4063
  * Transcript for TUI display. Full history is kept for export/resume-style
4098
4064
  * callers; live chat can collapse compacted history to keep the hot render
4099
4065
  * surface bounded. Display-only — NEVER feed the result to
4100
- * `agent.replaceMessages` or a provider. Because it is never re-obfuscated,
4101
- * it opts into legacy index-derived alias restoration so pre-keyed sessions
4102
- * still render their secrets; the agent-feeding paths
4103
- * (`buildDisplaySessionContext`) keep the keyed-only default.
4066
+ * `agent.replaceMessages` or a provider.
4104
4067
  */
4105
4068
  buildTranscriptSessionContext(
4106
4069
  options?: Pick<BuildSessionContextOptions, "collapseCompactedHistory" | "keepDanglingToolCalls">,
@@ -7373,6 +7336,14 @@ export class AgentSession {
7373
7336
  * (issue #5642).
7374
7337
  */
7375
7338
  reopenAsk?: { toolCallId: string; questions: AskToolInput["questions"] };
7339
+ /**
7340
+ * `true` when this call committed a new sibling answer for an `ask`
7341
+ * re-answer (`reanswerAskResult` was applied). The interactive caller
7342
+ * resumes the agent via {@link resumeAfterAskReanswer} *after* rebuilding
7343
+ * its transcript, so the resumed turn never renders against the stale
7344
+ * pre-rebuild UI (issue #6483).
7345
+ */
7346
+ askReanswerCommitted?: boolean;
7376
7347
  }> {
7377
7348
  await this.#bash.flushPending();
7378
7349
  const oldLeafId = this.sessionManager.getLeafId();
@@ -7523,6 +7494,10 @@ export class AgentSession {
7523
7494
  // Determine the new leaf position based on target type
7524
7495
  let newLeafId: string | null;
7525
7496
  let editorText: string | undefined;
7497
+ // Set when the second-pass `ask` re-answer branch below actually commits a
7498
+ // new sibling answer — the trigger for resuming the agent afterwards so the
7499
+ // model consumes it, mirroring a live `ask` completion (issue #6483).
7500
+ let isAskReanswerCompletion = false;
7526
7501
 
7527
7502
  if (targetEntry.type === "message" && targetEntry.message.role === "user") {
7528
7503
  // User message: leaf = parent (null if root), text goes to editor
@@ -7559,6 +7534,7 @@ export class AgentSession {
7559
7534
  timestamp: Date.now(),
7560
7535
  };
7561
7536
  newLeafId = this.sessionManager.appendMessageToBranch(toolResultMessage, targetEntry.parentId);
7537
+ isAskReanswerCompletion = true;
7562
7538
  } else {
7563
7539
  // Non-user message (or a user-invoked skill-prompt injection): land the
7564
7540
  // leaf on the selected node so it stays on the active branch. Skill
@@ -7604,6 +7580,14 @@ export class AgentSession {
7604
7580
 
7605
7581
  this.#branchSummaryAbortController = undefined;
7606
7582
 
7583
+ // Report a committed `ask` re-answer so the interactive caller can resume
7584
+ // the agent via `resumeAfterAskReanswer()` *after* rebuilding its
7585
+ // transcript. Scheduling the continue here instead would start a fresh
7586
+ // streaming turn whose `agent_start`/`turn_start` events could render
7587
+ // against the stale pre-rebuild UI and then be clobbered by the caller's
7588
+ // `renderInitialMessages(...)` (issue #6483). Plain leaf moves and the
7589
+ // read-only `reopenAsk` probe leave the flag unset.
7590
+
7607
7591
  // Emit session_tree event; only handlers can mutate session entries, so skip
7608
7592
  // the emit and the context rebuild when no handlers are registered (mirrors
7609
7593
  // the session_before_tree guard above).
@@ -7616,9 +7600,34 @@ export class AgentSession {
7616
7600
  fromExtension: summaryText ? fromExtension : undefined,
7617
7601
  });
7618
7602
  const rawContext = this.sessionManager.buildSessionContext();
7619
- return { editorText, cancelled: false, summaryEntry, sessionContext: rawContext };
7603
+ return {
7604
+ editorText,
7605
+ cancelled: false,
7606
+ summaryEntry,
7607
+ sessionContext: rawContext,
7608
+ askReanswerCommitted: isAskReanswerCompletion,
7609
+ };
7620
7610
  }
7621
- return { editorText, cancelled: false, summaryEntry, sessionContext: stateContext };
7611
+ return {
7612
+ editorText,
7613
+ cancelled: false,
7614
+ summaryEntry,
7615
+ sessionContext: stateContext,
7616
+ askReanswerCommitted: isAskReanswerCompletion,
7617
+ };
7618
+ }
7619
+
7620
+ /**
7621
+ * Resume the agent after the interactive `/tree` caller has committed an
7622
+ * `ask` re-answer (`navigateTree` returned `askReanswerCommitted`) and
7623
+ * rebuilt its transcript. Mirrors how a live `ask` completion drives a
7624
+ * follow-up turn, but is deferred to the caller so the resumed turn renders
7625
+ * against the rebuilt UI rather than the stale pre-navigation transcript
7626
+ * (issue #6483). The scheduled continue honors the same disposed/compacting
7627
+ * guards as every other post-prompt continuation.
7628
+ */
7629
+ resumeAfterAskReanswer(): void {
7630
+ this.#scheduleAgentContinue();
7622
7631
  }
7623
7632
 
7624
7633
  /**
@@ -1,7 +1,6 @@
1
1
  import type { Agent, AgentMessage, AgentToolResult, AgentTurnEndContext } from "@oh-my-pi/pi-agent-core";
2
2
  import { invalidateMessageCache } from "@oh-my-pi/pi-agent-core/compaction";
3
3
  import type { Model } from "@oh-my-pi/pi-ai";
4
- import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
5
4
  import { prompt } from "@oh-my-pi/pi-utils";
6
5
  import type { LocalProtocolOptions } from "../internal-urls";
7
6
  import { resolveApprovedPlan } from "../plan-mode/approved-plan";
@@ -11,7 +10,7 @@ import planYoloHandoffPrompt from "../prompts/system/plan-yolo-handoff.md" with
11
10
  import prewalkChecklistPrompt from "../prompts/system/prewalk-checklist.md" with { type: "text" };
12
11
  import prewalkContinuePrompt from "../prompts/system/prewalk-continue.md" with { type: "text" };
13
12
  import prewalkPlanPrompt from "../prompts/system/prewalk-plan.md" with { type: "text" };
14
- import type { ConfiguredThinkingLevel } from "../thinking";
13
+ import { type ConfiguredThinkingLevel, prewalkWouldBeNoop } from "../thinking";
15
14
  import type { PlanProposalHandler } from "../tools/resolve";
16
15
  import { ToolError } from "../tools/tool-errors";
17
16
  import type { PlanYolo, Prewalk } from "./agent-session-types";
@@ -31,6 +30,7 @@ export interface PrewalkCoordinatorHost {
31
30
  agent: Agent;
32
31
  sessionManager: SessionManager;
33
32
  model(): Model | undefined;
33
+ configuredThinkingLevel(): ConfiguredThinkingLevel | undefined;
34
34
  emitNotice(level: "info" | "warning" | "error", message: string, source?: string): void;
35
35
  setModelTemporary(
36
36
  model: Model,
@@ -126,8 +126,13 @@ export class PrewalkCoordinator {
126
126
  this.#scrubPlanNudge(liveMessages);
127
127
  const target = prewalk.target;
128
128
  const currentModel = this.#host.model();
129
- if (currentModel && modelsAreEqual(currentModel, target)) {
129
+ if (prewalkWouldBeNoop(currentModel, this.#host.configuredThinkingLevel(), target, prewalk.thinkingLevel)) {
130
130
  this.#prewalk = undefined;
131
+ this.#host.emitNotice(
132
+ "info",
133
+ `Prewalk: target ${target.provider}/${target.id} already matches the active model and thinking level; nothing to switch.`,
134
+ "prewalk",
135
+ );
131
136
  return;
132
137
  }
133
138
  await this.#host.setModelTemporary(target, prewalk.thinkingLevel, { ephemeral: true });
@@ -30,7 +30,7 @@ import type {
30
30
  ServiceTier,
31
31
  SimpleStreamOptions,
32
32
  } from "@oh-my-pi/pi-ai";
33
- import { isUsageLimitOutcome, resolveModelServiceTier } from "@oh-my-pi/pi-ai";
33
+ import { isUsageLimitOutcome, resolveModelServiceTier, streamSimple } from "@oh-my-pi/pi-ai";
34
34
  import * as AIError from "@oh-my-pi/pi-ai/error";
35
35
  import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
36
36
  import { extractHttpStatusFromError, extractRetryHint, logger } from "@oh-my-pi/pi-utils";
@@ -93,8 +93,10 @@ import { formatSessionDumpText } from "./session-dump-format";
93
93
  import type { CompactionEntry, SessionEntry } from "./session-entries";
94
94
  import { formatSessionHistoryMarkdown } from "./session-history-format";
95
95
  import type { SessionManager } from "./session-manager";
96
+ import { buildSessionMetadata } from "./session-metadata";
96
97
  import type { YieldQueue } from "./yield-queue";
97
98
 
99
+ const ADVISOR_CODEX_SSE_MAX_ATTEMPTS = 1;
98
100
  /** Advisor statistics for the advisor status command. */
99
101
  export interface AdvisorStats {
100
102
  configured: boolean;
@@ -328,6 +330,17 @@ export class SessionAdvisors {
328
330
  this.#resetAdvisorSessionState();
329
331
  }
330
332
 
333
+ /**
334
+ * Rebind every live advisor to the active primary conversation's provider
335
+ * identity (session id, prompt-cache key, credential + metadata resolvers,
336
+ * telemetry). Invoked on every provider-session change — including branch
337
+ * paths that skip conversation restore — so advisors never keep emitting the
338
+ * previous conversation's session id/metadata (issue #6625).
339
+ */
340
+ refreshProviderIdentity(): void {
341
+ for (const advisor of this.#advisors) this.#refreshAdvisorProviderIdentity(advisor);
342
+ }
343
+
331
344
  /** Re-primes advisor transcript views after an in-conversation history rewrite. */
332
345
  resetAllRuntimes(): void {
333
346
  this.#resetAllAdvisorRuntimes();
@@ -391,6 +404,38 @@ export class SessionAdvisors {
391
404
  this.#advisorInterruptImmuneTurnStart = this.#advisorPrimaryTurnsCompleted + 1;
392
405
  }
393
406
 
407
+ /** Rebind one advisor to the active primary conversation's provider identity. */
408
+ #refreshAdvisorProviderIdentity(advisor: ActiveAdvisor): void {
409
+ const primaryProviderSessionId = this.#host.sessionId();
410
+ const providerSessionId = getOrCreateAdvisorProviderSessionId(
411
+ this.#advisorProviderSessionIds,
412
+ primaryProviderSessionId,
413
+ advisor.slug,
414
+ );
415
+ advisor.providerSessionId = providerSessionId;
416
+ advisor.agent.sessionId = providerSessionId;
417
+ advisor.agent.promptCacheKey = this.#host.agent.promptCacheKey ?? providerSessionId;
418
+ advisor.agent.getApiKey = requestModel => this.#host.modelRegistry.resolver(requestModel, providerSessionId);
419
+ advisor.agent.setMetadataResolver(
420
+ providerSessionId
421
+ ? provider => buildSessionMetadata(providerSessionId, provider, this.#host.modelRegistry.authStorage)
422
+ : undefined,
423
+ );
424
+
425
+ const telemetry = advisor.agent.telemetry;
426
+ if (telemetry?.agent) {
427
+ advisor.agent.setTelemetry({
428
+ ...telemetry,
429
+ agent: {
430
+ ...telemetry.agent,
431
+ id: advisor.slug
432
+ ? `${primaryProviderSessionId}-advisor-${advisor.slug}`
433
+ : `${primaryProviderSessionId}-advisor`,
434
+ },
435
+ });
436
+ }
437
+ }
438
+
394
439
  /**
395
440
  * Re-prime the advisor across a conversation boundary: `/new`, `/branch`,
396
441
  * `/btw`, `/tree`, and session switch/resume. Beyond {@link AdvisorRuntime.reset}
@@ -644,6 +689,15 @@ export class SessionAdvisors {
644
689
  tools: advisorToolMap,
645
690
  allowNativeDelete: advisorCanMutateFiles,
646
691
  });
692
+ const baseAdvisorStreamFn = this.#advisorStreamFn ?? streamSimple;
693
+ const advisorStreamFn: StreamFn = (requestModel, context, options) =>
694
+ baseAdvisorStreamFn(
695
+ requestModel,
696
+ context,
697
+ requestModel.api === "openai-codex-responses"
698
+ ? { ...options, codexSseMaxAttempts: ADVISOR_CODEX_SSE_MAX_ATTEMPTS }
699
+ : options,
700
+ );
647
701
  const advisorAgent = new Agent({
648
702
  initialState: {
649
703
  systemPrompt,
@@ -659,7 +713,7 @@ export class SessionAdvisors {
659
713
  cwdResolver: () => this.#host.sessionManager.getCwd(),
660
714
  preferWebsockets: this.#host.preferWebsockets,
661
715
  getApiKey: requestModel => this.#host.modelRegistry.resolver(requestModel, advisorProviderSessionId),
662
- streamFn: this.#advisorStreamFn,
716
+ streamFn: advisorStreamFn,
663
717
  onPayload: this.#host.onPayload,
664
718
  onResponse: this.#host.onResponse,
665
719
  onSseEvent: this.#host.onSseEvent,
@@ -772,6 +826,7 @@ export class SessionAdvisors {
772
826
  retryFallbackPendingSuccess: false,
773
827
  signature,
774
828
  };
829
+ this.#refreshAdvisorProviderIdentity(advisorRef);
775
830
  this.#attachAdvisorRecorderFeed(advisorRef);
776
831
  if (seedToCurrent) runtime.seedTo(this.#host.agent.state.messages.length);
777
832
  this.#advisorStatuses.set(slug, { name: advisorName, status: "running" });
@@ -1247,7 +1302,15 @@ export class SessionAdvisors {
1247
1302
  for (const candidate of candidates) {
1248
1303
  const apiKey = await this.#host.modelRegistry.getApiKey(candidate, advisorProviderSessionId);
1249
1304
  if (!apiKey) continue;
1250
-
1305
+ // The advisor overflow-compaction one-shot bypasses the advisor `Agent`,
1306
+ // so its installed metadata resolver never runs. Emit the same
1307
+ // `metadata.user_id` identity here (resolved per candidate provider,
1308
+ // after the session-sticky credential is selected) so summarization
1309
+ // requests carry the advisor session id like every other advisor call
1310
+ // (issue #6625).
1311
+ const advisorMetadata = advisorProviderSessionId
1312
+ ? buildSessionMetadata(advisorProviderSessionId, candidate.provider, this.#host.modelRegistry.authStorage)
1313
+ : undefined;
1251
1314
  try {
1252
1315
  compactResult = await compact(
1253
1316
  preparation,
@@ -1262,6 +1325,7 @@ export class SessionAdvisors {
1262
1325
  tools: agent.state.tools,
1263
1326
  sessionId: advisorProviderSessionId,
1264
1327
  promptCacheKey: advisorProviderSessionId,
1328
+ metadata: advisorMetadata,
1265
1329
  providerSessionState: this.#host.providerSessionState,
1266
1330
  codexCompaction,
1267
1331
  },
@@ -0,0 +1,53 @@
1
+ import { deriveClaudeDeviceId } from "@oh-my-pi/pi-ai";
2
+ import { getInstallId } from "@oh-my-pi/pi-utils";
3
+ import type { AuthStorage } from "./auth-storage";
4
+
5
+ /**
6
+ * Build the per-request `metadata` payload for the Anthropic provider, shaped
7
+ * like real Claude Code's `getAPIMetadata` output (`{ session_id, account_uuid,
8
+ * device_id }`) so the backend buckets requests under one session and attributes
9
+ * them to the authenticated OAuth account when available. Resolved at request
10
+ * time so token refreshes and login/logout transitions don't strand a stale
11
+ * account UUID in memory. `account_uuid` and `device_id` are omitted for
12
+ * non-Anthropic providers to avoid leaking the user's Claude identity to
13
+ * third-party APIs (including Anthropic-format-compatible proxies such as
14
+ * cloudflare-ai-gateway or gitlab-duo).
15
+ *
16
+ * Installed via `Agent#setMetadataResolver` on the main `AgentSession`, each
17
+ * subagent session, and the separately constructed advisor `Agent` — each with
18
+ * its own provider session id — so Main, subagent, and Advisor requests each
19
+ * expose a distinct, stable provider-facing session identity.
20
+ *
21
+ * `provider` is the target provider string (e.g. `"anthropic"`) and gates the
22
+ * `account_uuid` and `device_id` lookups — only `"anthropic"` requests carry them.
23
+ *
24
+ * `sessionId` is forwarded to the auth-storage session-sticky lookup so that
25
+ * multi-credential setups attribute to the same OAuth account used for the
26
+ * actual API request rather than always picking the first credential.
27
+ *
28
+ * `authStorage` is treated as optional so test fixtures that stub `modelRegistry`
29
+ * without a real storage layer still work; the resolver simply skips the lookup
30
+ * and emits `{ session_id }` alone, matching the no-OAuth-credential path.
31
+ */
32
+ export function buildSessionMetadata(
33
+ sessionId: string,
34
+ provider: string,
35
+ authStorage: AuthStorage | undefined,
36
+ ): Record<string, unknown> {
37
+ const userId: Record<string, string> = { session_id: sessionId };
38
+ // Only look up account_uuid when the request is going to Anthropic. Injecting
39
+ // a Claude OAuth account_uuid into requests bound for other providers (including
40
+ // Anthropic-format-compatible proxies like cloudflare-ai-gateway or gitlab-duo)
41
+ // would leak the user's Anthropic identity to unrelated third-party APIs.
42
+ if (provider === "anthropic") {
43
+ const accountUuid = authStorage?.getOAuthAccountId("anthropic", sessionId);
44
+ if (typeof accountUuid === "string" && accountUuid.length > 0) {
45
+ userId.account_uuid = accountUuid;
46
+ // Claude Code's `device_id` is a stable 64-hex account-scoped install
47
+ // identifier. Include both omp's persistent install id and the Claude
48
+ // account UUID so two accounts on the same install do not share a device.
49
+ userId.device_id = deriveClaudeDeviceId(getInstallId(), accountUuid);
50
+ }
51
+ }
52
+ return { user_id: JSON.stringify(userId) };
53
+ }
@@ -84,7 +84,6 @@ export class SessionProviderBoundary {
84
84
  keepDanglingToolCalls: options?.keepDanglingToolCalls,
85
85
  }),
86
86
  this.#host.obfuscator,
87
- true,
88
87
  );
89
88
  }
90
89
 
@@ -17,6 +17,7 @@ import type { MemoryBackendStartOptions } from "../memory-backend/types";
17
17
  import xdevMountNoticePrompt from "../prompts/system/xdev-mount-notice.md" with { type: "text" };
18
18
  import { usesCodexTaskPrompt } from "../task/prompt-policy";
19
19
  import { isMCPToolName, normalizeToolNames } from "../tools/builtin-names";
20
+ import { computerExposureMode } from "../tools/computer/exposure";
20
21
  import { wrapToolWithMetaNotice } from "../tools/output-meta";
21
22
  import { ToolAbortError, ToolError } from "../tools/tool-errors";
22
23
  import { isMountableUnderXdev, type XdevRegistry } from "../tools/xdev";
@@ -294,6 +295,16 @@ export class SessionTools {
294
295
  return usesCodexTaskPrompt(model) ? "task-policy:gpt-5.6" : "task-policy:default";
295
296
  }
296
297
 
298
+ #logComputerState(message: string, enabled: boolean): void {
299
+ const model = this.#host.model();
300
+ logger.debug(message, {
301
+ enabled,
302
+ active: this.getEnabledToolNames().includes("computer"),
303
+ model: model ? formatModelString(model) : undefined,
304
+ exposure: computerExposureMode(model),
305
+ });
306
+ }
307
+
297
308
  /** Rebuilds model-dependent tool prompts after a model change. */
298
309
  async syncAfterModelChange(previousEditMode: EditMode): Promise<void> {
299
310
  const currentEditMode = this.resolveActiveEditMode();
@@ -303,6 +314,20 @@ export class SessionTools {
303
314
  if (editModeChanged || modelChanged) {
304
315
  await this.refreshBaseSystemPrompt();
305
316
  }
317
+ const computerExpected = this.#host.settings.get("computer.enabled");
318
+ const computerActive = this.getEnabledToolNames().includes("computer");
319
+ if (computerExpected && !computerActive) {
320
+ const model = this.#host.model();
321
+ const modelName = model ? formatModelString(model) : "the current model";
322
+ logger.warn("Enabled computer tool missing after model change", { model: modelName });
323
+ this.#host.emitNotice(
324
+ "warning",
325
+ `Computer use remains enabled, but the computer tool is unavailable to ${modelName}.`,
326
+ "computer",
327
+ );
328
+ } else if (computerExpected) {
329
+ this.#logComputerState("Computer tool retained after model change", true);
330
+ }
306
331
  }
307
332
 
308
333
  /** Enabled MCP tools in their current presentation partition. */
@@ -713,16 +738,24 @@ export class SessionTools {
713
738
  * tool (e.g. restricted child sessions have no factory).
714
739
  */
715
740
  async setComputerToolEnabled(enabled: boolean): Promise<boolean> {
741
+ const logState = (): void => this.#logComputerState("Computer tool state changed", enabled);
716
742
  const active = this.getEnabledToolNames();
717
743
  if (!enabled) {
718
744
  if (active.includes("computer")) {
719
745
  await this.applyActiveToolsByName(active.filter(name => name !== "computer"));
720
746
  }
747
+ logState();
721
748
  return true;
722
749
  }
723
750
  if (!this.#toolRegistry.has("computer")) {
724
751
  const tool = await this.#createComputerTool?.();
725
- if (tool?.name !== "computer") return false;
752
+ if (tool?.name !== "computer") {
753
+ const model = this.#host.model();
754
+ logger.warn("Computer tool could not be created", {
755
+ model: model ? formatModelString(model) : undefined,
756
+ });
757
+ return false;
758
+ }
726
759
  const wrapped = this.#wrapRuntimeTool(tool);
727
760
  this.#toolRegistry.set(wrapped.name, wrapped);
728
761
  this.#builtInToolNames.add(wrapped.name);
@@ -730,6 +763,7 @@ export class SessionTools {
730
763
  if (!active.includes("computer")) {
731
764
  await this.applyActiveToolsByName([...active, "computer"]);
732
765
  }
766
+ logState();
733
767
  return true;
734
768
  }
735
769
 
@@ -185,7 +185,7 @@ export class StreamingEditGuard {
185
185
  #abortForAutoGeneratedPath(toolCall: ToolCall, filePath: string, resolvedPath: string): void {
186
186
  if (this.#lastToolCallId === toolCall.id) return;
187
187
  this.#lastToolCallId = toolCall.id;
188
- void assertEditableFile(resolvedPath, filePath).catch(error => {
188
+ void assertEditableFile(resolvedPath, filePath, this.#host.settings).catch(error => {
189
189
  if (!(error instanceof ToolError) || this.#lastToolCallId !== toolCall.id) return;
190
190
  if (!this.#abortTriggered) {
191
191
  this.#abortTriggered = true;
@@ -774,10 +774,6 @@ export class TurnRecovery {
774
774
  return id;
775
775
  }
776
776
 
777
- #isGenericAbortSentinel(message: AssistantMessage): boolean {
778
- return message.errorMessage === "Request was aborted" || message.errorMessage === "Request was aborted.";
779
- }
780
-
781
777
  /**
782
778
  * Retry an empty, reason-less provider abort: a turn with no content that
783
779
  * carries the generic sentinel (bare `abort()`), whether the provider
@@ -806,7 +802,9 @@ export class TurnRecovery {
806
802
 
807
803
  const id = this.#classifyRetryMessage(message);
808
804
  if (message.stopReason === "aborted" && AIError.is(id, AIError.Flag.Abort)) return true;
809
- if (!this.#isGenericAbortSentinel(message)) return false;
805
+ if (message.errorMessage !== "Request was aborted" && message.errorMessage !== "Request was aborted.") {
806
+ return false;
807
+ }
810
808
 
811
809
  message.errorId = AIError.create(AIError.Flag.Abort);
812
810
  return true;
@@ -830,30 +828,48 @@ export class TurnRecovery {
830
828
  }
831
829
 
832
830
  /**
833
- * Resume a stalled turn after every emitted tool call has produced a result.
834
- * Cursor calls must also carry the server-execution marker. The failed
835
- * assistant/tool-result pair stays in context so completed side effects are
836
- * continued from rather than replayed.
831
+ * Classify a reasonless abort or stream stall whose emitted tool calls all
832
+ * have results. The failed assistant/tool-result pair stays in context so
833
+ * continuation cannot replay completed side effects; synthetic results tell
834
+ * the next turn that an unexecuted call must be reissued.
837
835
  */
838
- canResumeResolvedStreamStall(message: AssistantMessage): boolean {
839
- if (message.stopReason !== "error" || !message.errorMessage?.toLowerCase().includes("stream stall")) {
840
- return false;
841
- }
836
+ classifyResolvedInterruptedToolTurn(message: AssistantMessage): "reasonless-abort" | "stream-stall" | undefined {
842
837
  const id = this.#classifyRetryMessage(message);
843
- if (!AIError.retriable(id)) return false;
844
-
838
+ const genericAbort =
839
+ message.errorMessage === "Request was aborted" || message.errorMessage === "Request was aborted.";
840
+ const reasonlessAbort =
841
+ (message.stopReason === "aborted" || message.stopReason === "error") &&
842
+ !this.#host.abortInProgress() &&
843
+ !this.#host.isDisposed() &&
844
+ !this.#host.streamingEditAbortTriggered() &&
845
+ ((message.stopReason === "aborted" && AIError.is(id, AIError.Flag.Abort)) || genericAbort);
846
+ const streamStall =
847
+ message.stopReason === "error" &&
848
+ message.errorMessage?.toLowerCase().includes("stream stall") === true &&
849
+ AIError.retriable(id);
850
+ if (!reasonlessAbort && !streamStall) return undefined;
851
+ if (reasonlessAbort && genericAbort) message.errorId = AIError.create(AIError.Flag.Abort);
852
+
853
+ // The Cursor server-execution marker gate applies only to the stream-stall
854
+ // path: an unmarked/unresolved Cursor block there means the server has not
855
+ // finished executing, so resuming would race it. A reasonless abort instead
856
+ // ends the turn and the agent loop pairs every un-run call (Cursor's unmarked
857
+ // `todo`/MCP blocks included) with a synthetic `executed: false` result, so
858
+ // the tool-result reconciliation below is the safety gate and the marker is
859
+ // irrelevant.
845
860
  const resolvedToolCallIds: string[] = [];
846
861
  for (const block of message.content) {
847
862
  if (block.type !== "toolCall") continue;
848
863
  if (
864
+ streamStall &&
849
865
  message.provider === "cursor" &&
850
866
  (!(kCursorExecResolved in block) || block[kCursorExecResolved] !== true)
851
867
  ) {
852
- return false;
868
+ return undefined;
853
869
  }
854
870
  resolvedToolCallIds.push(block.id);
855
871
  }
856
- if (resolvedToolCallIds.length === 0) return false;
872
+ if (resolvedToolCallIds.length === 0) return undefined;
857
873
 
858
874
  const messages = this.#host.agent.state.messages;
859
875
  let assistantIndex = -1;
@@ -864,14 +880,15 @@ export class TurnRecovery {
864
880
  break;
865
881
  }
866
882
  }
867
- if (assistantIndex < 0) return false;
883
+ if (assistantIndex < 0) return undefined;
868
884
 
869
885
  const unresolvedToolCallIds = new Set(resolvedToolCallIds);
870
886
  for (let i = assistantIndex + 1; i < messages.length; i++) {
871
887
  const candidate = messages[i];
872
888
  if (candidate.role === "toolResult") unresolvedToolCallIds.delete(candidate.toolCallId);
873
889
  }
874
- return unresolvedToolCallIds.size === 0;
890
+ if (unresolvedToolCallIds.size > 0) return undefined;
891
+ return reasonlessAbort ? "reasonless-abort" : "stream-stall";
875
892
  }
876
893
  /**
877
894
  * Retried turns remove the failed assistant message from active context.