pim-agent 0.9.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 (71) hide show
  1. package/bin/pim.ts +1 -1
  2. package/package.json +2 -3
  3. package/packages/core/src/extensions/CoreExtensions.ts +19 -16
  4. package/packages/core/src/extensions/apply-patch/render.ts +16 -55
  5. package/packages/core/src/extensions/bash/index.ts +2 -1
  6. package/packages/core/src/extensions/bash/run.ts +8 -2
  7. package/packages/core/src/extensions/system-prompt/index.ts +45 -22
  8. package/packages/core/src/extensions/system-prompt/prompt.ts +6 -3
  9. package/packages/core/src/picker/PickerService.ts +23 -1
  10. package/packages/core/src/picker/token.ts +12 -5
  11. package/packages/core/src/session/EventLog.ts +22 -79
  12. package/packages/core/src/session/SearchIndex.ts +752 -0
  13. package/packages/core/src/session/SearchTokens.ts +97 -0
  14. package/packages/core/src/session/SessionDigest.ts +161 -0
  15. package/packages/core/src/session/SessionHost.ts +52 -10
  16. package/packages/core/src/session/SessionMeta.ts +267 -0
  17. package/packages/core/src/session/SessionName.ts +18 -0
  18. package/packages/core/src/session/SessionRegistry.ts +111 -15
  19. package/packages/core/src/session/SessionUi.ts +127 -0
  20. package/packages/core/src/session/WriteMark.ts +15 -1
  21. package/packages/core/src/shared/DiffLines.ts +1 -1
  22. package/packages/core/src/shared/DiffPatch.ts +70 -0
  23. package/packages/core/src/shared/DiffRenderer.ts +1 -1
  24. package/packages/core/src/shared/Directories.ts +39 -5
  25. package/packages/core/src/shared/ExtensionToggles.ts +8 -0
  26. package/packages/core/src/shared/FileWatch.ts +15 -1
  27. package/packages/core/src/shared/Git.ts +483 -42
  28. package/packages/core/src/shared/GitMonitor.ts +221 -0
  29. package/packages/core/src/shared/Levenshtein.ts +54 -1
  30. package/packages/core/src/shared/PiExtensions.ts +228 -0
  31. package/packages/core/src/shared/Proc.ts +21 -1
  32. package/packages/core/src/shared/RepoDiff.ts +741 -0
  33. package/packages/core/src/shared/Surface.ts +2 -0
  34. package/packages/core/src/shared/Updater.ts +21 -5
  35. package/packages/core/src/view/DiffExpand.ts +214 -0
  36. package/packages/core/src/view/DiffLayout.ts +7 -7
  37. package/packages/core/src/view/DiffPairs.ts +47 -0
  38. package/packages/core/src/view/MovePath.ts +70 -0
  39. package/packages/daemon/src/WebSurface.ts +1 -2
  40. package/packages/protocol/src/Command.ts +151 -3
  41. package/packages/protocol/src/Diff.ts +15 -0
  42. package/packages/protocol/src/ServerEvent.ts +191 -2
  43. package/packages/server/src/ClientConnection.ts +15 -3
  44. package/packages/server/src/ProbeClient.ts +111 -28
  45. package/packages/server/src/SessionCatalogue.ts +292 -38
  46. package/packages/server/src/SessionStream.ts +297 -45
  47. package/packages/server/src/StaticClient.ts +95 -12
  48. package/packages/server/src/WsGateway.ts +417 -45
  49. package/packages/server/src/probe.ts +77 -7
  50. package/packages/telegram/src/Session.ts +1 -0
  51. package/packages/tui/src/extensions/command-picker/index.ts +64 -32
  52. package/packages/tui/src/extensions/footer/index.ts +19 -43
  53. package/packages/tui/src/extensions/session-lease/index.ts +8 -3
  54. package/packages/web/dist/client/assets/CommitMonoV143-VF-ytizKI8U.woff2 +0 -0
  55. package/packages/web/dist/client/assets/{core-CGdQx9la.js → core-D3tyj0PP.js} +1 -1
  56. package/packages/web/dist/client/assets/index-CTrlTM53.js +45 -0
  57. package/packages/web/dist/client/assets/index-JnduS4Cd.css +1 -0
  58. package/packages/web/dist/client/index.html +2 -2
  59. package/packages/protocol/src/Protocol.ts +0 -6
  60. package/packages/web/dist/client/assets/commit-mono-latin-300-italic-B48RJMeK.woff +0 -0
  61. package/packages/web/dist/client/assets/commit-mono-latin-300-italic-DBq5bJCE.woff2 +0 -0
  62. package/packages/web/dist/client/assets/commit-mono-latin-400-italic-BXinMwCi.woff +0 -0
  63. package/packages/web/dist/client/assets/commit-mono-latin-400-italic-DjSHLl2N.woff2 +0 -0
  64. package/packages/web/dist/client/assets/commit-mono-latin-400-normal-s0S3qwFW.woff +0 -0
  65. package/packages/web/dist/client/assets/commit-mono-latin-400-normal-wzhe4RuD.woff2 +0 -0
  66. package/packages/web/dist/client/assets/commit-mono-latin-600-normal-BuQDXT0M.woff +0 -0
  67. package/packages/web/dist/client/assets/commit-mono-latin-600-normal-SwgSWHaV.woff2 +0 -0
  68. package/packages/web/dist/client/assets/commit-mono-latin-700-normal-1-GU0IUE.woff +0 -0
  69. package/packages/web/dist/client/assets/commit-mono-latin-700-normal-DU8mrtj2.woff2 +0 -0
  70. package/packages/web/dist/client/assets/index-BMNwleWO.js +0 -40
  71. package/packages/web/dist/client/assets/index-CqjB2RPr.css +0 -1
@@ -1,9 +1,12 @@
1
1
  import type { DirectoryListing } from "#core/shared/Directories";
2
+ import type { CommitResult, GitBranch } from "#core/shared/Git";
2
3
  import type { PickerItem } from "#core/picker/PickerItem";
4
+ import type { ExtensionEntry } from "#core/shared/PiExtensions";
5
+ import type { SearchRange, SearchSnippet } from "#core/session/SearchIndex";
3
6
  import type { LeaseFrontend } from "#core/session/SessionLease";
4
7
  import type { UpdateSkip } from "#core/shared/Updater";
5
8
  import type { NoticeSeverity, ToolView } from "#core/view/ViewBlock";
6
- import type { ProtocolVersion } from "./Protocol";
9
+ import type { ChangeList, FileDiff, FileLines } from "./Diff";
7
10
 
8
11
  export type SessionStatus = "idle" | "thinking" | "streaming" | "tool";
9
12
 
@@ -92,11 +95,11 @@ export type UpdateStateEvent =
92
95
  export type EphemeralEvent =
93
96
  | {
94
97
  readonly type: "attached";
95
- readonly protocolVersion: ProtocolVersion;
96
98
  readonly sessionId: string;
97
99
  readonly cwd: string;
98
100
  /** Highest durable `seq` at attach time; replay follows immediately. */
99
101
  readonly head: number;
102
+ /** The server's build; a client built from another one is stale and should reload. */
100
103
  readonly pimVersion: string;
101
104
  readonly piVersion: string;
102
105
  }
@@ -152,6 +155,29 @@ export type EphemeralEvent =
152
155
  readonly events: readonly StreamEvent[];
153
156
  }
154
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 }
155
181
  /** Sent to every connection, not just those attached; only sessions this server holds open are reported. */
156
182
  | {
157
183
  readonly type: "session_activity";
@@ -160,8 +186,34 @@ export type EphemeralEvent =
160
186
  }
161
187
  /** Sent to every connection; the read cursor is one per session, not one per client. */
162
188
  | { readonly type: "session_read"; readonly sessionId: string }
189
+ /** Sent to every connection: one session's name or overrides changed, so a listing a client holds can be patched in place. */
190
+ | {
191
+ readonly type: "session_meta";
192
+ readonly sessionId: string;
193
+ /** The session's own name, `null` once it is cleared. */
194
+ readonly name?: string | null;
195
+ readonly archived?: boolean;
196
+ readonly unread?: boolean;
197
+ }
198
+ /**
199
+ * Sent to every connection; keyed by absolute working directory, not by
200
+ * session. A patch like `session_meta`: only what changed is said, so a
201
+ * fold carries no claim about the pin beside it.
202
+ */
203
+ | {
204
+ readonly type: "project_meta";
205
+ readonly cwd: string;
206
+ readonly pinned?: boolean;
207
+ readonly expanded?: boolean;
208
+ /** What the project is called in a listing, `null` once it is cleared. */
209
+ readonly label?: string | null;
210
+ }
211
+ /** Sent to every connection: the pinned projects, in the order they are shown. */
212
+ | { readonly type: "pins_changed"; readonly order: readonly string[] }
163
213
  /** Sent to every connection: the sessions on disk changed, so any listing a client holds is stale. */
164
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" }
165
217
  /** Sent to every connection; the restart it ends in drops every socket. */
166
218
  | UpdateStateEvent
167
219
  | {
@@ -175,6 +227,8 @@ export type EphemeralEvent =
175
227
  readonly status: SessionStatus;
176
228
  /** False while another process holds this session's turn lease. */
177
229
  readonly writable: boolean;
230
+ /** A session in the same working directory is mid-turn, so nothing may move the repository under it. */
231
+ readonly repoBusy?: boolean;
178
232
  /** Who holds it; absent when nothing does, or when their record is torn. */
179
233
  readonly heldBy?: {
180
234
  readonly frontend: LeaseFrontend;
@@ -192,6 +246,8 @@ export type EphemeralEvent =
192
246
  readonly dirtyCount?: number;
193
247
  readonly ahead?: number;
194
248
  readonly behind?: number;
249
+ /** Changes whenever the working copy does, content of a dirty file included. */
250
+ readonly repoRevision?: string;
195
251
  }
196
252
  /** A frame the server could not attribute to any command. */
197
253
  | { readonly type: "error"; readonly message: string };
@@ -205,12 +261,68 @@ export type SessionSummaryView = {
205
261
  readonly settledAt: number;
206
262
  /** The session's first user message, trimmed; absent when it has none. */
207
263
  readonly title?: string;
264
+ /** True when `title` is a name somebody wrote, rather than the first message. */
265
+ readonly named?: true;
266
+ readonly archived?: true;
208
267
  /** Has answered since anything last read it; absent means it has not. */
209
268
  readonly unread?: boolean;
210
269
  /** Absent when idle, and for a session this server does not hold open. */
211
270
  readonly status?: SessionStatus;
212
271
  };
213
272
 
273
+ /** One working directory the catalogue holds sessions for, counted before any per-project cut. */
274
+ export type ProjectView = {
275
+ readonly cwd: string;
276
+ /** Every session in it the listing's scope allows, including the ones the cut dropped. */
277
+ readonly count: number;
278
+ readonly pinned?: true;
279
+ /** Where it sorts among the pinned, 0 first; absent unless it is pinned. */
280
+ readonly pinRank?: number;
281
+ /** The sidebar group stands unfolded; absent is folded. */
282
+ readonly expanded?: true;
283
+ /** What to call it instead of its base name; absent when it goes by the directory. */
284
+ readonly label?: string;
285
+ };
286
+
287
+ /**
288
+ * One session a search matched. Deliberately not a `SessionSummaryView`: what
289
+ * a ranked list needs is why the row matched, and `unread` is the sidebar's
290
+ * triage while `status` resolves itself the moment the session opens.
291
+ */
292
+ export type SearchHitView = {
293
+ readonly sessionId: string;
294
+ readonly cwd: string;
295
+ /** Its name if it has one, else the clamped opening message; the digest's, so it agrees with the sidebar's. */
296
+ readonly title?: string;
297
+ /** Offsets into the clamped `title`, empty when the title did not match. */
298
+ readonly titleRanges: readonly SearchRange[];
299
+ /** The session's opening ask, for a row whose name is all that matched and so has no snippet to show. */
300
+ readonly opening?: string;
301
+ /** End of the last completed turn and the row's clock, never the file mtime; falls back to when the session started, as the sidebar's does. */
302
+ readonly settledAt: number;
303
+ /** Searched and found anyway: the badge that makes "archived are in scope" honest. */
304
+ readonly archived?: true;
305
+ /** What matched, in the words it was said in, each with its own ranges. */
306
+ readonly snippets: readonly SearchSnippet[];
307
+ /** Matching messages in this session, before the snippet cut. */
308
+ readonly total: number;
309
+ };
310
+
311
+ /** One answer to `list_sessions`: the page of rows, and every directory that had one, counted whole. */
312
+ export type SessionListing = {
313
+ readonly sessions: readonly SessionSummaryView[];
314
+ /** Counted before the per-project cut, so a group can say what a page of it leaves out. */
315
+ readonly projects: readonly ProjectView[];
316
+ };
317
+
318
+ /** One answer to `search_sessions`: the ranked rows, the words no session held, and the scope it read. */
319
+ export type SessionSearch = {
320
+ readonly hits: readonly SearchHitView[];
321
+ readonly dropped: readonly string[];
322
+ /** Sessions searched, whole: what the empty state and the result footer say out loud. */
323
+ readonly scanned: number;
324
+ };
325
+
214
326
  /** One model the server can be switched to, for the composer's model menu. */
215
327
  export type ModelView = {
216
328
  readonly id: string;
@@ -229,14 +341,36 @@ export type ResponseEvent = {
229
341
  readonly items?: readonly PickerItem[];
230
342
  /** The catalogue, for `list_sessions`. */
231
343
  readonly sessions?: readonly SessionSummaryView[];
344
+ /** The directories those sessions came from, for `list_sessions`. */
345
+ readonly projects?: readonly ProjectView[];
346
+ /** The ranked sessions, for `search_sessions`. */
347
+ readonly hits?: readonly SearchHitView[];
348
+ /** Query words no session held, dropped so the rest could match, for `search_sessions`. */
349
+ readonly dropped?: readonly string[];
350
+ /** Sessions the query actually searched, for `search_sessions`. */
351
+ readonly scanned?: number;
232
352
  /** The model catalogue, for `list_models`. */
233
353
  readonly models?: readonly ModelView[];
234
354
  /** What the *current* model supports, on the same answer. */
235
355
  readonly thinkingLevels?: readonly string[];
356
+ /** The extension roster, for `list_extensions`. */
357
+ readonly extensions?: readonly ExtensionEntry[];
236
358
  /** One directory's subdirectories, for `list_dirs`. */
237
359
  readonly directory?: DirectoryListing;
360
+ /** The cwd's local branches, for `list_branches`. */
361
+ readonly branches?: readonly GitBranch[];
362
+ /** The commit that landed, for `commit`. */
363
+ readonly commit?: Pick<Extract<CommitResult, { readonly ok: true }>, "sha">;
364
+ /** The change list, for `list_changes`. */
365
+ readonly changes?: ChangeList;
366
+ /** One file's hunks, for `file_diff`. */
367
+ readonly fileDiff?: FileDiff;
368
+ /** The lines behind one gap, for `read_lines`. */
369
+ readonly fileLines?: FileLines;
238
370
  /** For `cancel` and `dequeue`: queued messages pi gave back, now owned by the client that asked. */
239
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;
240
374
  };
241
375
 
242
376
  export type ServerEvent = DurableEvent | EphemeralEvent | ResponseEvent;
@@ -249,3 +383,58 @@ export type ServerEventType = ServerEvent["type"];
249
383
  export function isDurableEvent(event: ServerEvent): event is DurableEvent {
250
384
  return "seq" in event;
251
385
  }
386
+
387
+ /**
388
+ * Whether the frame speaks for the one session a connection is attached to,
389
+ * and so says nothing to a client whose attach is still in flight. The rest
390
+ * name their own subject — another session, a directory, or nothing at all —
391
+ * and gating those on the attach window drops a broadcast every time a client
392
+ * switches session.
393
+ *
394
+ * Exhaustive by construction: a new event is a type error here until it is
395
+ * classified, which is the only reason this lives beside the wire types
396
+ * rather than in the client that gates on it.
397
+ */
398
+ export function isAttachScoped(event: ServerEvent): boolean {
399
+ switch (event.type) {
400
+ case "attached":
401
+ case "replay":
402
+ case "message":
403
+ case "message_start":
404
+ case "message_retire":
405
+ case "text_delta":
406
+ case "thinking_delta":
407
+ case "tool_call":
408
+ case "tool_update":
409
+ case "tool_end":
410
+ case "tool_result":
411
+ case "notice":
412
+ case "subagent_events":
413
+ case "turn_end":
414
+ case "session_state":
415
+ case "ui_notice":
416
+ case "ui_request":
417
+ case "ui_request_done":
418
+ // Names a cwd, but only ever reaches a client down the session stream it
419
+ // is attached to, so the old session's is the only one that can arrive
420
+ // mid-attach — and re-querying for it would warm the wrong cache.
421
+ case "picker_invalidate":
422
+ return true;
423
+ case "session_activity":
424
+ case "session_read":
425
+ case "session_meta":
426
+ case "project_meta":
427
+ case "pins_changed":
428
+ case "sessions_changed":
429
+ case "extensions_changed":
430
+ case "update_state":
431
+ // Sent for a frame the server could not read at all, which is likeliest
432
+ // before an attach has settled: gating it swallows the diagnostic.
433
+ case "error":
434
+ case "response":
435
+ return false;
436
+ default:
437
+ event satisfies never;
438
+ return false;
439
+ }
440
+ }
@@ -44,6 +44,7 @@ export class ClientConnection {
44
44
  private paused = false;
45
45
  private cursor = 0;
46
46
  private closed = false;
47
+ private reading = true;
47
48
  private watch: SubagentWatch | undefined;
48
49
 
49
50
  public constructor(ws: ServerWebSocket<undefined>) {
@@ -54,6 +55,11 @@ export class ClientConnection {
54
55
  return this.stream?.sessionId;
55
56
  }
56
57
 
58
+ /** Whether the reader behind this socket is looking at the session right now. */
59
+ public get attentive(): boolean {
60
+ return this.reading;
61
+ }
62
+
57
63
  /** Highest durable `seq` this client has been handed. */
58
64
  public get seq(): number {
59
65
  return this.cursor;
@@ -66,17 +72,23 @@ export class ClientConnection {
66
72
 
67
73
  public async attach(
68
74
  stream: AttachableStream,
69
- fromSeq: number
75
+ fromSeq: number,
76
+ attentive = true
70
77
  ): Promise<void> {
71
78
  this.detach();
72
79
  this.stream = stream;
73
80
  this.cursor = fromSeq;
81
+ this.reading = attentive;
74
82
  this.unsubscribe = stream.subscribe((event) => {
75
83
  this.onStreamEvent(event);
76
84
  });
77
85
  await this.sync();
78
86
  }
79
87
 
88
+ public setAttentive(attentive: boolean): void {
89
+ this.reading = attentive;
90
+ }
91
+
80
92
  /** Read a subagent's log alongside the attached session; at most one watch at a time. */
81
93
  public async watchSubagent(
82
94
  callId: string,
@@ -99,7 +111,7 @@ export class ClientConnection {
99
111
  if (this.closed) {
100
112
  return;
101
113
  }
102
- this.ws.send(frame(event));
114
+ this.ws.send(frame(event), true);
103
115
  }
104
116
 
105
117
  public onDrain(): void {
@@ -239,7 +251,7 @@ export class ClientConnection {
239
251
  if (this.closed || this.paused) {
240
252
  return false;
241
253
  }
242
- const status = this.ws.send(payload);
254
+ const status = this.ws.send(payload, true);
243
255
  if (status === 0) {
244
256
  this.paused = true;
245
257
  return false;
@@ -2,14 +2,20 @@ 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 { AttachmentRef, CommandDraft } from "#protocol/Command";
6
- import { PROTOCOL_VERSION } from "#protocol/Protocol";
5
+ import type { ExtensionEntry } from "#core/shared/PiExtensions";
6
+ import type {
7
+ AttachmentRef,
8
+ CommandDraft,
9
+ SearchScope,
10
+ SessionScope,
11
+ } from "#protocol/Command";
7
12
  import {
8
13
  isDurableEvent,
9
14
  type ModelView,
10
15
  type ResponseEvent,
11
16
  type ServerEvent,
12
- type SessionSummaryView,
17
+ type SessionListing,
18
+ type SessionSearch,
13
19
  } from "#protocol/ServerEvent";
14
20
 
15
21
  export type ProbeOptions = {
@@ -18,10 +24,10 @@ export type ProbeOptions = {
18
24
  readonly sessionId?: string;
19
25
  readonly cwd?: string;
20
26
  readonly fromSeq?: number;
27
+ /** Sent on the attach frame; false is a client that is connected but not looking. */
28
+ readonly attentive?: boolean;
21
29
  /** Called for every frame, in arrival order. */
22
30
  readonly onEvent?: (event: ServerEvent) => void;
23
- /** Sent instead of the real one, to exercise version rejection. */
24
- readonly protocolVersion?: number;
25
31
  /** Keystroke debounce for `files`; 0 makes tests deterministic. */
26
32
  readonly debounceMs?: number;
27
33
  };
@@ -56,9 +62,6 @@ export class ProbeClient {
56
62
  }>();
57
63
  private socket: WebSocket | undefined;
58
64
  private nextId = 0;
59
- private closeInfo:
60
- | { readonly code: number; readonly reason: string }
61
- | undefined;
62
65
 
63
66
  public constructor(options: ProbeOptions) {
64
67
  this.options = options;
@@ -78,7 +81,6 @@ export class ProbeClient {
78
81
  this.receive(String(event.data));
79
82
  });
80
83
  socket.addEventListener("close", (event) => {
81
- this.closeInfo = { code: event.code, reason: event.reason };
82
84
  for (const { reject } of this.pending.values()) {
83
85
  reject(new Error(`socket closed: ${event.code} ${event.reason}`));
84
86
  }
@@ -94,14 +96,20 @@ export class ProbeClient {
94
96
  });
95
97
  return await this.send({
96
98
  type: "attach",
97
- protocolVersion: (this.options.protocolVersion ??
98
- PROTOCOL_VERSION) as typeof PROTOCOL_VERSION,
99
99
  ...(this.sessionId === undefined ? {} : { sessionId: this.sessionId }),
100
100
  ...(this.options.cwd === undefined ? {} : { cwd: this.options.cwd }),
101
+ ...(this.options.attentive === undefined
102
+ ? {}
103
+ : { attentive: this.options.attentive }),
101
104
  fromSeq: this.seq,
102
105
  });
103
106
  }
104
107
 
108
+ /** Says whether this client's reader is present; regaining it reads the session. */
109
+ public attention(value: boolean): Promise<ResponseEvent> {
110
+ return this.send({ type: "attention", value });
111
+ }
112
+
105
113
  public send(command: CommandDraft): Promise<ResponseEvent> {
106
114
  const socket = this.socket;
107
115
  if (!socket) {
@@ -165,16 +173,82 @@ export class ProbeClient {
165
173
 
166
174
  /** Pi's session catalogue; answers whether or not this probe is attached. */
167
175
  public async listSessions(
168
- cwd?: string
169
- ): Promise<readonly SessionSummaryView[]> {
176
+ scope: SessionScope = {}
177
+ ): Promise<SessionListing["sessions"]> {
178
+ return (await this.catalogue(scope)).sessions;
179
+ }
180
+
181
+ /** The listing whole: the rows the page kept and the projects they came from. */
182
+ public async catalogue(scope: SessionScope = {}): Promise<SessionListing> {
183
+ const response = await this.send({ type: "list_sessions", ...scope });
184
+ if (!response.success) {
185
+ throw new Error(response.error ?? "list_sessions failed");
186
+ }
187
+ return {
188
+ sessions: response.sessions ?? [],
189
+ projects: response.projects ?? [],
190
+ };
191
+ }
192
+
193
+ /** Searches every session on disk; an empty query warms the index and answers with no hits. */
194
+ public async search(
195
+ query: string,
196
+ scope: SearchScope = {}
197
+ ): Promise<SessionSearch> {
170
198
  const response = await this.send({
171
- type: "list_sessions",
172
- ...(cwd === undefined ? {} : { cwd }),
199
+ type: "search_sessions",
200
+ query,
201
+ ...scope,
173
202
  });
174
203
  if (!response.success) {
175
- throw new Error(response.error ?? "list_sessions failed");
204
+ throw new Error(response.error ?? "search_sessions failed");
176
205
  }
177
- return response.sessions ?? [];
206
+ return {
207
+ hits: response.hits ?? [],
208
+ dropped: response.dropped ?? [],
209
+ scanned: response.scanned ?? 0,
210
+ };
211
+ }
212
+
213
+ /** Names a session through pi's own name; `null` clears it back to its opening message. */
214
+ public rename(
215
+ sessionId: string,
216
+ value: string | null
217
+ ): Promise<ResponseEvent> {
218
+ return this.send({ type: "set_session_name", sessionId, value });
219
+ }
220
+
221
+ /** Puts a session out of the live listing, or brings it back. */
222
+ public setArchived(
223
+ sessionId: string,
224
+ value: boolean
225
+ ): Promise<ResponseEvent> {
226
+ return this.send({ type: "set_session_archived", sessionId, value });
227
+ }
228
+
229
+ /** Holds a session unread until something reads it; survives a listing and a re-attach. */
230
+ public markUnread(sessionId: string, value: boolean): Promise<ResponseEvent> {
231
+ return this.send({ type: "set_session_unread", sessionId, value });
232
+ }
233
+
234
+ /** Pins a working directory, not a session. */
235
+ public setPinned(cwd: string, value: boolean): Promise<ResponseEvent> {
236
+ return this.send({ type: "set_project_pinned", cwd, value });
237
+ }
238
+
239
+ /** Re-orders the pinned directories; the whole order, pinned ones only. */
240
+ public setPinOrder(order: readonly string[]): Promise<ResponseEvent> {
241
+ return this.send({ type: "set_pin_order", order });
242
+ }
243
+
244
+ /** Unfolds a working directory's sidebar group, or folds it. */
245
+ public setExpanded(cwd: string, value: boolean): Promise<ResponseEvent> {
246
+ return this.send({ type: "set_project_expanded", cwd, value });
247
+ }
248
+
249
+ /** Names a working directory for the listings; `null` puts it back to its base name. */
250
+ public setLabel(cwd: string, value: string | null): Promise<ResponseEvent> {
251
+ return this.send({ type: "set_project_label", cwd, value });
178
252
  }
179
253
 
180
254
  /** The model catalogue, plus this session's thinking levels; empty when unattached. */
@@ -192,6 +266,26 @@ export class ProbeClient {
192
266
  };
193
267
  }
194
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
+
195
289
  /** Transfers a client-local file into the server's world; only the bytes and bare filename are sent. */
196
290
  public async upload(localPath: string): Promise<UploadedFile> {
197
291
  const file = Bun.file(localPath);
@@ -249,17 +343,6 @@ export class ProbeClient {
249
343
  });
250
344
  }
251
345
 
252
- /** Resolves when the socket closes, e.g. after a protocol rejection. */
253
- public async closed(): Promise<{
254
- readonly code: number;
255
- readonly reason: string;
256
- }> {
257
- while (!this.closeInfo) {
258
- await Bun.sleep(5);
259
- }
260
- return this.closeInfo;
261
- }
262
-
263
346
  /** Drops the socket without a close frame, the way a killed client would. */
264
347
  public kill(): void {
265
348
  this.socket?.close(4000, "probe killed");