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.
Files changed (41) hide show
  1. package/package.json +3 -4
  2. package/packages/core/src/extensions/apply-patch/model.ts +1 -12
  3. package/packages/core/src/extensions/bash/index.ts +2 -1
  4. package/packages/core/src/extensions/bash/run.ts +8 -2
  5. package/packages/core/src/extensions/edit/index.ts +1 -1
  6. package/packages/core/src/extensions/subagent/index.ts +2 -1
  7. package/packages/core/src/extensions/subagent/subagent.ts +10 -1
  8. package/packages/core/src/extensions/system-prompt/index.ts +33 -24
  9. package/packages/core/src/extensions/system-prompt/prompt.ts +43 -67
  10. package/packages/core/src/session/SessionHost.ts +35 -9
  11. package/packages/core/src/session/SessionRegistry.ts +22 -1
  12. package/packages/core/src/session/SessionUi.ts +127 -0
  13. package/packages/core/src/shared/Directories.ts +39 -5
  14. package/packages/core/src/shared/ExtensionToggles.ts +8 -0
  15. package/packages/core/src/shared/PiExtensions.ts +228 -0
  16. package/packages/core/src/shared/Tools.ts +3 -3
  17. package/packages/protocol/src/Command.ts +22 -1
  18. package/packages/protocol/src/ServerEvent.ts +34 -0
  19. package/packages/server/src/ProbeClient.ts +21 -0
  20. package/packages/server/src/SessionStream.ts +228 -2
  21. package/packages/server/src/WsGateway.ts +153 -16
  22. package/packages/server/src/probe.ts +16 -0
  23. package/packages/web/dist/client/assets/CommitMonoV143-VF-ytizKI8U.woff2 +0 -0
  24. package/packages/web/dist/client/assets/{core-CA0aSzPu.js → core-DKI-ZG3V.js} +1 -1
  25. package/packages/web/dist/client/assets/index-BCSESkyh.css +1 -0
  26. package/packages/web/dist/client/assets/index-BWUZzdeu.js +45 -0
  27. package/packages/web/dist/client/index.html +2 -2
  28. package/packages/web/dist/client/assets/commit-mono-latin-300-italic-B48RJMeK.woff +0 -0
  29. package/packages/web/dist/client/assets/commit-mono-latin-300-italic-DBq5bJCE.woff2 +0 -0
  30. package/packages/web/dist/client/assets/commit-mono-latin-300-normal-B-iV2FbL.woff2 +0 -0
  31. package/packages/web/dist/client/assets/commit-mono-latin-300-normal-kM0OTZCv.woff +0 -0
  32. package/packages/web/dist/client/assets/commit-mono-latin-400-italic-BXinMwCi.woff +0 -0
  33. package/packages/web/dist/client/assets/commit-mono-latin-400-italic-DjSHLl2N.woff2 +0 -0
  34. package/packages/web/dist/client/assets/commit-mono-latin-400-normal-s0S3qwFW.woff +0 -0
  35. package/packages/web/dist/client/assets/commit-mono-latin-400-normal-wzhe4RuD.woff2 +0 -0
  36. package/packages/web/dist/client/assets/commit-mono-latin-600-normal-BuQDXT0M.woff +0 -0
  37. package/packages/web/dist/client/assets/commit-mono-latin-600-normal-SwgSWHaV.woff2 +0 -0
  38. package/packages/web/dist/client/assets/commit-mono-latin-700-normal-1-GU0IUE.woff +0 -0
  39. package/packages/web/dist/client/assets/commit-mono-latin-700-normal-DU8mrtj2.woff2 +0 -0
  40. package/packages/web/dist/client/assets/index-B9be-i9d.css +0 -1
  41. 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(this.requireStream(connection), command);
429
- return {};
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
- stream.subscribe((event) => {
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
- ): void {
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
- const text = [command.text, ...lines].filter(Boolean).join("\n\n").trim();
758
- this.prompt(stream, text, images);
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`);