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.
Files changed (36) hide show
  1. package/package.json +1 -2
  2. package/packages/core/src/extensions/bash/index.ts +2 -1
  3. package/packages/core/src/extensions/bash/run.ts +8 -2
  4. package/packages/core/src/extensions/system-prompt/index.ts +16 -1
  5. package/packages/core/src/extensions/system-prompt/prompt.ts +1 -2
  6. package/packages/core/src/session/SessionHost.ts +35 -9
  7. package/packages/core/src/session/SessionRegistry.ts +22 -1
  8. package/packages/core/src/session/SessionUi.ts +127 -0
  9. package/packages/core/src/shared/Directories.ts +39 -5
  10. package/packages/core/src/shared/ExtensionToggles.ts +8 -0
  11. package/packages/core/src/shared/PiExtensions.ts +228 -0
  12. package/packages/protocol/src/Command.ts +22 -1
  13. package/packages/protocol/src/ServerEvent.ts +34 -0
  14. package/packages/server/src/ProbeClient.ts +21 -0
  15. package/packages/server/src/SessionStream.ts +228 -2
  16. package/packages/server/src/WsGateway.ts +153 -16
  17. package/packages/server/src/probe.ts +16 -0
  18. package/packages/web/dist/client/assets/CommitMonoV143-VF-ytizKI8U.woff2 +0 -0
  19. package/packages/web/dist/client/assets/{core-CA0aSzPu.js → core-D3tyj0PP.js} +1 -1
  20. package/packages/web/dist/client/assets/index-CTrlTM53.js +45 -0
  21. package/packages/web/dist/client/assets/index-JnduS4Cd.css +1 -0
  22. package/packages/web/dist/client/index.html +2 -2
  23. package/packages/web/dist/client/assets/commit-mono-latin-300-italic-B48RJMeK.woff +0 -0
  24. package/packages/web/dist/client/assets/commit-mono-latin-300-italic-DBq5bJCE.woff2 +0 -0
  25. package/packages/web/dist/client/assets/commit-mono-latin-300-normal-B-iV2FbL.woff2 +0 -0
  26. package/packages/web/dist/client/assets/commit-mono-latin-300-normal-kM0OTZCv.woff +0 -0
  27. package/packages/web/dist/client/assets/commit-mono-latin-400-italic-BXinMwCi.woff +0 -0
  28. package/packages/web/dist/client/assets/commit-mono-latin-400-italic-DjSHLl2N.woff2 +0 -0
  29. package/packages/web/dist/client/assets/commit-mono-latin-400-normal-s0S3qwFW.woff +0 -0
  30. package/packages/web/dist/client/assets/commit-mono-latin-400-normal-wzhe4RuD.woff2 +0 -0
  31. package/packages/web/dist/client/assets/commit-mono-latin-600-normal-BuQDXT0M.woff +0 -0
  32. package/packages/web/dist/client/assets/commit-mono-latin-600-normal-SwgSWHaV.woff2 +0 -0
  33. package/packages/web/dist/client/assets/commit-mono-latin-700-normal-1-GU0IUE.woff +0 -0
  34. package/packages/web/dist/client/assets/commit-mono-latin-700-normal-DU8mrtj2.woff2 +0 -0
  35. package/packages/web/dist/client/assets/index-B9be-i9d.css +0 -1
  36. 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
  }