pim-agent 0.10.0 → 0.12.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 +3 -4
- package/packages/core/src/extensions/apply-patch/model.ts +1 -12
- 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/edit/index.ts +1 -1
- package/packages/core/src/extensions/subagent/index.ts +2 -1
- package/packages/core/src/extensions/subagent/subagent.ts +10 -1
- package/packages/core/src/extensions/system-prompt/index.ts +33 -24
- package/packages/core/src/extensions/system-prompt/prompt.ts +43 -67
- 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/core/src/shared/Tools.ts +3 -3
- 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-DKI-ZG3V.js} +1 -1
- package/packages/web/dist/client/assets/index-BCSESkyh.css +1 -0
- package/packages/web/dist/client/assets/index-BWUZzdeu.js +45 -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
|
@@ -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
|
}
|
|
@@ -9,6 +9,11 @@ import { Directories } from "#core/shared/Directories";
|
|
|
9
9
|
import type { DirectoryListing } from "#core/shared/Directories";
|
|
10
10
|
import { Git, type GitBranch } from "#core/shared/Git";
|
|
11
11
|
import { GitMonitor, type GitRun } from "#core/shared/GitMonitor";
|
|
12
|
+
import {
|
|
13
|
+
PiExtensions,
|
|
14
|
+
type ExtensionEntry,
|
|
15
|
+
type ExtensionScope,
|
|
16
|
+
} from "#core/shared/PiExtensions";
|
|
12
17
|
import { RepoDiff } from "#core/shared/RepoDiff";
|
|
13
18
|
import { ReadCursors } from "#core/session/ReadCursors";
|
|
14
19
|
import type { SessionHost } from "#core/session/SessionHost";
|
|
@@ -51,6 +56,10 @@ export type WsGatewayDeps = {
|
|
|
51
56
|
readonly sessionMetaPath?: string;
|
|
52
57
|
/** How often a session file is polled where `fs.watch` says nothing; the default is a second. */
|
|
53
58
|
readonly pollMs?: number;
|
|
59
|
+
/** Longest a dialog may hold an extension waiting; the default is `SessionStream`'s three minutes. */
|
|
60
|
+
readonly requestCeilingMs?: number;
|
|
61
|
+
/** How long a pending dialog outlives its last reader; the default is `SessionStream`'s fifteen seconds. */
|
|
62
|
+
readonly detachGraceMs?: number;
|
|
54
63
|
/** The built web client; defaults to the bundle shipped beside this package. */
|
|
55
64
|
readonly clientDir?: string;
|
|
56
65
|
/** Runs the update a `reload` asks for, reporting each step as it starts. */
|
|
@@ -69,6 +78,7 @@ type Outcome = {
|
|
|
69
78
|
readonly scanned?: number;
|
|
70
79
|
readonly models?: readonly ModelView[];
|
|
71
80
|
readonly thinkingLevels?: readonly string[];
|
|
81
|
+
readonly extensions?: readonly ExtensionEntry[];
|
|
72
82
|
readonly directory?: DirectoryListing;
|
|
73
83
|
readonly branches?: readonly GitBranch[];
|
|
74
84
|
readonly commit?: { readonly sha: string };
|
|
@@ -76,6 +86,8 @@ type Outcome = {
|
|
|
76
86
|
readonly fileDiff?: FileDiff;
|
|
77
87
|
readonly fileLines?: FileLines;
|
|
78
88
|
readonly restored?: readonly string[];
|
|
89
|
+
/** The message was dispatched as an extension command: no turn started, no entry written. */
|
|
90
|
+
readonly dispatched?: boolean;
|
|
79
91
|
readonly after?: () => void;
|
|
80
92
|
};
|
|
81
93
|
|
|
@@ -92,6 +104,15 @@ export const DEFAULT_HOSTNAME = "127.0.0.1";
|
|
|
92
104
|
// Bun 1.3.14 never frees the slot of a server-closed socket, so an unbounded `Server.stop(true)` hangs.
|
|
93
105
|
const STOP_GRACE_MS = 250;
|
|
94
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Pi's own dispatch parse, three lines of `_tryExecuteExtensionCommand`
|
|
109
|
+
* (`agent-session.js:954`); a pi upgrade re-verifies it.
|
|
110
|
+
*/
|
|
111
|
+
function commandNameOf(text: string): string {
|
|
112
|
+
const spaceIndex = text.indexOf(" ");
|
|
113
|
+
return spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
|
|
114
|
+
}
|
|
115
|
+
|
|
95
116
|
/** The transport half of pim-server: a WebSocket endpoint over the session runtime in `core`. */
|
|
96
117
|
export class WsGateway {
|
|
97
118
|
private readonly registry: SessionRegistry;
|
|
@@ -111,15 +132,26 @@ export class WsGateway {
|
|
|
111
132
|
private readonly reloader: Reloader;
|
|
112
133
|
private readonly git = new GitMonitor();
|
|
113
134
|
private readonly pollMs: number | undefined;
|
|
135
|
+
private readonly requestCeilingMs: number | undefined;
|
|
136
|
+
private readonly detachGraceMs: number | undefined;
|
|
114
137
|
private readonly busyRepos = new Set<string>();
|
|
115
138
|
private versionsRead: Promise<readonly [string, string]> | undefined;
|
|
116
139
|
private server: Server<undefined> | undefined;
|
|
117
140
|
|
|
118
141
|
public constructor(deps: WsGatewayDeps) {
|
|
119
142
|
this.registry = deps.registry;
|
|
143
|
+
// `SessionStream` is the sink, so the whole routing is a lookup: a host
|
|
144
|
+
// outlives the stream that speaks for it, and pi asks per call.
|
|
145
|
+
this.registry.setUi((host) =>
|
|
146
|
+
host.sessionId === undefined
|
|
147
|
+
? undefined
|
|
148
|
+
: this.streams.get(host.sessionId)
|
|
149
|
+
);
|
|
120
150
|
this.hostname = deps.hostname ?? DEFAULT_HOSTNAME;
|
|
121
151
|
this.requestedPort = deps.port ?? DEFAULT_PORT;
|
|
122
152
|
this.pollMs = deps.pollMs;
|
|
153
|
+
this.requestCeilingMs = deps.requestCeilingMs;
|
|
154
|
+
this.detachGraceMs = deps.detachGraceMs;
|
|
123
155
|
this.uploads = new AttachmentEndpoint(
|
|
124
156
|
deps.attachmentsRoot === undefined ? {} : { root: deps.attachmentsRoot }
|
|
125
157
|
);
|
|
@@ -362,8 +394,26 @@ export class WsGateway {
|
|
|
362
394
|
thinkingLevels:
|
|
363
395
|
this.streamFor(connection)?.host.supportedThinkingLevels ?? [],
|
|
364
396
|
};
|
|
397
|
+
case "list_extensions":
|
|
398
|
+
return {
|
|
399
|
+
extensions: await PiExtensions.list(this.scopeFor(connection)),
|
|
400
|
+
};
|
|
401
|
+
case "set_extension":
|
|
402
|
+
await PiExtensions.setEnabled(
|
|
403
|
+
command.extensionId,
|
|
404
|
+
command.value,
|
|
405
|
+
this.scopeFor(connection)
|
|
406
|
+
);
|
|
407
|
+
return {
|
|
408
|
+
after: () => {
|
|
409
|
+
this.rebuildAgents();
|
|
410
|
+
},
|
|
411
|
+
};
|
|
365
412
|
case "list_dirs":
|
|
366
413
|
return { directory: await Directories.list(command.path) };
|
|
414
|
+
case "create_dir":
|
|
415
|
+
await Directories.create(command.path);
|
|
416
|
+
return {};
|
|
367
417
|
case "refresh_git": {
|
|
368
418
|
const stream = this.requireStream(connection);
|
|
369
419
|
await stream.refreshGit(command.fetch === true);
|
|
@@ -425,8 +475,16 @@ export class WsGateway {
|
|
|
425
475
|
case "reload":
|
|
426
476
|
return this.reload(command.force === true);
|
|
427
477
|
case "user_message":
|
|
428
|
-
this.promptWithAttachments(
|
|
429
|
-
|
|
478
|
+
return await this.promptWithAttachments(
|
|
479
|
+
this.requireStream(connection),
|
|
480
|
+
command
|
|
481
|
+
);
|
|
482
|
+
case "ui_response": {
|
|
483
|
+
const stream = this.requireStream(connection);
|
|
484
|
+
return stream.answer(command.requestId, command)
|
|
485
|
+
? {}
|
|
486
|
+
: { error: `no request ${command.requestId} is waiting` };
|
|
487
|
+
}
|
|
430
488
|
case "cancel": {
|
|
431
489
|
const { cancelled, restored } =
|
|
432
490
|
await this.requireStream(connection).host.cancel();
|
|
@@ -492,6 +550,27 @@ export class WsGateway {
|
|
|
492
550
|
return sessionId ? this.streams.get(sessionId) : undefined;
|
|
493
551
|
}
|
|
494
552
|
|
|
553
|
+
/** A session's directory decides which project extensions are listed; a connection without one reads this process's. */
|
|
554
|
+
private scopeFor(connection: ClientConnection): ExtensionScope {
|
|
555
|
+
return {
|
|
556
|
+
cwd: this.streamFor(connection)?.host.cwd ?? process.cwd(),
|
|
557
|
+
agentDir: this.registry.agentDir,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private rebuildAgents(): void {
|
|
562
|
+
for (const stream of this.streams.values()) {
|
|
563
|
+
const { host } = stream;
|
|
564
|
+
// Through the turn queue: detaching the agent under a running turn kills it.
|
|
565
|
+
void host
|
|
566
|
+
.serialize(() => host.invalidate())
|
|
567
|
+
.catch((err: unknown) => {
|
|
568
|
+
this.failed(stream, err, "extension reload");
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
this.broadcast({ type: "extensions_changed" });
|
|
572
|
+
}
|
|
573
|
+
|
|
495
574
|
private requireStream(connection: ClientConnection): SessionStream {
|
|
496
575
|
const stream = this.streamFor(connection);
|
|
497
576
|
if (!stream) {
|
|
@@ -732,10 +811,14 @@ export class WsGateway {
|
|
|
732
811
|
git: this.git,
|
|
733
812
|
repoBusy: () => this.repoBusy(host.cwd),
|
|
734
813
|
pollMs: this.pollMs,
|
|
814
|
+
requestCeilingMs: this.requestCeilingMs,
|
|
815
|
+
detachGraceMs: this.detachGraceMs,
|
|
735
816
|
});
|
|
736
817
|
stream.start();
|
|
737
818
|
this.catalogue.track(id, host.status);
|
|
738
|
-
|
|
819
|
+
// Observed rather than subscribed: this never leaves, and a reader that
|
|
820
|
+
// never leaves is a session that is never detached.
|
|
821
|
+
stream.observe((event) => {
|
|
739
822
|
if (event.type === "session_state") {
|
|
740
823
|
this.catalogue.onStatus(id, event.status);
|
|
741
824
|
this.syncRepoBusy(stream.host.cwd, id);
|
|
@@ -745,17 +828,70 @@ export class WsGateway {
|
|
|
745
828
|
return stream;
|
|
746
829
|
}
|
|
747
830
|
|
|
748
|
-
private promptWithAttachments(
|
|
831
|
+
private async promptWithAttachments(
|
|
749
832
|
stream: SessionStream,
|
|
750
833
|
command: Command & { readonly type: "user_message" }
|
|
751
|
-
):
|
|
834
|
+
): Promise<Outcome> {
|
|
835
|
+
// Trimmed once, here: pi dispatches on the text it is handed after the
|
|
836
|
+
// fallthrough trims it, so a client that does not trim would otherwise
|
|
837
|
+
// slip a command past this check and into pi's own.
|
|
838
|
+
const text = command.text.trim();
|
|
839
|
+
// Read before anything is rendered: what a command is, is its own text.
|
|
840
|
+
const dispatched = await this.dispatchCommand(stream, text);
|
|
752
841
|
const taken = this.uploads.take(
|
|
753
842
|
stream.sessionId,
|
|
754
843
|
(command.attachments ?? []).map((ref) => ref.id)
|
|
755
844
|
);
|
|
845
|
+
// A slash command carries no pictures; the uploads are still taken, so the
|
|
846
|
+
// endpoint stops holding their bytes for the rest of the session.
|
|
847
|
+
if (dispatched) {
|
|
848
|
+
return { dispatched: true };
|
|
849
|
+
}
|
|
756
850
|
const { lines, images } = Attachments.render(taken);
|
|
757
|
-
|
|
758
|
-
|
|
851
|
+
this.prompt(stream, [text, ...lines].filter(Boolean).join("\n\n"), images);
|
|
852
|
+
return {};
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Whether `text` names an extension command, having started it if it does.
|
|
857
|
+
* Only the decision is awaited: the handler runs on beyond the ack, exactly
|
|
858
|
+
* as a turn does.
|
|
859
|
+
*/
|
|
860
|
+
private async dispatchCommand(
|
|
861
|
+
stream: SessionStream,
|
|
862
|
+
text: string
|
|
863
|
+
): Promise<boolean> {
|
|
864
|
+
const host = stream.host;
|
|
865
|
+
if (!text.startsWith("/")) {
|
|
866
|
+
return false;
|
|
867
|
+
}
|
|
868
|
+
// The picker offers extension commands only once an agent exists, so this
|
|
869
|
+
// is someone typing from memory; cold is idle, so nothing is queued behind it.
|
|
870
|
+
const agent = host.agentSession ?? (await host.ensureAgent());
|
|
871
|
+
const name = commandNameOf(text);
|
|
872
|
+
if (agent.extensionRunner.getCommand(name) === undefined) {
|
|
873
|
+
return false;
|
|
874
|
+
}
|
|
875
|
+
void stream
|
|
876
|
+
.dispatch(`/${name}`, async () => {
|
|
877
|
+
// Prompted whole, never merged into the queue: a merged string starts
|
|
878
|
+
// with the earlier message, and pi reads the leading `/` before it
|
|
879
|
+
// looks at `isStreaming` — so the turn we are holding dispatches it.
|
|
880
|
+
if (host.isStreaming) {
|
|
881
|
+
await agent.prompt(text, {
|
|
882
|
+
streamingBehavior: "steer",
|
|
883
|
+
source: "rpc",
|
|
884
|
+
});
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
await host.run(async (session) => {
|
|
888
|
+
await session.prompt(text, { source: "rpc" });
|
|
889
|
+
});
|
|
890
|
+
})
|
|
891
|
+
.catch((err: unknown) => {
|
|
892
|
+
this.failed(stream, err, "command");
|
|
893
|
+
});
|
|
894
|
+
return true;
|
|
759
895
|
}
|
|
760
896
|
|
|
761
897
|
// Merge into the queued message: pi holds at most one, so a second queued send would be unreachable.
|
|
@@ -767,13 +903,6 @@ export class WsGateway {
|
|
|
767
903
|
const attached = images.length === 0 ? {} : { images: [...images] };
|
|
768
904
|
const host = stream.host;
|
|
769
905
|
const agent = host.agentSession;
|
|
770
|
-
const failed = (err: unknown, what: string): void => {
|
|
771
|
-
console.error(`[gateway] ${what} failed:`, err);
|
|
772
|
-
stream.push({
|
|
773
|
-
type: "error",
|
|
774
|
-
message: (err as Error).message || String(err),
|
|
775
|
-
});
|
|
776
|
-
};
|
|
777
906
|
if (agent && host.isStreaming) {
|
|
778
907
|
const queued = [...host.takeBack(), text].join("\n\n");
|
|
779
908
|
void agent
|
|
@@ -783,7 +912,7 @@ export class WsGateway {
|
|
|
783
912
|
...attached,
|
|
784
913
|
})
|
|
785
914
|
.catch((err: unknown) => {
|
|
786
|
-
failed(err, "steer");
|
|
915
|
+
this.failed(stream, err, "steer");
|
|
787
916
|
});
|
|
788
917
|
return;
|
|
789
918
|
}
|
|
@@ -792,7 +921,15 @@ export class WsGateway {
|
|
|
792
921
|
await session.prompt(text, { source: "rpc", ...attached });
|
|
793
922
|
})
|
|
794
923
|
.catch((err: unknown) => {
|
|
795
|
-
failed(err, "turn");
|
|
924
|
+
this.failed(stream, err, "turn");
|
|
796
925
|
});
|
|
797
926
|
}
|
|
927
|
+
|
|
928
|
+
private failed(stream: SessionStream, err: unknown, what: string): void {
|
|
929
|
+
console.error(`[gateway] ${what} failed:`, err);
|
|
930
|
+
stream.push({
|
|
931
|
+
type: "error",
|
|
932
|
+
message: (err as Error).message || String(err),
|
|
933
|
+
});
|
|
934
|
+
}
|
|
798
935
|
}
|
|
@@ -33,6 +33,8 @@ const USAGE = `pim probe — CLI client for pim-server, dumps every frame as JSO
|
|
|
33
33
|
--pin <path> pin a working directory, so its sessions sort first
|
|
34
34
|
--unpin <path> unpin it again
|
|
35
35
|
--list-models print the models this server can switch to
|
|
36
|
+
--list-extensions print the extensions this server can switch
|
|
37
|
+
--set-extension <id> switch one extension off, or on with --on
|
|
36
38
|
--upload <path> transfer a local file to the server, attach it to
|
|
37
39
|
--prompt (repeatable)
|
|
38
40
|
--cancel cancel the current turn
|
|
@@ -61,6 +63,9 @@ const { values } = parseArgs({
|
|
|
61
63
|
pin: { type: "string" },
|
|
62
64
|
unpin: { type: "string" },
|
|
63
65
|
"list-models": { type: "boolean", default: false },
|
|
66
|
+
"list-extensions": { type: "boolean", default: false },
|
|
67
|
+
"set-extension": { type: "string" },
|
|
68
|
+
on: { type: "boolean", default: false },
|
|
64
69
|
upload: { type: "string", multiple: true },
|
|
65
70
|
cancel: { type: "boolean", default: false },
|
|
66
71
|
dequeue: { type: "boolean", default: false },
|
|
@@ -129,6 +134,17 @@ if (values["list-sessions"]) {
|
|
|
129
134
|
if (values["list-models"]) {
|
|
130
135
|
process.stdout.write(`${JSON.stringify(await probe.listModels())}\n`);
|
|
131
136
|
}
|
|
137
|
+
if (values["list-extensions"]) {
|
|
138
|
+
for (const entry of await probe.listExtensions()) {
|
|
139
|
+
process.stdout.write(`${JSON.stringify(entry)}\n`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (values["set-extension"] !== undefined) {
|
|
143
|
+
await probe.setExtension(values["set-extension"], values.on);
|
|
144
|
+
process.stderr.write(
|
|
145
|
+
`set-extension: ${values["set-extension"]} ${values.on ? "on" : "off"}\n`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
132
148
|
if (values["pick-commands"] !== undefined) {
|
|
133
149
|
const items = await probe.pickCommands(values["pick-commands"]);
|
|
134
150
|
process.stderr.write(`pick-commands: ${items.length} rows\n`);
|
|
Binary file
|