@oh-my-pi/pi-coding-agent 15.0.0 → 15.0.2
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 +79 -0
- package/examples/extensions/plan-mode.ts +0 -1
- package/package.json +10 -10
- package/scripts/build-binary.ts +5 -0
- package/src/autoresearch/helpers.ts +17 -0
- package/src/autoresearch/tools/log-experiment.ts +9 -17
- package/src/autoresearch/tools/run-experiment.ts +2 -17
- package/src/capability/skill.ts +7 -0
- package/src/cli/list-models.ts +1 -1
- package/src/cli/shell-cli.ts +3 -13
- package/src/cli/update-cli.ts +1 -1
- package/src/cli.ts +10 -29
- package/src/commands/commit.ts +10 -0
- package/src/commit/agentic/tools/propose-changelog.ts +8 -1
- package/src/commit/analysis/conventional.ts +8 -66
- package/src/commit/map-reduce/reduce-phase.ts +6 -65
- package/src/commit/pipeline.ts +2 -2
- package/src/commit/shared-llm.ts +89 -0
- package/src/config/config-file.ts +210 -0
- package/src/config/model-equivalence.ts +8 -11
- package/src/config/model-registry.ts +44 -3
- package/src/config/model-resolver.ts +1 -4
- package/src/config/settings-schema.ts +82 -1
- package/src/config/settings.ts +1 -1
- package/src/config.ts +3 -219
- package/src/discovery/claude-plugins.ts +19 -7
- package/src/edit/renderer.ts +7 -1
- package/src/eval/js/executor.ts +3 -0
- package/src/eval/js/shared/rewrite-imports.ts +2 -2
- package/src/eval/py/executor.ts +5 -0
- package/src/eval/py/runner.py +42 -11
- package/src/eval/py/runtime.ts +1 -0
- package/src/exa/factory.ts +2 -2
- package/src/exa/mcp-client.ts +74 -1
- package/src/exec/bash-executor.ts +5 -1
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +0 -11
- package/src/extensibility/extensions/get-commands-handler.ts +77 -0
- package/src/extensibility/extensions/runner.ts +1 -1
- package/src/extensibility/extensions/types.ts +89 -223
- package/src/extensibility/hooks/types.ts +89 -314
- package/src/extensibility/plugins/legacy-pi-compat.ts +48 -31
- package/src/extensibility/shared-events.ts +343 -0
- package/src/extensibility/skills.ts +9 -0
- package/src/goals/index.ts +3 -0
- package/src/goals/runtime.ts +500 -0
- package/src/goals/state.ts +37 -0
- package/src/goals/tools/goal-tool.ts +237 -0
- package/src/hashline/anchors.ts +2 -2
- package/src/hashline/input.ts +2 -1
- package/src/hashline/parser.ts +27 -3
- package/src/hindsight/mental-models.ts +1 -1
- package/src/internal-urls/agent-protocol.ts +1 -20
- package/src/internal-urls/artifact-protocol.ts +1 -19
- package/src/internal-urls/docs-index.generated.ts +11 -12
- package/src/internal-urls/registry-helpers.ts +25 -0
- package/src/internal-urls/router.ts +8 -0
- package/src/internal-urls/types.ts +21 -0
- package/src/lsp/config.ts +15 -6
- package/src/lsp/defaults.json +6 -2
- package/src/main.ts +11 -2
- package/src/mcp/oauth-flow.ts +20 -0
- package/src/modes/acp/acp-agent.ts +327 -95
- package/src/modes/components/assistant-message.ts +14 -8
- package/src/modes/components/bash-execution.ts +24 -63
- package/src/modes/components/custom-message.ts +14 -40
- package/src/modes/components/eval-execution.ts +27 -57
- package/src/modes/components/execution-shared.ts +102 -0
- package/src/modes/components/hook-message.ts +17 -49
- package/src/modes/components/mcp-add-wizard.ts +26 -5
- package/src/modes/components/message-frame.ts +88 -0
- package/src/modes/components/model-selector.ts +1 -1
- package/src/modes/components/session-observer-overlay.ts +6 -2
- package/src/modes/components/session-selector.ts +1 -1
- package/src/modes/components/status-line/segments.ts +93 -8
- package/src/modes/components/status-line/types.ts +4 -0
- package/src/modes/components/status-line.ts +28 -10
- package/src/modes/components/tool-execution.ts +7 -8
- package/src/modes/controllers/command-controller-shared.ts +108 -0
- package/src/modes/controllers/command-controller.ts +13 -4
- package/src/modes/controllers/event-controller.ts +36 -7
- package/src/modes/controllers/extension-ui-controller.ts +3 -2
- package/src/modes/controllers/input-controller.ts +13 -0
- package/src/modes/controllers/mcp-command-controller.ts +56 -61
- package/src/modes/controllers/ssh-command-controller.ts +18 -57
- package/src/modes/interactive-mode.ts +624 -52
- package/src/modes/print-mode.ts +16 -86
- package/src/modes/rpc/host-uris.ts +235 -0
- package/src/modes/rpc/rpc-mode.ts +41 -88
- package/src/modes/rpc/rpc-types.ts +57 -0
- package/src/modes/runtime-init.ts +116 -0
- package/src/modes/theme/defaults/dark-poimandres.json +3 -0
- package/src/modes/theme/defaults/light-poimandres.json +3 -0
- package/src/modes/theme/theme.ts +24 -6
- package/src/modes/types.ts +14 -3
- package/src/modes/utils/context-usage.ts +13 -13
- package/src/modes/utils/ui-helpers.ts +10 -3
- package/src/plan-mode/approved-plan.ts +35 -1
- package/src/prompts/goals/goal-budget-limit.md +16 -0
- package/src/prompts/goals/goal-continuation.md +28 -0
- package/src/prompts/goals/goal-mode-active.md +23 -0
- package/src/prompts/system/plan-mode-active.md +5 -5
- package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
- package/src/prompts/tools/bash.md +6 -0
- package/src/prompts/tools/github.md +4 -4
- package/src/prompts/tools/goal.md +13 -0
- package/src/prompts/tools/hashline.md +101 -117
- package/src/prompts/tools/read.md +55 -36
- package/src/prompts/tools/resolve.md +6 -5
- package/src/sdk.ts +12 -5
- package/src/session/agent-session.ts +428 -106
- package/src/session/blob-store.ts +36 -3
- package/src/session/messages.ts +67 -2
- package/src/session/session-manager.ts +131 -12
- package/src/session/session-storage.ts +33 -15
- package/src/session/streaming-output.ts +309 -13
- package/src/slash-commands/builtin-registry.ts +18 -0
- package/src/ssh/ssh-executor.ts +5 -0
- package/src/system-prompt.ts +4 -2
- package/src/task/discovery.ts +5 -2
- package/src/task/executor.ts +19 -8
- package/src/task/index.ts +3 -0
- package/src/task/render.ts +21 -15
- package/src/task/types.ts +4 -0
- package/src/tools/ast-edit.ts +21 -120
- package/src/tools/ast-grep.ts +21 -119
- package/src/tools/bash-command-fixup.ts +47 -0
- package/src/tools/bash-interactive.ts +9 -1
- package/src/tools/bash.ts +66 -19
- package/src/tools/browser/attach.ts +3 -3
- package/src/tools/browser/launch.ts +81 -18
- package/src/tools/browser/registry.ts +1 -5
- package/src/tools/browser/render.ts +2 -2
- package/src/tools/browser/tab-supervisor.ts +51 -14
- package/src/tools/conflict-detect.ts +15 -4
- package/src/tools/eval.ts +12 -2
- package/src/tools/find.ts +20 -38
- package/src/tools/gh.ts +44 -10
- package/src/tools/index.ts +22 -11
- package/src/tools/inspect-image.ts +3 -10
- package/src/tools/job.ts +16 -7
- package/src/tools/output-meta.ts +202 -37
- package/src/tools/path-utils.ts +125 -2
- package/src/tools/read.ts +548 -237
- package/src/tools/render-utils.ts +92 -0
- package/src/tools/renderers.ts +2 -0
- package/src/tools/resolve.ts +72 -44
- package/src/tools/search.ts +120 -186
- package/src/tools/ssh.ts +3 -2
- package/src/tools/write.ts +64 -9
- package/src/utils/file-mentions.ts +1 -1
- package/src/utils/image-loading.ts +7 -3
- package/src/utils/image-resize.ts +32 -43
- package/src/vim/parser.ts +0 -17
- package/src/vim/render.ts +1 -1
- package/src/vim/types.ts +1 -1
- package/src/web/search/providers/anthropic.ts +5 -0
- package/src/web/search/providers/exa.ts +3 -0
- package/src/web/search/providers/gemini.ts +40 -95
- package/src/web/search/providers/jina.ts +5 -2
- package/src/web/search/providers/zai.ts +5 -2
- package/src/prompts/tools/exit-plan-mode.md +0 -6
- package/src/tools/exit-plan-mode.ts +0 -97
- package/src/utils/fuzzy.ts +0 -108
- package/src/utils/image-convert.ts +0 -27
|
@@ -9,6 +9,9 @@ import {
|
|
|
9
9
|
type ClientCapabilities,
|
|
10
10
|
type CloseSessionRequest,
|
|
11
11
|
type CloseSessionResponse,
|
|
12
|
+
type CreateElicitationResponse,
|
|
13
|
+
type ElicitationContentValue,
|
|
14
|
+
type ElicitationPropertySchema,
|
|
12
15
|
type ForkSessionRequest,
|
|
13
16
|
type ForkSessionResponse,
|
|
14
17
|
type InitializeRequest,
|
|
@@ -44,8 +47,9 @@ import { logger, VERSION } from "@oh-my-pi/pi-utils";
|
|
|
44
47
|
import { disableProvider, enableProvider, reset as resetCapabilities } from "../../capability";
|
|
45
48
|
import { Settings } from "../../config/settings";
|
|
46
49
|
import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
|
|
47
|
-
import type { ExtensionUIContext } from "../../extensibility/extensions";
|
|
50
|
+
import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../../extensibility/extensions";
|
|
48
51
|
import { runExtensionCompact } from "../../extensibility/extensions/compact-handler";
|
|
52
|
+
import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler";
|
|
49
53
|
import { buildSkillPromptMessage, getSkillSlashCommandName } from "../../extensibility/skills";
|
|
50
54
|
import { loadSlashCommands } from "../../extensibility/slash-commands";
|
|
51
55
|
import { MCPManager } from "../../mcp/manager";
|
|
@@ -53,7 +57,7 @@ import type { MCPServerConfig } from "../../mcp/types";
|
|
|
53
57
|
import { loadAllExtensions } from "../../modes/components/extensions/state-manager";
|
|
54
58
|
import { theme } from "../../modes/theme/theme";
|
|
55
59
|
import type { AgentSession, AgentSessionEvent } from "../../session/agent-session";
|
|
56
|
-
import { SKILL_PROMPT_MESSAGE_TYPE } from "../../session/messages";
|
|
60
|
+
import { isSilentAbort, SKILL_PROMPT_MESSAGE_TYPE } from "../../session/messages";
|
|
57
61
|
import {
|
|
58
62
|
SessionManager,
|
|
59
63
|
type SessionInfo as StoredSessionInfo,
|
|
@@ -73,6 +77,15 @@ const MODEL_CONFIG_ID = "model";
|
|
|
73
77
|
const THINKING_CONFIG_ID = "thinking";
|
|
74
78
|
const THINKING_OFF = "off";
|
|
75
79
|
const SESSION_PAGE_SIZE = 50;
|
|
80
|
+
/**
|
|
81
|
+
* Delay between `session/new` (or `session/load` / `session/resume` /
|
|
82
|
+
* `unstable_session/fork`) returning and the agent firing the first
|
|
83
|
+
* notifications against the new session id. Mitigates Zed's
|
|
84
|
+
* `Received session notification for unknown session` race — see
|
|
85
|
+
* `#scheduleBootstrapUpdates`. Exported so the ACP test harness can
|
|
86
|
+
* wait past this guard without hard-coding the literal.
|
|
87
|
+
*/
|
|
88
|
+
export const ACP_BOOTSTRAP_RACE_GUARD_MS = 50;
|
|
76
89
|
|
|
77
90
|
type AgentImageContent = {
|
|
78
91
|
type: "image";
|
|
@@ -80,6 +93,11 @@ type AgentImageContent = {
|
|
|
80
93
|
mimeType: string;
|
|
81
94
|
};
|
|
82
95
|
|
|
96
|
+
type PromptQueueState = {
|
|
97
|
+
promise: Promise<void>;
|
|
98
|
+
release: (() => void) | undefined;
|
|
99
|
+
};
|
|
100
|
+
|
|
83
101
|
type PromptTurnState = {
|
|
84
102
|
userMessageId: string;
|
|
85
103
|
cancelRequested: boolean;
|
|
@@ -88,15 +106,20 @@ type PromptTurnState = {
|
|
|
88
106
|
unsubscribe: (() => void) | undefined;
|
|
89
107
|
resolve: (value: PromptResponse) => void;
|
|
90
108
|
reject: (reason?: unknown) => void;
|
|
109
|
+
promise: Promise<PromptResponse>;
|
|
91
110
|
};
|
|
92
111
|
|
|
93
112
|
type ManagedSessionRecord = {
|
|
94
113
|
session: AgentSession;
|
|
95
114
|
mcpManager: MCPManager | undefined;
|
|
96
115
|
promptTurn: PromptTurnState | undefined;
|
|
116
|
+
promptQueue: PromptQueueState;
|
|
97
117
|
liveMessageId: string | undefined;
|
|
98
118
|
liveMessageProgress: { textEmitted: boolean; thoughtEmitted: boolean } | undefined;
|
|
99
119
|
extensionsConfigured: boolean;
|
|
120
|
+
// Installed inside `#scheduleBootstrapUpdates` (post-race-guard); released
|
|
121
|
+
// in `#disposeSessionRecord`. Lives independent of any prompt turn.
|
|
122
|
+
lifetimeUnsubscribe: (() => void) | undefined;
|
|
100
123
|
};
|
|
101
124
|
|
|
102
125
|
type ReplayableMessage = {
|
|
@@ -126,35 +149,185 @@ type MCPSourceMap = {
|
|
|
126
149
|
|
|
127
150
|
type CreateAcpSession = (cwd: string) => Promise<AgentSession>;
|
|
128
151
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
152
|
+
/**
|
|
153
|
+
* Bridge a single ExtensionUIContext call to the ACP `unstable_createElicitation`
|
|
154
|
+
* surface. Skills/extensions ask for one value at a time (a chosen option, a
|
|
155
|
+
* confirmation, a piece of text), so every elicitation here uses a one-property
|
|
156
|
+
* `value` schema; the caller narrows the resulting `ElicitationContentValue`
|
|
157
|
+
* back to its concrete primitive type.
|
|
158
|
+
*
|
|
159
|
+
* `dialogOptions.signal` short-circuits the elicitation if it is already
|
|
160
|
+
* aborted and races the in-flight request against the abort event. The SDK
|
|
161
|
+
* exposes no `cancel_elicitation` surface for form-mode elicitations
|
|
162
|
+
* (`unstable_completeElicitation` is URL-mode only), so the ACP request itself
|
|
163
|
+
* keeps running on the client side until the user dismisses it — but
|
|
164
|
+
* resolving the local promise unblocks the caller (matches the RPC mode
|
|
165
|
+
* pattern in `requestRpcEditor`). The abort listener is removed once the
|
|
166
|
+
* elicitation settles so that callers which reuse the same signal across many
|
|
167
|
+
* elicitations (e.g. `ask` multi-select loops) don't accumulate listeners and
|
|
168
|
+
* trip Node's `MaxListeners` warning.
|
|
169
|
+
*
|
|
170
|
+
* `dialogOptions.timeout` mirrors `RpcExtensionUIContext.#createDialogPromise`:
|
|
171
|
+
* when the timer fires before the client responds, `onTimeout` is invoked and
|
|
172
|
+
* the caller's promise resolves to the stub fallback. Late SDK responses that
|
|
173
|
+
* arrive after abort/timeout — both rejections and successful `accept`s —
|
|
174
|
+
* are dropped silently (no `logger.warn`) to keep operator logs clean.
|
|
175
|
+
*/
|
|
176
|
+
async function elicitFromAcpClient(
|
|
177
|
+
connection: AgentSideConnection,
|
|
178
|
+
sessionId: string,
|
|
179
|
+
method: "select" | "confirm" | "input",
|
|
180
|
+
message: string,
|
|
181
|
+
property: ElicitationPropertySchema,
|
|
182
|
+
dialogOptions: ExtensionUIDialogOptions | undefined,
|
|
183
|
+
): Promise<ElicitationContentValue | undefined> {
|
|
184
|
+
const signal = dialogOptions?.signal;
|
|
185
|
+
if (signal?.aborted) {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
const { promise, resolve } = Promise.withResolvers<CreateElicitationResponse | undefined>();
|
|
189
|
+
let settled = false;
|
|
190
|
+
let timeoutId: NodeJS.Timeout | undefined;
|
|
191
|
+
const finish = (value: CreateElicitationResponse | undefined) => {
|
|
192
|
+
if (settled) return;
|
|
193
|
+
settled = true;
|
|
194
|
+
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
|
195
|
+
signal?.removeEventListener("abort", onAbort);
|
|
196
|
+
resolve(value);
|
|
197
|
+
};
|
|
198
|
+
const onAbort = () => finish(undefined);
|
|
199
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
200
|
+
if (dialogOptions?.timeout !== undefined) {
|
|
201
|
+
timeoutId = setTimeout(() => {
|
|
202
|
+
if (settled) return;
|
|
203
|
+
try {
|
|
204
|
+
dialogOptions.onTimeout?.();
|
|
205
|
+
} catch (error) {
|
|
206
|
+
// A throwing `onTimeout` must not leave the elicitation promise
|
|
207
|
+
// pending — settle it via `finish` below regardless.
|
|
208
|
+
logger.warn("ACP elicitation onTimeout threw", { sessionId, method, error });
|
|
209
|
+
}
|
|
210
|
+
finish(undefined);
|
|
211
|
+
}, dialogOptions.timeout);
|
|
212
|
+
// A long pending timeout alone shouldn't keep the event loop alive when
|
|
213
|
+
// the rest of the agent has shut down — matches `job-manager.ts` /
|
|
214
|
+
// `executor.ts` timer hygiene. Connection + session lifetimes keep the
|
|
215
|
+
// loop alive on the happy path.
|
|
216
|
+
timeoutId.unref();
|
|
217
|
+
}
|
|
218
|
+
connection
|
|
219
|
+
.unstable_createElicitation({
|
|
220
|
+
mode: "form",
|
|
221
|
+
sessionId,
|
|
222
|
+
message,
|
|
223
|
+
requestedSchema: {
|
|
224
|
+
type: "object",
|
|
225
|
+
properties: { value: property },
|
|
226
|
+
required: ["value"],
|
|
227
|
+
},
|
|
228
|
+
})
|
|
229
|
+
.then(finish, error => {
|
|
230
|
+
// Caller may already have moved on via abort/timeout; suppress noise.
|
|
231
|
+
if (settled) return;
|
|
232
|
+
logger.warn("ACP elicitation failed", { sessionId, method, error });
|
|
233
|
+
finish(undefined);
|
|
234
|
+
});
|
|
235
|
+
const response = await promise;
|
|
236
|
+
if (!response || response.action !== "accept" || !response.content) {
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
return response.content.value;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Build an {@link ExtensionUIContext} that translates skill/extension UI
|
|
244
|
+
* requests into ACP elicitations against `connection` for the session
|
|
245
|
+
* returned by `getSessionId()`. The id is read lazily at each elicitation
|
|
246
|
+
* because `AgentSession.sessionId` is a getter over `sessionManager` state
|
|
247
|
+
* that mutates when an extension command calls `ctx.newSession` /
|
|
248
|
+
* `ctx.switchSession` — snapshotting it once at factory time would route
|
|
249
|
+
* later elicitations to the pre-switch id. Live reads keep the bridge
|
|
250
|
+
* symmetric with every other `sessionUpdate` call in this file
|
|
251
|
+
* (`record.session.sessionId` is always evaluated at emit time).
|
|
252
|
+
*
|
|
253
|
+
* The non-elicitation surface (custom components, editor, theming,
|
|
254
|
+
* terminal input) remains stubbed — ACP clients render those themselves
|
|
255
|
+
* or not at all. Capability gating respects the client's `initialize`
|
|
256
|
+
* advertisement.
|
|
257
|
+
*/
|
|
258
|
+
export function createAcpExtensionUiContext(
|
|
259
|
+
connection: AgentSideConnection,
|
|
260
|
+
getSessionId: () => string,
|
|
261
|
+
clientCapabilities: ClientCapabilities | undefined,
|
|
262
|
+
): ExtensionUIContext {
|
|
263
|
+
const supportsForm = clientCapabilities?.elicitation?.form != null;
|
|
264
|
+
return {
|
|
265
|
+
select: async (title, options, dialogOptions) => {
|
|
266
|
+
if (!supportsForm) return undefined;
|
|
267
|
+
const value = await elicitFromAcpClient(
|
|
268
|
+
connection,
|
|
269
|
+
getSessionId(),
|
|
270
|
+
"select",
|
|
271
|
+
title,
|
|
272
|
+
{ type: "string", enum: options },
|
|
273
|
+
dialogOptions,
|
|
274
|
+
);
|
|
275
|
+
return typeof value === "string" ? value : undefined;
|
|
276
|
+
},
|
|
277
|
+
confirm: async (title, message, dialogOptions) => {
|
|
278
|
+
if (!supportsForm) return false;
|
|
279
|
+
const value = await elicitFromAcpClient(
|
|
280
|
+
connection,
|
|
281
|
+
getSessionId(),
|
|
282
|
+
"confirm",
|
|
283
|
+
message.trim().length > 0 ? `${title}\n\n${message}` : title,
|
|
284
|
+
{ type: "boolean" },
|
|
285
|
+
dialogOptions,
|
|
286
|
+
);
|
|
287
|
+
return typeof value === "boolean" ? value : false;
|
|
288
|
+
},
|
|
289
|
+
input: async (title, placeholder, dialogOptions) => {
|
|
290
|
+
if (!supportsForm) return undefined;
|
|
291
|
+
const value = await elicitFromAcpClient(
|
|
292
|
+
connection,
|
|
293
|
+
getSessionId(),
|
|
294
|
+
"input",
|
|
295
|
+
title,
|
|
296
|
+
// ACP's `StringPropertySchema` has no `placeholder` field, so we
|
|
297
|
+
// surface the placeholder text as `description` — the closest
|
|
298
|
+
// semantic field a client can render alongside the input.
|
|
299
|
+
// Empty / whitespace-only placeholders are treated as absent.
|
|
300
|
+
{ type: "string", ...(placeholder?.trim() ? { description: placeholder } : {}) },
|
|
301
|
+
dialogOptions,
|
|
302
|
+
);
|
|
303
|
+
return typeof value === "string" ? value : undefined;
|
|
304
|
+
},
|
|
305
|
+
notify: (message, type) => {
|
|
306
|
+
logger.debug("ACP extension notification", { message, type });
|
|
307
|
+
},
|
|
308
|
+
onTerminalInput: () => () => {},
|
|
309
|
+
setStatus: () => {},
|
|
310
|
+
setWorkingMessage: () => {},
|
|
311
|
+
setWidget: () => {},
|
|
312
|
+
setFooter: () => {},
|
|
313
|
+
setHeader: () => {},
|
|
314
|
+
setTitle: () => {},
|
|
315
|
+
custom: async () => undefined as never,
|
|
316
|
+
pasteToEditor: () => {},
|
|
317
|
+
setEditorText: () => {},
|
|
318
|
+
getEditorText: () => "",
|
|
319
|
+
editor: async () => undefined,
|
|
320
|
+
setEditorComponent: () => {},
|
|
321
|
+
get theme() {
|
|
322
|
+
return theme;
|
|
323
|
+
},
|
|
324
|
+
getAllThemes: async () => [],
|
|
325
|
+
getTheme: async () => undefined,
|
|
326
|
+
setTheme: async () => ({ success: false, error: "Theme changes are unavailable in ACP mode" }),
|
|
327
|
+
getToolsExpanded: () => false,
|
|
328
|
+
setToolsExpanded: () => {},
|
|
329
|
+
};
|
|
330
|
+
}
|
|
158
331
|
|
|
159
332
|
export class AcpAgent implements Agent {
|
|
160
333
|
#connection: AgentSideConnection;
|
|
@@ -314,13 +487,7 @@ export class AcpAgent implements Agent {
|
|
|
314
487
|
sessionId: record.session.sessionId,
|
|
315
488
|
update: this.#buildCurrentModeUpdate(record.session),
|
|
316
489
|
});
|
|
317
|
-
await this.#
|
|
318
|
-
sessionId: record.session.sessionId,
|
|
319
|
-
update: {
|
|
320
|
-
sessionUpdate: "config_option_update",
|
|
321
|
-
configOptions: this.#buildConfigOptions(record.session),
|
|
322
|
-
},
|
|
323
|
-
});
|
|
490
|
+
await this.#pushConfigOptionUpdate(record);
|
|
324
491
|
return {};
|
|
325
492
|
}
|
|
326
493
|
|
|
@@ -354,57 +521,78 @@ export class AcpAgent implements Agent {
|
|
|
354
521
|
});
|
|
355
522
|
}
|
|
356
523
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
}
|
|
365
|
-
return { configOptions };
|
|
524
|
+
// For `thinking` the lifetime subscription pushes post-bootstrap; only
|
|
525
|
+
// push here when it's not yet installed so pre-bootstrap callers still
|
|
526
|
+
// see the change without a post-bootstrap duplicate.
|
|
527
|
+
const thinkingHandledBySubscription =
|
|
528
|
+
params.configId === THINKING_CONFIG_ID && record.lifetimeUnsubscribe !== undefined;
|
|
529
|
+
if (!thinkingHandledBySubscription) {
|
|
530
|
+
await this.#pushConfigOptionUpdate(record);
|
|
531
|
+
}
|
|
532
|
+
return { configOptions: this.#buildConfigOptions(record.session) };
|
|
366
533
|
}
|
|
367
534
|
|
|
368
535
|
async unstable_setSessionModel(params: SetSessionModelRequest): Promise<SetSessionModelResponse> {
|
|
369
536
|
const record = this.#getSessionRecord(params.sessionId);
|
|
370
537
|
await this.#setModelById(record.session, params.modelId);
|
|
371
|
-
await this.#
|
|
372
|
-
sessionId: record.session.sessionId,
|
|
373
|
-
update: {
|
|
374
|
-
sessionUpdate: "config_option_update",
|
|
375
|
-
configOptions: this.#buildConfigOptions(record.session),
|
|
376
|
-
},
|
|
377
|
-
});
|
|
538
|
+
await this.#pushConfigOptionUpdate(record);
|
|
378
539
|
return {};
|
|
379
540
|
}
|
|
380
541
|
|
|
381
542
|
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
|
382
543
|
const record = this.#getSessionRecord(params.sessionId);
|
|
383
|
-
|
|
544
|
+
const activeTurn = record.promptTurn;
|
|
545
|
+
if (activeTurn && !activeTurn.settled && record.session.isStreaming) {
|
|
384
546
|
throw new Error("ACP prompt already in progress for this session");
|
|
385
547
|
}
|
|
548
|
+
return await this.#queuePrompt(record, async () => {
|
|
549
|
+
const queuedTurn = record.promptTurn;
|
|
550
|
+
if (queuedTurn && !queuedTurn.settled) {
|
|
551
|
+
await queuedTurn.promise.catch(() => undefined);
|
|
552
|
+
}
|
|
386
553
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
554
|
+
const converted = this.#convertPromptBlocks(params.prompt);
|
|
555
|
+
const pendingPrompt = Promise.withResolvers<PromptResponse>();
|
|
556
|
+
record.promptTurn = {
|
|
557
|
+
userMessageId: params.messageId ?? crypto.randomUUID(),
|
|
558
|
+
cancelRequested: false,
|
|
559
|
+
settled: false,
|
|
560
|
+
usageBaseline: this.#cloneUsageStatistics(record.session.sessionManager.getUsageStatistics()),
|
|
561
|
+
unsubscribe: undefined,
|
|
562
|
+
resolve: pendingPrompt.resolve,
|
|
563
|
+
reject: pendingPrompt.reject,
|
|
564
|
+
promise: pendingPrompt.promise,
|
|
565
|
+
};
|
|
398
566
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
567
|
+
record.promptTurn.unsubscribe = record.session.subscribe(event => {
|
|
568
|
+
void this.#handlePromptEvent(record, event);
|
|
569
|
+
});
|
|
402
570
|
|
|
403
|
-
|
|
404
|
-
|
|
571
|
+
this.#runPromptOrCommand(record, converted.text, converted.images).catch((error: unknown) => {
|
|
572
|
+
this.#finishPrompt(record, undefined, error);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
return await pendingPrompt.promise;
|
|
405
576
|
});
|
|
577
|
+
}
|
|
406
578
|
|
|
407
|
-
|
|
579
|
+
async #queuePrompt(record: ManagedSessionRecord, run: () => Promise<PromptResponse>): Promise<PromptResponse> {
|
|
580
|
+
const nextQueue = Promise.withResolvers<void>();
|
|
581
|
+
const releaseQueue = nextQueue.resolve;
|
|
582
|
+
const previousQueue = record.promptQueue;
|
|
583
|
+
record.promptQueue = {
|
|
584
|
+
promise: nextQueue.promise,
|
|
585
|
+
release: releaseQueue,
|
|
586
|
+
};
|
|
587
|
+
await previousQueue.promise;
|
|
588
|
+
try {
|
|
589
|
+
return await run();
|
|
590
|
+
} finally {
|
|
591
|
+
releaseQueue();
|
|
592
|
+
if (record.promptQueue.release === releaseQueue) {
|
|
593
|
+
record.promptQueue.release = undefined;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
408
596
|
}
|
|
409
597
|
|
|
410
598
|
async #runPromptOrCommand(record: ManagedSessionRecord, text: string, images: AgentImageContent[]): Promise<void> {
|
|
@@ -432,13 +620,7 @@ export class AcpAgent implements Agent {
|
|
|
432
620
|
});
|
|
433
621
|
},
|
|
434
622
|
notifyConfigChanged: async () => {
|
|
435
|
-
await this.#
|
|
436
|
-
sessionId: record.session.sessionId,
|
|
437
|
-
update: {
|
|
438
|
-
sessionUpdate: "config_option_update",
|
|
439
|
-
configOptions: this.#buildConfigOptions(record.session),
|
|
440
|
-
},
|
|
441
|
-
});
|
|
623
|
+
await this.#pushConfigOptionUpdate(record);
|
|
442
624
|
},
|
|
443
625
|
});
|
|
444
626
|
if (builtinResult !== false) {
|
|
@@ -688,6 +870,8 @@ export class AcpAgent implements Agent {
|
|
|
688
870
|
async #registerPreparedSession(session: AgentSession, mcpServers: McpServer[]): Promise<ManagedSessionRecord> {
|
|
689
871
|
const record = this.#createManagedSessionRecord(session);
|
|
690
872
|
session.setClientBridge(createAcpClientBridge(this.#connection, session.sessionId, this.#clientCapabilities));
|
|
873
|
+
// `record.lifetimeUnsubscribe` is installed in `#scheduleBootstrapUpdates`
|
|
874
|
+
// so it shares the bootstrap race guard — see that comment for why.
|
|
691
875
|
try {
|
|
692
876
|
await this.#configureExtensions(record);
|
|
693
877
|
await this.#configureMcpServers(record, mcpServers);
|
|
@@ -704,12 +888,28 @@ export class AcpAgent implements Agent {
|
|
|
704
888
|
session,
|
|
705
889
|
mcpManager: undefined,
|
|
706
890
|
promptTurn: undefined,
|
|
891
|
+
promptQueue: { promise: Promise.resolve(), release: undefined },
|
|
707
892
|
liveMessageId: undefined,
|
|
708
893
|
liveMessageProgress: undefined,
|
|
709
894
|
extensionsConfigured: false,
|
|
895
|
+
lifetimeUnsubscribe: undefined,
|
|
710
896
|
};
|
|
711
897
|
}
|
|
712
898
|
|
|
899
|
+
async #handleLifetimeEvent(record: ManagedSessionRecord, event: AgentSessionEvent): Promise<void> {
|
|
900
|
+
if (event.type !== "thinking_level_changed") {
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
try {
|
|
904
|
+
await this.#pushConfigOptionUpdate(record);
|
|
905
|
+
} catch (error) {
|
|
906
|
+
logger.warn("Failed to push thinking-level config_option_update", {
|
|
907
|
+
sessionId: record.session.sessionId,
|
|
908
|
+
error,
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
713
913
|
#getSessionRecord(sessionId: string): ManagedSessionRecord {
|
|
714
914
|
const record = this.#sessions.get(sessionId);
|
|
715
915
|
if (!record) {
|
|
@@ -766,6 +966,7 @@ export class AcpAgent implements Agent {
|
|
|
766
966
|
|
|
767
967
|
if (event.type === "agent_end") {
|
|
768
968
|
await this.#emitEndOfTurnUpdates(record);
|
|
969
|
+
await record.session.waitForIdle();
|
|
769
970
|
this.#finishPrompt(record, {
|
|
770
971
|
stopReason: this.#resolveStopReason(event, promptTurn.cancelRequested),
|
|
771
972
|
usage: this.#buildTurnUsage(promptTurn.usageBaseline, record.session.sessionManager.getUsageStatistics()),
|
|
@@ -912,6 +1113,16 @@ export class AcpAgent implements Agent {
|
|
|
912
1113
|
};
|
|
913
1114
|
}
|
|
914
1115
|
|
|
1116
|
+
async #pushConfigOptionUpdate(record: ManagedSessionRecord): Promise<void> {
|
|
1117
|
+
await this.#connection.sessionUpdate({
|
|
1118
|
+
sessionId: record.session.sessionId,
|
|
1119
|
+
update: {
|
|
1120
|
+
sessionUpdate: "config_option_update",
|
|
1121
|
+
configOptions: this.#buildConfigOptions(record.session),
|
|
1122
|
+
},
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
|
|
915
1126
|
#buildConfigOptions(session: AgentSession): SessionConfigOption[] {
|
|
916
1127
|
const currentModeId = this.#getCurrentModeId(session);
|
|
917
1128
|
const modeOptions = this.#getAvailableModes(session).map(mode => ({
|
|
@@ -1124,18 +1335,25 @@ export class AcpAgent implements Agent {
|
|
|
1124
1335
|
}
|
|
1125
1336
|
|
|
1126
1337
|
#scheduleBootstrapUpdates(sessionId: string): void {
|
|
1127
|
-
//
|
|
1128
|
-
//
|
|
1129
|
-
//
|
|
1130
|
-
//
|
|
1131
|
-
//
|
|
1132
|
-
//
|
|
1133
|
-
//
|
|
1134
|
-
//
|
|
1135
|
-
//
|
|
1136
|
-
//
|
|
1137
|
-
//
|
|
1138
|
-
//
|
|
1338
|
+
// Defer first notifications until the response has reached the client.
|
|
1339
|
+
// Zed's agent-client-protocol reader dispatches responses and
|
|
1340
|
+
// notifications to different async tasks; sending the first
|
|
1341
|
+
// `available_commands_update` from `setTimeout(0)` reliably loses the
|
|
1342
|
+
// race against the response handler and Zed logs `Received session
|
|
1343
|
+
// notification for unknown session` then drops the update — leaving
|
|
1344
|
+
// the slash-command palette empty (#1015 follow-up; see
|
|
1345
|
+
// zed-industries/zed#55965 for the same race biting other ACP agents).
|
|
1346
|
+
// `ACP_BOOTSTRAP_RACE_GUARD_MS` is invisible to the operator and large
|
|
1347
|
+
// enough that the response future has scheduled before our timer fires
|
|
1348
|
+
// on stdio-only transports.
|
|
1349
|
+
//
|
|
1350
|
+
// The session-lifetime subscription is installed inside the same timer
|
|
1351
|
+
// so it shares this guard — without it, an extension's `session_start`
|
|
1352
|
+
// handler (or any async work it schedules) calling `setThinkingLevel`
|
|
1353
|
+
// would push a `config_option_update` for a session id the client
|
|
1354
|
+
// hasn't been told about yet. The pre-bootstrap thinking level is
|
|
1355
|
+
// reported in the response's `configOptions`, so deferring the
|
|
1356
|
+
// notification loses no state.
|
|
1139
1357
|
setTimeout(() => {
|
|
1140
1358
|
if (this.#connection.signal.aborted) {
|
|
1141
1359
|
return;
|
|
@@ -1144,8 +1362,13 @@ export class AcpAgent implements Agent {
|
|
|
1144
1362
|
if (!record) {
|
|
1145
1363
|
return;
|
|
1146
1364
|
}
|
|
1365
|
+
if (!record.lifetimeUnsubscribe) {
|
|
1366
|
+
record.lifetimeUnsubscribe = record.session.subscribe(event => {
|
|
1367
|
+
void this.#handleLifetimeEvent(record, event);
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1147
1370
|
void this.#emitBootstrapUpdates(sessionId, record);
|
|
1148
|
-
},
|
|
1371
|
+
}, ACP_BOOTSTRAP_RACE_GUARD_MS);
|
|
1149
1372
|
}
|
|
1150
1373
|
|
|
1151
1374
|
async #emitBootstrapUpdates(sessionId: string, record: ManagedSessionRecord): Promise<void> {
|
|
@@ -1393,7 +1616,7 @@ export class AcpAgent implements Agent {
|
|
|
1393
1616
|
}
|
|
1394
1617
|
}
|
|
1395
1618
|
}
|
|
1396
|
-
if (notifications.length === 0 && message.errorMessage) {
|
|
1619
|
+
if (notifications.length === 0 && message.errorMessage && !isSilentAbort(message.errorMessage)) {
|
|
1397
1620
|
notifications.push({
|
|
1398
1621
|
sessionId,
|
|
1399
1622
|
update: {
|
|
@@ -1519,7 +1742,7 @@ export class AcpAgent implements Agent {
|
|
|
1519
1742
|
getActiveTools: () => record.session.getActiveToolNames(),
|
|
1520
1743
|
getAllTools: () => record.session.getAllToolNames(),
|
|
1521
1744
|
setActiveTools: toolNames => record.session.setActiveToolsByName(toolNames),
|
|
1522
|
-
getCommands: () =>
|
|
1745
|
+
getCommands: () => getSessionSlashCommands(record.session),
|
|
1523
1746
|
setModel: async model => {
|
|
1524
1747
|
const apiKey = await record.session.modelRegistry.getApiKey(model);
|
|
1525
1748
|
if (!apiKey) {
|
|
@@ -1574,7 +1797,15 @@ export class AcpAgent implements Agent {
|
|
|
1574
1797
|
},
|
|
1575
1798
|
compact: instructionsOrOptions => runExtensionCompact(record.session, instructionsOrOptions),
|
|
1576
1799
|
},
|
|
1577
|
-
|
|
1800
|
+
// Per-session getter: `record.session.sessionId` reads through to
|
|
1801
|
+
// `sessionManager.getSessionId()` (it's a getter, not a field), so an
|
|
1802
|
+
// extension command that calls `ctx.newSession` / `ctx.switchSession`
|
|
1803
|
+
// — both exposed in the block just above — mutates the underlying id
|
|
1804
|
+
// mid-flight. Reading lazily on each elicitation matches every other
|
|
1805
|
+
// `sessionUpdate` call in this file. Hoisting the factory to an
|
|
1806
|
+
// `AcpAgent` field would still be wrong because it would also lose
|
|
1807
|
+
// the per-`record` binding.
|
|
1808
|
+
createAcpExtensionUiContext(this.#connection, () => record.session.sessionId, this.#clientCapabilities),
|
|
1578
1809
|
);
|
|
1579
1810
|
await extensionRunner.emit({ type: "session_start" });
|
|
1580
1811
|
record.extensionsConfigured = true;
|
|
@@ -1674,6 +1905,7 @@ export class AcpAgent implements Agent {
|
|
|
1674
1905
|
}
|
|
1675
1906
|
|
|
1676
1907
|
async #disposeSessionRecord(record: ManagedSessionRecord): Promise<void> {
|
|
1908
|
+
record.lifetimeUnsubscribe?.();
|
|
1677
1909
|
if (record.mcpManager) {
|
|
1678
1910
|
try {
|
|
1679
1911
|
await record.mcpManager.disconnectAll();
|
|
@@ -3,8 +3,8 @@ import { Container, Image, ImageProtocol, Markdown, Spacer, TERMINAL, Text } fro
|
|
|
3
3
|
import { formatNumber } from "@oh-my-pi/pi-utils";
|
|
4
4
|
import { settings } from "../../config/settings";
|
|
5
5
|
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
|
|
6
|
+
import { isSilentAbort } from "../../session/messages";
|
|
6
7
|
import { resolveImageOptions } from "../../tools/render-utils";
|
|
7
|
-
import { convertToPng } from "../../utils/image-convert";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Component that renders a complete assistant message
|
|
@@ -76,14 +76,15 @@ export class AssistantMessageComponent extends Container {
|
|
|
76
76
|
const key = `${toolCallId}:${index}`;
|
|
77
77
|
if (this.#convertedKittyImages.has(key) || this.#kittyConversionsInFlight.has(key)) continue;
|
|
78
78
|
this.#kittyConversionsInFlight.add(key);
|
|
79
|
-
|
|
80
|
-
.
|
|
79
|
+
new Bun.Image(Buffer.from(image.data, "base64"))
|
|
80
|
+
.png()
|
|
81
|
+
.toBase64()
|
|
82
|
+
.then(data => {
|
|
81
83
|
this.#kittyConversionsInFlight.delete(key);
|
|
82
|
-
if (!converted) return;
|
|
83
84
|
this.#convertedKittyImages.set(key, {
|
|
84
85
|
type: "image",
|
|
85
|
-
data
|
|
86
|
-
mimeType:
|
|
86
|
+
data,
|
|
87
|
+
mimeType: "image/png",
|
|
87
88
|
});
|
|
88
89
|
if (this.#lastMessage) {
|
|
89
90
|
this.updateContent(this.#lastMessage);
|
|
@@ -184,7 +185,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
184
185
|
// But only if there are no tool calls (tool execution components will show the error)
|
|
185
186
|
const hasToolCalls = message.content.some(c => c.type === "toolCall");
|
|
186
187
|
if (!hasToolCalls) {
|
|
187
|
-
if (message.stopReason === "aborted") {
|
|
188
|
+
if (message.stopReason === "aborted" && !isSilentAbort(message.errorMessage)) {
|
|
188
189
|
const abortMessage =
|
|
189
190
|
message.errorMessage && message.errorMessage !== "Request was aborted"
|
|
190
191
|
? message.errorMessage
|
|
@@ -201,7 +202,12 @@ export class AssistantMessageComponent extends Container {
|
|
|
201
202
|
this.#contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), 1, 0));
|
|
202
203
|
}
|
|
203
204
|
}
|
|
204
|
-
if (
|
|
205
|
+
if (
|
|
206
|
+
message.errorMessage &&
|
|
207
|
+
!isSilentAbort(message.errorMessage) &&
|
|
208
|
+
message.stopReason !== "aborted" &&
|
|
209
|
+
message.stopReason !== "error"
|
|
210
|
+
) {
|
|
205
211
|
this.#contentContainer.addChild(new Spacer(1));
|
|
206
212
|
this.#contentContainer.addChild(new Text(theme.fg("error", `Error: ${message.errorMessage}`), 1, 0));
|
|
207
213
|
}
|