@oh-my-pi/pi-coding-agent 17.1.3 → 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 (100) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/cli.js +3759 -3751
  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/bash.d.ts +5 -4
  30. package/dist/types/tools/computer/exposure.d.ts +8 -0
  31. package/dist/types/tools/computer/supervisor.d.ts +1 -1
  32. package/dist/types/tools/computer/worker-entry.d.ts +1 -1
  33. package/dist/types/tools/computer/worker.d.ts +1 -1
  34. package/dist/types/tools/computer.d.ts +6 -0
  35. package/dist/types/tools/shell-tokenize.d.ts +14 -0
  36. package/dist/types/tools/todo.d.ts +7 -1
  37. package/package.json +12 -12
  38. package/src/advisor/__tests__/advisor.test.ts +80 -10
  39. package/src/advisor/runtime.ts +27 -1
  40. package/src/cli/__tests__/auth-gateway-catalog.test.ts +111 -0
  41. package/src/cli/auth-gateway-cli.ts +63 -16
  42. package/src/cli/config-cli.ts +25 -7
  43. package/src/cli/update-cli.ts +229 -29
  44. package/src/cli/usage-cli.ts +144 -15
  45. package/src/cli.ts +21 -15
  46. package/src/commands/update.ts +6 -0
  47. package/src/config/__tests__/model-registry.test.ts +42 -7
  48. package/src/config/model-registry.ts +67 -4
  49. package/src/config/settings-schema.ts +39 -8
  50. package/src/config/settings.ts +24 -2
  51. package/src/cursor.ts +153 -0
  52. package/src/dap/session.ts +70 -11
  53. package/src/eval/__tests__/js-context-manager.test.ts +9 -1
  54. package/src/eval/__tests__/process-entry-import.test.ts +111 -1
  55. package/src/eval/js/process-entry.ts +9 -5
  56. package/src/eval/js/worker-core.ts +6 -2
  57. package/src/exec/bash-executor.ts +4 -2
  58. package/src/extensibility/extensions/runner.ts +23 -8
  59. package/src/extensibility/legacy-pi-ai-shim.ts +9 -0
  60. package/src/extensibility/legacy-pi-coding-agent-shim.ts +14 -2
  61. package/src/extensibility/legacy-pi-tui-shim.ts +33 -0
  62. package/src/extensibility/shared-events.ts +2 -0
  63. package/src/main.ts +22 -1
  64. package/src/modes/acp/acp-agent.ts +6 -0
  65. package/src/modes/components/assistant-message.ts +88 -11
  66. package/src/modes/components/chat-transcript-builder.ts +2 -0
  67. package/src/modes/components/custom-editor.test.ts +170 -0
  68. package/src/modes/components/custom-editor.ts +79 -29
  69. package/src/modes/components/settings-defs.ts +5 -1
  70. package/src/modes/controllers/event-controller.ts +96 -4
  71. package/src/modes/controllers/selector-controller.ts +14 -0
  72. package/src/modes/interactive-mode.ts +34 -8
  73. package/src/modes/utils/context-usage.ts +19 -2
  74. package/src/modes/utils/interactive-context-helpers.ts +1 -0
  75. package/src/plan-mode/approved-plan.ts +18 -10
  76. package/src/prompts/system/custom-system-prompt.md +1 -1
  77. package/src/prompts/system/system-prompt.md +10 -1
  78. package/src/sdk.ts +4 -0
  79. package/src/secrets/obfuscator.ts +36 -126
  80. package/src/session/agent-session.ts +69 -60
  81. package/src/session/prewalk.ts +8 -3
  82. package/src/session/session-advisors.ts +67 -3
  83. package/src/session/session-metadata.ts +53 -0
  84. package/src/session/session-provider-boundary.ts +0 -1
  85. package/src/session/session-tools.ts +35 -1
  86. package/src/session/turn-recovery.ts +36 -19
  87. package/src/slash-commands/builtin-registry.ts +49 -7
  88. package/src/stt/asr-worker.ts +2 -37
  89. package/src/stt/sherpa-runtime.ts +71 -0
  90. package/src/task/executor.ts +6 -5
  91. package/src/thinking.ts +39 -0
  92. package/src/tools/bash.ts +43 -15
  93. package/src/tools/computer/exposure.ts +38 -0
  94. package/src/tools/computer/supervisor.ts +61 -13
  95. package/src/tools/computer/worker-entry.ts +28 -19
  96. package/src/tools/computer/worker.ts +3 -7
  97. package/src/tools/computer.ts +65 -10
  98. package/src/tools/gh-cache-invalidation.ts +2 -82
  99. package/src/tools/shell-tokenize.ts +83 -0
  100. package/src/tools/todo.ts +44 -17
@@ -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
 
@@ -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.
@@ -7,7 +7,12 @@ import { APP_NAME, getProjectDir, setProjectDir } from "@oh-my-pi/pi-utils";
7
7
  import { reset as resetCapabilities } from "../capability";
8
8
  import { COLLAB_GUEST_ALLOWED_COMMANDS, CollabGuestLink } from "../collab/guest";
9
9
  import { CollabHost } from "../collab/host";
10
- import { expandRoleAlias, getModelMatchPreferences, resolveCliModel } from "../config/model-resolver";
10
+ import {
11
+ expandRoleAlias,
12
+ formatModelString,
13
+ getModelMatchPreferences,
14
+ resolveCliModel,
15
+ } from "../config/model-resolver";
11
16
  import { applyProviderGlobalsFromSettings } from "../config/provider-globals";
12
17
  import type { SettingPath, SettingValue } from "../config/settings";
13
18
  import { settings } from "../config/settings";
@@ -37,6 +42,8 @@ import type { SessionOAuthAccountList } from "../session/agent-session-types";
37
42
  import { COMPACT_MODES, parseCompactArgs } from "../session/compact-modes";
38
43
  import { resolveResumableSession } from "../session/session-listing";
39
44
  import { formatShakeSummary, type ShakeMode } from "../session/shake-types";
45
+ import type { ComputerTool } from "../tools/computer";
46
+ import { computerExposureMode } from "../tools/computer/exposure";
40
47
  import { expandTilde, resolveToCwd } from "../tools/path-utils";
41
48
  import { urlHyperlinkAlways } from "../tui";
42
49
  import {
@@ -90,9 +97,41 @@ function formatFastModeStatus(session: AgentSession): string {
90
97
  return session.isFastModeEnabled() ? "on" : "off";
91
98
  }
92
99
 
93
- /** `/computer status` label for the session-effective `computer.enabled` value. */
100
+ /** Detailed, session-effective `/computer status` diagnostics. */
94
101
  function formatComputerUseStatus(session: AgentSession): string {
95
- return session.settings.get("computer.enabled") ? "on" : "off";
102
+ const enabled = session.settings.get("computer.enabled");
103
+ const active = session.getEnabledToolNames().includes("computer");
104
+ const model = session.model;
105
+ const modelName = model ? formatModelString(model) : "none";
106
+ const exposure = !enabled || !active ? "not exposed" : computerExposureMode(model);
107
+ const toolState = active ? "active" : enabled ? "unavailable" : "inactive";
108
+ const configured = {
109
+ backend: session.settings.get("computer.backend"),
110
+ display: session.settings.get("computer.display"),
111
+ maxWidth: session.settings.get("computer.maxWidth"),
112
+ maxHeight: session.settings.get("computer.maxHeight"),
113
+ };
114
+ const computerTool = session.getToolByName("computer") as Pick<ComputerTool, "effectiveConfiguration"> | undefined;
115
+ const effective = computerTool?.effectiveConfiguration ?? configured;
116
+ const configurationChanged =
117
+ effective.backend !== configured.backend ||
118
+ effective.display !== configured.display ||
119
+ effective.maxWidth !== configured.maxWidth ||
120
+ effective.maxHeight !== configured.maxHeight;
121
+ return [
122
+ `Computer use: ${enabled ? "enabled" : "disabled"}`,
123
+ `tool: ${toolState}`,
124
+ `backend: ${effective.backend}`,
125
+ `display: ${effective.display}`,
126
+ `capture: ${effective.maxWidth}×${effective.maxHeight}`,
127
+ ...(configurationChanged
128
+ ? [
129
+ `next-session settings: backend=${configured.backend}, display=${configured.display}, capture=${configured.maxWidth}×${configured.maxHeight}`,
130
+ ]
131
+ : []),
132
+ `model: ${modelName}`,
133
+ `exposure: ${exposure}`,
134
+ ].join(" · ");
96
135
  }
97
136
 
98
137
  /**
@@ -107,7 +146,9 @@ async function applyComputerUseToggle(session: AgentSession, enable: boolean): P
107
146
  return "Computer use is unavailable in this session.";
108
147
  }
109
148
  session.settings.override("computer.enabled", enable);
110
- return `Computer use ${enable ? "enabled" : "disabled"} for this session.`;
149
+ return enable
150
+ ? `Computer use enabled for this session. ${formatComputerUseStatus(session)}`
151
+ : "Computer use disabled for this session.";
111
152
  }
112
153
 
113
154
  const AUTOCOMPLETE_DETAIL_LIMIT = 48;
@@ -573,11 +614,12 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
573
614
  { name: "status", description: "Show computer use status" },
574
615
  ],
575
616
  allowArgs: true,
576
- getTuiAutocompleteDescription: runtime => `Computer: ${formatComputerUseStatus(runtime.ctx.session)}`,
617
+ getTuiAutocompleteDescription: runtime =>
618
+ `Computer: ${runtime.ctx.session.settings.get("computer.enabled") ? "on" : "off"}`,
577
619
  handle: async (command, runtime) => {
578
620
  const arg = command.args.trim().toLowerCase();
579
621
  if (arg === "status") {
580
- await runtime.output(`Computer use is ${formatComputerUseStatus(runtime.session)}.`);
622
+ await runtime.output(formatComputerUseStatus(runtime.session));
581
623
  return commandConsumed();
582
624
  }
583
625
  if (!arg || arg === "toggle" || arg === "on" || arg === "off") {
@@ -590,7 +632,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
590
632
  handleTui: async (command, runtime) => {
591
633
  const arg = command.args.trim().toLowerCase();
592
634
  if (arg === "status") {
593
- runtime.ctx.showStatus(`Computer use is ${formatComputerUseStatus(runtime.ctx.session)}.`);
635
+ runtime.ctx.showStatus(formatComputerUseStatus(runtime.ctx.session));
594
636
  runtime.ctx.editor.setText("");
595
637
  return;
596
638
  }
@@ -35,6 +35,7 @@ import {
35
35
  type SttModelKey,
36
36
  type TransformersSttModelSpec,
37
37
  } from "./models";
38
+ import { loadSourceSherpaRuntime, type SherpaOfflineRecognizer, type SherpaRuntime } from "./sherpa-runtime";
38
39
 
39
40
  const ASR_TASK = "automatic-speech-recognition";
40
41
  const SHERPA_PACKAGE = "sherpa-onnx-node";
@@ -50,7 +51,6 @@ const HF_RESOLVE_BASE = "https://huggingface.co";
50
51
  // Coalesce download progress so streaming a multi-hundred-MB model file doesn't
51
52
  // flood the IPC channel with one event per chunk.
52
53
  const PROGRESS_EMIT_BYTES = 4_000_000;
53
- const sourceRequire = createRequire(import.meta.url);
54
54
 
55
55
  const sttModelDevicePreference = resolveTinyModelDevicePreference();
56
56
  const sttModelDtypeOverride = resolveTinyModelDtypeOverride();
@@ -90,41 +90,6 @@ interface TransformersRuntime {
90
90
  ) => Promise<AutomaticSpeechRecognitionPipeline>;
91
91
  }
92
92
 
93
- /** Recognition result returned by `sherpa-onnx-node`'s offline recognizer. */
94
- interface SherpaOfflineResult {
95
- text?: string;
96
- }
97
-
98
- /** A sherpa-onnx offline stream that accepts a single waveform before decoding. */
99
- interface SherpaOfflineStream {
100
- acceptWaveform(audio: { samples: Float32Array; sampleRate: number }): void;
101
- }
102
-
103
- interface SherpaOfflineRecognizer {
104
- createStream(): SherpaOfflineStream;
105
- decodeAsync(stream: SherpaOfflineStream): Promise<SherpaOfflineResult>;
106
- }
107
-
108
- /** Offline recognizer config passed to `sherpa-onnx-node` (transducer family). */
109
- interface SherpaOfflineConfig {
110
- modelConfig: {
111
- transducer: { encoder: string; decoder: string; joiner: string };
112
- tokens: string;
113
- modelType: string;
114
- numThreads: number;
115
- provider: string;
116
- debug: number;
117
- };
118
- decodingMethod: string;
119
- }
120
-
121
- /** Subset of the native `sherpa-onnx-node` module surface we use. */
122
- interface SherpaRuntime {
123
- OfflineRecognizer: {
124
- createAsync(config: SherpaOfflineConfig): Promise<SherpaOfflineRecognizer>;
125
- };
126
- }
127
-
128
93
  /** A warm model plus the engine that loaded it; cached per tier key. */
129
94
  type LoadedModel =
130
95
  | { engine: "transformers"; pipeline: AutomaticSpeechRecognitionPipeline }
@@ -182,7 +147,7 @@ function getSherpaRuntimeDir(): string {
182
147
  */
183
148
  function loadSherpaRuntime(transport: SttTransport, requestId: string, modelKey: SttModelKey): Promise<SherpaRuntime> {
184
149
  return sherpaRuntime.load(async () => {
185
- if (!isCompiledBinary()) return sourceRequire(SHERPA_PACKAGE) as SherpaRuntime;
150
+ if (!isCompiledBinary()) return loadSourceSherpaRuntime(import.meta.url);
186
151
  const runtimeDir = await ensureRuntimeInstalled({
187
152
  runtimeDir: getSherpaRuntimeDir(),
188
153
  install: { dependencies: { [SHERPA_PACKAGE]: getSherpaVersionSpec() } },
@@ -0,0 +1,71 @@
1
+ import { createRequire } from "node:module";
2
+ import * as os from "node:os";
3
+ import { resolveRuntimeModule } from "@oh-my-pi/pi-utils";
4
+
5
+ const SHERPA_PACKAGE = "sherpa-onnx-node";
6
+
7
+ interface SherpaOfflineResult {
8
+ text?: string;
9
+ }
10
+
11
+ interface SherpaOfflineStream {
12
+ acceptWaveform(audio: { samples: Float32Array; sampleRate: number }): void;
13
+ }
14
+
15
+ interface SherpaOfflineConfig {
16
+ modelConfig: {
17
+ transducer: { encoder: string; decoder: string; joiner: string };
18
+ tokens: string;
19
+ modelType: string;
20
+ numThreads: number;
21
+ provider: string;
22
+ debug: number;
23
+ };
24
+ decodingMethod: string;
25
+ }
26
+
27
+ /** A sherpa-onnx recognizer instance used by the STT worker. */
28
+ export interface SherpaOfflineRecognizer {
29
+ createStream(): SherpaOfflineStream;
30
+ decodeAsync(stream: SherpaOfflineStream): Promise<SherpaOfflineResult>;
31
+ }
32
+
33
+ /** The native sherpa-onnx module surface used by the STT worker. */
34
+ export interface SherpaRuntime {
35
+ OfflineRecognizer: {
36
+ createAsync(config: SherpaOfflineConfig): Promise<SherpaOfflineRecognizer>;
37
+ };
38
+ }
39
+
40
+ /** Loads the nearest working source-workspace sherpa wrapper, including hoisted fallbacks. */
41
+ export function loadSourceSherpaRuntime(sourceUrl: string): SherpaRuntime {
42
+ const sourceRequire = createRequire(sourceUrl);
43
+ const nearestEntry = sourceRequire.resolve(SHERPA_PACKAGE);
44
+ try {
45
+ return createRequire(nearestEntry)(nearestEntry);
46
+ } catch (error) {
47
+ if (!(error instanceof Error && error.message.startsWith("Could not find sherpa-onnx-node. Tried"))) {
48
+ throw error;
49
+ }
50
+ const platform = os.platform();
51
+ const platformPackage = `sherpa-onnx-${platform === "win32" ? "win" : platform}-${os.arch()}`;
52
+ for (const nodeModules of sourceRequire.resolve.paths(SHERPA_PACKAGE) ?? []) {
53
+ if (!resolveRuntimeModule(nodeModules, platformPackage)) continue;
54
+ const entry = resolveRuntimeModule(nodeModules, SHERPA_PACKAGE);
55
+ if (!entry || entry === nearestEntry) continue;
56
+ try {
57
+ return createRequire(entry)(entry);
58
+ } catch (candidateError) {
59
+ if (
60
+ !(
61
+ candidateError instanceof Error &&
62
+ candidateError.message.startsWith("Could not find sherpa-onnx-node. Tried")
63
+ )
64
+ ) {
65
+ throw candidateError;
66
+ }
67
+ }
68
+ }
69
+ throw error;
70
+ }
71
+ }
@@ -46,7 +46,7 @@ import type { AuthStorage } from "../session/auth-storage";
46
46
  import { SKILL_PROMPT_MESSAGE_TYPE, USER_INTERRUPT_LABEL } from "../session/messages";
47
47
  import { SessionManager } from "../session/session-manager";
48
48
  import { truncateTail } from "../session/streaming-output";
49
- import { type ConfiguredThinkingLevel, resolveTaskEffortLevel, type TaskEffort } from "../thinking";
49
+ import { type ConfiguredThinkingLevel, prewalkWouldBeNoop, resolveTaskEffortLevel, type TaskEffort } from "../thinking";
50
50
  import type { ContextFileEntry, ToolSession } from "../tools";
51
51
  import { resolveEvalBackends } from "../tools/eval-backends";
52
52
  import { isIrcEnabled } from "../tools/hub";
@@ -2683,10 +2683,11 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2683
2683
  pattern: prewalkPattern,
2684
2684
  warning: resolvedPrewalk.warning,
2685
2685
  });
2686
- } else if (model && target.provider === model.provider && target.id === model.id) {
2687
- // Switching to the starting model is a no-op that would still inject
2688
- // the plan/checklist nudges — skip.
2689
- logger.debug("Subagent prewalk target equals starting model; skipping prewalk", {
2686
+ } else if (prewalkWouldBeNoop(model, effectiveThinkingLevel, target, resolvedPrewalk.thinkingLevel)) {
2687
+ // Same model AND same effective thinking level: switching would only
2688
+ // inject the plan/checklist nudges for no gain — skip. An effort-only
2689
+ // delta on the same model still arms (it is a real cheapening hand-off).
2690
+ logger.debug("Subagent prewalk target matches starting model and thinking level; skipping prewalk", {
2690
2691
  agent: agent.name,
2691
2692
  pattern: prewalkPattern,
2692
2693
  });