pim-agent 0.10.0 → 0.11.0
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/package.json +1 -2
- package/packages/core/src/extensions/bash/index.ts +2 -1
- package/packages/core/src/extensions/bash/run.ts +8 -2
- package/packages/core/src/extensions/system-prompt/index.ts +16 -1
- package/packages/core/src/extensions/system-prompt/prompt.ts +1 -2
- package/packages/core/src/session/SessionHost.ts +35 -9
- package/packages/core/src/session/SessionRegistry.ts +22 -1
- package/packages/core/src/session/SessionUi.ts +127 -0
- package/packages/core/src/shared/Directories.ts +39 -5
- package/packages/core/src/shared/ExtensionToggles.ts +8 -0
- package/packages/core/src/shared/PiExtensions.ts +228 -0
- package/packages/protocol/src/Command.ts +22 -1
- package/packages/protocol/src/ServerEvent.ts +34 -0
- package/packages/server/src/ProbeClient.ts +21 -0
- package/packages/server/src/SessionStream.ts +228 -2
- package/packages/server/src/WsGateway.ts +153 -16
- package/packages/server/src/probe.ts +16 -0
- package/packages/web/dist/client/assets/CommitMonoV143-VF-ytizKI8U.woff2 +0 -0
- package/packages/web/dist/client/assets/{core-CA0aSzPu.js → core-D3tyj0PP.js} +1 -1
- package/packages/web/dist/client/assets/index-CTrlTM53.js +45 -0
- package/packages/web/dist/client/assets/index-JnduS4Cd.css +1 -0
- package/packages/web/dist/client/index.html +2 -2
- package/packages/web/dist/client/assets/commit-mono-latin-300-italic-B48RJMeK.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-300-italic-DBq5bJCE.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-300-normal-B-iV2FbL.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-300-normal-kM0OTZCv.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-italic-BXinMwCi.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-italic-DjSHLl2N.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-normal-s0S3qwFW.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-normal-wzhe4RuD.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-600-normal-BuQDXT0M.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-600-normal-SwgSWHaV.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-700-normal-1-GU0IUE.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-700-normal-DU8mrtj2.woff2 +0 -0
- package/packages/web/dist/client/assets/index-B9be-i9d.css +0 -1
- package/packages/web/dist/client/assets/index-CpinM5NK.js +0 -45
|
@@ -118,8 +118,19 @@ export type Command =
|
|
|
118
118
|
}
|
|
119
119
|
/** The models this server can switch to, plus the current model's thinking levels; answers without a session. */
|
|
120
120
|
| { readonly id: string; readonly type: "list_models" }
|
|
121
|
+
/** The extensions this server can switch on and off, read against the connection's cwd when it has one. */
|
|
122
|
+
| { readonly id: string; readonly type: "list_extensions" }
|
|
123
|
+
/** Switch one extension on or off; the sessions built from here pick it up as each rebuilds its agent. */
|
|
124
|
+
| {
|
|
125
|
+
readonly id: string;
|
|
126
|
+
readonly type: "set_extension";
|
|
127
|
+
readonly extensionId: string;
|
|
128
|
+
readonly value: boolean;
|
|
129
|
+
}
|
|
121
130
|
/** Subdirectories of `path` on the server's filesystem; errors rather than answering empty when it is not a readable directory. */
|
|
122
131
|
| { readonly id: string; readonly type: "list_dirs"; readonly path: string }
|
|
132
|
+
/** Makes the directory `path` names, one level inside an existing one; answers empty, and the caller re-lists. */
|
|
133
|
+
| { readonly id: string; readonly type: "create_dir"; readonly path: string }
|
|
123
134
|
/** Re-read the cwd's git state now; `fetch` asks the remote first, which is the only thing that moves ahead and behind. */
|
|
124
135
|
| {
|
|
125
136
|
readonly id: string;
|
|
@@ -195,7 +206,17 @@ export type Command =
|
|
|
195
206
|
readonly callId: string;
|
|
196
207
|
}
|
|
197
208
|
/** Update this install and restart it unconditionally; refused while any session is mid-turn unless `force`. */
|
|
198
|
-
| { readonly id: string; readonly type: "reload"; readonly force?: boolean }
|
|
209
|
+
| { readonly id: string; readonly type: "reload"; readonly force?: boolean }
|
|
210
|
+
/** Answers one `ui_request`; dismissal is `cancelled`, and a late answer to a settled request is refused. */
|
|
211
|
+
| {
|
|
212
|
+
readonly id: string;
|
|
213
|
+
readonly type: "ui_response";
|
|
214
|
+
readonly sessionId: string;
|
|
215
|
+
readonly requestId: string;
|
|
216
|
+
readonly value?: string;
|
|
217
|
+
readonly confirmed?: boolean;
|
|
218
|
+
readonly cancelled?: boolean;
|
|
219
|
+
};
|
|
199
220
|
|
|
200
221
|
export type CommandType = Command["type"];
|
|
201
222
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { DirectoryListing } from "#core/shared/Directories";
|
|
2
2
|
import type { CommitResult, GitBranch } from "#core/shared/Git";
|
|
3
3
|
import type { PickerItem } from "#core/picker/PickerItem";
|
|
4
|
+
import type { ExtensionEntry } from "#core/shared/PiExtensions";
|
|
4
5
|
import type { SearchRange, SearchSnippet } from "#core/session/SearchIndex";
|
|
5
6
|
import type { LeaseFrontend } from "#core/session/SessionLease";
|
|
6
7
|
import type { UpdateSkip } from "#core/shared/Updater";
|
|
@@ -154,6 +155,29 @@ export type EphemeralEvent =
|
|
|
154
155
|
readonly events: readonly StreamEvent[];
|
|
155
156
|
}
|
|
156
157
|
| { readonly type: "turn_end"; readonly stats: TurnStats }
|
|
158
|
+
/** Something an extension said, in Markdown; shown and then forgotten, never written to the session. */
|
|
159
|
+
| {
|
|
160
|
+
readonly type: "ui_notice";
|
|
161
|
+
readonly id: string;
|
|
162
|
+
readonly severity: NoticeSeverity;
|
|
163
|
+
readonly text: string;
|
|
164
|
+
/** The command being dispatched when it was said, like `/login`; absent means nobody asked for it. */
|
|
165
|
+
readonly command?: string;
|
|
166
|
+
}
|
|
167
|
+
/** An extension is waiting on a human; the first `ui_response` wins and the rest are refused. */
|
|
168
|
+
| {
|
|
169
|
+
readonly type: "ui_request";
|
|
170
|
+
readonly requestId: string;
|
|
171
|
+
readonly method: "select" | "confirm" | "input";
|
|
172
|
+
readonly title: string;
|
|
173
|
+
readonly message?: string;
|
|
174
|
+
readonly options?: readonly string[];
|
|
175
|
+
readonly placeholder?: string;
|
|
176
|
+
/** The command that asked, like `/login`; absent where an extension asked unprompted. */
|
|
177
|
+
readonly command?: string;
|
|
178
|
+
}
|
|
179
|
+
/** The request is settled, by whoever answered it or by the server answering for them; drop its control. */
|
|
180
|
+
| { readonly type: "ui_request_done"; readonly requestId: string }
|
|
157
181
|
/** Sent to every connection, not just those attached; only sessions this server holds open are reported. */
|
|
158
182
|
| {
|
|
159
183
|
readonly type: "session_activity";
|
|
@@ -188,6 +212,8 @@ export type EphemeralEvent =
|
|
|
188
212
|
| { readonly type: "pins_changed"; readonly order: readonly string[] }
|
|
189
213
|
/** Sent to every connection: the sessions on disk changed, so any listing a client holds is stale. */
|
|
190
214
|
| { readonly type: "sessions_changed" }
|
|
215
|
+
/** Sent to every connection: an extension was switched, so any roster a client holds is stale. */
|
|
216
|
+
| { readonly type: "extensions_changed" }
|
|
191
217
|
/** Sent to every connection; the restart it ends in drops every socket. */
|
|
192
218
|
| UpdateStateEvent
|
|
193
219
|
| {
|
|
@@ -327,6 +353,8 @@ export type ResponseEvent = {
|
|
|
327
353
|
readonly models?: readonly ModelView[];
|
|
328
354
|
/** What the *current* model supports, on the same answer. */
|
|
329
355
|
readonly thinkingLevels?: readonly string[];
|
|
356
|
+
/** The extension roster, for `list_extensions`. */
|
|
357
|
+
readonly extensions?: readonly ExtensionEntry[];
|
|
330
358
|
/** One directory's subdirectories, for `list_dirs`. */
|
|
331
359
|
readonly directory?: DirectoryListing;
|
|
332
360
|
/** The cwd's local branches, for `list_branches`. */
|
|
@@ -341,6 +369,8 @@ export type ResponseEvent = {
|
|
|
341
369
|
readonly fileLines?: FileLines;
|
|
342
370
|
/** For `cancel` and `dequeue`: queued messages pi gave back, now owned by the client that asked. */
|
|
343
371
|
readonly restored?: readonly string[];
|
|
372
|
+
/** For `user_message`: it named an extension command, so no turn started and no entry was written. */
|
|
373
|
+
readonly dispatched?: boolean;
|
|
344
374
|
};
|
|
345
375
|
|
|
346
376
|
export type ServerEvent = DurableEvent | EphemeralEvent | ResponseEvent;
|
|
@@ -382,6 +412,9 @@ export function isAttachScoped(event: ServerEvent): boolean {
|
|
|
382
412
|
case "subagent_events":
|
|
383
413
|
case "turn_end":
|
|
384
414
|
case "session_state":
|
|
415
|
+
case "ui_notice":
|
|
416
|
+
case "ui_request":
|
|
417
|
+
case "ui_request_done":
|
|
385
418
|
// Names a cwd, but only ever reaches a client down the session stream it
|
|
386
419
|
// is attached to, so the old session's is the only one that can arrive
|
|
387
420
|
// mid-attach — and re-querying for it would warm the wrong cache.
|
|
@@ -393,6 +426,7 @@ export function isAttachScoped(event: ServerEvent): boolean {
|
|
|
393
426
|
case "project_meta":
|
|
394
427
|
case "pins_changed":
|
|
395
428
|
case "sessions_changed":
|
|
429
|
+
case "extensions_changed":
|
|
396
430
|
case "update_state":
|
|
397
431
|
// Sent for a frame the server could not read at all, which is likeliest
|
|
398
432
|
// before an attach has settled: gating it swallows the diagnostic.
|
|
@@ -2,6 +2,7 @@ import { basename } from "node:path";
|
|
|
2
2
|
|
|
3
3
|
import type { PickerItem } from "#core/picker/PickerItem";
|
|
4
4
|
import { RemoteFilePickerSuggestionEngine } from "#core/picker/RemoteFilePickerSuggestionEngine";
|
|
5
|
+
import type { ExtensionEntry } from "#core/shared/PiExtensions";
|
|
5
6
|
import type {
|
|
6
7
|
AttachmentRef,
|
|
7
8
|
CommandDraft,
|
|
@@ -265,6 +266,26 @@ export class ProbeClient {
|
|
|
265
266
|
};
|
|
266
267
|
}
|
|
267
268
|
|
|
269
|
+
/** Every extension this server can switch, scoped to the attached session's cwd. */
|
|
270
|
+
public async listExtensions(): Promise<readonly ExtensionEntry[]> {
|
|
271
|
+
const response = await this.send({ type: "list_extensions" });
|
|
272
|
+
if (!response.success) {
|
|
273
|
+
throw new Error(response.error ?? "list_extensions failed");
|
|
274
|
+
}
|
|
275
|
+
return response.extensions ?? [];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
public async setExtension(id: string, value: boolean): Promise<void> {
|
|
279
|
+
const response = await this.send({
|
|
280
|
+
type: "set_extension",
|
|
281
|
+
extensionId: id,
|
|
282
|
+
value,
|
|
283
|
+
});
|
|
284
|
+
if (!response.success) {
|
|
285
|
+
throw new Error(response.error ?? "set_extension failed");
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
268
289
|
/** Transfers a client-local file into the server's world; only the bytes and bare filename are sent. */
|
|
269
290
|
public async upload(localPath: string): Promise<UploadedFile> {
|
|
270
291
|
const file = Bun.file(localPath);
|
|
@@ -4,10 +4,12 @@ import { PickerService } from "#core/picker/PickerService";
|
|
|
4
4
|
import { MessageText } from "#core/session/MessageText";
|
|
5
5
|
import type { LeaseState, SessionHost } from "#core/session/SessionHost";
|
|
6
6
|
import { SessionLease, type LeaseRecord } from "#core/session/SessionLease";
|
|
7
|
+
import type { SessionUi, UiAsk } from "#core/session/SessionUi";
|
|
7
8
|
import { FileWatch } from "#core/shared/FileWatch";
|
|
8
9
|
import { GitMonitor } from "#core/shared/GitMonitor";
|
|
9
10
|
import { Tools } from "#core/shared/Tools";
|
|
10
|
-
import type { ToolView } from "#core/view/ViewBlock";
|
|
11
|
+
import type { NoticeSeverity, ToolView } from "#core/view/ViewBlock";
|
|
12
|
+
import type { Command } from "#protocol/Command";
|
|
11
13
|
import type {
|
|
12
14
|
EphemeralEvent,
|
|
13
15
|
ServerEvent,
|
|
@@ -17,6 +19,21 @@ import { SessionProjection } from "./SessionProjection";
|
|
|
17
19
|
|
|
18
20
|
export type StreamListener = (event: ServerEvent) => void;
|
|
19
21
|
|
|
22
|
+
/** What one client sent back for a `ui_request`; a dismissal is `cancelled`. */
|
|
23
|
+
export type UiAnswer = Omit<
|
|
24
|
+
Extract<Command, { readonly type: "ui_response" }>,
|
|
25
|
+
"id" | "type" | "sessionId" | "requestId"
|
|
26
|
+
>;
|
|
27
|
+
|
|
28
|
+
type PendingRequest = {
|
|
29
|
+
/** Kept whole: a client that reattaches inside the grace is shown the dialog again. */
|
|
30
|
+
readonly asked: Extract<EphemeralEvent, { readonly type: "ui_request" }>;
|
|
31
|
+
/** Answers the waiting extension; only the first call of the first caller lands. */
|
|
32
|
+
readonly settle: (answer: UiAnswer | undefined, because?: string) => void;
|
|
33
|
+
readonly expiry: ReturnType<typeof setTimeout>;
|
|
34
|
+
grace: ReturnType<typeof setTimeout> | undefined;
|
|
35
|
+
};
|
|
36
|
+
|
|
20
37
|
type LiveTool = {
|
|
21
38
|
readonly callId: string;
|
|
22
39
|
readonly name: string;
|
|
@@ -46,8 +63,18 @@ export type SessionStreamDeps = {
|
|
|
46
63
|
* reports nothing rather than guessing from itself.
|
|
47
64
|
*/
|
|
48
65
|
readonly repoBusy?: () => boolean;
|
|
66
|
+
/** Longest a dialog may hold an extension waiting; defaults to `REQUEST_CEILING_MS`. */
|
|
67
|
+
readonly requestCeilingMs?: number;
|
|
68
|
+
/** How long a pending dialog outlives its last reader; defaults to `DETACH_GRACE_MS`. */
|
|
69
|
+
readonly detachGraceMs?: number;
|
|
49
70
|
};
|
|
50
71
|
|
|
72
|
+
/** No extension may be parked on a human forever, whatever it asked for; `opts.timeout` still wins when shorter. */
|
|
73
|
+
const REQUEST_CEILING_MS = 180_000;
|
|
74
|
+
|
|
75
|
+
/** A reload, a tunnel blip and a phone unlock all read as a detach, and all three are back inside this. */
|
|
76
|
+
const DETACH_GRACE_MS = 15_000;
|
|
77
|
+
|
|
51
78
|
function sameLease(a: LeaseState, b: LeaseState): boolean {
|
|
52
79
|
return (
|
|
53
80
|
a.writable === b.writable &&
|
|
@@ -57,16 +84,20 @@ function sameLease(a: LeaseState, b: LeaseState): boolean {
|
|
|
57
84
|
}
|
|
58
85
|
|
|
59
86
|
/** One session's view of the world, shared by every client attached to it and kept running when none are. */
|
|
60
|
-
export class SessionStream {
|
|
87
|
+
export class SessionStream implements SessionUi {
|
|
61
88
|
public readonly sessionId: string;
|
|
62
89
|
public readonly host: SessionHost;
|
|
63
90
|
public readonly picker: PickerService;
|
|
64
91
|
private readonly sessionPath: string;
|
|
65
92
|
private readonly projection: SessionProjection;
|
|
66
93
|
private readonly listeners = new Set<StreamListener>();
|
|
94
|
+
private readonly observers = new Set<StreamListener>();
|
|
67
95
|
private readonly pollMs: number | undefined;
|
|
68
96
|
private readonly git: GitMonitor;
|
|
69
97
|
private readonly repoBusy: (() => boolean) | undefined;
|
|
98
|
+
private readonly requestCeilingMs: number;
|
|
99
|
+
private readonly detachGraceMs: number;
|
|
100
|
+
private readonly pending = new Map<string, PendingRequest>();
|
|
70
101
|
private liveTurn: LiveMessage[] = [];
|
|
71
102
|
private unsubscribe: (() => void) | undefined;
|
|
72
103
|
private unsubscribeLease: (() => void) | undefined;
|
|
@@ -78,6 +109,8 @@ export class SessionStream {
|
|
|
78
109
|
private drainQueued = false;
|
|
79
110
|
private sentSeq = 0;
|
|
80
111
|
private liveMessageId = 0;
|
|
112
|
+
private uiSeq = 0;
|
|
113
|
+
private readonly dispatching: string[] = [];
|
|
81
114
|
private turnStartedAt = 0;
|
|
82
115
|
private gitCwd: string | undefined;
|
|
83
116
|
private gitStop: (() => void) | undefined;
|
|
@@ -95,6 +128,8 @@ export class SessionStream {
|
|
|
95
128
|
this.pollMs = deps.pollMs;
|
|
96
129
|
this.git = deps.git ?? new GitMonitor();
|
|
97
130
|
this.repoBusy = deps.repoBusy;
|
|
131
|
+
this.requestCeilingMs = deps.requestCeilingMs ?? REQUEST_CEILING_MS;
|
|
132
|
+
this.detachGraceMs = deps.detachGraceMs ?? DETACH_GRACE_MS;
|
|
98
133
|
this.projection = new SessionProjection(sessionPath, () => host.cwd);
|
|
99
134
|
this.picker = new PickerService({
|
|
100
135
|
cwd: () => host.cwd,
|
|
@@ -194,11 +229,187 @@ export class SessionStream {
|
|
|
194
229
|
|
|
195
230
|
public subscribe(listener: StreamListener): () => void {
|
|
196
231
|
this.listeners.add(listener);
|
|
232
|
+
this.holdRequests();
|
|
197
233
|
return () => {
|
|
198
234
|
this.listeners.delete(listener);
|
|
235
|
+
this.holdRequests();
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Hears everything a client hears without counting as one: the server's own
|
|
241
|
+
* bookkeeping must not make a session nobody is reading look attended, or
|
|
242
|
+
* the rules below park an extension on a dialog with no eyes on it.
|
|
243
|
+
*/
|
|
244
|
+
public observe(listener: StreamListener): () => void {
|
|
245
|
+
this.observers.add(listener);
|
|
246
|
+
return () => {
|
|
247
|
+
this.observers.delete(listener);
|
|
199
248
|
};
|
|
200
249
|
}
|
|
201
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Runs `run` as the answer to something the user typed, so anything it says
|
|
253
|
+
* or asks reaches the client under `command`'s name. A stack rather than a
|
|
254
|
+
* name: one command handler may prompt another, and the innermost is the
|
|
255
|
+
* one speaking.
|
|
256
|
+
*/
|
|
257
|
+
public async dispatch<T>(command: string, run: () => Promise<T>): Promise<T> {
|
|
258
|
+
this.dispatching.push(command);
|
|
259
|
+
try {
|
|
260
|
+
return await run();
|
|
261
|
+
} finally {
|
|
262
|
+
this.dispatching.pop();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Fire-and-forget: with nobody attached the notice is dropped rather than held. */
|
|
267
|
+
public notify(text: string, severity: NoticeSeverity): void {
|
|
268
|
+
this.emit({
|
|
269
|
+
type: "ui_notice",
|
|
270
|
+
id: this.nextUiId("notice"),
|
|
271
|
+
severity,
|
|
272
|
+
text,
|
|
273
|
+
...this.asked(),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Whose words these are: the command being dispatched, if any is. */
|
|
278
|
+
private asked(): { readonly command?: string } {
|
|
279
|
+
const command = this.dispatching.at(-1);
|
|
280
|
+
return command === undefined ? {} : { command };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
public async select(
|
|
284
|
+
title: string,
|
|
285
|
+
options: readonly string[],
|
|
286
|
+
opts?: UiAsk
|
|
287
|
+
): Promise<string | undefined> {
|
|
288
|
+
return (
|
|
289
|
+
await this.ask({ method: "select", title, options: [...options] }, opts)
|
|
290
|
+
)?.value;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
public async confirm(
|
|
294
|
+
title: string,
|
|
295
|
+
message: string,
|
|
296
|
+
opts?: UiAsk
|
|
297
|
+
): Promise<boolean> {
|
|
298
|
+
return (
|
|
299
|
+
(await this.ask({ method: "confirm", title, message }, opts))
|
|
300
|
+
?.confirmed === true
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
public async input(
|
|
305
|
+
title: string,
|
|
306
|
+
placeholder?: string,
|
|
307
|
+
opts?: UiAsk
|
|
308
|
+
): Promise<string | undefined> {
|
|
309
|
+
return (
|
|
310
|
+
await this.ask(
|
|
311
|
+
{
|
|
312
|
+
method: "input",
|
|
313
|
+
title,
|
|
314
|
+
...(placeholder === undefined ? {} : { placeholder }),
|
|
315
|
+
},
|
|
316
|
+
opts
|
|
317
|
+
)
|
|
318
|
+
)?.value;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Whether the answer was taken; a second one for the same request is refused. */
|
|
322
|
+
public answer(requestId: string, answer: UiAnswer): boolean {
|
|
323
|
+
const request = this.pending.get(requestId);
|
|
324
|
+
if (request === undefined) {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
request.settle(answer);
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The answer as the client sent it, or `undefined` where it was cancelled, timed out or never asked. */
|
|
332
|
+
private ask(
|
|
333
|
+
request: Omit<
|
|
334
|
+
Extract<ServerEvent, { readonly type: "ui_request" }>,
|
|
335
|
+
"type" | "requestId" | "command"
|
|
336
|
+
>,
|
|
337
|
+
opts: UiAsk | undefined
|
|
338
|
+
): Promise<UiAnswer | undefined> {
|
|
339
|
+
// A headless session may not park an extension on a dialog nobody can see.
|
|
340
|
+
if (this.listeners.size === 0) {
|
|
341
|
+
this.answered(request.title, "nobody was attached");
|
|
342
|
+
return Promise.resolve(undefined);
|
|
343
|
+
}
|
|
344
|
+
const requestId = this.nextUiId("request");
|
|
345
|
+
const waitMs = Math.min(this.requestCeilingMs, opts?.timeout ?? Infinity);
|
|
346
|
+
const asked = {
|
|
347
|
+
...request,
|
|
348
|
+
...this.asked(),
|
|
349
|
+
type: "ui_request",
|
|
350
|
+
requestId,
|
|
351
|
+
} as const;
|
|
352
|
+
return new Promise<UiAnswer | undefined>((resolve) => {
|
|
353
|
+
const settle = (answer: UiAnswer | undefined, because?: string): void => {
|
|
354
|
+
const entry = this.pending.get(requestId);
|
|
355
|
+
if (entry === undefined) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
this.pending.delete(requestId);
|
|
359
|
+
clearTimeout(entry.expiry);
|
|
360
|
+
clearTimeout(entry.grace);
|
|
361
|
+
opts?.signal?.removeEventListener("abort", abort);
|
|
362
|
+
this.emit({ type: "ui_request_done", requestId });
|
|
363
|
+
if (because !== undefined) {
|
|
364
|
+
this.answered(asked.title, because);
|
|
365
|
+
}
|
|
366
|
+
resolve(answer?.cancelled === true ? undefined : answer);
|
|
367
|
+
};
|
|
368
|
+
const abort = (): void => {
|
|
369
|
+
settle(undefined, "the extension withdrew it");
|
|
370
|
+
};
|
|
371
|
+
this.pending.set(requestId, {
|
|
372
|
+
asked,
|
|
373
|
+
settle,
|
|
374
|
+
expiry: setTimeout(() => {
|
|
375
|
+
settle(undefined, "it timed out");
|
|
376
|
+
}, waitMs),
|
|
377
|
+
grace: undefined,
|
|
378
|
+
});
|
|
379
|
+
opts?.signal?.addEventListener("abort", abort, { once: true });
|
|
380
|
+
this.emit(asked);
|
|
381
|
+
// A listener is never called for a signal that was already spent.
|
|
382
|
+
if (opts?.signal?.aborted === true) {
|
|
383
|
+
abort();
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** A dialog answered by the server is never silent: the human is told what was decided for them. */
|
|
389
|
+
private answered(title: string, why: string): void {
|
|
390
|
+
this.notify(`Answered “${title}” for you: ${why}.`, "warn");
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** The last reader leaving starts the grace; one arriving inside it calls the whole thing off. */
|
|
394
|
+
private holdRequests(): void {
|
|
395
|
+
const detached = this.listeners.size === 0;
|
|
396
|
+
for (const [requestId, request] of this.pending) {
|
|
397
|
+
if (!detached) {
|
|
398
|
+
clearTimeout(request.grace);
|
|
399
|
+
request.grace = undefined;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
request.grace ??= setTimeout(() => {
|
|
403
|
+
this.pending.get(requestId)?.settle(undefined, "everyone had left");
|
|
404
|
+
}, this.detachGraceMs);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private nextUiId(kind: "notice" | "request"): string {
|
|
409
|
+
this.uiSeq += 1;
|
|
410
|
+
return `${this.sessionId}:${kind}:${this.uiSeq}`;
|
|
411
|
+
}
|
|
412
|
+
|
|
202
413
|
/** Whether `callId` names a tool this session is still running, log written or not. */
|
|
203
414
|
public isRunning(callId: string): boolean {
|
|
204
415
|
return this.findTool(callId)?.done === false;
|
|
@@ -243,6 +454,11 @@ export class SessionStream {
|
|
|
243
454
|
}
|
|
244
455
|
}
|
|
245
456
|
}
|
|
457
|
+
// A dialog is only ever answered by whoever is attached now, so a client
|
|
458
|
+
// arriving mid-question is handed it rather than left waiting on a grace.
|
|
459
|
+
for (const request of this.pending.values()) {
|
|
460
|
+
events.push(request.asked);
|
|
461
|
+
}
|
|
246
462
|
events.push(this.sessionState());
|
|
247
463
|
return events;
|
|
248
464
|
}
|
|
@@ -346,7 +562,12 @@ export class SessionStream {
|
|
|
346
562
|
this.unsubscribeForeign?.();
|
|
347
563
|
this.unsubscribeForeign = undefined;
|
|
348
564
|
this.watchFiles(false);
|
|
565
|
+
// Deleting the entry each settle clears is what a Map iterator is allowed to outlive.
|
|
566
|
+
for (const request of this.pending.values()) {
|
|
567
|
+
request.settle(undefined, "the session stopped");
|
|
568
|
+
}
|
|
349
569
|
this.listeners.clear();
|
|
570
|
+
this.observers.clear();
|
|
350
571
|
}
|
|
351
572
|
|
|
352
573
|
private onAgentEvent(event: AgentSessionEvent): void {
|
|
@@ -620,6 +841,11 @@ export class SessionStream {
|
|
|
620
841
|
}
|
|
621
842
|
|
|
622
843
|
private emit(event: ServerEvent): void {
|
|
844
|
+
// Observers first: the bookkeeping one does may broadcast server-wide, and
|
|
845
|
+
// that belongs ahead of the frame that caused it on every socket.
|
|
846
|
+
for (const observer of this.observers) {
|
|
847
|
+
observer(event);
|
|
848
|
+
}
|
|
623
849
|
for (const listener of this.listeners) {
|
|
624
850
|
listener(event);
|
|
625
851
|
}
|