@oh-my-pi/pi-coding-agent 15.0.1 → 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 +38 -0
- package/package.json +8 -8
- package/src/commands/commit.ts +10 -0
- package/src/config/model-registry.ts +31 -1
- package/src/config/settings-schema.ts +11 -0
- package/src/discovery/claude-plugins.ts +19 -7
- package/src/eval/py/runner.py +42 -11
- package/src/eval/py/runtime.ts +1 -0
- package/src/extensibility/extensions/get-commands-handler.ts +77 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +48 -31
- package/src/hashline/input.ts +2 -1
- package/src/hashline/parser.ts +27 -3
- package/src/internal-urls/docs-index.generated.ts +8 -8
- 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/modes/acp/acp-agent.ts +248 -50
- package/src/modes/components/status-line/segments.ts +38 -4
- package/src/modes/controllers/extension-ui-controller.ts +3 -2
- package/src/modes/rpc/host-uris.ts +235 -0
- package/src/modes/rpc/rpc-mode.ts +27 -1
- package/src/modes/rpc/rpc-types.ts +57 -0
- package/src/modes/runtime-init.ts +2 -1
- package/src/modes/theme/defaults/dark-poimandres.json +1 -0
- package/src/modes/theme/defaults/light-poimandres.json +1 -0
- package/src/modes/theme/theme.ts +6 -0
- package/src/prompts/tools/github.md +4 -4
- package/src/prompts/tools/hashline.md +22 -26
- package/src/prompts/tools/read.md +55 -37
- package/src/task/discovery.ts +5 -2
- package/src/task/executor.ts +2 -1
- package/src/tools/bash-command-fixup.ts +47 -0
- package/src/tools/bash.ts +39 -15
- package/src/tools/browser/render.ts +2 -2
- package/src/tools/eval.ts +10 -2
- package/src/tools/gh.ts +37 -4
- package/src/tools/job.ts +16 -7
- package/src/tools/output-meta.ts +26 -0
- package/src/tools/read.ts +32 -4
- package/src/tools/ssh.ts +3 -2
- package/src/tools/write.ts +20 -0
- 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 +5 -0
- package/src/web/search/providers/jina.ts +5 -2
- package/src/web/search/providers/zai.ts +5 -2
|
@@ -50,6 +50,14 @@ export class InternalUrlRouter {
|
|
|
50
50
|
this.#handlers.set(handler.scheme.toLowerCase(), handler);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
unregister(scheme: string): boolean {
|
|
54
|
+
return this.#handlers.delete(scheme.toLowerCase());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
getHandler(scheme: string): ProtocolHandler | undefined {
|
|
58
|
+
return this.#handlers.get(scheme.toLowerCase());
|
|
59
|
+
}
|
|
60
|
+
|
|
53
61
|
canHandle(input: string): boolean {
|
|
54
62
|
const match = input.match(/^([a-z][a-z0-9+.-]*):\/\//i);
|
|
55
63
|
if (!match) return false;
|
|
@@ -63,6 +63,18 @@ export interface ResolveContext {
|
|
|
63
63
|
signal?: AbortSignal;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Caller context for write operations dispatched to host-owned URI handlers.
|
|
68
|
+
* Mirrors {@link ResolveContext} so handlers that share read/write state can
|
|
69
|
+
* accept the same shape.
|
|
70
|
+
*/
|
|
71
|
+
export interface WriteContext {
|
|
72
|
+
/** Working directory of the calling session. */
|
|
73
|
+
cwd?: string;
|
|
74
|
+
/** Caller's abort signal. */
|
|
75
|
+
signal?: AbortSignal;
|
|
76
|
+
}
|
|
77
|
+
|
|
66
78
|
/**
|
|
67
79
|
* Handler for a specific internal URL scheme (e.g., agent://, memory://, skill://, mcp://).
|
|
68
80
|
*/
|
|
@@ -86,4 +98,13 @@ export interface ProtocolHandler {
|
|
|
86
98
|
* @throws Error with user-friendly message if resolution fails
|
|
87
99
|
*/
|
|
88
100
|
resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource>;
|
|
101
|
+
/**
|
|
102
|
+
* Optional write hook. When present, the write tool dispatches
|
|
103
|
+
* `write(url, content)` to this handler instead of writing to a filesystem
|
|
104
|
+
* path. The handler is responsible for any persistence and validation.
|
|
105
|
+
*
|
|
106
|
+
* Handlers that omit this method are treated as read-only; the write tool
|
|
107
|
+
* surfaces a clear "not writable" error when invoked against them.
|
|
108
|
+
*/
|
|
109
|
+
write?(url: InternalUrl, content: string, context?: WriteContext): Promise<void>;
|
|
89
110
|
}
|
package/src/lsp/config.ts
CHANGED
|
@@ -154,16 +154,25 @@ function applyRuntimeDefaults(servers: Record<string, ServerConfig>): Record<str
|
|
|
154
154
|
* Check if any root marker file exists in the directory
|
|
155
155
|
*/
|
|
156
156
|
export function hasRootMarkers(cwd: string, markers: string[]): boolean {
|
|
157
|
+
let entries: string[] | null = null;
|
|
157
158
|
for (const marker of markers) {
|
|
158
|
-
// Handle glob-like patterns (e.g., "*.cabal")
|
|
159
|
+
// Handle glob-like patterns (e.g., "*.cabal"). Root markers live at the
|
|
160
|
+
// project root, so a one-level readdir is sufficient — and avoids
|
|
161
|
+
// Bun.Glob descending into node_modules for patterns like "**/*.cabal".
|
|
159
162
|
if (marker.includes("*")) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
+
if (entries === null) {
|
|
164
|
+
try {
|
|
165
|
+
entries = fs.readdirSync(cwd);
|
|
166
|
+
} catch {
|
|
167
|
+
entries = [];
|
|
168
|
+
logger.warn("Failed to list directory for glob root marker.", { marker, cwd });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const glob = new Bun.Glob(marker);
|
|
172
|
+
for (const entry of entries) {
|
|
173
|
+
if (glob.match(entry)) {
|
|
163
174
|
return true;
|
|
164
175
|
}
|
|
165
|
-
} catch {
|
|
166
|
-
logger.warn("Failed to resolve glob root marker.", { marker, cwd });
|
|
167
176
|
}
|
|
168
177
|
continue;
|
|
169
178
|
}
|
package/src/lsp/defaults.json
CHANGED
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
"fileTypes": [".rs"],
|
|
6
6
|
"rootMarkers": ["Cargo.toml", "rust-analyzer.toml"],
|
|
7
7
|
"initOptions": {},
|
|
8
|
-
"settings": {
|
|
8
|
+
"settings": {
|
|
9
|
+
"rust-analyzer": {
|
|
10
|
+
"checkOnSave": false
|
|
11
|
+
}
|
|
12
|
+
},
|
|
9
13
|
"capabilities": {
|
|
10
14
|
"flycheck": true,
|
|
11
15
|
"ssr": true,
|
|
@@ -424,7 +428,7 @@
|
|
|
424
428
|
"args": ["server"],
|
|
425
429
|
"fileTypes": [".md", ".markdown"],
|
|
426
430
|
"rootMarkers": [".marksman.toml", ".git"],
|
|
427
|
-
"warmupTimeoutMs":
|
|
431
|
+
"warmupTimeoutMs": 2000
|
|
428
432
|
},
|
|
429
433
|
"texlab": {
|
|
430
434
|
"command": "texlab",
|
|
@@ -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";
|
|
@@ -89,6 +93,11 @@ type AgentImageContent = {
|
|
|
89
93
|
mimeType: string;
|
|
90
94
|
};
|
|
91
95
|
|
|
96
|
+
type PromptQueueState = {
|
|
97
|
+
promise: Promise<void>;
|
|
98
|
+
release: (() => void) | undefined;
|
|
99
|
+
};
|
|
100
|
+
|
|
92
101
|
type PromptTurnState = {
|
|
93
102
|
userMessageId: string;
|
|
94
103
|
cancelRequested: boolean;
|
|
@@ -97,12 +106,14 @@ type PromptTurnState = {
|
|
|
97
106
|
unsubscribe: (() => void) | undefined;
|
|
98
107
|
resolve: (value: PromptResponse) => void;
|
|
99
108
|
reject: (reason?: unknown) => void;
|
|
109
|
+
promise: Promise<PromptResponse>;
|
|
100
110
|
};
|
|
101
111
|
|
|
102
112
|
type ManagedSessionRecord = {
|
|
103
113
|
session: AgentSession;
|
|
104
114
|
mcpManager: MCPManager | undefined;
|
|
105
115
|
promptTurn: PromptTurnState | undefined;
|
|
116
|
+
promptQueue: PromptQueueState;
|
|
106
117
|
liveMessageId: string | undefined;
|
|
107
118
|
liveMessageProgress: { textEmitted: boolean; thoughtEmitted: boolean } | undefined;
|
|
108
119
|
extensionsConfigured: boolean;
|
|
@@ -138,35 +149,185 @@ type MCPSourceMap = {
|
|
|
138
149
|
|
|
139
150
|
type CreateAcpSession = (cwd: string) => Promise<AgentSession>;
|
|
140
151
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
+
}
|
|
170
331
|
|
|
171
332
|
export class AcpAgent implements Agent {
|
|
172
333
|
#connection: AgentSideConnection;
|
|
@@ -380,31 +541,58 @@ export class AcpAgent implements Agent {
|
|
|
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> {
|
|
@@ -700,6 +888,7 @@ export class AcpAgent implements Agent {
|
|
|
700
888
|
session,
|
|
701
889
|
mcpManager: undefined,
|
|
702
890
|
promptTurn: undefined,
|
|
891
|
+
promptQueue: { promise: Promise.resolve(), release: undefined },
|
|
703
892
|
liveMessageId: undefined,
|
|
704
893
|
liveMessageProgress: undefined,
|
|
705
894
|
extensionsConfigured: false,
|
|
@@ -777,6 +966,7 @@ export class AcpAgent implements Agent {
|
|
|
777
966
|
|
|
778
967
|
if (event.type === "agent_end") {
|
|
779
968
|
await this.#emitEndOfTurnUpdates(record);
|
|
969
|
+
await record.session.waitForIdle();
|
|
780
970
|
this.#finishPrompt(record, {
|
|
781
971
|
stopReason: this.#resolveStopReason(event, promptTurn.cancelRequested),
|
|
782
972
|
usage: this.#buildTurnUsage(promptTurn.usageBaseline, record.session.sessionManager.getUsageStatistics()),
|
|
@@ -1552,7 +1742,7 @@ export class AcpAgent implements Agent {
|
|
|
1552
1742
|
getActiveTools: () => record.session.getActiveToolNames(),
|
|
1553
1743
|
getAllTools: () => record.session.getAllToolNames(),
|
|
1554
1744
|
setActiveTools: toolNames => record.session.setActiveToolsByName(toolNames),
|
|
1555
|
-
getCommands: () =>
|
|
1745
|
+
getCommands: () => getSessionSlashCommands(record.session),
|
|
1556
1746
|
setModel: async model => {
|
|
1557
1747
|
const apiKey = await record.session.modelRegistry.getApiKey(model);
|
|
1558
1748
|
if (!apiKey) {
|
|
@@ -1607,7 +1797,15 @@ export class AcpAgent implements Agent {
|
|
|
1607
1797
|
},
|
|
1608
1798
|
compact: instructionsOrOptions => runExtensionCompact(record.session, instructionsOrOptions),
|
|
1609
1799
|
},
|
|
1610
|
-
|
|
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),
|
|
1611
1809
|
);
|
|
1612
1810
|
await extensionRunner.emit({ type: "session_start" });
|
|
1613
1811
|
record.extensionsConfigured = true;
|
|
@@ -2,7 +2,7 @@ import * as os from "node:os";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
4
4
|
import { TERMINAL } from "@oh-my-pi/pi-tui";
|
|
5
|
-
import { formatDuration, formatNumber, getProjectDir, relativePathWithinRoot } from "@oh-my-pi/pi-utils";
|
|
5
|
+
import { formatDuration, formatNumber, getProjectDir, pathIsWithin, relativePathWithinRoot } from "@oh-my-pi/pi-utils";
|
|
6
6
|
import { type ThemeColor, theme } from "../../../modes/theme/theme";
|
|
7
7
|
import { shortenPath } from "../../../tools/render-utils";
|
|
8
8
|
import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/session-color";
|
|
@@ -32,6 +32,33 @@ function normalizePremiumRequests(value: number): number {
|
|
|
32
32
|
return Math.round((value + Number.EPSILON) * 100) / 100;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
const SCRATCH_ROOTS: readonly string[] = (() => {
|
|
36
|
+
const roots = new Set<string>([os.tmpdir(), path.join(os.homedir(), "tmp")]);
|
|
37
|
+
if (process.platform === "win32") {
|
|
38
|
+
const { TEMP, TMP, SystemRoot } = process.env;
|
|
39
|
+
if (TEMP) roots.add(TEMP);
|
|
40
|
+
if (TMP) roots.add(TMP);
|
|
41
|
+
if (SystemRoot) roots.add(path.join(SystemRoot, "Temp"));
|
|
42
|
+
} else {
|
|
43
|
+
roots.add("/tmp");
|
|
44
|
+
roots.add("/var/tmp");
|
|
45
|
+
if (process.platform === "darwin") {
|
|
46
|
+
roots.add("/private/tmp");
|
|
47
|
+
roots.add("/private/var/tmp");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return [...roots];
|
|
51
|
+
})();
|
|
52
|
+
|
|
53
|
+
function classifyProjectDir(pwd: string): { scratch: boolean; relative: string | null } {
|
|
54
|
+
for (const root of SCRATCH_ROOTS) {
|
|
55
|
+
if (pathIsWithin(root, pwd)) {
|
|
56
|
+
return { scratch: true, relative: relativePathWithinRoot(root, pwd) };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { scratch: false, relative: null };
|
|
60
|
+
}
|
|
61
|
+
|
|
35
62
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
36
63
|
// Segment Implementations
|
|
37
64
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -150,10 +177,16 @@ const pathSegment: StatusLineSegment = {
|
|
|
150
177
|
render(ctx) {
|
|
151
178
|
const opts = ctx.options.path ?? {};
|
|
152
179
|
|
|
153
|
-
|
|
180
|
+
const projectDir = getProjectDir();
|
|
181
|
+
const { scratch, relative } = classifyProjectDir(projectDir);
|
|
182
|
+
let pwd = projectDir;
|
|
154
183
|
|
|
155
184
|
if (opts.stripWorkPrefix !== false) {
|
|
156
|
-
|
|
185
|
+
if (scratch) {
|
|
186
|
+
if (relative) pwd = relative;
|
|
187
|
+
} else {
|
|
188
|
+
pwd = stripDisplayRoot(pwd);
|
|
189
|
+
}
|
|
157
190
|
}
|
|
158
191
|
if (opts.abbreviate !== false) {
|
|
159
192
|
pwd = shortenPath(pwd);
|
|
@@ -166,7 +199,8 @@ const pathSegment: StatusLineSegment = {
|
|
|
166
199
|
pwd = `${ellipsis}${pwd.slice(-sliceLen)}`;
|
|
167
200
|
}
|
|
168
201
|
|
|
169
|
-
const
|
|
202
|
+
const icon = scratch ? theme.icon.scratchFolder : theme.icon.folder;
|
|
203
|
+
const content = withIcon(icon, pwd);
|
|
170
204
|
return { content: theme.fg("statusLinePath", content), visible: true };
|
|
171
205
|
},
|
|
172
206
|
};
|
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
SendUserMessageHandler,
|
|
17
17
|
TerminalInputHandler,
|
|
18
18
|
} from "../../extensibility/extensions";
|
|
19
|
+
import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler";
|
|
19
20
|
import { HookEditorComponent } from "../../modes/components/hook-editor";
|
|
20
21
|
import { HookInputComponent } from "../../modes/components/hook-input";
|
|
21
22
|
import { HookSelectorComponent } from "../../modes/components/hook-selector";
|
|
@@ -109,7 +110,7 @@ export class ExtensionUiController {
|
|
|
109
110
|
},
|
|
110
111
|
getThinkingLevel: () => this.ctx.session.thinkingLevel,
|
|
111
112
|
setThinkingLevel: level => this.ctx.session.setThinkingLevel(level),
|
|
112
|
-
getCommands: () =>
|
|
113
|
+
getCommands: () => getSessionSlashCommands(this.ctx.session),
|
|
113
114
|
getSessionName: () => this.ctx.sessionManager.getSessionName(),
|
|
114
115
|
setSessionName: name => this.#updateSessionName(name),
|
|
115
116
|
};
|
|
@@ -349,7 +350,7 @@ export class ExtensionUiController {
|
|
|
349
350
|
},
|
|
350
351
|
getThinkingLevel: () => this.ctx.session.thinkingLevel,
|
|
351
352
|
setThinkingLevel: (level, persist) => this.ctx.session.setThinkingLevel(level, persist),
|
|
352
|
-
getCommands: () =>
|
|
353
|
+
getCommands: () => getSessionSlashCommands(this.ctx.session),
|
|
353
354
|
getSessionName: () => this.ctx.sessionManager.getSessionName(),
|
|
354
355
|
setSessionName: name => this.#updateSessionName(name),
|
|
355
356
|
};
|