@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
@@ -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
  });
package/src/thinking.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type ResolvedThinkingLevel, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
2
  import { Effort, type Model, THINKING_EFFORTS } from "@oh-my-pi/pi-ai";
3
3
  import { clampThinkingLevelForModel, getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
4
+ import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
4
5
 
5
6
  /**
6
7
  * Metadata used to render thinking selector values in the coding-agent UI.
@@ -142,6 +143,44 @@ export function concreteThinkingLevel(level: ConfiguredThinkingLevel | undefined
142
143
  return level === AUTO_THINKING ? undefined : level;
143
144
  }
144
145
 
146
+ /**
147
+ * True when a prewalk hand-off from `current`/`currentLevel` to
148
+ * `target`/`targetLevel` would change nothing observable: same model id, same
149
+ * auto/fixed mode, and the same model-clamped effective effort. Prewalk arms and
150
+ * switches only when this is false.
151
+ *
152
+ * An effort-only delta on the same model id is a legitimate cheapening hand-off
153
+ * — on a reasoning model the effort is the bulk of the cost — so it is NOT a
154
+ * no-op and must still switch. A `targetLevel` of `undefined` means the prewalk
155
+ * pattern carried no explicit `:level` suffix (no effort change requested),
156
+ * which on the same model is a no-op.
157
+ *
158
+ * `auto` mode is compared before efforts: `auto` and a fixed selector that both
159
+ * resolve to `undefined` effort (e.g. `:inherit`) are NOT interchangeable —
160
+ * applying the fixed selector clears per-turn classification, so switching
161
+ * auto↔fixed is always a real change even when the clamped efforts match.
162
+ *
163
+ * Efforts are otherwise compared AFTER model clamping, so a target the model
164
+ * cannot honor (e.g. `:xhigh` on a model capped at `high`) — which
165
+ * `setThinkingLevel` would clamp straight back to the active effort — is
166
+ * recognized as a no-op instead of triggering an ephemeral reset and the
167
+ * plan/checklist nudges for nothing.
168
+ */
169
+ export function prewalkWouldBeNoop(
170
+ current: Model | undefined,
171
+ currentLevel: ConfiguredThinkingLevel | undefined,
172
+ target: Model,
173
+ targetLevel: ConfiguredThinkingLevel | undefined,
174
+ ): boolean {
175
+ if (!modelsAreEqual(current, target)) return false;
176
+ if (targetLevel === undefined) return true;
177
+ if ((targetLevel === AUTO_THINKING) !== (currentLevel === AUTO_THINKING)) return false;
178
+ return (
179
+ resolveThinkingLevelForModel(target, concreteThinkingLevel(targetLevel)) ===
180
+ resolveThinkingLevelForModel(target, concreteThinkingLevel(currentLevel))
181
+ );
182
+ }
183
+
145
184
  /** Metadata used to render the `auto` selector value alongside concrete levels. */
146
185
  export interface ConfiguredThinkingLevelMetadata {
147
186
  value: ConfiguredThinkingLevel;
@@ -7,7 +7,8 @@
7
7
  import * as path from "node:path";
8
8
  import { isEnoent, peekFile } from "@oh-my-pi/pi-utils";
9
9
  import { LRUCache } from "lru-cache/raw";
10
- import { settings } from "../config/settings";
10
+ import { settings as globalSettings, isSettingsInitialized, type Settings } from "../config/settings";
11
+ import { getDefault } from "../config/settings-schema";
11
12
  import { ToolError } from "./tool-errors";
12
13
 
13
14
  /**
@@ -283,15 +284,26 @@ async function getAutoGeneratedMarker(filePath: string): Promise<string | undefi
283
284
  return marker;
284
285
  }
285
286
 
287
+ function shouldBlockAutoGeneratedFiles(activeSettings?: Settings): boolean {
288
+ if (activeSettings) return activeSettings.get("edit.blockAutoGenerated");
289
+ if (isSettingsInitialized()) return globalSettings.get("edit.blockAutoGenerated");
290
+ return getDefault("edit.blockAutoGenerated");
291
+ }
292
+
286
293
  /**
287
294
  * Check if a file is auto-generated by examining its content.
288
295
  * Throws ToolError if the file appears to be auto-generated.
289
296
  *
290
297
  * @param absolutePath - Absolute path to the file
291
298
  * @param displayPath - Path to show in error messages (relative or as provided)
299
+ * @param activeSettings - Session settings; falls back to global settings or the schema default
292
300
  */
293
- export async function assertEditableFile(absolutePath: string, displayPath?: string) {
294
- if (!settings.get("edit.blockAutoGenerated")) {
301
+ export async function assertEditableFile(
302
+ absolutePath: string,
303
+ displayPath?: string,
304
+ activeSettings?: Settings,
305
+ ): Promise<void> {
306
+ if (!shouldBlockAutoGeneratedFiles(activeSettings)) {
295
307
  return;
296
308
  }
297
309
  const pathForDisplay = displayPath ?? absolutePath;
@@ -308,9 +320,10 @@ export async function assertEditableFile(absolutePath: string, displayPath?: str
308
320
  *
309
321
  * @param content - File content to check (can be full content or prefix)
310
322
  * @param displayPath - Path to show in error messages
323
+ * @param activeSettings - Session settings; falls back to global settings or the schema default
311
324
  */
312
- export function assertEditableFileContent(content: string, displayPath: string): void {
313
- if (!settings.get("edit.blockAutoGenerated")) {
325
+ export function assertEditableFileContent(content: string, displayPath: string, activeSettings?: Settings): void {
326
+ if (!shouldBlockAutoGeneratedFiles(activeSettings)) {
314
327
  return;
315
328
  }
316
329
 
package/src/tools/bash.ts CHANGED
@@ -48,6 +48,7 @@ import {
48
48
  previewWindowRows,
49
49
  replaceTabs,
50
50
  } from "./render-utils";
51
+ import { tokenizeShellSegments } from "./shell-tokenize";
51
52
  import { ToolAbortError, ToolError } from "./tool-errors";
52
53
  import { toolResult } from "./tool-result";
53
54
  import { clampTimeout, TOOL_TIMEOUTS } from "./tool-timeouts";
@@ -71,12 +72,13 @@ const BASH_PATTERN_APPROVAL_VALUES = new Set(["allow", "deny", "prompt"]);
71
72
  * containing a space, pipe, `&&`, redirect, or `$(...)`.
72
73
  *
73
74
  * The wrap reuses the same shell binary + args the local `bash-executor` would
74
- * pick via `settings.getShellConfig()` — Git Bash / `bash.exe` on Windows,
75
+ * pick via `settings.getShellConfig()` — Git Bash / `bash.exe` on Windows
76
+ * (`cmd.exe /c` as the last-resort fallback when no bash exists on the host),
75
77
  * `$SHELL` (bash/zsh) with the `sh` fallback on POSIX — so the ACP path
76
78
  * preserves `bash` tool semantics (`$VAR`, `$(...)`, `source`, POSIX quoting,
77
- * `-l`) instead of dropping to `cmd.exe` on Windows. The agent host's shell
78
- * path is used as a proxy for the client's, matching the near-universal
79
- * ACP deployment shape of an editor spawning omp as a co-hosted subprocess.
79
+ * `-l`) wherever a POSIX shell is available. The agent host's shell path is
80
+ * used as a proxy for the client's, matching the near-universal ACP
81
+ * deployment shape of an editor spawning omp as a co-hosted subprocess.
80
82
  */
81
83
  export function wrapShellLineForClientTerminal(
82
84
  line: string,
@@ -189,16 +191,43 @@ function commandMatchesBashApprovalPattern(command: string, pattern: string): bo
189
191
  return bashApprovalPatternToRegExp(pattern).test(normalizedCommand);
190
192
  }
191
193
 
194
+ // `deny`/`prompt` rules are matched per segment so a dangerous command buried in
195
+ // a compound line (`cd x && rm -rf /`, `sleep 1 & rm -rf /`) is still caught.
196
+ // Reuse the shared shell tokenizer so segmentation stays in one place and honors
197
+ // every command boundary (`;`, `&&`, `||`, `|`, `&`, subshells, newlines).
198
+ function bashCommandSegments(command: string): string[] {
199
+ return tokenizeShellSegments(command)
200
+ .map(segment => segment.join(" "))
201
+ .filter(segment => segment.length > 0);
202
+ }
203
+
204
+ // `deny`/`prompt` matching: the rule fires when its glob matches the whole
205
+ // command or any single segment of a compound command.
206
+ function commandSegmentMatchesBashApprovalPattern(command: string, pattern: string): boolean {
207
+ const regex = bashApprovalPatternToRegExp(pattern);
208
+ const normalizedCommand = normalizeBashApprovalPattern(command);
209
+ if (normalizedCommand.length === 0) return false;
210
+ if (regex.test(normalizedCommand)) return true;
211
+ return bashCommandSegments(command).some(segment => regex.test(segment));
212
+ }
213
+
214
+ // A rule "applies" to a command under approval-specific semantics: `allow` must
215
+ // vouch for the ENTIRE command and never rides a compound line (shell control
216
+ // syntax could smuggle an unsafe segment past a narrow allow), while `deny` and
217
+ // `prompt` fire on any matching segment so they mean what they appear to.
218
+ function bashApprovalRuleMatches(command: string, rule: BashApprovalPatternRule): boolean {
219
+ if (rule.approval === "allow") {
220
+ if (BASH_APPROVAL_SHELL_CONTROL_RE.test(command)) return false;
221
+ return commandMatchesBashApprovalPattern(command, rule.match);
222
+ }
223
+ return commandSegmentMatchesBashApprovalPattern(command, rule.match);
224
+ }
225
+
192
226
  function findBashApprovalPatternRule(
193
227
  command: string,
194
228
  rules: readonly BashApprovalPatternRule[],
195
229
  ): BashApprovalPatternRule | undefined {
196
- return rules.find(rule => {
197
- if (rule.approval === "allow" && BASH_APPROVAL_SHELL_CONTROL_RE.test(command)) {
198
- return false;
199
- }
200
- return commandMatchesBashApprovalPattern(command, rule.match);
201
- });
230
+ return rules.find(rule => bashApprovalRuleMatches(command, rule));
202
231
  }
203
232
 
204
233
  async function saveBashOriginalArtifact(session: ToolSession, originalText: string): Promise<string | undefined> {
@@ -459,7 +488,7 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
459
488
  const rawCommand = (args as Partial<BashToolInput>).command;
460
489
  const command = typeof rawCommand === "string" ? rawCommand : "";
461
490
  const patternRules = getBashApprovalPatternRules(this.session.settings.get("bash.patterns"));
462
- const patternRule = patternRules.find(rule => commandMatchesBashApprovalPattern(command, rule.match));
491
+ const patternRule = findBashApprovalPatternRule(command, patternRules);
463
492
  if (patternRule?.approval === "deny") {
464
493
  return {
465
494
  tier: "exec",
@@ -471,14 +500,13 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
471
500
  if (command !== "" && CRITICAL_BASH_PATTERNS.some(pattern => pattern.test(command))) {
472
501
  return { tier: "exec", override: true, reason: "Critical pattern detected" };
473
502
  }
474
- const safePatternRule = findBashApprovalPatternRule(command, patternRules);
475
- if (safePatternRule?.approval === "allow") return { tier: "write", policy: "allow" };
476
- if (safePatternRule?.approval === "prompt") {
503
+ if (patternRule?.approval === "allow") return { tier: "write", policy: "allow" };
504
+ if (patternRule?.approval === "prompt") {
477
505
  return {
478
506
  tier: "exec",
479
507
  override: true,
480
508
  policy: "prompt",
481
- reason: `Prompt required by bash pattern: ${safePatternRule.match}`,
509
+ reason: `Prompt required by bash pattern: ${patternRule.match}`,
482
510
  };
483
511
  }
484
512
  return "exec";
@@ -0,0 +1,38 @@
1
+ import type { Model } from "@oh-my-pi/pi-ai";
2
+
3
+ export type ComputerExposureMode = "native" | "function" | "unavailable";
4
+
5
+ export interface ComputerExposureOptions {
6
+ azureBaseUrl?: string;
7
+ azureResourceName?: string;
8
+ }
9
+
10
+ function isFirstPartyAzureEndpoint(baseUrl: string): boolean {
11
+ try {
12
+ const url = new URL(baseUrl);
13
+ return (
14
+ url.protocol === "https:" &&
15
+ (url.hostname.endsWith(".openai.azure.com") || url.hostname === "models.inference.ai.azure.com")
16
+ );
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ /** Match the provider transport's effective Computer Use tool representation. */
23
+ export function computerExposureMode(
24
+ model: Model | undefined,
25
+ options?: ComputerExposureOptions,
26
+ ): ComputerExposureMode {
27
+ if (!model) return "unavailable";
28
+ if (model.supportsComputerUse !== true) return "function";
29
+ if (model.api !== "azure-openai-responses" || model.supportsComputerUseConfig !== undefined) return "native";
30
+ const baseUrl =
31
+ options?.azureBaseUrl?.trim() ||
32
+ process.env.AZURE_OPENAI_BASE_URL?.trim() ||
33
+ (options?.azureResourceName || process.env.AZURE_OPENAI_RESOURCE_NAME
34
+ ? `https://${options?.azureResourceName || process.env.AZURE_OPENAI_RESOURCE_NAME}.openai.azure.com/openai/v1`
35
+ : undefined) ||
36
+ model.baseUrl;
37
+ return baseUrl && isFirstPartyAzureEndpoint(baseUrl) ? "native" : "function";
38
+ }
@@ -1,5 +1,7 @@
1
1
  import type { DesktopAction, DesktopCapabilities, DesktopCapture, DesktopSessionOptions } from "@oh-my-pi/pi-natives";
2
- import { withTimeout, workerHostEntry } from "@oh-my-pi/pi-utils";
2
+ import { withTimeout } from "@oh-my-pi/pi-utils/async";
3
+ import * as logger from "@oh-my-pi/pi-utils/logger";
4
+ import { workerHostEntry } from "@oh-my-pi/pi-utils/worker-host";
3
5
  import { ToolAbortError, ToolError } from "../tool-errors";
4
6
  import {
5
7
  COMPUTER_WORKER_ARG,
@@ -138,11 +140,23 @@ export class ComputerSupervisor implements ComputerController {
138
140
  if (this.#startPromise) return this.#startPromise;
139
141
  const ready = Promise.withResolvers<void>();
140
142
  try {
143
+ logger.debug("Starting native computer worker", {
144
+ backend: this.options.backend,
145
+ display: this.options.display,
146
+ maxWidth: this.options.maxWidth,
147
+ maxHeight: this.options.maxHeight,
148
+ });
141
149
  const worker = this.createWorker();
142
150
  this.#worker = worker;
143
151
  this.#unsubscribeMessage = worker.onMessage(message => {
144
152
  if (message.type === "ready") {
145
153
  this.#capabilities = message.capabilities;
154
+ logger.debug("Native computer worker ready", {
155
+ backend: message.capabilities.backend,
156
+ capturePermission: message.capabilities.capturePermission,
157
+ inputPermission: message.capabilities.inputPermission,
158
+ displayCount: message.capabilities.displayCount,
159
+ });
146
160
  ready.resolve();
147
161
  return;
148
162
  }
@@ -151,10 +165,22 @@ export class ComputerSupervisor implements ComputerController {
151
165
  const pending = this.#pending.get(message.id);
152
166
  this.#pending.delete(message.id);
153
167
  pending?.resolve(message.capture);
168
+ logger.debug("Native computer capture completed", {
169
+ requestId: message.id,
170
+ backend: message.capture.backend,
171
+ capturePermission: message.capture.capturePermission,
172
+ inputPermission: message.capture.inputPermission,
173
+ width: message.capture.width,
174
+ height: message.capture.height,
175
+ });
154
176
  return;
155
177
  }
156
178
  if (message.type === "error") {
157
179
  const error = workerError(message.error);
180
+ logger.warn("Native computer worker request failed", {
181
+ requestId: message.id,
182
+ errorName: message.error.name,
183
+ });
158
184
  if (message.id) {
159
185
  const pending = this.#pending.get(message.id);
160
186
  this.#pending.delete(message.id);
@@ -184,6 +210,9 @@ export class ComputerSupervisor implements ComputerController {
184
210
  }
185
211
 
186
212
  async #terminate(reason: unknown): Promise<void> {
213
+ logger.debug("Terminating native computer worker", {
214
+ reason: reason instanceof Error ? reason.name : typeof reason,
215
+ });
187
216
  const worker = this.#worker;
188
217
  this.#worker = undefined;
189
218
  this.#startPromise = undefined;
@@ -239,20 +268,39 @@ export async function releaseComputerSessionsForOwner(ownerId: string | undefine
239
268
  await Promise.allSettled(Array.from(controllers, controller => controller.close()));
240
269
  }
241
270
 
242
- export async function smokeTestComputerWorker(timeoutMs = SMOKE_TIMEOUT_MS): Promise<void> {
243
- const worker = spawnComputerWorker();
244
- const id = "computer-smoke";
245
- const pong = Promise.withResolvers<void>();
246
- const unsubscribeMessage = worker.onMessage(message => {
247
- if (message.type === "pong" && message.id === id) pong.resolve();
248
- });
249
- const unsubscribeError = worker.onError(error => pong.reject(error));
271
+ export async function smokeTestComputerWorker(
272
+ timeoutMs = SMOKE_TIMEOUT_MS,
273
+ createWorker: ComputerWorkerFactory = spawnComputerWorker,
274
+ ): Promise<void> {
275
+ const worker = createWorker();
276
+ const exchange = async (
277
+ message: ComputerWorkerInbound,
278
+ expected: ComputerWorkerOutbound["type"],
279
+ failureMessage: string,
280
+ ): Promise<void> => {
281
+ const response = Promise.withResolvers<void>();
282
+ const unsubscribeMessage = worker.onMessage(received => {
283
+ if (received.type === expected) response.resolve();
284
+ else if (received.type === "error") response.reject(workerError(received.error));
285
+ });
286
+ const unsubscribeError = worker.onError(error => response.reject(error));
287
+ try {
288
+ worker.send(message);
289
+ await withTimeout(response.promise, timeoutMs, failureMessage);
290
+ } finally {
291
+ unsubscribeMessage();
292
+ unsubscribeError();
293
+ }
294
+ };
295
+
250
296
  try {
251
- worker.send({ type: "ping", id });
252
- await withTimeout(pong.promise, timeoutMs, "Computer worker smoke ping timed out");
297
+ await exchange(
298
+ { type: "init", options: { backend: "auto", display: "all", maxWidth: 1920, maxHeight: 1200 } },
299
+ "ready",
300
+ "Computer worker smoke initialization timed out",
301
+ );
302
+ await exchange({ type: "close" }, "closed", "Computer worker smoke close timed out");
253
303
  } finally {
254
- unsubscribeMessage();
255
- unsubscribeError();
256
304
  await worker.terminate();
257
305
  }
258
306
  }