@oh-my-pi/pi-coding-agent 15.1.3 → 15.1.5

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,26 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.1.5] - 2026-05-19
6
+
7
+ ### Fixed
8
+
9
+ - Fixed `ast_grep` and `ast_edit` tool details retaining every per-file parse error — a scan over hundreds of files with syntax-error nodes inflated `details.parseErrors` to one entry per file, leaking into traces and the renderer's "X more" overflow. Errors are now capped at `PARSE_ERRORS_LIMIT` (20) at the source, with the original total preserved in a new `parseErrorsTotal` field for accurate count labels.
10
+
11
+ ## [15.1.4] - 2026-05-19
12
+
13
+ ### Fixed
14
+
15
+ - 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))
16
+ - 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))
17
+
18
+ - 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
19
+ - Fixed ACP async-job draining to be scoped by session owner so `getAsyncJobSnapshot` and `drainAsyncJobDeliveriesForAcp` no longer consume or expose jobs from other sessions
20
+ - Fixed async job status reporting to include in-flight completions so queued/delivering indicators remain accurate while callbacks are still running
21
+ - Fixed `deferAgentInitiatedTurns` handling during ACP async-job draining so background completion follow-up turns are delivered even when agent-initiated turns are deferred
22
+ - 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))
23
+ - 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))
24
+
5
25
  ## [15.1.3] - 2026-05-17
6
26
  ### Breaking Changes
7
27
 
@@ -44,6 +64,10 @@
44
64
  - Fixed auth-gateway request cancellation for requests that are already aborted before dispatch.
45
65
  - 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
66
 
67
+ ### Fixed
68
+
69
+ - 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`.
70
+
47
71
  ## [15.1.2] - 2026-05-15
48
72
  ### Fixed
49
73
 
@@ -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;
@@ -19,6 +19,8 @@ export interface AstEditToolDetails {
19
19
  applied: boolean;
20
20
  limitReached: boolean;
21
21
  parseErrors?: string[];
22
+ /** Total parse error count before {@link PARSE_ERRORS_LIMIT} capping. Omitted when no errors. */
23
+ parseErrorsTotal?: number;
22
24
  scopePath?: string;
23
25
  files?: string[];
24
26
  fileReplacements?: Array<{
@@ -16,6 +16,8 @@ export interface AstGrepToolDetails {
16
16
  filesSearched: number;
17
17
  limitReached: boolean;
18
18
  parseErrors?: string[];
19
+ /** Total parse error count before {@link PARSE_ERRORS_LIMIT} capping. Omitted when no errors. */
20
+ parseErrorsTotal?: number;
19
21
  scopePath?: string;
20
22
  files?: string[];
21
23
  fileMatches?: Array<{
@@ -117,7 +117,17 @@ export declare function formatScreenshot(opts: {
117
117
  export declare function wrapBrackets(text: string, theme: Theme): string;
118
118
  export declare const PARSE_ERRORS_LIMIT = 20;
119
119
  export declare function dedupeParseErrors(errors: string[] | undefined): string[];
120
- export declare function formatParseErrors(errors: string[]): string[];
120
+ export declare function formatParseErrors(errors: string[], total?: number): string[];
121
+ /**
122
+ * Cap an upstream parse-error list to {@link PARSE_ERRORS_LIMIT} unique entries,
123
+ * preserving the original deduplicated total. Use this at the source so tool
124
+ * details never carry thousands of per-file parse errors into traces or
125
+ * renderers.
126
+ */
127
+ export declare function capParseErrors(errors: string[] | undefined, limit?: number): {
128
+ errors: string[];
129
+ total: number;
130
+ };
121
131
  /**
122
132
  * Group `rawLines` by blank-line separators, mirroring the historical search /
123
133
  * ast-grep / ast-edit renderer behavior: if any blank line is present, splits on
@@ -135,12 +145,12 @@ export declare function createCachedComponent(getExpanded: () => boolean, comput
135
145
  * {@link PARSE_ERRORS_LIMIT}) to `lines`, with an overflow summary line if the
136
146
  * total exceeds the cap. No-op when `parseErrors` is empty.
137
147
  */
138
- export declare function appendParseErrorsBulletList(lines: string[], parseErrors: readonly string[] | undefined, theme: Theme): void;
148
+ export declare function appendParseErrorsBulletList(lines: string[], parseErrors: readonly string[] | undefined, theme: Theme, total?: number): void;
139
149
  /**
140
150
  * Human-readable summary string for the parse-issues count, capped by
141
151
  * {@link PARSE_ERRORS_LIMIT}.
142
152
  */
143
- export declare function formatParseErrorsCountLabel(parseErrors: readonly string[]): string;
153
+ export declare function formatParseErrorsCountLabel(parseErrors: readonly string[], total?: number): string;
144
154
  export interface LspBatchRequest {
145
155
  id: string;
146
156
  flush: boolean;
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.5",
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.5",
51
+ "@oh-my-pi/pi-agent-core": "15.1.5",
52
+ "@oh-my-pi/pi-ai": "15.1.5",
53
+ "@oh-my-pi/pi-natives": "15.1.5",
54
+ "@oh-my-pi/pi-tui": "15.1.5",
55
+ "@oh-my-pi/pi-utils": "15.1.5",
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 {