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
@@ -7,16 +7,27 @@ import { Attachments } from "#core/attachments/Attachments";
7
7
  import type { PickerItem } from "#core/picker/PickerItem";
8
8
  import { Directories } from "#core/shared/Directories";
9
9
  import type { DirectoryListing } from "#core/shared/Directories";
10
+ import { Git, type GitBranch } from "#core/shared/Git";
11
+ import { GitMonitor, type GitRun } from "#core/shared/GitMonitor";
12
+ import {
13
+ PiExtensions,
14
+ type ExtensionEntry,
15
+ type ExtensionScope,
16
+ } from "#core/shared/PiExtensions";
17
+ import { RepoDiff } from "#core/shared/RepoDiff";
10
18
  import { ReadCursors } from "#core/session/ReadCursors";
11
19
  import type { SessionHost } from "#core/session/SessionHost";
20
+ import { SessionMeta } from "#core/session/SessionMeta";
12
21
  import type { SessionRegistry } from "#core/session/SessionRegistry";
13
22
  import { PimVersion } from "#core/shared/PimVersion";
14
23
  import { SubagentLogs } from "#core/shared/SubagentLogs";
15
24
  import type { UpdateOutcome } from "#core/shared/Updater";
16
25
  import type { Command } from "#protocol/Command";
17
- import { CLOSE_PROTOCOL_MISMATCH, PROTOCOL_VERSION } from "#protocol/Protocol";
26
+ import type { ChangeList, FileDiff, FileLines } from "#protocol/Diff";
18
27
  import type {
19
28
  ModelView,
29
+ ProjectView,
30
+ SearchHitView,
20
31
  ServerEvent,
21
32
  SessionSummaryView,
22
33
  } from "#protocol/ServerEvent";
@@ -41,6 +52,14 @@ export type WsGatewayDeps = {
41
52
  readonly imagesRoot?: string;
42
53
  /** Where the read cursors live; defaults to `~/.pim/read.json`. */
43
54
  readonly readCursorsPath?: string;
55
+ /** Where the per-session and per-project overrides live; defaults to `~/.pim/sessions.json`. */
56
+ readonly sessionMetaPath?: string;
57
+ /** How often a session file is polled where `fs.watch` says nothing; the default is a second. */
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;
44
63
  /** The built web client; defaults to the bundle shipped beside this package. */
45
64
  readonly clientDir?: string;
46
65
  /** Runs the update a `reload` asks for, reporting each step as it starts. */
@@ -53,10 +72,22 @@ type Outcome = {
53
72
  readonly error?: string;
54
73
  readonly items?: readonly PickerItem[];
55
74
  readonly sessions?: readonly SessionSummaryView[];
75
+ readonly projects?: readonly ProjectView[];
76
+ readonly hits?: readonly SearchHitView[];
77
+ readonly dropped?: readonly string[];
78
+ readonly scanned?: number;
56
79
  readonly models?: readonly ModelView[];
57
80
  readonly thinkingLevels?: readonly string[];
81
+ readonly extensions?: readonly ExtensionEntry[];
58
82
  readonly directory?: DirectoryListing;
83
+ readonly branches?: readonly GitBranch[];
84
+ readonly commit?: { readonly sha: string };
85
+ readonly changes?: ChangeList;
86
+ readonly fileDiff?: FileDiff;
87
+ readonly fileLines?: FileLines;
59
88
  readonly restored?: readonly string[];
89
+ /** The message was dispatched as an extension command: no turn started, no entry written. */
90
+ readonly dispatched?: boolean;
60
91
  readonly after?: () => void;
61
92
  };
62
93
 
@@ -73,6 +104,15 @@ export const DEFAULT_HOSTNAME = "127.0.0.1";
73
104
  // Bun 1.3.14 never frees the slot of a server-closed socket, so an unbounded `Server.stop(true)` hangs.
74
105
  const STOP_GRACE_MS = 250;
75
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
+
76
116
  /** The transport half of pim-server: a WebSocket endpoint over the session runtime in `core`. */
77
117
  export class WsGateway {
78
118
  private readonly registry: SessionRegistry;
@@ -81,6 +121,7 @@ export class WsGateway {
81
121
  private readonly uploads: AttachmentEndpoint;
82
122
  private readonly images: ImageEndpoint;
83
123
  private readonly catalogue: SessionCatalogue;
124
+ private readonly meta: SessionMeta;
84
125
  private readonly client: StaticClient;
85
126
  private readonly streams = new Map<string, SessionStream>();
86
127
  private readonly opening = new Map<string, Promise<SessionStream>>();
@@ -89,22 +130,39 @@ export class WsGateway {
89
130
  ClientConnection
90
131
  >();
91
132
  private readonly reloader: Reloader;
133
+ private readonly git = new GitMonitor();
134
+ private readonly pollMs: number | undefined;
135
+ private readonly requestCeilingMs: number | undefined;
136
+ private readonly detachGraceMs: number | undefined;
137
+ private readonly busyRepos = new Set<string>();
92
138
  private versionsRead: Promise<readonly [string, string]> | undefined;
93
139
  private server: Server<undefined> | undefined;
94
140
 
95
141
  public constructor(deps: WsGatewayDeps) {
96
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
+ );
97
150
  this.hostname = deps.hostname ?? DEFAULT_HOSTNAME;
98
151
  this.requestedPort = deps.port ?? DEFAULT_PORT;
152
+ this.pollMs = deps.pollMs;
153
+ this.requestCeilingMs = deps.requestCeilingMs;
154
+ this.detachGraceMs = deps.detachGraceMs;
99
155
  this.uploads = new AttachmentEndpoint(
100
156
  deps.attachmentsRoot === undefined ? {} : { root: deps.attachmentsRoot }
101
157
  );
102
158
  this.images = new ImageEndpoint(
103
159
  deps.imagesRoot === undefined ? {} : { root: deps.imagesRoot }
104
160
  );
161
+ this.meta = new SessionMeta(deps.sessionMetaPath);
105
162
  this.catalogue = new SessionCatalogue({
106
163
  registry: deps.registry,
107
164
  cursors: new ReadCursors(deps.readCursorsPath),
165
+ meta: this.meta,
108
166
  liveStatus: (sessionId) => this.streams.get(sessionId)?.host.status,
109
167
  liveSessionIds: () => this.streams.keys(),
110
168
  isBeingRead: (sessionId) => this.isBeingRead(sessionId),
@@ -145,7 +203,9 @@ export class WsGateway {
145
203
  fetch: (req, server) => {
146
204
  const { pathname } = new URL(req.url);
147
205
  if (pathname === "/health") {
148
- return Response.json({ ok: true, protocolVersion: PROTOCOL_VERSION });
206
+ return this.versions().then(([pimVersion]) =>
207
+ Response.json({ ok: true, pimVersion })
208
+ );
149
209
  }
150
210
  if (AttachmentEndpoint.owns(pathname)) {
151
211
  return this.uploads.handle(req);
@@ -159,6 +219,8 @@ export class WsGateway {
159
219
  websocket: {
160
220
  backpressureLimit: 8 << 20,
161
221
  closeOnBackpressureLimit: false,
222
+ // Dedicated, so the window survives between frames: the repeated event envelope is most of a delta.
223
+ perMessageDeflate: { compress: "dedicated", decompress: "dedicated" },
162
224
  open: (ws) => {
163
225
  this.connections.set(ws, new ClientConnection(ws));
164
226
  this.syncWatches();
@@ -184,6 +246,8 @@ export class WsGateway {
184
246
  }
185
247
  this.streams.clear();
186
248
  this.opening.clear();
249
+ this.git.dispose();
250
+ this.busyRepos.clear();
187
251
  this.catalogue.watch(false);
188
252
  this.catalogue.clear();
189
253
  await this.catalogue.flush();
@@ -217,19 +281,6 @@ export class WsGateway {
217
281
  connection.send({ type: "error", message: "frame is not a command" });
218
282
  return;
219
283
  }
220
- if (
221
- command.type === "attach" &&
222
- command.protocolVersion !== PROTOCOL_VERSION
223
- ) {
224
- connection.send({
225
- type: "response",
226
- id: command.id,
227
- success: false,
228
- error: `unsupported protocol version ${String(command.protocolVersion)}; this server speaks ${PROTOCOL_VERSION}`,
229
- });
230
- ws.close(CLOSE_PROTOCOL_MISMATCH, "protocol version mismatch");
231
- return;
232
- }
233
284
  try {
234
285
  const { error, after, ...answer } = await this.dispatch(
235
286
  connection,
@@ -260,24 +311,180 @@ export class WsGateway {
260
311
  switch (command.type) {
261
312
  case "attach":
262
313
  return await this.attach(connection, command);
314
+ case "attention": {
315
+ const regained = command.value && !connection.attentive;
316
+ connection.setAttentive(command.value);
317
+ const sessionId = connection.sessionId;
318
+ if (regained && sessionId !== undefined) {
319
+ await this.catalogue.markRead(sessionId);
320
+ }
321
+ return {};
322
+ }
263
323
  case "list_sessions":
264
- return { sessions: await this.catalogue.list(command) };
324
+ return await this.catalogue.list(command);
325
+ case "search_sessions":
326
+ return await this.catalogue.search(command);
327
+ // The four below take a session this server may never have opened: a row
328
+ // is archived or renamed from the sidebar without being attached to, so
329
+ // none of them may reach for a stream. A sidecar write moves no session
330
+ // file and produces no `sessions_changed`, so the broadcast each ends
331
+ // with is the only word a listing already in a client's hands gets.
332
+ case "set_session_name": {
333
+ const name = await this.registry.setName(
334
+ command.sessionId,
335
+ command.value
336
+ );
337
+ this.broadcast({
338
+ type: "session_meta",
339
+ sessionId: command.sessionId,
340
+ name: name ?? null,
341
+ });
342
+ return {};
343
+ }
344
+ case "set_session_archived":
345
+ await this.meta.setArchived(command.sessionId, command.value);
346
+ this.broadcast({
347
+ type: "session_meta",
348
+ sessionId: command.sessionId,
349
+ archived: command.value,
350
+ });
351
+ return {};
352
+ case "set_session_unread":
353
+ await this.meta.setUnread(command.sessionId, command.value);
354
+ this.broadcast({
355
+ type: "session_meta",
356
+ sessionId: command.sessionId,
357
+ unread: command.value,
358
+ });
359
+ return {};
360
+ case "set_project_pinned":
361
+ await this.meta.setPinned(command.cwd, command.value);
362
+ this.broadcast({
363
+ type: "project_meta",
364
+ cwd: command.cwd,
365
+ pinned: command.value,
366
+ });
367
+ // The flag and the place it takes are one fact to a sidebar; a pin
368
+ // that arrived without a rank would sort by nothing until the next listing.
369
+ this.broadcast({ type: "pins_changed", order: await this.meta.pins() });
370
+ return {};
371
+ case "set_project_expanded":
372
+ await this.meta.setExpanded(command.cwd, command.value);
373
+ this.broadcast({
374
+ type: "project_meta",
375
+ cwd: command.cwd,
376
+ expanded: command.value,
377
+ });
378
+ return {};
379
+ case "set_project_label":
380
+ await this.meta.setLabel(command.cwd, command.value);
381
+ this.broadcast({
382
+ type: "project_meta",
383
+ cwd: command.cwd,
384
+ label: command.value,
385
+ });
386
+ return {};
387
+ case "set_pin_order":
388
+ await this.meta.setPinOrder(command.order);
389
+ this.broadcast({ type: "pins_changed", order: await this.meta.pins() });
390
+ return {};
265
391
  case "list_models":
266
392
  return {
267
393
  models: this.registry.models(),
268
394
  thinkingLevels:
269
395
  this.streamFor(connection)?.host.supportedThinkingLevels ?? [],
270
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
+ };
271
412
  case "list_dirs":
272
413
  return { directory: await Directories.list(command.path) };
414
+ case "create_dir":
415
+ await Directories.create(command.path);
416
+ return {};
417
+ case "refresh_git": {
418
+ const stream = this.requireStream(connection);
419
+ await stream.refreshGit(command.fetch === true);
420
+ return {};
421
+ }
422
+ case "list_branches":
423
+ return {
424
+ branches: await Git.listBranches(
425
+ this.requireStream(connection).host.cwd
426
+ ),
427
+ };
428
+ // Reading the repository never moves it, so neither of these takes the `repoBusy` refusal.
429
+ case "list_changes":
430
+ return {
431
+ changes: await RepoDiff.listChanges(
432
+ this.requireStream(connection).host.cwd,
433
+ command.base,
434
+ this.git
435
+ ),
436
+ };
437
+ case "file_diff":
438
+ return {
439
+ fileDiff: await RepoDiff.fileDiff(
440
+ this.requireStream(connection).host.cwd,
441
+ command.base,
442
+ command.path,
443
+ this.git,
444
+ command.context
445
+ ),
446
+ };
447
+ case "read_lines":
448
+ return {
449
+ fileLines: await RepoDiff.readLines(
450
+ this.requireStream(connection).host.cwd,
451
+ command.base,
452
+ command.path,
453
+ command.spans,
454
+ this.git
455
+ ),
456
+ };
457
+ case "checkout": {
458
+ const stream = this.requireStream(connection);
459
+ return await this.runGit(stream, true, (cwd) =>
460
+ Git.checkout(cwd, command.branch)
461
+ );
462
+ }
463
+ case "commit":
464
+ return await this.commit(this.requireStream(connection), command);
465
+ case "pull":
466
+ case "push":
467
+ return await this.runGit(
468
+ this.requireStream(connection),
469
+ command.type === "pull",
470
+ command.type === "pull" ? Git.pull : Git.push
471
+ );
273
472
  case "unwatch_subagent":
274
473
  connection.unwatchSubagent(command.callId);
275
474
  return {};
276
475
  case "reload":
277
476
  return this.reload(command.force === true);
278
477
  case "user_message":
279
- this.promptWithAttachments(this.requireStream(connection), command);
280
- 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
+ }
281
488
  case "cancel": {
282
489
  const { cancelled, restored } =
283
490
  await this.requireStream(connection).host.cancel();
@@ -288,6 +495,7 @@ export class WsGateway {
288
495
  case "set_cwd": {
289
496
  const stream = this.requireStream(connection);
290
497
  const result = await stream.host.setCwd(command.value);
498
+ stream.syncGit();
291
499
  stream.push(stream.sessionState());
292
500
  if (!result.ok) {
293
501
  return { error: result.error };
@@ -342,6 +550,27 @@ export class WsGateway {
342
550
  return sessionId ? this.streams.get(sessionId) : undefined;
343
551
  }
344
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
+
345
574
  private requireStream(connection: ClientConnection): SessionStream {
346
575
  const stream = this.streamFor(connection);
347
576
  if (!stream) {
@@ -365,7 +594,8 @@ export class WsGateway {
365
594
  if (path === null) {
366
595
  return { error: `malformed call id: ${command.callId}` };
367
596
  }
368
- if (!(await Bun.file(path).exists())) {
597
+ // A running call may not have written its log yet; the watch waits for it.
598
+ if (!stream.isRunning(command.callId) && !(await Bun.file(path).exists())) {
369
599
  return { error: `no subagent log for call ${command.callId}` };
370
600
  }
371
601
  await connection.watchSubagent(
@@ -380,6 +610,7 @@ export class WsGateway {
380
610
  connection: ClientConnection,
381
611
  command: Command & { readonly type: "attach" }
382
612
  ): Promise<Outcome> {
613
+ const attentive = command.attentive !== false;
383
614
  const like =
384
615
  command.like === undefined
385
616
  ? undefined
@@ -396,27 +627,98 @@ export class WsGateway {
396
627
  ]);
397
628
  connection.send({
398
629
  type: "attached",
399
- protocolVersion: PROTOCOL_VERSION,
400
630
  sessionId: stream.sessionId,
401
631
  cwd: stream.host.cwd,
402
632
  head: await stream.refresh(),
403
633
  pimVersion,
404
634
  piVersion,
405
635
  });
406
- await connection.attach(stream, command.fromSeq);
636
+ await connection.attach(stream, command.fromSeq, attentive);
407
637
  this.syncWatches();
408
- await this.catalogue.markRead(stream.sessionId);
638
+ if (attentive) {
639
+ await this.catalogue.markRead(stream.sessionId);
640
+ }
409
641
  return {};
410
642
  }
411
643
 
412
- /** A watch costs a poll, so only sessions someone is reading get one — and the catalogue only while someone is connected. */
644
+ /** A watch costs a poll, so only attached sessions get one — and the catalogue only while someone is connected. */
413
645
  private syncWatches(): void {
414
646
  for (const [sessionId, stream] of this.streams) {
415
- stream.watchFiles(this.isBeingRead(sessionId));
647
+ // Attachment, not attention: a hidden tab that lost its watches would stop hearing about the session.
648
+ stream.watchFiles(this.isAttached(sessionId));
416
649
  }
417
650
  this.catalogue.watch(this.connections.size > 0);
418
651
  }
419
652
 
653
+ /**
654
+ * An operation that moves the working tree cannot run under an agent that
655
+ * may be halfway through an edit; `push` leaves the tree alone, so it can.
656
+ */
657
+ private async runGit<T = never>(
658
+ stream: SessionStream,
659
+ movesTree: boolean,
660
+ operation: (cwd: string) => Promise<GitRun<T>>
661
+ ): Promise<Outcome & { readonly value?: T }> {
662
+ const cwd = stream.host.cwd;
663
+ if (movesTree && this.repoBusy(cwd)) {
664
+ return {
665
+ error: `an agent is working in ${cwd}; wait for its turn to end`,
666
+ };
667
+ }
668
+ const result = await this.git.run<T>(cwd, () => operation(cwd));
669
+ if (!result.ok) {
670
+ return { error: result.error };
671
+ }
672
+ return result.value === undefined ? {} : { value: result.value };
673
+ }
674
+
675
+ private async commit(
676
+ stream: SessionStream,
677
+ command: Command & { readonly type: "commit" }
678
+ ): Promise<Outcome> {
679
+ const { value, ...outcome } = await this.runGit<string>(
680
+ stream,
681
+ true,
682
+ async (cwd) => {
683
+ const result = await Git.commit(cwd, {
684
+ message: command.message,
685
+ paths: command.paths,
686
+ });
687
+ return result.ok
688
+ ? { ok: true, value: result.sha }
689
+ : { ok: false, error: result.error };
690
+ }
691
+ );
692
+ return value === undefined ? outcome : { commit: { sha: value } };
693
+ }
694
+
695
+ private repoBusy(cwd: string): boolean {
696
+ for (const stream of this.streams.values()) {
697
+ if (stream.host.cwd === cwd && stream.host.status !== "idle") {
698
+ return true;
699
+ }
700
+ }
701
+ return false;
702
+ }
703
+
704
+ /** A turn starting anywhere freezes the branch menu of every session beside it, which only their own state can say. */
705
+ private syncRepoBusy(cwd: string, source: string): void {
706
+ const busy = this.repoBusy(cwd);
707
+ if (this.busyRepos.has(cwd) === busy) {
708
+ return;
709
+ }
710
+ if (busy) {
711
+ this.busyRepos.add(cwd);
712
+ } else {
713
+ this.busyRepos.delete(cwd);
714
+ }
715
+ for (const [sessionId, stream] of this.streams) {
716
+ if (sessionId !== source && stream.host.cwd === cwd) {
717
+ stream.push(stream.sessionState());
718
+ }
719
+ }
720
+ }
721
+
420
722
  private reload(force: boolean): Outcome {
421
723
  const busy = [...this.streams.values()]
422
724
  .filter((stream) => stream.host.status !== "idle")
@@ -439,13 +741,20 @@ export class WsGateway {
439
741
  return this.versionsRead;
440
742
  }
441
743
 
744
+ private isAttached(sessionId: string): boolean {
745
+ return this.connections
746
+ .values()
747
+ .some((connection) => connection.sessionId === sessionId);
748
+ }
749
+
750
+ // The TUI never attaches, so reading a session there clears no dot here and a turn it runs trips no mark.
442
751
  private isBeingRead(sessionId: string): boolean {
443
- for (const connection of this.connections.values()) {
444
- if (connection.sessionId === sessionId) {
445
- return true;
446
- }
447
- }
448
- return false;
752
+ return this.connections
753
+ .values()
754
+ .some(
755
+ (connection) =>
756
+ connection.sessionId === sessionId && connection.attentive
757
+ );
449
758
  }
450
759
 
451
760
  private broadcast(event: ServerEvent): void {
@@ -498,29 +807,91 @@ export class WsGateway {
498
807
  if (existing) {
499
808
  return existing;
500
809
  }
501
- const stream = new SessionStream(id, host, path);
810
+ const stream = new SessionStream(id, host, path, {
811
+ git: this.git,
812
+ repoBusy: () => this.repoBusy(host.cwd),
813
+ pollMs: this.pollMs,
814
+ requestCeilingMs: this.requestCeilingMs,
815
+ detachGraceMs: this.detachGraceMs,
816
+ });
502
817
  stream.start();
503
818
  this.catalogue.track(id, host.status);
504
- 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) => {
505
822
  if (event.type === "session_state") {
506
823
  this.catalogue.onStatus(id, event.status);
824
+ this.syncRepoBusy(stream.host.cwd, id);
507
825
  }
508
826
  });
509
827
  this.streams.set(id, stream);
510
828
  return stream;
511
829
  }
512
830
 
513
- private promptWithAttachments(
831
+ private async promptWithAttachments(
514
832
  stream: SessionStream,
515
833
  command: Command & { readonly type: "user_message" }
516
- ): 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);
517
841
  const taken = this.uploads.take(
518
842
  stream.sessionId,
519
843
  (command.attachments ?? []).map((ref) => ref.id)
520
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
+ }
521
850
  const { lines, images } = Attachments.render(taken);
522
- const text = [command.text, ...lines].filter(Boolean).join("\n\n").trim();
523
- 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;
524
895
  }
525
896
 
526
897
  // Merge into the queued message: pi holds at most one, so a second queued send would be unreachable.
@@ -532,13 +903,6 @@ export class WsGateway {
532
903
  const attached = images.length === 0 ? {} : { images: [...images] };
533
904
  const host = stream.host;
534
905
  const agent = host.agentSession;
535
- const failed = (err: unknown, what: string): void => {
536
- console.error(`[gateway] ${what} failed:`, err);
537
- stream.push({
538
- type: "error",
539
- message: (err as Error).message || String(err),
540
- });
541
- };
542
906
  if (agent && host.isStreaming) {
543
907
  const queued = [...host.takeBack(), text].join("\n\n");
544
908
  void agent
@@ -548,7 +912,7 @@ export class WsGateway {
548
912
  ...attached,
549
913
  })
550
914
  .catch((err: unknown) => {
551
- failed(err, "steer");
915
+ this.failed(stream, err, "steer");
552
916
  });
553
917
  return;
554
918
  }
@@ -557,7 +921,15 @@ export class WsGateway {
557
921
  await session.prompt(text, { source: "rpc", ...attached });
558
922
  })
559
923
  .catch((err: unknown) => {
560
- failed(err, "turn");
924
+ this.failed(stream, err, "turn");
561
925
  });
562
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
+ }
563
935
  }