@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 +24 -0
- package/dist/types/async/job-manager.d.ts +3 -2
- package/dist/types/main.d.ts +11 -2
- package/dist/types/modes/acp/acp-agent.d.ts +1 -1
- package/dist/types/modes/acp/acp-event-mapper.d.ts +13 -1
- package/dist/types/modes/acp/acp-mode.d.ts +3 -1
- package/dist/types/plan-mode/approved-plan.d.ts +6 -4
- package/dist/types/session/agent-session.d.ts +6 -2
- package/dist/types/session/client-bridge.d.ts +3 -0
- package/dist/types/tools/ast-edit.d.ts +2 -0
- package/dist/types/tools/ast-grep.d.ts +2 -0
- package/dist/types/tools/render-utils.d.ts +13 -3
- package/package.json +7 -7
- package/src/async/job-manager.ts +111 -13
- package/src/cli/update-cli.ts +1 -5
- package/src/eval/js/shared/runtime.ts +82 -2
- package/src/extensibility/typebox.ts +44 -17
- package/src/main.ts +215 -148
- package/src/modes/acp/acp-agent.ts +115 -32
- package/src/modes/acp/acp-client-bridge.ts +2 -1
- package/src/modes/acp/acp-event-mapper.ts +208 -32
- package/src/modes/acp/acp-mode.ts +11 -3
- package/src/modes/components/tree-selector.ts +26 -7
- package/src/plan-mode/approved-plan.ts +21 -9
- package/src/prompts/agents/oracle.md +56 -0
- package/src/prompts/tools/ask.md +4 -3
- package/src/sdk.ts +3 -1
- package/src/session/agent-session.ts +186 -54
- package/src/session/client-bridge.ts +3 -0
- package/src/task/agents.ts +2 -0
- package/src/tools/ast-edit.ts +19 -11
- package/src/tools/ast-grep.ts +14 -10
- package/src/tools/render-utils.ts +26 -12
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;
|
package/dist/types/main.d.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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.
|
|
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.
|
|
51
|
-
"@oh-my-pi/pi-agent-core": "15.1.
|
|
52
|
-
"@oh-my-pi/pi-ai": "15.1.
|
|
53
|
-
"@oh-my-pi/pi-natives": "15.1.
|
|
54
|
-
"@oh-my-pi/pi-tui": "15.1.
|
|
55
|
-
"@oh-my-pi/pi-utils": "15.1.
|
|
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",
|
package/src/async/job-manager.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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:
|
|
237
|
+
pendingJobIds: deliveries.concat(inFlightDeliveries).map(delivery => delivery.jobId),
|
|
233
238
|
};
|
|
234
239
|
}
|
|
235
240
|
|
|
236
|
-
hasPendingDeliveries(): boolean {
|
|
237
|
-
return this
|
|
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 {
|
package/src/cli/update-cli.ts
CHANGED
|
@@ -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.
|
|
113
|
-
|
|
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 {
|