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
@@ -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
- import { Git, type GitState } from "#core/shared/Git";
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;
@@ -35,7 +52,28 @@ type LiveMessage = {
35
52
  readonly tools: LiveTool[];
36
53
  };
37
54
 
38
- const GIT_TTL_MS = 5_000;
55
+ export type SessionStreamDeps = {
56
+ /** How often the file watch falls back to a poll; the default is a second. */
57
+ readonly pollMs?: number;
58
+ /** Shared with every other session in the same directory; one is made here when none is given. */
59
+ readonly git?: GitMonitor;
60
+ /**
61
+ * Whether a session in this stream's directory is mid-turn, this one
62
+ * included. Only the gateway can see the others, so a stream standing alone
63
+ * reports nothing rather than guessing from itself.
64
+ */
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;
70
+ };
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;
39
77
 
40
78
  function sameLease(a: LeaseState, b: LeaseState): boolean {
41
79
  return (
@@ -46,14 +84,20 @@ function sameLease(a: LeaseState, b: LeaseState): boolean {
46
84
  }
47
85
 
48
86
  /** One session's view of the world, shared by every client attached to it and kept running when none are. */
49
- export class SessionStream {
87
+ export class SessionStream implements SessionUi {
50
88
  public readonly sessionId: string;
51
89
  public readonly host: SessionHost;
52
90
  public readonly picker: PickerService;
53
91
  private readonly sessionPath: string;
54
92
  private readonly projection: SessionProjection;
55
93
  private readonly listeners = new Set<StreamListener>();
94
+ private readonly observers = new Set<StreamListener>();
56
95
  private readonly pollMs: number | undefined;
96
+ private readonly git: GitMonitor;
97
+ private readonly repoBusy: (() => boolean) | undefined;
98
+ private readonly requestCeilingMs: number;
99
+ private readonly detachGraceMs: number;
100
+ private readonly pending = new Map<string, PendingRequest>();
57
101
  private liveTurn: LiveMessage[] = [];
58
102
  private unsubscribe: (() => void) | undefined;
59
103
  private unsubscribeLease: (() => void) | undefined;
@@ -65,23 +109,27 @@ export class SessionStream {
65
109
  private drainQueued = false;
66
110
  private sentSeq = 0;
67
111
  private liveMessageId = 0;
112
+ private uiSeq = 0;
113
+ private readonly dispatching: string[] = [];
68
114
  private turnStartedAt = 0;
69
- private git: GitState = Git.EMPTY;
70
- private gitCwd = "";
71
- private gitReadAt = 0;
72
- private gitInFlight = false;
115
+ private gitCwd: string | undefined;
116
+ private gitStop: (() => void) | undefined;
117
+ private gitBranch: string | null = null;
73
118
 
74
119
  public constructor(
75
120
  sessionId: string,
76
121
  host: SessionHost,
77
122
  sessionPath: string,
78
- /** How often the file watch falls back to a poll; the default is a second. */
79
- pollMs?: number
123
+ deps: SessionStreamDeps = {}
80
124
  ) {
81
125
  this.sessionId = sessionId;
82
126
  this.host = host;
83
127
  this.sessionPath = sessionPath;
84
- this.pollMs = pollMs;
128
+ this.pollMs = deps.pollMs;
129
+ this.git = deps.git ?? new GitMonitor();
130
+ this.repoBusy = deps.repoBusy;
131
+ this.requestCeilingMs = deps.requestCeilingMs ?? REQUEST_CEILING_MS;
132
+ this.detachGraceMs = deps.detachGraceMs ?? DETACH_GRACE_MS;
85
133
  this.projection = new SessionProjection(sessionPath, () => host.cwd);
86
134
  this.picker = new PickerService({
87
135
  cwd: () => host.cwd,
@@ -120,6 +168,7 @@ export class SessionStream {
120
168
  if (!active) {
121
169
  this.unwatch?.();
122
170
  this.unwatch = undefined;
171
+ this.syncGit();
123
172
  return;
124
173
  }
125
174
  const stops = [
@@ -139,6 +188,37 @@ export class SessionStream {
139
188
  stop();
140
189
  }
141
190
  };
191
+ this.syncGit();
192
+ }
193
+
194
+ /** Re-points the git watch after the session moves; the monitor is shared, so a repeat is free. */
195
+ public syncGit(): void {
196
+ const wanted = this.unwatch === undefined ? undefined : this.host.cwd;
197
+ if (wanted === this.gitCwd) {
198
+ return;
199
+ }
200
+ this.gitStop?.();
201
+ this.gitStop = undefined;
202
+ this.gitCwd = wanted;
203
+ if (wanted === undefined) {
204
+ return;
205
+ }
206
+ this.gitStop = this.git.watch(wanted, (state) => {
207
+ // Whoever moved it — this client, the terminal, another window — every
208
+ // path the pickers hold is from the branch that just left. The first
209
+ // reading is a discovery rather than a move, and drops nothing.
210
+ const moved = this.gitBranch !== null && state.branch !== this.gitBranch;
211
+ this.gitBranch = state.branch;
212
+ if (moved) {
213
+ this.invalidatePickers("files");
214
+ }
215
+ this.emit(this.sessionState());
216
+ });
217
+ }
218
+
219
+ /** Reads the repository again for a client that has reason to think its picture is old. */
220
+ public async refreshGit(fetch: boolean): Promise<void> {
221
+ await this.git.refresh(this.host.cwd, { fetch });
142
222
  }
143
223
 
144
224
  /** Project whatever pi has appended since the last read, telling every client; returns the head. */
@@ -149,11 +229,192 @@ export class SessionStream {
149
229
 
150
230
  public subscribe(listener: StreamListener): () => void {
151
231
  this.listeners.add(listener);
232
+ this.holdRequests();
152
233
  return () => {
153
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);
154
248
  };
155
249
  }
156
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
+
413
+ /** Whether `callId` names a tool this session is still running, log written or not. */
414
+ public isRunning(callId: string): boolean {
415
+ return this.findTool(callId)?.done === false;
416
+ }
417
+
157
418
  /** Everything a client at `fromSeq` has not seen: the durable tail, the in-flight turn coalesced, then state. */
158
419
  public async replay(fromSeq: number): Promise<readonly StreamEvent[]> {
159
420
  await Promise.all([this.flushDurable(), this.readLease()]);
@@ -193,6 +454,11 @@ export class SessionStream {
193
454
  }
194
455
  }
195
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
+ }
196
462
  events.push(this.sessionState());
197
463
  return events;
198
464
  }
@@ -211,7 +477,9 @@ export class SessionStream {
211
477
  public sessionState(): EphemeralEvent {
212
478
  const tps = this.host.tps;
213
479
  const usage = this.host.usage();
214
- const { branch, dirtyCount, ahead, behind } = this.gitState();
480
+ const cwd = this.host.cwd;
481
+ const { branch, dirtyCount, ahead, behind, revision } =
482
+ this.git.stateOf(cwd);
215
483
  const modelLabel = this.host.currentModelLabel;
216
484
  const turnElapsedMs =
217
485
  this.host.status === "idle" || this.turnStartedAt === 0
@@ -219,13 +487,14 @@ export class SessionStream {
219
487
  : Date.now() - this.turnStartedAt;
220
488
  return {
221
489
  type: "session_state",
222
- cwd: this.host.cwd,
490
+ cwd,
223
491
  model: this.host.currentModelId ?? "",
224
492
  ...(modelLabel === undefined ? {} : { modelLabel }),
225
493
  thinking: this.host.currentThinkingLevel,
226
494
  cost: this.host.settings.cumulativeCost ?? 0,
227
495
  status: this.host.status,
228
496
  ...this.leaseState(),
497
+ ...(this.repoBusy?.() === true ? { repoBusy: true } : {}),
229
498
  ...(tps === undefined ? {} : { tps }),
230
499
  ...(turnElapsedMs === undefined ? {} : { turnElapsedMs }),
231
500
  ...(usage?.percent === null || usage === undefined
@@ -234,7 +503,9 @@ export class SessionStream {
234
503
  contextPercent: usage.percent,
235
504
  contextWindow: usage.contextWindow,
236
505
  }),
237
- ...(branch === null ? {} : { branch, dirtyCount, ahead, behind }),
506
+ ...(branch === null
507
+ ? {}
508
+ : { branch, dirtyCount, ahead, behind, repoRevision: revision }),
238
509
  };
239
510
  }
240
511
 
@@ -283,37 +554,6 @@ export class SessionStream {
283
554
  }
284
555
  }
285
556
 
286
- private gitState(): GitState {
287
- const cwd = this.host.cwd;
288
- const moved = cwd !== this.gitCwd;
289
- if (moved) {
290
- this.gitCwd = cwd;
291
- this.git = Git.EMPTY;
292
- }
293
- if (
294
- (moved || Date.now() - this.gitReadAt > GIT_TTL_MS) &&
295
- !this.gitInFlight
296
- ) {
297
- this.gitInFlight = true;
298
- void Git.fetchStatus(cwd)
299
- .then((next) => {
300
- this.gitInFlight = false;
301
- this.gitReadAt = Date.now();
302
- const changed =
303
- next.branch !== this.git.branch ||
304
- next.dirtyCount !== this.git.dirtyCount ||
305
- next.ahead !== this.git.ahead ||
306
- next.behind !== this.git.behind;
307
- this.git = next;
308
- if (changed) {
309
- this.emit(this.sessionState());
310
- }
311
- })
312
- .catch(() => {});
313
- }
314
- return this.git;
315
- }
316
-
317
557
  public dispose(): void {
318
558
  this.unsubscribe?.();
319
559
  this.unsubscribe = undefined;
@@ -322,7 +562,12 @@ export class SessionStream {
322
562
  this.unsubscribeForeign?.();
323
563
  this.unsubscribeForeign = undefined;
324
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
+ }
325
569
  this.listeners.clear();
570
+ this.observers.clear();
326
571
  }
327
572
 
328
573
  private onAgentEvent(event: AgentSessionEvent): void {
@@ -413,6 +658,8 @@ export class SessionStream {
413
658
  });
414
659
  if (Tools.effectOf(event.toolName)?.kind !== "readOnly") {
415
660
  this.invalidatePickers("files");
661
+ // The tool just wrote the worktree, which `.git` never reports.
662
+ void this.git.refresh(this.host.cwd);
416
663
  }
417
664
  return;
418
665
  }
@@ -594,6 +841,11 @@ export class SessionStream {
594
841
  }
595
842
 
596
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
+ }
597
849
  for (const listener of this.listeners) {
598
850
  listener(event);
599
851
  }
@@ -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;