@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
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;
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
  }
@@ -1,25 +1,34 @@
1
1
  import { parentPort } from "node:worker_threads";
2
- import { consumeWorkerInbox } from "@oh-my-pi/pi-utils/worker-host";
2
+ import { consumeWorkerInbox, isWorkerHostSelector } from "@oh-my-pi/pi-utils/worker-host";
3
3
  import type { ComputerWorkerInbound, ComputerWorkerTransport } from "./protocol";
4
4
  import { ComputerWorkerCore } from "./worker";
5
5
 
6
- if (!parentPort) throw new Error("computer-worker-entry: missing parentPort");
6
+ export function startComputerWorker(): void {
7
+ if (!parentPort) throw new Error("computer-worker-entry: missing parentPort");
7
8
 
8
- const port = parentPort;
9
- const inbox = consumeWorkerInbox();
10
- const transport: ComputerWorkerTransport = {
11
- send(message, transfer) {
12
- port.postMessage(message, transfer ?? []);
13
- },
14
- onMessage(handler) {
15
- if (inbox) return inbox.bind(message => handler(message as ComputerWorkerInbound));
16
- const listener = (message: unknown): void => handler(message as ComputerWorkerInbound);
17
- port.on("message", listener);
18
- return () => port.off("message", listener);
19
- },
20
- close() {
21
- port.close();
22
- },
23
- };
9
+ const port = parentPort;
10
+ const inbox = consumeWorkerInbox();
11
+ const transport: ComputerWorkerTransport = {
12
+ send(message, transfer) {
13
+ port.postMessage(message, transfer ?? []);
14
+ },
15
+ onMessage(handler) {
16
+ if (inbox) return inbox.bind(message => handler(message as ComputerWorkerInbound));
17
+ const listener = (message: unknown): void => handler(message as ComputerWorkerInbound);
18
+ port.on("message", listener);
19
+ return () => port.off("message", listener);
20
+ },
21
+ close() {
22
+ port.close();
23
+ },
24
+ };
24
25
 
25
- new ComputerWorkerCore(transport);
26
+ new ComputerWorkerCore(transport);
27
+ }
28
+
29
+ // Bun workers report `import.meta.main === false`. The source fallback still
30
+ // enters this file directly, while packaged CLI workers carry the selector and
31
+ // start the named entry only after installing its inbox.
32
+ if (!Bun.isMainThread && !process.argv.some(isWorkerHostSelector) && import.meta.path === Bun.main) {
33
+ startComputerWorker();
34
+ }
@@ -1,9 +1,5 @@
1
- import {
2
- type DesktopAction,
3
- type DesktopCapture,
4
- DesktopSession,
5
- type DesktopSessionOptions,
6
- } from "@oh-my-pi/pi-natives";
1
+ import type { DesktopAction, DesktopCapture, DesktopSession, DesktopSessionOptions } from "@oh-my-pi/pi-natives";
2
+ import { createDesktopSession } from "@oh-my-pi/pi-natives/desktop";
7
3
  import type { ComputerWorkerError, ComputerWorkerInbound, ComputerWorkerTransport } from "./protocol";
8
4
 
9
5
  export interface NativeDesktopSession {
@@ -44,7 +40,7 @@ export class ComputerWorkerCore {
44
40
 
45
41
  constructor(
46
42
  private readonly transport: ComputerWorkerTransport,
47
- private readonly createSession: NativeDesktopSessionFactory = options => new DesktopSession(options),
43
+ private readonly createSession: NativeDesktopSessionFactory = createDesktopSession,
48
44
  ) {
49
45
  this.#unsubscribe = transport.onMessage(message => this.#onMessage(message));
50
46
  }
@@ -5,7 +5,8 @@ import type {
5
5
  AgentToolUpdateCallback,
6
6
  ToolApprovalDecision,
7
7
  } from "@oh-my-pi/pi-agent-core";
8
- import type { ComputerAction, ComputerSafetyCheck, ComputerToolCallMetadata } from "@oh-my-pi/pi-ai";
8
+ import type { ComputerAction, ComputerSafetyCheck, ComputerToolCallMetadata, Model } from "@oh-my-pi/pi-ai";
9
+ import { isClaudeModelId } from "@oh-my-pi/pi-catalog/identity";
9
10
  import type {
10
11
  DesktopAction,
11
12
  DesktopCapabilities,
@@ -21,6 +22,36 @@ import { type ComputerController, ComputerSupervisor, registerComputerController
21
22
  import type { ToolSession } from "./index";
22
23
  import { ToolError, throwIfAborted } from "./tool-errors";
23
24
 
25
+ // Image transports that cannot preserve native screenshot detail resize frames
26
+ // without returning transformed dimensions. Keep their native coordinate frames
27
+ // below the empirically verified threshold so pointer actions match what the
28
+ // model sees. Claude paths predate the resolved transport capability and retain
29
+ // their established model-family fallback.
30
+ const COORDINATE_SAFE_MAX_CAPTURE_WIDTH = 1280;
31
+ const COORDINATE_SAFE_MAX_CAPTURE_HEIGHT = 896;
32
+
33
+ function usesCoordinateSafeImageSizing(model: Model | undefined): boolean {
34
+ if (!model) return false;
35
+ const compat = model.compat;
36
+ return (
37
+ (!!compat && "supportsImageDetailOriginal" in compat && compat.supportsImageDetailOriginal === false) ||
38
+ isClaudeModelId(model.id) ||
39
+ (model.requestModelId !== undefined && isClaudeModelId(model.requestModelId)) ||
40
+ (typeof model.name === "string" && /^claude(?:\s|$)/i.test(model.name))
41
+ );
42
+ }
43
+
44
+ function captureOptions(session: ToolSession, coordinateSafeImageSizing: boolean): DesktopSessionOptions {
45
+ const maxWidth = session.settings.get("computer.maxWidth");
46
+ const maxHeight = session.settings.get("computer.maxHeight");
47
+ return {
48
+ backend: session.settings.get("computer.backend"),
49
+ display: session.settings.get("computer.display"),
50
+ maxWidth: coordinateSafeImageSizing ? Math.min(maxWidth, COORDINATE_SAFE_MAX_CAPTURE_WIDTH) : maxWidth,
51
+ maxHeight: coordinateSafeImageSizing ? Math.min(maxHeight, COORDINATE_SAFE_MAX_CAPTURE_HEIGHT) : maxHeight,
52
+ };
53
+ }
54
+
24
55
  // Desktop actions cross the N-API boundary as i32; out-of-range JS numbers
25
56
  // must fail closed here instead of truncating in the napi conversion.
26
57
  const INT32_MIN = -2_147_483_648;
@@ -230,7 +261,7 @@ function isComputerAction(value: unknown): value is ComputerAction {
230
261
  function parseActions(value: unknown): ComputerAction[] {
231
262
  // Missing or empty action batches degrade to a plain screenshot so a
232
263
  // function-calling model can observe the screen before acting.
233
- if (value === undefined) return [{ type: "screenshot" }];
264
+ if (value == null) return [{ type: "screenshot" }];
234
265
  if (!Array.isArray(value)) throw new ToolError("Computer call requires an array of actions");
235
266
  if (value.length === 0) return [{ type: "screenshot" }];
236
267
  if (!value.every(isComputerAction)) throw new ToolError("Computer call contains an invalid action");
@@ -286,6 +317,7 @@ function callMetadata(context: AgentToolContext | undefined): ComputerToolCallMe
286
317
  export function computerApproval(args: unknown): ToolApprovalDecision {
287
318
  const actions =
288
319
  args && typeof args === "object" && "actions" in args ? (args as { actions?: unknown }).actions : undefined;
320
+ if (actions == null) return "read";
289
321
  if (!Array.isArray(actions)) return "exec";
290
322
  return actions.every(action => {
291
323
  if (!action || typeof action !== "object") return false;
@@ -356,8 +388,16 @@ export class ComputerTool implements AgentTool<typeof computerSchema, ComputerTo
356
388
  const actions = args && typeof args === "object" ? (args as { actions?: unknown }).actions : undefined;
357
389
  return approvalActionSummary(actions);
358
390
  };
359
- readonly #controller: ComputerController;
360
- readonly #unregisterOwner: () => void;
391
+ /**
392
+ * Settings snapshot used to create the tool's current controller; refreshed
393
+ * when a model switch crosses the coordinate-safe sizing boundary. Surfaced
394
+ * by `/computer status`.
395
+ */
396
+ effectiveConfiguration: Readonly<DesktopSessionOptions>;
397
+ readonly #createController: ComputerControllerFactory;
398
+ #controller: ComputerController;
399
+ #unregisterOwner: () => void;
400
+ #usesCoordinateSafeImageSizing: boolean;
361
401
  #closed = false;
362
402
  #description?: string;
363
403
 
@@ -365,12 +405,10 @@ export class ComputerTool implements AgentTool<typeof computerSchema, ComputerTo
365
405
  readonly session: ToolSession,
366
406
  createController: ComputerControllerFactory = options => new ComputerSupervisor(options),
367
407
  ) {
368
- this.#controller = createController({
369
- backend: session.settings.get("computer.backend"),
370
- display: session.settings.get("computer.display"),
371
- maxWidth: session.settings.get("computer.maxWidth"),
372
- maxHeight: session.settings.get("computer.maxHeight"),
373
- });
408
+ this.#createController = createController;
409
+ this.#usesCoordinateSafeImageSizing = usesCoordinateSafeImageSizing(session.getActiveModel?.());
410
+ this.effectiveConfiguration = Object.freeze(captureOptions(session, this.#usesCoordinateSafeImageSizing));
411
+ this.#controller = createController(this.effectiveConfiguration);
374
412
  this.#unregisterOwner = registerComputerController(
375
413
  session.getEvalKernelOwnerId?.() ?? undefined,
376
414
  this.#controller,
@@ -381,6 +419,21 @@ export class ComputerTool implements AgentTool<typeof computerSchema, ComputerTo
381
419
  return this.#description;
382
420
  }
383
421
 
422
+ async #refreshControllerForModel(): Promise<void> {
423
+ const nextUsesCoordinateSafeImageSizing = usesCoordinateSafeImageSizing(this.session.getActiveModel?.());
424
+ if (nextUsesCoordinateSafeImageSizing === this.#usesCoordinateSafeImageSizing) return;
425
+
426
+ const previous = this.#controller;
427
+ const nextOptions = Object.freeze(captureOptions(this.session, nextUsesCoordinateSafeImageSizing));
428
+ const next = this.#createController(nextOptions);
429
+ this.#unregisterOwner();
430
+ this.#controller = next;
431
+ this.#unregisterOwner = registerComputerController(this.session.getEvalKernelOwnerId?.() ?? undefined, next);
432
+ this.#usesCoordinateSafeImageSizing = nextUsesCoordinateSafeImageSizing;
433
+ this.effectiveConfiguration = nextOptions;
434
+ await previous.close();
435
+ }
436
+
384
437
  async execute(
385
438
  _toolCallId: string,
386
439
  params: ComputerParams,
@@ -396,6 +449,8 @@ export class ComputerTool implements AgentTool<typeof computerSchema, ComputerTo
396
449
  if (pendingSafetyChecks.length > 0 && context?.providerSafetyApproved !== true) {
397
450
  throw new ToolError("Provider safety checks require interactive approval before computer input");
398
451
  }
452
+ await this.#refreshControllerForModel();
453
+ throwIfAborted(signal);
399
454
  const capture = await this.#controller.execute(actions.map(toDesktopAction), signal);
400
455
  throwIfAborted(signal);
401
456
  const data = Buffer.from(capture.data).toBase64();
@@ -18,6 +18,7 @@
18
18
  * dwarfs the cost of one cache miss.
19
19
  */
20
20
  import { invalidateAllForNumber, invalidateAllForRepo } from "./github-cache";
21
+ import { tokenizeShellSegments } from "./shell-tokenize";
21
22
 
22
23
  const PR_URL_PATTERN = /^https:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/pull\/(\d+)(?:[/?#].*)?$/i;
23
24
  const ISSUE_URL_PATTERN = /^https:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/issues\/(\d+)(?:[/?#].*)?$/i;
@@ -154,87 +155,6 @@ function detectGhMutation(tokens: readonly string[]): { number?: number; repo?:
154
155
  return repo !== undefined ? { repo } : {};
155
156
  }
156
157
 
157
- /**
158
- * Conservative tokenizer that splits a bash command into individual word
159
- * tokens. Handles single/double-quoted strings, backslash escapes, and
160
- * standard operators (`;`, `&&`, `||`, `|`, `&`, newlines) as token
161
- * boundaries that emit a sentinel `";"` so the caller treats the segments
162
- * as independent command sequences. We do not attempt full POSIX shell
163
- * parsing — heredocs, command substitution, and arithmetic expansion are
164
- * out of scope; the detector simply falls through when it cannot find a
165
- * clean `gh issue|pr <subcmd>` triple.
166
- */
167
- function tokenize(command: string): string[][] {
168
- const segments: string[][] = [];
169
- let current: string[] = [];
170
- let buffer = "";
171
- let inSingle = false;
172
- let inDouble = false;
173
- const pushBuffer = () => {
174
- if (buffer.length > 0) {
175
- current.push(buffer);
176
- buffer = "";
177
- }
178
- };
179
- const pushSegment = () => {
180
- pushBuffer();
181
- if (current.length > 0) segments.push(current);
182
- current = [];
183
- };
184
- for (let i = 0; i < command.length; i++) {
185
- const ch = command[i];
186
- if (inSingle) {
187
- if (ch === "'") {
188
- inSingle = false;
189
- continue;
190
- }
191
- buffer += ch;
192
- continue;
193
- }
194
- if (inDouble) {
195
- if (ch === "\\" && i + 1 < command.length) {
196
- const next = command[i + 1];
197
- if (next === '"' || next === "\\" || next === "$" || next === "`") {
198
- buffer += next;
199
- i++;
200
- continue;
201
- }
202
- }
203
- if (ch === '"') {
204
- inDouble = false;
205
- continue;
206
- }
207
- buffer += ch;
208
- continue;
209
- }
210
- if (ch === "'") {
211
- inSingle = true;
212
- continue;
213
- }
214
- if (ch === '"') {
215
- inDouble = true;
216
- continue;
217
- }
218
- if (ch === "\\" && i + 1 < command.length) {
219
- buffer += command[i + 1];
220
- i++;
221
- continue;
222
- }
223
- if (ch === " " || ch === "\t") {
224
- pushBuffer();
225
- continue;
226
- }
227
- if (ch === "\n" || ch === ";" || ch === "&" || ch === "|" || ch === "(" || ch === ")") {
228
- pushSegment();
229
- // `&&`, `||` already collapsed by the segment break above.
230
- continue;
231
- }
232
- buffer += ch;
233
- }
234
- pushSegment();
235
- return segments;
236
- }
237
-
238
158
  /**
239
159
  * Drop `github-cache` rows for any `gh issue|pr <mutating-subcmd>` call
240
160
  * embedded in `command`. Safe to invoke unconditionally; no-op when the
@@ -242,7 +162,7 @@ function tokenize(command: string): string[][] {
242
162
  */
243
163
  export function invalidateGithubCacheForBashCommand(command: string): void {
244
164
  if (!command?.includes("gh")) return;
245
- const segments = tokenize(command);
165
+ const segments = tokenizeShellSegments(command);
246
166
  for (const segment of segments) {
247
167
  const hit = detectGhMutation(segment);
248
168
  if (!hit) continue;