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.
- package/bin/pim.ts +1 -1
- package/package.json +2 -3
- package/packages/core/src/extensions/CoreExtensions.ts +19 -16
- package/packages/core/src/extensions/apply-patch/render.ts +16 -55
- package/packages/core/src/extensions/bash/index.ts +2 -1
- package/packages/core/src/extensions/bash/run.ts +8 -2
- package/packages/core/src/extensions/system-prompt/index.ts +45 -22
- package/packages/core/src/extensions/system-prompt/prompt.ts +6 -3
- package/packages/core/src/picker/PickerService.ts +23 -1
- package/packages/core/src/picker/token.ts +12 -5
- package/packages/core/src/session/EventLog.ts +22 -79
- package/packages/core/src/session/SearchIndex.ts +752 -0
- package/packages/core/src/session/SearchTokens.ts +97 -0
- package/packages/core/src/session/SessionDigest.ts +161 -0
- package/packages/core/src/session/SessionHost.ts +52 -10
- package/packages/core/src/session/SessionMeta.ts +267 -0
- package/packages/core/src/session/SessionName.ts +18 -0
- package/packages/core/src/session/SessionRegistry.ts +111 -15
- package/packages/core/src/session/SessionUi.ts +127 -0
- package/packages/core/src/session/WriteMark.ts +15 -1
- package/packages/core/src/shared/DiffLines.ts +1 -1
- package/packages/core/src/shared/DiffPatch.ts +70 -0
- package/packages/core/src/shared/DiffRenderer.ts +1 -1
- package/packages/core/src/shared/Directories.ts +39 -5
- package/packages/core/src/shared/ExtensionToggles.ts +8 -0
- package/packages/core/src/shared/FileWatch.ts +15 -1
- package/packages/core/src/shared/Git.ts +483 -42
- package/packages/core/src/shared/GitMonitor.ts +221 -0
- package/packages/core/src/shared/Levenshtein.ts +54 -1
- package/packages/core/src/shared/PiExtensions.ts +228 -0
- package/packages/core/src/shared/Proc.ts +21 -1
- package/packages/core/src/shared/RepoDiff.ts +741 -0
- package/packages/core/src/shared/Surface.ts +2 -0
- package/packages/core/src/shared/Updater.ts +21 -5
- package/packages/core/src/view/DiffExpand.ts +214 -0
- package/packages/core/src/view/DiffLayout.ts +7 -7
- package/packages/core/src/view/DiffPairs.ts +47 -0
- package/packages/core/src/view/MovePath.ts +70 -0
- package/packages/daemon/src/WebSurface.ts +1 -2
- package/packages/protocol/src/Command.ts +151 -3
- package/packages/protocol/src/Diff.ts +15 -0
- package/packages/protocol/src/ServerEvent.ts +191 -2
- package/packages/server/src/ClientConnection.ts +15 -3
- package/packages/server/src/ProbeClient.ts +111 -28
- package/packages/server/src/SessionCatalogue.ts +292 -38
- package/packages/server/src/SessionStream.ts +297 -45
- package/packages/server/src/StaticClient.ts +95 -12
- package/packages/server/src/WsGateway.ts +417 -45
- package/packages/server/src/probe.ts +77 -7
- package/packages/telegram/src/Session.ts +1 -0
- package/packages/tui/src/extensions/command-picker/index.ts +64 -32
- package/packages/tui/src/extensions/footer/index.ts +19 -43
- package/packages/tui/src/extensions/session-lease/index.ts +8 -3
- package/packages/web/dist/client/assets/CommitMonoV143-VF-ytizKI8U.woff2 +0 -0
- package/packages/web/dist/client/assets/{core-CGdQx9la.js → core-D3tyj0PP.js} +1 -1
- package/packages/web/dist/client/assets/index-CTrlTM53.js +45 -0
- package/packages/web/dist/client/assets/index-JnduS4Cd.css +1 -0
- package/packages/web/dist/client/index.html +2 -2
- package/packages/protocol/src/Protocol.ts +0 -6
- package/packages/web/dist/client/assets/commit-mono-latin-300-italic-B48RJMeK.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-300-italic-DBq5bJCE.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-italic-BXinMwCi.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-italic-DjSHLl2N.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-normal-s0S3qwFW.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-normal-wzhe4RuD.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-600-normal-BuQDXT0M.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-600-normal-SwgSWHaV.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-700-normal-1-GU0IUE.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-700-normal-DU8mrtj2.woff2 +0 -0
- package/packages/web/dist/client/assets/index-BMNwleWO.js +0 -40
- 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 {
|
|
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
|
-
|
|
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
|
|
70
|
-
private
|
|
71
|
-
private
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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;
|