@oh-my-pi/pi-coding-agent 15.1.3 → 15.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.1.4] - 2026-05-19
6
+
7
+ ### Fixed
8
+
9
+ - Fixed `normalizePlanTitle` rejecting plan titles that contain spaces or common punctuation (e.g. "My Feature Plan") — spaces are now converted to hyphens and other invalid characters are dropped, so models that produce natural-language plan titles no longer loop forever trying to call `resolve`. ([#1176](https://github.com/can1357/oh-my-pi/issues/1176))
10
+ - Fixed `ask` tool prompt example showing the legacy `question`/`options` top-level format instead of the current `questions: [{id, question, options}]` array format; models that closely followed the example generated calls that always failed schema validation. ([#1176](https://github.com/can1357/oh-my-pi/issues/1176))
11
+
12
+ - Fixed ACP command and custom tool-call notifications to carry the original tool arguments in replayed and final updates, so command text is preserved and raw input is no longer wrapped
13
+ - Fixed ACP async-job draining to be scoped by session owner so `getAsyncJobSnapshot` and `drainAsyncJobDeliveriesForAcp` no longer consume or expose jobs from other sessions
14
+ - Fixed async job status reporting to include in-flight completions so queued/delivering indicators remain accurate while callbacks are still running
15
+ - Fixed `deferAgentInitiatedTurns` handling during ACP async-job draining so background completion follow-up turns are delivered even when agent-initiated turns are deferred
16
+ - Fixed ACP ordinary file-editing calls (`edit`, `write`, `ast_edit`) incorrectly requesting `session/request_permission` before every call, while keeping permission prompts for edit operations that delete or move files; permission requests now report the gated tool call as `pending` so clients can render the approval UI instead of returning `Permission request cancelled` without a visible prompt. ([#1134](https://github.com/can1357/oh-my-pi/pull/1134) by [@jiwangyihao](https://github.com/jiwangyihao))
17
+ - Fixed the session tree selector to preserve a readable message column when deeply nested branch gutters would otherwise consume the viewport. ([#1144](https://github.com/can1357/oh-my-pi/issues/1144))
18
+
5
19
  ## [15.1.3] - 2026-05-17
6
20
  ### Breaking Changes
7
21
 
@@ -44,6 +58,10 @@
44
58
  - Fixed auth-gateway request cancellation for requests that are already aborted before dispatch.
45
59
  - Fixed `/login` and `/logout` provider selector overflowing tall provider lists off-screen on small terminals. The selector now scrolls a 10-item window centered on the highlighted entry, shows a `(n/total)` indicator when windowed, and accepts PageUp/PageDown for faster navigation.
46
60
 
61
+ ### Fixed
62
+
63
+ - Fixed `.env` loading so malformed variable names and NUL-containing values are ignored before they can poison `Bun.env` and break bash/external process execution with `nul byte found in provided data`.
64
+
47
65
  ## [15.1.2] - 2026-05-15
48
66
  ### Fixed
49
67
 
@@ -65,8 +65,8 @@ export declare class AsyncJobManager {
65
65
  getRunningJobs(filter?: AsyncJobFilter): AsyncJob[];
66
66
  getRecentJobs(limit?: number, filter?: AsyncJobFilter): AsyncJob[];
67
67
  getAllJobs(filter?: AsyncJobFilter): AsyncJob[];
68
- getDeliveryState(): AsyncJobDeliveryState;
69
- hasPendingDeliveries(): boolean;
68
+ getDeliveryState(filter?: AsyncJobFilter): AsyncJobDeliveryState;
69
+ hasPendingDeliveries(filter?: AsyncJobFilter): boolean;
70
70
  watchJobs(jobIds: string[]): number;
71
71
  unwatchJobs(jobIds: string[]): number;
72
72
  acknowledgeDeliveries(jobIds: string[]): number;
@@ -79,6 +79,7 @@ export declare class AsyncJobManager {
79
79
  waitForAll(): Promise<void>;
80
80
  drainDeliveries(options?: {
81
81
  timeoutMs?: number;
82
+ filter?: AsyncJobFilter;
82
83
  }): Promise<boolean>;
83
84
  dispose(options?: {
84
85
  timeoutMs?: number;
@@ -5,13 +5,22 @@
5
5
  * createAgentSession() options. The SDK does the heavy lifting.
6
6
  */
7
7
  import type { Args } from "./cli/args";
8
- import { InteractiveMode } from "./modes";
8
+ import { Settings } from "./config/settings";
9
+ import { InteractiveMode, runAcpMode } from "./modes";
9
10
  import type { SubmittedUserInput } from "./modes/types";
11
+ import { createAgentSession, discoverAuthStorage } from "./sdk";
10
12
  import type { AgentSession } from "./session/agent-session";
11
13
  export interface InteractiveModeNotify {
12
14
  kind: "warn" | "error" | "info";
13
15
  message: string;
14
16
  }
15
17
  export declare function submitInteractiveInput(mode: Pick<InteractiveMode, "markPendingSubmissionStarted" | "finishPendingSubmission" | "showError" | "checkShutdownRequested">, session: Pick<AgentSession, "prompt" | "promptCustomMessage">, input: SubmittedUserInput): Promise<void>;
16
- export declare function runRootCommand(parsed: Args, rawArgs: string[]): Promise<void>;
18
+ interface RunRootCommandDependencies {
19
+ createAgentSession?: typeof createAgentSession;
20
+ discoverAuthStorage?: typeof discoverAuthStorage;
21
+ runAcpMode?: typeof runAcpMode;
22
+ settings?: Settings;
23
+ }
24
+ export declare function runRootCommand(parsed: Args, rawArgs: string[], deps?: RunRootCommandDependencies): Promise<void>;
17
25
  export declare function main(args: string[]): Promise<void>;
26
+ export {};
@@ -30,7 +30,7 @@ type CreateAcpSession = (cwd: string) => Promise<AgentSession>;
30
30
  export declare function createAcpExtensionUiContext(connection: AgentSideConnection, getSessionId: () => string, clientCapabilities: ClientCapabilities | undefined): ExtensionUIContext;
31
31
  export declare class AcpAgent implements Agent {
32
32
  #private;
33
- constructor(connection: AgentSideConnection, initialSession: AgentSession, createSession: CreateAcpSession);
33
+ constructor(connection: AgentSideConnection, createSession: CreateAcpSession, initialSession?: AgentSession);
34
34
  setCancelCleanupTimeoutForTesting(timeoutMs: number): void;
35
35
  initialize(params: InitializeRequest): Promise<InitializeResponse>;
36
36
  authenticate(params: AuthenticateRequest): Promise<AuthenticateResponse>;
@@ -1,4 +1,4 @@
1
- import type { SessionNotification, ToolKind } from "@agentclientprotocol/sdk";
1
+ import type { SessionNotification, SessionUpdate, ToolKind } from "@agentclientprotocol/sdk";
2
2
  import type { AgentSessionEvent } from "../../session/agent-session";
3
3
  interface MessageProgress {
4
4
  textEmitted: boolean;
@@ -7,6 +7,7 @@ interface MessageProgress {
7
7
  interface AcpEventMapperOptions {
8
8
  getMessageId?: (message: unknown) => string | undefined;
9
9
  getMessageProgress?: (message: unknown) => MessageProgress | undefined;
10
+ getToolArgs?: (toolCallId: string) => unknown;
10
11
  /**
11
12
  * Session cwd. Tool call locations sent to ACP clients must be absolute
12
13
  * (the editor host needs them to open or focus files). When provided,
@@ -17,4 +18,15 @@ interface AcpEventMapperOptions {
17
18
  }
18
19
  export declare function mapToolKind(toolName: string): ToolKind;
19
20
  export declare function mapAgentSessionEventToAcpSessionUpdates(event: AgentSessionEvent, sessionId: string, options?: AcpEventMapperOptions): SessionNotification[];
21
+ export declare function buildToolCallStartUpdate(input: {
22
+ toolCallId: string;
23
+ toolName: string;
24
+ args: unknown;
25
+ intent?: string;
26
+ cwd?: string;
27
+ status?: "pending" | "completed";
28
+ }): SessionUpdate;
29
+ export declare function normalizeReplayToolArguments(value: unknown): {
30
+ args: unknown;
31
+ };
20
32
  export {};
@@ -1,3 +1,5 @@
1
+ import { AgentSideConnection, type Stream } from "@agentclientprotocol/sdk";
1
2
  import type { AgentSession } from "../../session/agent-session";
2
3
  export type AcpSessionFactory = (cwd: string) => Promise<AgentSession>;
3
- export declare function runAcpMode(session: AgentSession, createSession: AcpSessionFactory): Promise<never>;
4
+ export declare function createAcpConnection(transport: Stream, createSession: AcpSessionFactory, initialSession?: AgentSession): AgentSideConnection;
5
+ export declare function runAcpMode(createSession: AcpSessionFactory, initialSession?: AgentSession): Promise<never>;
@@ -7,10 +7,12 @@ export interface PlanApprovalDetails {
7
7
  title: string;
8
8
  planExists: boolean;
9
9
  }
10
- /** Validate the agent-supplied plan title and derive the destination filename.
11
- * Filename uses the title with a `.md` suffix; characters are restricted to
12
- * letters, numbers, underscores, and hyphens so the value is safe to splice
13
- * into a `local://` URL without escaping. */
10
+ /** Validate and normalize the agent-supplied plan title into a safe filename stem.
11
+ * Spaces and other URL-safe punctuation are replaced with hyphens so models that
12
+ * produce natural-language titles (e.g. "My feature plan") still succeed.
13
+ * Characters that cannot be safely represented after replacement are dropped.
14
+ * The result is restricted to letters, numbers, underscores, and hyphens so it
15
+ * is safe to splice into a `local://` URL without escaping. */
14
16
  export declare function normalizePlanTitle(title: string): {
15
17
  title: string;
16
18
  fileName: string;
@@ -15,7 +15,7 @@
15
15
  import { type Agent, type AgentEvent, type AgentMessage, type AgentState, type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
16
16
  import { type CompactionResult } from "@oh-my-pi/pi-agent-core/compaction";
17
17
  import type { AssistantMessage, Effort, ImageContent, Message, MessageAttribution, Model, ProviderSessionState, ServiceTier, SimpleStreamOptions, TextContent, ToolChoice, UsageReport } from "@oh-my-pi/pi-ai";
18
- import { type AsyncJob, AsyncJobManager } from "../async";
18
+ import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async";
19
19
  import type { Rule } from "../capability/rule";
20
20
  import { type ModelRegistry } from "../config/model-registry";
21
21
  import { type ResolvedModelRoleValue } from "../config/model-resolver";
@@ -111,6 +111,7 @@ export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "la
111
111
  export interface AsyncJobSnapshot {
112
112
  running: AsyncJobSnapshotItem[];
113
113
  recent: AsyncJobSnapshotItem[];
114
+ delivery: AsyncJobDeliveryState;
114
115
  }
115
116
  export interface AgentSessionConfig {
116
117
  agent: Agent;
@@ -347,6 +348,9 @@ export declare class AgentSession {
347
348
  get isStreaming(): boolean;
348
349
  /** Wait until streaming and deferred recovery work are fully settled. */
349
350
  waitForIdle(): Promise<void>;
351
+ drainAsyncJobDeliveriesForAcp(options?: {
352
+ timeoutMs?: number;
353
+ }): Promise<boolean>;
350
354
  /** Most recent assistant message in agent state. */
351
355
  getLastAssistantMessage(): AssistantMessage | undefined;
352
356
  /** Current effective system prompt blocks (includes any per-turn extension modifications) */
@@ -495,7 +499,7 @@ export declare class AgentSession {
495
499
  *
496
500
  * Handles three cases:
497
501
  * - Streaming: queue as steer/follow-up or store for next turn
498
- * - Not streaming + triggerTurn: appends to state/session, starts new turn
502
+ * - Not streaming + triggerTurn: appends to state/session, starts new turn unless the client cannot own it
499
503
  * - Not streaming + no trigger: appends to state/session, no turn
500
504
  */
501
505
  sendCustomMessage<T = unknown>(message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">, options?: {
@@ -23,6 +23,7 @@ export interface ClientBridgePermissionToolCall {
23
23
  toolName: string;
24
24
  title: string;
25
25
  kind?: string;
26
+ status?: "pending" | "in_progress" | "completed" | "failed";
26
27
  rawInput?: unknown;
27
28
  locations?: {
28
29
  path: string;
@@ -70,6 +71,8 @@ export interface ClientBridgeCreateTerminalParams {
70
71
  }
71
72
  export interface ClientBridge {
72
73
  readonly capabilities: ClientBridgeCapabilities;
74
+ /** ACP v1 clients cannot show server-initiated turns as busy after prompt response. */
75
+ readonly deferAgentInitiatedTurns?: boolean;
73
76
  readTextFile?(params: {
74
77
  path: string;
75
78
  line?: number;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "15.1.3",
4
+ "version": "15.1.4",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -47,12 +47,12 @@
47
47
  "@agentclientprotocol/sdk": "0.21.0",
48
48
  "@babel/parser": "^7.29.3",
49
49
  "@mozilla/readability": "^0.6.0",
50
- "@oh-my-pi/omp-stats": "15.1.3",
51
- "@oh-my-pi/pi-agent-core": "15.1.3",
52
- "@oh-my-pi/pi-ai": "15.1.3",
53
- "@oh-my-pi/pi-natives": "15.1.3",
54
- "@oh-my-pi/pi-tui": "15.1.3",
55
- "@oh-my-pi/pi-utils": "15.1.3",
50
+ "@oh-my-pi/omp-stats": "15.1.4",
51
+ "@oh-my-pi/pi-agent-core": "15.1.4",
52
+ "@oh-my-pi/pi-ai": "15.1.4",
53
+ "@oh-my-pi/pi-natives": "15.1.4",
54
+ "@oh-my-pi/pi-tui": "15.1.4",
55
+ "@oh-my-pi/pi-utils": "15.1.4",
56
56
  "@puppeteer/browsers": "^2.13.0",
57
57
  "@types/turndown": "5.0.6",
58
58
  "@xterm/headless": "^6.0.0",
@@ -37,6 +37,8 @@ interface AsyncJobDelivery {
37
37
  attempt: number;
38
38
  nextAttemptAt: number;
39
39
  lastError?: string;
40
+ ownerId?: string;
41
+ promise?: Promise<void>;
40
42
  }
41
43
 
42
44
  export interface AsyncJobDeliveryState {
@@ -82,6 +84,7 @@ export class AsyncJobManager {
82
84
 
83
85
  readonly #jobs = new Map<string, AsyncJob>();
84
86
  readonly #deliveries: AsyncJobDelivery[] = [];
87
+ readonly #inFlightDeliveries: AsyncJobDelivery[] = [];
85
88
  readonly #suppressedDeliveries = new Set<string>();
86
89
  readonly #watchedJobs = new Set<string>();
87
90
  readonly #evictionTimers = new Map<string, NodeJS.Timeout>();
@@ -219,22 +222,24 @@ export class AsyncJobManager {
219
222
  return this.#filterJobs(this.#jobs.values(), filter);
220
223
  }
221
224
 
222
- getDeliveryState(): AsyncJobDeliveryState {
223
- const nextRetryAt = this.#deliveries.reduce<number | undefined>((next, delivery) => {
225
+ getDeliveryState(filter?: AsyncJobFilter): AsyncJobDeliveryState {
226
+ const deliveries = this.#filterDeliveries(filter);
227
+ const inFlightDeliveries = this.#filterInFlightDeliveries(filter);
228
+ const nextRetryAt = deliveries.reduce<number | undefined>((next, delivery) => {
224
229
  if (next === undefined) return delivery.nextAttemptAt;
225
230
  return Math.min(next, delivery.nextAttemptAt);
226
231
  }, undefined);
227
232
 
228
233
  return {
229
- queued: this.#deliveries.length,
230
- delivering: this.#deliveryLoop !== undefined,
234
+ queued: deliveries.length + inFlightDeliveries.length,
235
+ delivering: inFlightDeliveries.length > 0 || (this.#deliveryLoop !== undefined && deliveries.length > 0),
231
236
  nextRetryAt,
232
- pendingJobIds: this.#deliveries.map(delivery => delivery.jobId),
237
+ pendingJobIds: deliveries.concat(inFlightDeliveries).map(delivery => delivery.jobId),
233
238
  };
234
239
  }
235
240
 
236
- hasPendingDeliveries(): boolean {
237
- return this.#deliveries.length > 0;
241
+ hasPendingDeliveries(filter?: AsyncJobFilter): boolean {
242
+ return this.getDeliveryState(filter).queued > 0;
238
243
  }
239
244
 
240
245
  watchJobs(jobIds: string[]): number {
@@ -290,12 +295,25 @@ export class AsyncJobManager {
290
295
  await Promise.all(Array.from(this.#jobs.values()).map(job => job.promise));
291
296
  }
292
297
 
293
- async drainDeliveries(options?: { timeoutMs?: number }): Promise<boolean> {
298
+ async drainDeliveries(options?: { timeoutMs?: number; filter?: AsyncJobFilter }): Promise<boolean> {
294
299
  const timeoutMs = options?.timeoutMs;
300
+ const filter = options?.filter;
295
301
  const hasDeadline = timeoutMs !== undefined;
296
302
  const deadline = hasDeadline ? Date.now() + Math.max(timeoutMs, 0) : Number.POSITIVE_INFINITY;
297
303
 
298
- while (this.hasPendingDeliveries()) {
304
+ while (this.hasPendingDeliveries(filter)) {
305
+ if (filter?.ownerId) {
306
+ const delivered = await this.#deliverNextFiltered(filter, deadline);
307
+ if (delivered) continue;
308
+ return false;
309
+ }
310
+ const inFlightDeliveries = this.#filterInFlightDeliveries();
311
+ if (inFlightDeliveries.length > 0 && this.#filterDeliveries().length === 0) {
312
+ const delivered = await this.#waitForDeliveryPromise(inFlightDeliveries[0]?.promise, deadline);
313
+ if (delivered) continue;
314
+ return false;
315
+ }
316
+
299
317
  this.#ensureDeliveryLoop();
300
318
  const loop = this.#deliveryLoop;
301
319
  if (!loop) {
@@ -313,7 +331,7 @@ export class AsyncJobManager {
313
331
  }
314
332
 
315
333
  await Promise.race([loop, Bun.sleep(remainingMs)]);
316
- if (Date.now() >= deadline && this.hasPendingDeliveries()) {
334
+ if (Date.now() >= deadline && this.hasPendingDeliveries(filter)) {
317
335
  return false;
318
336
  }
319
337
  }
@@ -330,6 +348,7 @@ export class AsyncJobManager {
330
348
  this.#clearEvictionTimers();
331
349
  this.#jobs.clear();
332
350
  this.#deliveries.length = 0;
351
+ this.#inFlightDeliveries.length = 0;
333
352
  this.#suppressedDeliveries.clear();
334
353
  this.#watchedJobs.clear();
335
354
  return drained;
@@ -388,6 +407,55 @@ export class AsyncJobManager {
388
407
  this.#evictionTimers.clear();
389
408
  }
390
409
 
410
+ #filterDeliveries(filter?: AsyncJobFilter): AsyncJobDelivery[] {
411
+ const ownerId = filter?.ownerId;
412
+ if (!ownerId) return this.#deliveries.filter(delivery => !this.isDeliverySuppressed(delivery.jobId));
413
+ return this.#deliveries.filter(
414
+ delivery => delivery.ownerId === ownerId && !this.isDeliverySuppressed(delivery.jobId),
415
+ );
416
+ }
417
+
418
+ #filterInFlightDeliveries(filter?: AsyncJobFilter): AsyncJobDelivery[] {
419
+ const ownerId = filter?.ownerId;
420
+ if (!ownerId) return this.#inFlightDeliveries.filter(delivery => !this.isDeliverySuppressed(delivery.jobId));
421
+ return this.#inFlightDeliveries.filter(
422
+ delivery => delivery.ownerId === ownerId && !this.isDeliverySuppressed(delivery.jobId),
423
+ );
424
+ }
425
+
426
+ async #deliverNextFiltered(filter: AsyncJobFilter, deadline: number): Promise<boolean> {
427
+ while (true) {
428
+ let selected: AsyncJobDelivery | undefined;
429
+ for (const delivery of this.#deliveries) {
430
+ if (delivery.ownerId !== filter.ownerId) continue;
431
+ if (this.isDeliverySuppressed(delivery.jobId)) continue;
432
+ if (!selected || delivery.nextAttemptAt < selected.nextAttemptAt) {
433
+ selected = delivery;
434
+ }
435
+ }
436
+
437
+ if (!selected) {
438
+ const inFlight = this.#filterInFlightDeliveries(filter);
439
+ if (inFlight.length === 0) return true;
440
+ return this.#waitForDeliveryPromise(inFlight[0]?.promise, deadline);
441
+ }
442
+
443
+ const now = Date.now();
444
+ if (selected.nextAttemptAt > now) {
445
+ if (selected.nextAttemptAt > deadline) return false;
446
+ await Bun.sleep(selected.nextAttemptAt - now);
447
+ continue;
448
+ }
449
+
450
+ const index = this.#deliveries.indexOf(selected);
451
+ if (index === -1) continue;
452
+ this.#deliveries.splice(index, 1);
453
+ if (this.isDeliverySuppressed(selected.jobId)) continue;
454
+
455
+ return this.#waitForDeliveryPromise(this.#deliverDelivery(selected), deadline);
456
+ }
457
+ }
458
+
391
459
  isDeliverySuppressed(jobId: string): boolean {
392
460
  return this.#suppressedDeliveries.has(jobId) || this.#watchedJobs.has(jobId);
393
461
  }
@@ -402,6 +470,7 @@ export class AsyncJobManager {
402
470
  text,
403
471
  attempt: 0,
404
472
  nextAttemptAt: Date.now(),
473
+ ownerId: this.#jobs.get(jobId)?.ownerId,
405
474
  });
406
475
  this.#ensureDeliveryLoop();
407
476
  }
@@ -437,20 +506,25 @@ export class AsyncJobManager {
437
506
  if (this.#deliveries[0] !== delivery) {
438
507
  continue;
439
508
  }
440
- // Check again after sleep
441
509
  if (this.isDeliverySuppressed(delivery.jobId)) {
442
510
  this.#deliveries.shift();
443
511
  continue;
444
512
  }
445
513
 
514
+ this.#deliveries.shift();
515
+ await this.#deliverDelivery(delivery);
516
+ }
517
+ }
518
+
519
+ #deliverDelivery(delivery: AsyncJobDelivery): Promise<void> {
520
+ const promise = (async () => {
521
+ this.#inFlightDeliveries.push(delivery);
446
522
  try {
447
523
  await this.#onJobComplete(delivery.jobId, delivery.text, this.#jobs.get(delivery.jobId));
448
- this.#deliveries.shift();
449
524
  } catch (error) {
450
525
  delivery.attempt += 1;
451
526
  delivery.lastError = error instanceof Error ? error.message : String(error);
452
527
  delivery.nextAttemptAt = Date.now() + this.#getRetryDelay(delivery.attempt);
453
- this.#deliveries.shift();
454
528
  if (!this.isDeliverySuppressed(delivery.jobId)) {
455
529
  this.#deliveries.push(delivery);
456
530
  }
@@ -460,8 +534,32 @@ export class AsyncJobManager {
460
534
  nextRetryAt: delivery.nextAttemptAt,
461
535
  error: delivery.lastError,
462
536
  });
537
+ } finally {
538
+ const index = this.#inFlightDeliveries.indexOf(delivery);
539
+ if (index !== -1) this.#inFlightDeliveries.splice(index, 1);
540
+ if (this.#deliveries.length > 0) this.#ensureDeliveryLoop();
463
541
  }
542
+ })();
543
+ delivery.promise = promise;
544
+ return promise;
545
+ }
546
+
547
+ async #waitForDeliveryPromise(promise: Promise<void> | undefined, deadline: number): Promise<boolean> {
548
+ if (!promise) return true;
549
+ if (deadline === Number.POSITIVE_INFINITY) {
550
+ await promise;
551
+ return true;
464
552
  }
553
+ const remainingMs = deadline - Date.now();
554
+ if (remainingMs <= 0) return false;
555
+ let timedOut = false;
556
+ await Promise.race([
557
+ promise,
558
+ Bun.sleep(remainingMs).then(() => {
559
+ timedOut = true;
560
+ }),
561
+ ]);
562
+ return !timedOut;
465
563
  }
466
564
 
467
565
  #getRetryDelay(attempt: number): number {
@@ -235,11 +235,7 @@ async function printVerification(expectedVersion: string): Promise<void> {
235
235
  chalk.yellow(`\nWarning: could not verify updated version${result.path ? ` at ${result.path}` : ""}`),
236
236
  );
237
237
  }
238
- console.log(
239
- chalk.yellow(
240
- `You may need to reinstall: curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | bash`,
241
- ),
242
- );
238
+ console.log(chalk.yellow(`You may need to reinstall: curl -fsSL https://omp.sh/install | sh`));
243
239
  }
244
240
 
245
241
  /**
@@ -38,6 +38,75 @@ export interface RuntimeOptions {
38
38
  extraGlobals?: Record<string, unknown>;
39
39
  }
40
40
 
41
+ // Strict base64: characters from the standard alphabet plus optional `=` padding, and a
42
+ // length that is a multiple of four. URL-safe base64 and embedded whitespace are not
43
+ // accepted — the Anthropic API only honors strict base64 in image sources.
44
+ const BASE64_STRICT_RE = /^[A-Za-z0-9+/]+={0,2}$/;
45
+ const DECIMAL_CSV_RE = /^\d{1,3}(?:,\d{1,3})*$/;
46
+
47
+ function isStrictBase64(s: string): boolean {
48
+ if (s.length === 0 || s.length % 4 !== 0) return false;
49
+ return BASE64_STRICT_RE.test(s);
50
+ }
51
+
52
+ /**
53
+ * Normalize the `data` field of an `{ type: "image", data, mimeType }` display payload
54
+ * into strict base64. Accepts:
55
+ * - already-valid base64 strings (passed through verbatim)
56
+ * - `Uint8Array` / `Buffer` / `ArrayBuffer` / typed array views
57
+ * - `{ type: "Buffer", data: number[] }` (the shape Node serializes Buffers to via
58
+ * `JSON.stringify`)
59
+ * - decimal-CSV byte strings (the output of `uint8array.toString("base64")`, which
60
+ * silently ignores the encoding argument and returns `Array.prototype.toString` —
61
+ * a footgun for callers expecting `Buffer.toString` semantics)
62
+ * Returns `null` if no recovery is possible.
63
+ */
64
+ function coerceImageBase64(data: unknown): string | null {
65
+ if (typeof data === "string") {
66
+ if (isStrictBase64(data)) return data;
67
+ if (DECIMAL_CSV_RE.test(data)) {
68
+ const parts = data.split(",");
69
+ const bytes = new Uint8Array(parts.length);
70
+ for (let i = 0; i < parts.length; i++) {
71
+ const n = Number(parts[i]);
72
+ if (!Number.isInteger(n) || n < 0 || n > 255) return null;
73
+ bytes[i] = n;
74
+ }
75
+ return Buffer.from(bytes).toString("base64");
76
+ }
77
+ return null;
78
+ }
79
+ if (data instanceof Uint8Array) return Buffer.from(data).toString("base64");
80
+ if (data instanceof ArrayBuffer) return Buffer.from(data).toString("base64");
81
+ if (ArrayBuffer.isView(data)) {
82
+ const view = data as ArrayBufferView;
83
+ return Buffer.from(view.buffer, view.byteOffset, view.byteLength).toString("base64");
84
+ }
85
+ if (data && typeof data === "object") {
86
+ const obj = data as { type?: unknown; data?: unknown };
87
+ if (obj.type === "Buffer" && Array.isArray(obj.data)) {
88
+ const arr = obj.data as unknown[];
89
+ const bytes = new Uint8Array(arr.length);
90
+ for (let i = 0; i < arr.length; i++) {
91
+ const n = arr[i];
92
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 0 || n > 255) return null;
93
+ bytes[i] = n;
94
+ }
95
+ return Buffer.from(bytes).toString("base64");
96
+ }
97
+ }
98
+ return null;
99
+ }
100
+
101
+ function describeDataType(data: unknown): string {
102
+ if (data === null) return "null";
103
+ if (data instanceof Uint8Array) return "Uint8Array";
104
+ if (data instanceof ArrayBuffer) return "ArrayBuffer";
105
+ if (ArrayBuffer.isView(data)) return data.constructor.name;
106
+ if (typeof data === "string") return `string(${data.length})`;
107
+ return typeof data;
108
+ }
109
+
41
110
  /**
42
111
  * Shared JS runtime for the eval worker and the browser tab worker. Owns the prelude,
43
112
  * helper bag, console bridge, and indirect-eval execution. Emits text/display/tool-call
@@ -109,8 +178,19 @@ export class JsRuntime {
109
178
  if (!hooks) return;
110
179
  if (value && typeof value === "object") {
111
180
  const record = value as Record<string, unknown>;
112
- if (record.type === "image" && typeof record.data === "string" && typeof record.mimeType === "string") {
113
- hooks.onDisplay({ type: "image", data: record.data, mimeType: record.mimeType });
181
+ if (record.type === "image" && typeof record.mimeType === "string") {
182
+ const data = coerceImageBase64(record.data);
183
+ if (data !== null) {
184
+ hooks.onDisplay({ type: "image", data, mimeType: record.mimeType });
185
+ return;
186
+ }
187
+ logger.warn("js displayValue: dropping image with unrecognized data shape", {
188
+ mimeType: record.mimeType,
189
+ dataType: describeDataType(record.data),
190
+ });
191
+ hooks.onText(
192
+ `[display: image dropped — \`data\` must be a base64 string, Uint8Array/Buffer, or ArrayBuffer; got ${describeDataType(record.data)}]\n`,
193
+ );
114
194
  return;
115
195
  }
116
196
  try {
@@ -21,7 +21,7 @@
21
21
  * `@sinclair/typebox` directly in their own package.
22
22
  */
23
23
 
24
- import { areJsonValuesEqual } from "@oh-my-pi/pi-ai/utils/schema";
24
+ import { areJsonValuesEqual, zodToWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
25
25
  import {
26
26
  type ZodArray,
27
27
  type ZodEnum,
@@ -104,19 +104,46 @@ interface ObjectOpts extends Meta {
104
104
  // Helpers
105
105
  // ---------------------------------------------------------------------------
106
106
 
107
+ /**
108
+ * Stamp a non-enumerable `toJSON()` on a schema so `JSON.stringify(schema)`
109
+ * yields a clean draft 2020-12 JSON Schema — matching real TypeBox semantics
110
+ * where the schema object IS already a JSON Schema. Without this, an extension
111
+ * author who serialises the schema across any JSON boundary (worker
112
+ * postMessage, MCP transport, config persistence, network hop, structuredClone
113
+ * fallback) ships the raw Zod internals (`def`, `_zod`, object-shaped `enum`,
114
+ * `"type":"enum"`) — neither valid JSON Schema nor parseable Zod. See
115
+ * issue #1101 for the symptoms when this leaks into a tool's `input_schema`.
116
+ *
117
+ * Idempotent: re-stamping the same instance is a no-op.
118
+ */
119
+ function wire<T extends ZodType>(schema: T): T {
120
+ if (!Object.hasOwn(schema as object, "toJSON")) {
121
+ Object.defineProperty(schema as object, "toJSON", {
122
+ value: function toJSON(this: ZodType) {
123
+ return zodToWireSchema(this);
124
+ },
125
+ enumerable: false,
126
+ writable: true,
127
+ configurable: true,
128
+ });
129
+ }
130
+ return schema;
131
+ }
132
+
107
133
  function withMeta<T extends ZodType>(schema: T, opts: Meta | undefined): T {
108
- if (!opts) return schema;
109
134
  let out: ZodType = schema;
110
- if (typeof opts.description === "string") out = out.describe(opts.description);
111
- if ("default" in opts) out = out.default(opts.default as never) as unknown as ZodType;
135
+ if (opts) {
136
+ if (typeof opts.description === "string") out = out.describe(opts.description);
137
+ if ("default" in opts) out = out.default(opts.default as never) as unknown as ZodType;
112
138
 
113
- const metadata: Record<string, unknown> = {};
114
- for (const [key, value] of Object.entries(opts)) {
115
- if (key === "description" || key === "default" || key === "additionalProperties") continue;
116
- metadata[key] = value;
139
+ const metadata: Record<string, unknown> = {};
140
+ for (const key in opts) {
141
+ if (key === "description" || key === "default" || key === "additionalProperties") continue;
142
+ metadata[key] = opts[key];
143
+ }
144
+ if (Object.keys(metadata).length > 0) out = out.meta(metadata);
117
145
  }
118
- if (Object.keys(metadata).length > 0) out = out.meta(metadata);
119
- return out as T;
146
+ return wire(out as T);
120
147
  }
121
148
 
122
149
  // ---------------------------------------------------------------------------
@@ -313,7 +340,8 @@ function tRecord<V extends ZodType>(key: ZodType, value: V, opts?: Meta): ZodTyp
313
340
  }
314
341
 
315
342
  function tOptional<E extends ZodType>(schema: E, _opts?: Meta): ZodOptional<E> {
316
- return isOptional(schema) ? (schema as unknown as ZodOptional<E>) : (schema.optional() as ZodOptional<E>);
343
+ if (isOptional(schema)) return wire(schema as unknown as ZodOptional<E>);
344
+ return wire(schema.optional() as ZodOptional<E>);
317
345
  }
318
346
 
319
347
  function tNullable<E extends ZodType>(schema: E, opts?: Meta): ZodType {
@@ -322,27 +350,26 @@ function tNullable<E extends ZodType>(schema: E, opts?: Meta): ZodType {
322
350
 
323
351
  function tReadonly<E extends ZodType>(schema: E): E {
324
352
  // TypeBox's `Type.Readonly` is purely a marker; runtime parsing is identical.
325
- return schema;
353
+ return wire(schema);
326
354
  }
327
355
 
328
356
  function tPartial<P extends ZodRawShape>(obj: ZodObject<P>): ZodObject<P> {
329
- return obj.partial() as unknown as ZodObject<P>;
357
+ return wire(obj.partial() as unknown as ZodObject<P>);
330
358
  }
331
359
 
332
360
  function tRequired<P extends ZodRawShape>(obj: ZodObject<P>): ZodObject<P> {
333
- return obj.required() as unknown as ZodObject<P>;
361
+ return wire(obj.required() as unknown as ZodObject<P>);
334
362
  }
335
363
 
336
364
  function tPick<P extends ZodRawShape, K extends keyof P>(obj: ZodObject<P>, keys: readonly K[]): ZodObject<Pick<P, K>> {
337
365
  const mask = Object.fromEntries(keys.map(k => [k as string, true]));
338
- return obj.pick(mask as never) as unknown as ZodObject<Pick<P, K>>;
366
+ return wire(obj.pick(mask as never) as unknown as ZodObject<Pick<P, K>>);
339
367
  }
340
368
 
341
369
  function tOmit<P extends ZodRawShape, K extends keyof P>(obj: ZodObject<P>, keys: readonly K[]): ZodObject<Omit<P, K>> {
342
370
  const mask = Object.fromEntries(keys.map(k => [k as string, true]));
343
- return obj.omit(mask as never) as unknown as ZodObject<Omit<P, K>>;
371
+ return wire(obj.omit(mask as never) as unknown as ZodObject<Omit<P, K>>);
344
372
  }
345
-
346
373
  function tComposite(objects: readonly ZodObject<ZodRawShape>[], opts?: Meta): ZodObject<ZodRawShape> {
347
374
  // `Type.Composite([...])` flattens every object schema into one object schema
348
375
  // rather than producing an intersection. Mirror that via repeated `extend`.