pim-agent 0.9.0 → 0.10.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 (56) hide show
  1. package/bin/pim.ts +1 -1
  2. package/package.json +2 -2
  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/system-prompt/index.ts +29 -21
  6. package/packages/core/src/extensions/system-prompt/prompt.ts +5 -1
  7. package/packages/core/src/picker/PickerService.ts +23 -1
  8. package/packages/core/src/picker/token.ts +12 -5
  9. package/packages/core/src/session/EventLog.ts +22 -79
  10. package/packages/core/src/session/SearchIndex.ts +752 -0
  11. package/packages/core/src/session/SearchTokens.ts +97 -0
  12. package/packages/core/src/session/SessionDigest.ts +161 -0
  13. package/packages/core/src/session/SessionHost.ts +17 -1
  14. package/packages/core/src/session/SessionMeta.ts +267 -0
  15. package/packages/core/src/session/SessionName.ts +18 -0
  16. package/packages/core/src/session/SessionRegistry.ts +89 -14
  17. package/packages/core/src/session/WriteMark.ts +15 -1
  18. package/packages/core/src/shared/DiffLines.ts +1 -1
  19. package/packages/core/src/shared/DiffPatch.ts +70 -0
  20. package/packages/core/src/shared/DiffRenderer.ts +1 -1
  21. package/packages/core/src/shared/FileWatch.ts +15 -1
  22. package/packages/core/src/shared/Git.ts +483 -42
  23. package/packages/core/src/shared/GitMonitor.ts +221 -0
  24. package/packages/core/src/shared/Levenshtein.ts +54 -1
  25. package/packages/core/src/shared/Proc.ts +21 -1
  26. package/packages/core/src/shared/RepoDiff.ts +741 -0
  27. package/packages/core/src/shared/Surface.ts +2 -0
  28. package/packages/core/src/shared/Updater.ts +21 -5
  29. package/packages/core/src/view/DiffExpand.ts +214 -0
  30. package/packages/core/src/view/DiffLayout.ts +7 -7
  31. package/packages/core/src/view/DiffPairs.ts +47 -0
  32. package/packages/core/src/view/MovePath.ts +70 -0
  33. package/packages/daemon/src/WebSurface.ts +1 -2
  34. package/packages/protocol/src/Command.ts +129 -2
  35. package/packages/protocol/src/Diff.ts +15 -0
  36. package/packages/protocol/src/ServerEvent.ts +157 -2
  37. package/packages/server/src/ClientConnection.ts +15 -3
  38. package/packages/server/src/ProbeClient.ts +90 -28
  39. package/packages/server/src/SessionCatalogue.ts +292 -38
  40. package/packages/server/src/SessionStream.ts +69 -43
  41. package/packages/server/src/StaticClient.ts +95 -12
  42. package/packages/server/src/WsGateway.ts +264 -29
  43. package/packages/server/src/probe.ts +61 -7
  44. package/packages/telegram/src/Session.ts +1 -0
  45. package/packages/tui/src/extensions/command-picker/index.ts +64 -32
  46. package/packages/tui/src/extensions/footer/index.ts +19 -43
  47. package/packages/tui/src/extensions/session-lease/index.ts +8 -3
  48. package/packages/web/dist/client/assets/commit-mono-latin-300-normal-B-iV2FbL.woff2 +0 -0
  49. package/packages/web/dist/client/assets/commit-mono-latin-300-normal-kM0OTZCv.woff +0 -0
  50. package/packages/web/dist/client/assets/{core-CGdQx9la.js → core-CA0aSzPu.js} +1 -1
  51. package/packages/web/dist/client/assets/index-B9be-i9d.css +1 -0
  52. package/packages/web/dist/client/assets/index-CpinM5NK.js +45 -0
  53. package/packages/web/dist/client/index.html +2 -2
  54. package/packages/protocol/src/Protocol.ts +0 -6
  55. package/packages/web/dist/client/assets/index-BMNwleWO.js +0 -40
  56. package/packages/web/dist/client/assets/index-CqjB2RPr.css +0 -1
@@ -1,4 +1,7 @@
1
1
  import { join, resolve, sep } from "node:path";
2
+ import { brotliCompressSync, constants } from "node:zlib";
3
+
4
+ import type { BunFile } from "bun";
2
5
 
3
6
  /** The built Vite bundle, resolved relative to this file so it works installed. */
4
7
  export const DEFAULT_CLIENT_DIR = resolve(
@@ -18,6 +21,45 @@ const BUILD_HINT =
18
21
  /** Vite fingerprints everything under `assets/`, so it can never go stale. */
19
22
  export const IMMUTABLE = "public, max-age=31536000, immutable";
20
23
 
24
+ const COMPRESSIBLE =
25
+ /^(?:text\/|image\/svg\+xml|application\/(?:javascript|json|wasm|xml))/;
26
+
27
+ /** Below this a deflate stream is its own header: the framing costs more than the saving. */
28
+ const MIN_COMPRESSED = 1024;
29
+
30
+ type Bytes = Uint8Array<ArrayBuffer>;
31
+
32
+ type Encoding = {
33
+ readonly name: string;
34
+ readonly compress: (bytes: Bytes) => Bytes;
35
+ };
36
+
37
+ /** Best first. Brotli at q5 beats gzip -9 on both size and time; q11 would cost 40x the CPU for 8%. */
38
+ const ENCODINGS: readonly Encoding[] = [
39
+ {
40
+ name: "br",
41
+ compress: (bytes) =>
42
+ new Uint8Array(
43
+ brotliCompressSync(bytes, {
44
+ params: { [constants.BROTLI_PARAM_QUALITY]: 5 },
45
+ })
46
+ ),
47
+ },
48
+ { name: "gzip", compress: (bytes) => Bun.gzipSync(bytes) },
49
+ ];
50
+
51
+ type Encoded = { readonly stamp: string; readonly bytes: Bytes };
52
+
53
+ function accepts(header: string, name: string): boolean {
54
+ return header.split(",").some((entry) => {
55
+ const [token, ...params] = entry.trim().split(";");
56
+ return (
57
+ token?.toLowerCase() === name &&
58
+ !params.some((param) => param.replaceAll(" ", "") === "q=0")
59
+ );
60
+ });
61
+ }
62
+
21
63
  /** A file off disk under a caching policy, or the 404 that a path naming nothing is. */
22
64
  export async function serveFile(
23
65
  path: string,
@@ -37,6 +79,7 @@ function looksLikeAsset(pathname: string): boolean {
37
79
  /** Serves the built browser client as a static SPA; unknown routes fall back to `index.html`. */
38
80
  export class StaticClient {
39
81
  private readonly root: string;
82
+ private readonly encoded = new Map<string, Encoded>();
40
83
 
41
84
  public constructor(root: string = DEFAULT_CLIENT_DIR) {
42
85
  this.root = resolve(root);
@@ -48,13 +91,11 @@ export class StaticClient {
48
91
  if (path !== undefined) {
49
92
  const file = Bun.file(path);
50
93
  if (await file.exists()) {
51
- return new Response(file, {
52
- headers: {
53
- "cache-control": pathname.startsWith("/assets/")
54
- ? IMMUTABLE
55
- : "no-cache",
56
- },
57
- });
94
+ return await this.respond(
95
+ file,
96
+ request,
97
+ pathname.startsWith("/assets/") ? IMMUTABLE : "no-cache"
98
+ );
58
99
  }
59
100
  }
60
101
  if (looksLikeAsset(pathname)) {
@@ -67,14 +108,56 @@ export class StaticClient {
67
108
  headers: { "content-type": "text/plain; charset=utf-8" },
68
109
  });
69
110
  }
70
- return new Response(index, {
71
- headers: {
72
- "content-type": "text/html; charset=utf-8",
73
- "cache-control": "no-cache",
74
- },
111
+ return await this.respond(
112
+ index,
113
+ request,
114
+ "no-cache",
115
+ "text/html; charset=utf-8"
116
+ );
117
+ }
118
+
119
+ // `vary` rides every answer, compressed or not: a cache must not hand one client's encoding to another.
120
+ private async respond(
121
+ file: BunFile,
122
+ request: Request,
123
+ cacheControl: string,
124
+ contentType = file.type
125
+ ): Promise<Response> {
126
+ const headers: Record<string, string> = {
127
+ "content-type": contentType,
128
+ "cache-control": cacheControl,
129
+ vary: "accept-encoding",
130
+ };
131
+ const encoding = this.negotiate(file, request);
132
+ if (encoding === undefined) {
133
+ return new Response(file, { headers });
134
+ }
135
+ return new Response(await this.compress(file, encoding), {
136
+ headers: { ...headers, "content-encoding": encoding.name },
75
137
  });
76
138
  }
77
139
 
140
+ private negotiate(file: BunFile, request: Request): Encoding | undefined {
141
+ if (!COMPRESSIBLE.test(file.type) || file.size < MIN_COMPRESSED) {
142
+ return undefined;
143
+ }
144
+ const accepted = request.headers.get("accept-encoding") ?? "";
145
+ return ENCODINGS.find((encoding) => accepts(accepted, encoding.name));
146
+ }
147
+
148
+ // Keyed by name, stamped by mtime: a rebuilt bundle replaces its entry instead of adding one.
149
+ private async compress(file: BunFile, encoding: Encoding): Promise<Bytes> {
150
+ const key = `${file.name ?? ""}\0${encoding.name}`;
151
+ const stamp = `${file.lastModified}:${file.size}`;
152
+ const cached = this.encoded.get(key);
153
+ if (cached?.stamp === stamp) {
154
+ return cached.bytes;
155
+ }
156
+ const bytes = encoding.compress(await file.bytes());
157
+ this.encoded.set(key, { stamp, bytes });
158
+ return bytes;
159
+ }
160
+
78
161
  // `new URL()` normalises literal `../` but not its percent-encoded spelling: re-resolve the decoded path.
79
162
  private within(pathname: string): string | undefined {
80
163
  let decoded: string;
@@ -7,16 +7,22 @@ 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 { RepoDiff } from "#core/shared/RepoDiff";
10
13
  import { ReadCursors } from "#core/session/ReadCursors";
11
14
  import type { SessionHost } from "#core/session/SessionHost";
15
+ import { SessionMeta } from "#core/session/SessionMeta";
12
16
  import type { SessionRegistry } from "#core/session/SessionRegistry";
13
17
  import { PimVersion } from "#core/shared/PimVersion";
14
18
  import { SubagentLogs } from "#core/shared/SubagentLogs";
15
19
  import type { UpdateOutcome } from "#core/shared/Updater";
16
20
  import type { Command } from "#protocol/Command";
17
- import { CLOSE_PROTOCOL_MISMATCH, PROTOCOL_VERSION } from "#protocol/Protocol";
21
+ import type { ChangeList, FileDiff, FileLines } from "#protocol/Diff";
18
22
  import type {
19
23
  ModelView,
24
+ ProjectView,
25
+ SearchHitView,
20
26
  ServerEvent,
21
27
  SessionSummaryView,
22
28
  } from "#protocol/ServerEvent";
@@ -41,6 +47,10 @@ export type WsGatewayDeps = {
41
47
  readonly imagesRoot?: string;
42
48
  /** Where the read cursors live; defaults to `~/.pim/read.json`. */
43
49
  readonly readCursorsPath?: string;
50
+ /** Where the per-session and per-project overrides live; defaults to `~/.pim/sessions.json`. */
51
+ readonly sessionMetaPath?: string;
52
+ /** How often a session file is polled where `fs.watch` says nothing; the default is a second. */
53
+ readonly pollMs?: number;
44
54
  /** The built web client; defaults to the bundle shipped beside this package. */
45
55
  readonly clientDir?: string;
46
56
  /** Runs the update a `reload` asks for, reporting each step as it starts. */
@@ -53,9 +63,18 @@ type Outcome = {
53
63
  readonly error?: string;
54
64
  readonly items?: readonly PickerItem[];
55
65
  readonly sessions?: readonly SessionSummaryView[];
66
+ readonly projects?: readonly ProjectView[];
67
+ readonly hits?: readonly SearchHitView[];
68
+ readonly dropped?: readonly string[];
69
+ readonly scanned?: number;
56
70
  readonly models?: readonly ModelView[];
57
71
  readonly thinkingLevels?: readonly string[];
58
72
  readonly directory?: DirectoryListing;
73
+ readonly branches?: readonly GitBranch[];
74
+ readonly commit?: { readonly sha: string };
75
+ readonly changes?: ChangeList;
76
+ readonly fileDiff?: FileDiff;
77
+ readonly fileLines?: FileLines;
59
78
  readonly restored?: readonly string[];
60
79
  readonly after?: () => void;
61
80
  };
@@ -81,6 +100,7 @@ export class WsGateway {
81
100
  private readonly uploads: AttachmentEndpoint;
82
101
  private readonly images: ImageEndpoint;
83
102
  private readonly catalogue: SessionCatalogue;
103
+ private readonly meta: SessionMeta;
84
104
  private readonly client: StaticClient;
85
105
  private readonly streams = new Map<string, SessionStream>();
86
106
  private readonly opening = new Map<string, Promise<SessionStream>>();
@@ -89,6 +109,9 @@ export class WsGateway {
89
109
  ClientConnection
90
110
  >();
91
111
  private readonly reloader: Reloader;
112
+ private readonly git = new GitMonitor();
113
+ private readonly pollMs: number | undefined;
114
+ private readonly busyRepos = new Set<string>();
92
115
  private versionsRead: Promise<readonly [string, string]> | undefined;
93
116
  private server: Server<undefined> | undefined;
94
117
 
@@ -96,15 +119,18 @@ export class WsGateway {
96
119
  this.registry = deps.registry;
97
120
  this.hostname = deps.hostname ?? DEFAULT_HOSTNAME;
98
121
  this.requestedPort = deps.port ?? DEFAULT_PORT;
122
+ this.pollMs = deps.pollMs;
99
123
  this.uploads = new AttachmentEndpoint(
100
124
  deps.attachmentsRoot === undefined ? {} : { root: deps.attachmentsRoot }
101
125
  );
102
126
  this.images = new ImageEndpoint(
103
127
  deps.imagesRoot === undefined ? {} : { root: deps.imagesRoot }
104
128
  );
129
+ this.meta = new SessionMeta(deps.sessionMetaPath);
105
130
  this.catalogue = new SessionCatalogue({
106
131
  registry: deps.registry,
107
132
  cursors: new ReadCursors(deps.readCursorsPath),
133
+ meta: this.meta,
108
134
  liveStatus: (sessionId) => this.streams.get(sessionId)?.host.status,
109
135
  liveSessionIds: () => this.streams.keys(),
110
136
  isBeingRead: (sessionId) => this.isBeingRead(sessionId),
@@ -145,7 +171,9 @@ export class WsGateway {
145
171
  fetch: (req, server) => {
146
172
  const { pathname } = new URL(req.url);
147
173
  if (pathname === "/health") {
148
- return Response.json({ ok: true, protocolVersion: PROTOCOL_VERSION });
174
+ return this.versions().then(([pimVersion]) =>
175
+ Response.json({ ok: true, pimVersion })
176
+ );
149
177
  }
150
178
  if (AttachmentEndpoint.owns(pathname)) {
151
179
  return this.uploads.handle(req);
@@ -159,6 +187,8 @@ export class WsGateway {
159
187
  websocket: {
160
188
  backpressureLimit: 8 << 20,
161
189
  closeOnBackpressureLimit: false,
190
+ // Dedicated, so the window survives between frames: the repeated event envelope is most of a delta.
191
+ perMessageDeflate: { compress: "dedicated", decompress: "dedicated" },
162
192
  open: (ws) => {
163
193
  this.connections.set(ws, new ClientConnection(ws));
164
194
  this.syncWatches();
@@ -184,6 +214,8 @@ export class WsGateway {
184
214
  }
185
215
  this.streams.clear();
186
216
  this.opening.clear();
217
+ this.git.dispose();
218
+ this.busyRepos.clear();
187
219
  this.catalogue.watch(false);
188
220
  this.catalogue.clear();
189
221
  await this.catalogue.flush();
@@ -217,19 +249,6 @@ export class WsGateway {
217
249
  connection.send({ type: "error", message: "frame is not a command" });
218
250
  return;
219
251
  }
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
252
  try {
234
253
  const { error, after, ...answer } = await this.dispatch(
235
254
  connection,
@@ -260,8 +279,83 @@ export class WsGateway {
260
279
  switch (command.type) {
261
280
  case "attach":
262
281
  return await this.attach(connection, command);
282
+ case "attention": {
283
+ const regained = command.value && !connection.attentive;
284
+ connection.setAttentive(command.value);
285
+ const sessionId = connection.sessionId;
286
+ if (regained && sessionId !== undefined) {
287
+ await this.catalogue.markRead(sessionId);
288
+ }
289
+ return {};
290
+ }
263
291
  case "list_sessions":
264
- return { sessions: await this.catalogue.list(command) };
292
+ return await this.catalogue.list(command);
293
+ case "search_sessions":
294
+ return await this.catalogue.search(command);
295
+ // The four below take a session this server may never have opened: a row
296
+ // is archived or renamed from the sidebar without being attached to, so
297
+ // none of them may reach for a stream. A sidecar write moves no session
298
+ // file and produces no `sessions_changed`, so the broadcast each ends
299
+ // with is the only word a listing already in a client's hands gets.
300
+ case "set_session_name": {
301
+ const name = await this.registry.setName(
302
+ command.sessionId,
303
+ command.value
304
+ );
305
+ this.broadcast({
306
+ type: "session_meta",
307
+ sessionId: command.sessionId,
308
+ name: name ?? null,
309
+ });
310
+ return {};
311
+ }
312
+ case "set_session_archived":
313
+ await this.meta.setArchived(command.sessionId, command.value);
314
+ this.broadcast({
315
+ type: "session_meta",
316
+ sessionId: command.sessionId,
317
+ archived: command.value,
318
+ });
319
+ return {};
320
+ case "set_session_unread":
321
+ await this.meta.setUnread(command.sessionId, command.value);
322
+ this.broadcast({
323
+ type: "session_meta",
324
+ sessionId: command.sessionId,
325
+ unread: command.value,
326
+ });
327
+ return {};
328
+ case "set_project_pinned":
329
+ await this.meta.setPinned(command.cwd, command.value);
330
+ this.broadcast({
331
+ type: "project_meta",
332
+ cwd: command.cwd,
333
+ pinned: command.value,
334
+ });
335
+ // The flag and the place it takes are one fact to a sidebar; a pin
336
+ // that arrived without a rank would sort by nothing until the next listing.
337
+ this.broadcast({ type: "pins_changed", order: await this.meta.pins() });
338
+ return {};
339
+ case "set_project_expanded":
340
+ await this.meta.setExpanded(command.cwd, command.value);
341
+ this.broadcast({
342
+ type: "project_meta",
343
+ cwd: command.cwd,
344
+ expanded: command.value,
345
+ });
346
+ return {};
347
+ case "set_project_label":
348
+ await this.meta.setLabel(command.cwd, command.value);
349
+ this.broadcast({
350
+ type: "project_meta",
351
+ cwd: command.cwd,
352
+ label: command.value,
353
+ });
354
+ return {};
355
+ case "set_pin_order":
356
+ await this.meta.setPinOrder(command.order);
357
+ this.broadcast({ type: "pins_changed", order: await this.meta.pins() });
358
+ return {};
265
359
  case "list_models":
266
360
  return {
267
361
  models: this.registry.models(),
@@ -270,6 +364,61 @@ export class WsGateway {
270
364
  };
271
365
  case "list_dirs":
272
366
  return { directory: await Directories.list(command.path) };
367
+ case "refresh_git": {
368
+ const stream = this.requireStream(connection);
369
+ await stream.refreshGit(command.fetch === true);
370
+ return {};
371
+ }
372
+ case "list_branches":
373
+ return {
374
+ branches: await Git.listBranches(
375
+ this.requireStream(connection).host.cwd
376
+ ),
377
+ };
378
+ // Reading the repository never moves it, so neither of these takes the `repoBusy` refusal.
379
+ case "list_changes":
380
+ return {
381
+ changes: await RepoDiff.listChanges(
382
+ this.requireStream(connection).host.cwd,
383
+ command.base,
384
+ this.git
385
+ ),
386
+ };
387
+ case "file_diff":
388
+ return {
389
+ fileDiff: await RepoDiff.fileDiff(
390
+ this.requireStream(connection).host.cwd,
391
+ command.base,
392
+ command.path,
393
+ this.git,
394
+ command.context
395
+ ),
396
+ };
397
+ case "read_lines":
398
+ return {
399
+ fileLines: await RepoDiff.readLines(
400
+ this.requireStream(connection).host.cwd,
401
+ command.base,
402
+ command.path,
403
+ command.spans,
404
+ this.git
405
+ ),
406
+ };
407
+ case "checkout": {
408
+ const stream = this.requireStream(connection);
409
+ return await this.runGit(stream, true, (cwd) =>
410
+ Git.checkout(cwd, command.branch)
411
+ );
412
+ }
413
+ case "commit":
414
+ return await this.commit(this.requireStream(connection), command);
415
+ case "pull":
416
+ case "push":
417
+ return await this.runGit(
418
+ this.requireStream(connection),
419
+ command.type === "pull",
420
+ command.type === "pull" ? Git.pull : Git.push
421
+ );
273
422
  case "unwatch_subagent":
274
423
  connection.unwatchSubagent(command.callId);
275
424
  return {};
@@ -288,6 +437,7 @@ export class WsGateway {
288
437
  case "set_cwd": {
289
438
  const stream = this.requireStream(connection);
290
439
  const result = await stream.host.setCwd(command.value);
440
+ stream.syncGit();
291
441
  stream.push(stream.sessionState());
292
442
  if (!result.ok) {
293
443
  return { error: result.error };
@@ -365,7 +515,8 @@ export class WsGateway {
365
515
  if (path === null) {
366
516
  return { error: `malformed call id: ${command.callId}` };
367
517
  }
368
- if (!(await Bun.file(path).exists())) {
518
+ // A running call may not have written its log yet; the watch waits for it.
519
+ if (!stream.isRunning(command.callId) && !(await Bun.file(path).exists())) {
369
520
  return { error: `no subagent log for call ${command.callId}` };
370
521
  }
371
522
  await connection.watchSubagent(
@@ -380,6 +531,7 @@ export class WsGateway {
380
531
  connection: ClientConnection,
381
532
  command: Command & { readonly type: "attach" }
382
533
  ): Promise<Outcome> {
534
+ const attentive = command.attentive !== false;
383
535
  const like =
384
536
  command.like === undefined
385
537
  ? undefined
@@ -396,27 +548,98 @@ export class WsGateway {
396
548
  ]);
397
549
  connection.send({
398
550
  type: "attached",
399
- protocolVersion: PROTOCOL_VERSION,
400
551
  sessionId: stream.sessionId,
401
552
  cwd: stream.host.cwd,
402
553
  head: await stream.refresh(),
403
554
  pimVersion,
404
555
  piVersion,
405
556
  });
406
- await connection.attach(stream, command.fromSeq);
557
+ await connection.attach(stream, command.fromSeq, attentive);
407
558
  this.syncWatches();
408
- await this.catalogue.markRead(stream.sessionId);
559
+ if (attentive) {
560
+ await this.catalogue.markRead(stream.sessionId);
561
+ }
409
562
  return {};
410
563
  }
411
564
 
412
- /** A watch costs a poll, so only sessions someone is reading get one — and the catalogue only while someone is connected. */
565
+ /** A watch costs a poll, so only attached sessions get one — and the catalogue only while someone is connected. */
413
566
  private syncWatches(): void {
414
567
  for (const [sessionId, stream] of this.streams) {
415
- stream.watchFiles(this.isBeingRead(sessionId));
568
+ // Attachment, not attention: a hidden tab that lost its watches would stop hearing about the session.
569
+ stream.watchFiles(this.isAttached(sessionId));
416
570
  }
417
571
  this.catalogue.watch(this.connections.size > 0);
418
572
  }
419
573
 
574
+ /**
575
+ * An operation that moves the working tree cannot run under an agent that
576
+ * may be halfway through an edit; `push` leaves the tree alone, so it can.
577
+ */
578
+ private async runGit<T = never>(
579
+ stream: SessionStream,
580
+ movesTree: boolean,
581
+ operation: (cwd: string) => Promise<GitRun<T>>
582
+ ): Promise<Outcome & { readonly value?: T }> {
583
+ const cwd = stream.host.cwd;
584
+ if (movesTree && this.repoBusy(cwd)) {
585
+ return {
586
+ error: `an agent is working in ${cwd}; wait for its turn to end`,
587
+ };
588
+ }
589
+ const result = await this.git.run<T>(cwd, () => operation(cwd));
590
+ if (!result.ok) {
591
+ return { error: result.error };
592
+ }
593
+ return result.value === undefined ? {} : { value: result.value };
594
+ }
595
+
596
+ private async commit(
597
+ stream: SessionStream,
598
+ command: Command & { readonly type: "commit" }
599
+ ): Promise<Outcome> {
600
+ const { value, ...outcome } = await this.runGit<string>(
601
+ stream,
602
+ true,
603
+ async (cwd) => {
604
+ const result = await Git.commit(cwd, {
605
+ message: command.message,
606
+ paths: command.paths,
607
+ });
608
+ return result.ok
609
+ ? { ok: true, value: result.sha }
610
+ : { ok: false, error: result.error };
611
+ }
612
+ );
613
+ return value === undefined ? outcome : { commit: { sha: value } };
614
+ }
615
+
616
+ private repoBusy(cwd: string): boolean {
617
+ for (const stream of this.streams.values()) {
618
+ if (stream.host.cwd === cwd && stream.host.status !== "idle") {
619
+ return true;
620
+ }
621
+ }
622
+ return false;
623
+ }
624
+
625
+ /** A turn starting anywhere freezes the branch menu of every session beside it, which only their own state can say. */
626
+ private syncRepoBusy(cwd: string, source: string): void {
627
+ const busy = this.repoBusy(cwd);
628
+ if (this.busyRepos.has(cwd) === busy) {
629
+ return;
630
+ }
631
+ if (busy) {
632
+ this.busyRepos.add(cwd);
633
+ } else {
634
+ this.busyRepos.delete(cwd);
635
+ }
636
+ for (const [sessionId, stream] of this.streams) {
637
+ if (sessionId !== source && stream.host.cwd === cwd) {
638
+ stream.push(stream.sessionState());
639
+ }
640
+ }
641
+ }
642
+
420
643
  private reload(force: boolean): Outcome {
421
644
  const busy = [...this.streams.values()]
422
645
  .filter((stream) => stream.host.status !== "idle")
@@ -439,13 +662,20 @@ export class WsGateway {
439
662
  return this.versionsRead;
440
663
  }
441
664
 
665
+ private isAttached(sessionId: string): boolean {
666
+ return this.connections
667
+ .values()
668
+ .some((connection) => connection.sessionId === sessionId);
669
+ }
670
+
671
+ // The TUI never attaches, so reading a session there clears no dot here and a turn it runs trips no mark.
442
672
  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;
673
+ return this.connections
674
+ .values()
675
+ .some(
676
+ (connection) =>
677
+ connection.sessionId === sessionId && connection.attentive
678
+ );
449
679
  }
450
680
 
451
681
  private broadcast(event: ServerEvent): void {
@@ -498,12 +728,17 @@ export class WsGateway {
498
728
  if (existing) {
499
729
  return existing;
500
730
  }
501
- const stream = new SessionStream(id, host, path);
731
+ const stream = new SessionStream(id, host, path, {
732
+ git: this.git,
733
+ repoBusy: () => this.repoBusy(host.cwd),
734
+ pollMs: this.pollMs,
735
+ });
502
736
  stream.start();
503
737
  this.catalogue.track(id, host.status);
504
738
  stream.subscribe((event) => {
505
739
  if (event.type === "session_state") {
506
740
  this.catalogue.onStatus(id, event.status);
741
+ this.syncRepoBusy(stream.host.cwd, id);
507
742
  }
508
743
  });
509
744
  this.streams.set(id, stream);