@agentclientprotocol/claude-agent-acp 0.88.1-preview.6 → 0.88.1-preview.7
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/dist/acp-agent.d.ts +26 -1
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +91 -17
- package/dist/air-extension.d.ts +23 -0
- package/dist/air-extension.d.ts.map +1 -1
- package/dist/air-extension.js +23 -0
- package/dist/diff.d.ts +2 -0
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +37 -1
- package/dist/mcp-command.d.ts.map +1 -1
- package/dist/mcp-command.js +5 -0
- package/dist/session-index/activity.d.ts +56 -0
- package/dist/session-index/activity.d.ts.map +1 -0
- package/dist/session-index/activity.js +96 -0
- package/dist/session-index/archive-title.d.ts +52 -0
- package/dist/session-index/archive-title.d.ts.map +1 -0
- package/dist/session-index/archive-title.js +77 -0
- package/dist/session-index/connection.d.ts +152 -0
- package/dist/session-index/connection.d.ts.map +1 -0
- package/dist/session-index/connection.js +310 -0
- package/dist/session-index/dir-listing.d.ts +30 -0
- package/dist/session-index/dir-listing.d.ts.map +1 -0
- package/dist/session-index/dir-listing.js +110 -0
- package/dist/session-index/explicit-title.d.ts +34 -0
- package/dist/session-index/explicit-title.d.ts.map +1 -0
- package/dist/session-index/explicit-title.js +65 -0
- package/dist/session-index/first-prompt.d.ts +37 -0
- package/dist/session-index/first-prompt.d.ts.map +1 -0
- package/dist/session-index/first-prompt.js +195 -0
- package/dist/session-index/list-subscriptions.d.ts +190 -0
- package/dist/session-index/list-subscriptions.d.ts.map +1 -0
- package/dist/session-index/list-subscriptions.js +1163 -0
- package/dist/session-index/live-registry.d.ts +72 -0
- package/dist/session-index/live-registry.d.ts.map +1 -0
- package/dist/session-index/live-registry.js +240 -0
- package/dist/session-index/project-dirs.d.ts +45 -0
- package/dist/session-index/project-dirs.d.ts.map +1 -0
- package/dist/session-index/project-dirs.js +122 -0
- package/dist/session-index/service.d.ts +158 -0
- package/dist/session-index/service.d.ts.map +1 -0
- package/dist/session-index/service.js +546 -0
- package/dist/session-index/session-index.d.ts +201 -0
- package/dist/session-index/session-index.d.ts.map +1 -0
- package/dist/session-index/session-index.js +740 -0
- package/dist/session-index/session-info.d.ts +16 -0
- package/dist/session-index/session-info.d.ts.map +1 -0
- package/dist/session-index/session-info.js +51 -0
- package/dist/session-index/transcript-scan.d.ts +137 -0
- package/dist/session-index/transcript-scan.d.ts.map +1 -0
- package/dist/session-index/transcript-scan.js +545 -0
- package/dist/session-index/worktrees.d.ts +25 -0
- package/dist/session-index/worktrees.d.ts.map +1 -0
- package/dist/session-index/worktrees.js +128 -0
- package/dist/session-titles.d.ts +21 -0
- package/dist/session-titles.d.ts.map +1 -1
- package/dist/session-titles.js +101 -5
- package/dist/utils.d.ts +31 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +60 -2
- package/package.json +1 -1
|
@@ -0,0 +1,1163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `_session/list/subscribe`: pushes the changed rows of a cwd's session list
|
|
3
|
+
* to a `sessionIndex` client.
|
|
4
|
+
*
|
|
5
|
+
* A subscription covers the sessions of its cwd and of the same subdirectory
|
|
6
|
+
* in every linked worktree (the `includeWorktrees` scope of the list), in any
|
|
7
|
+
* archive state. All subscriptions of one cwd share one {@link ScopeWatch}:
|
|
8
|
+
* a non-recursive `fs.watch` on each project directory of the scope, the
|
|
9
|
+
* stats of its transcripts, and the row of each session read since. The
|
|
10
|
+
* registry and the projects root are watched once per connection.
|
|
11
|
+
*
|
|
12
|
+
* Subscribe reads no row: it opens the watchers, stats the transcripts and
|
|
13
|
+
* reads the registry. A row is read on the first
|
|
14
|
+
* event of its session and sent unconditionally; later rows are compared
|
|
15
|
+
* with it.
|
|
16
|
+
*
|
|
17
|
+
* - A transcript event names the file: only that file is stat'ed and, when
|
|
18
|
+
* it changed, its metadata read again (the index's metadata cache and tail
|
|
19
|
+
* scan).
|
|
20
|
+
* - A registry event names the record: only that record is read, and only
|
|
21
|
+
* the sessions whose record changed get their `state` recomputed.
|
|
22
|
+
* - A session this connection runs reports its SDK state at once.
|
|
23
|
+
*
|
|
24
|
+
* Events are coalesced: 150 ms after the last one, at most 1 s after the
|
|
25
|
+
* first. Each subscription compares a recomputed row with the last row it
|
|
26
|
+
* sent; a change of `updatedAt` alone is no change. A session is sent at most
|
|
27
|
+
* once a second per subscription. A rescan every 10 s stats the scope again
|
|
28
|
+
* and reads the transcripts whose stat changed (or whose last read
|
|
29
|
+
* failed), which covers events `fs.watch` missed;
|
|
30
|
+
* the state of a live session ages from the metadata the index holds.
|
|
31
|
+
*
|
|
32
|
+
* Delivery is best effort: there is no resync and no sequence number; the
|
|
33
|
+
* client also reads the list now and then.
|
|
34
|
+
*/
|
|
35
|
+
import { randomUUID } from "node:crypto";
|
|
36
|
+
import * as fs from "node:fs";
|
|
37
|
+
import * as fsp from "node:fs/promises";
|
|
38
|
+
import * as path from "node:path";
|
|
39
|
+
import { RequestError } from "@agentclientprotocol/sdk";
|
|
40
|
+
import { bySession, liveRegistryDir, } from "./live-registry.js";
|
|
41
|
+
import { isExactProjectDir, isSessionId, projectDirMatches, projectsRoot } from "./project-dirs.js";
|
|
42
|
+
import { changeSignature, sessionInfoOf } from "./session-info.js";
|
|
43
|
+
export const LIST_SUBSCRIBE_METHOD = "_session/list/subscribe";
|
|
44
|
+
export const LIST_UNSUBSCRIBE_METHOD = "_session/list/unsubscribe";
|
|
45
|
+
export const LIST_CHANGES_METHOD = "_session/list/changes";
|
|
46
|
+
/** Subscriptions per connection. */
|
|
47
|
+
export const MAX_SUBSCRIPTIONS = 128;
|
|
48
|
+
const DEBOUNCE_MS = 150;
|
|
49
|
+
const MAX_WAIT_MS = 1_000;
|
|
50
|
+
const RESCAN_MS = 10_000;
|
|
51
|
+
/** When a rescan follows the opening of a directory watcher. */
|
|
52
|
+
const SETTLE_MS = 1_000;
|
|
53
|
+
/** The least time between two changes of one session to one subscription. */
|
|
54
|
+
const MIN_SESSION_INTERVAL_MS = 1_000;
|
|
55
|
+
function asRecord(value) {
|
|
56
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
57
|
+
? value
|
|
58
|
+
: {};
|
|
59
|
+
}
|
|
60
|
+
/** `{ cwd }`: an absolute path. Other parameters are ignored. */
|
|
61
|
+
export function parseListSubscribeRequest(value) {
|
|
62
|
+
const cwd = asRecord(value).cwd;
|
|
63
|
+
if (typeof cwd !== "string" || !path.isAbsolute(cwd)) {
|
|
64
|
+
throw RequestError.invalidParams({ cwd }, "params require an absolute cwd");
|
|
65
|
+
}
|
|
66
|
+
return { cwd };
|
|
67
|
+
}
|
|
68
|
+
/** `{ subscriptionId }`. */
|
|
69
|
+
export function parseListUnsubscribeRequest(value) {
|
|
70
|
+
const subscriptionId = asRecord(value).subscriptionId;
|
|
71
|
+
if (typeof subscriptionId !== "string") {
|
|
72
|
+
throw RequestError.invalidParams(undefined, "params require a subscriptionId");
|
|
73
|
+
}
|
|
74
|
+
return { subscriptionId };
|
|
75
|
+
}
|
|
76
|
+
/** A non-recursive watch of `dir`, or null when it cannot be watched (it
|
|
77
|
+
* does not exist yet). `onError` runs when the watch fails later (the
|
|
78
|
+
* directory was removed): the watcher is closed then. */
|
|
79
|
+
function watchDir(dir, onEvent, onError) {
|
|
80
|
+
try {
|
|
81
|
+
const watcher = fs.watch(dir, { persistent: false }, (_event, filename) => onEvent(filename === null || filename === undefined ? null : String(filename)));
|
|
82
|
+
watcher.on("error", () => {
|
|
83
|
+
watcher.close();
|
|
84
|
+
onError();
|
|
85
|
+
});
|
|
86
|
+
return watcher;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// A directory that does not exist yet is covered by the rescan.
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const RECORD_FILE = /^\d+\.json$/;
|
|
94
|
+
/** Which directory a path is now (device and inode), or undefined. A
|
|
95
|
+
* directory replaced by another one keeps no watcher of the old one. */
|
|
96
|
+
function dirIdentity(dir) {
|
|
97
|
+
try {
|
|
98
|
+
const stats = fs.statSync(dir);
|
|
99
|
+
return stats.isDirectory() ? `${stats.dev}:${stats.ino}` : undefined;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** The scope paths as one comparable value. */
|
|
106
|
+
function scopeKey(paths) {
|
|
107
|
+
return [...paths].sort().join("\0");
|
|
108
|
+
}
|
|
109
|
+
/** The session id of a transcript file name, else undefined. */
|
|
110
|
+
function transcriptId(filename) {
|
|
111
|
+
if (!filename.endsWith(".jsonl"))
|
|
112
|
+
return undefined;
|
|
113
|
+
const id = filename.slice(0, -6);
|
|
114
|
+
return isSessionId(id) ? id : undefined;
|
|
115
|
+
}
|
|
116
|
+
export class ListSubscriptions {
|
|
117
|
+
deps;
|
|
118
|
+
subscriptions = new Map();
|
|
119
|
+
/** Sessions whose row shows this connection's SDK state: the rescan
|
|
120
|
+
* presents them again, in case a change of it was not reported. */
|
|
121
|
+
ownShown = new Set();
|
|
122
|
+
watches = new Map();
|
|
123
|
+
shared = new Map();
|
|
124
|
+
/** The directory each shared watcher watches (see {@link dirIdentity}). */
|
|
125
|
+
sharedIdentity = new Map();
|
|
126
|
+
/** The nearest existing ancestor of each shared directory that does not
|
|
127
|
+
* exist yet, watched for its creation. */
|
|
128
|
+
ancestors = new Map();
|
|
129
|
+
rescanTimer;
|
|
130
|
+
/** The live records by registry file name. */
|
|
131
|
+
liveFiles = new Map();
|
|
132
|
+
live = new Map();
|
|
133
|
+
registryLoaded;
|
|
134
|
+
registryNames = new Set();
|
|
135
|
+
registryFull = false;
|
|
136
|
+
registryDebounce;
|
|
137
|
+
registryMaxWait;
|
|
138
|
+
registryWorking;
|
|
139
|
+
disposed = false;
|
|
140
|
+
/** Orders subscriptions and session births (see {@link ScopeWatch.born}). */
|
|
141
|
+
seq = 0;
|
|
142
|
+
now;
|
|
143
|
+
debounceMs;
|
|
144
|
+
maxWaitMs;
|
|
145
|
+
rescanMs;
|
|
146
|
+
minIntervalMs;
|
|
147
|
+
constructor(deps) {
|
|
148
|
+
this.deps = deps;
|
|
149
|
+
this.now = deps.now ?? Date.now;
|
|
150
|
+
this.debounceMs = deps.debounceMs ?? DEBOUNCE_MS;
|
|
151
|
+
this.maxWaitMs = deps.maxWaitMs ?? MAX_WAIT_MS;
|
|
152
|
+
this.rescanMs = deps.rescanMs ?? RESCAN_MS;
|
|
153
|
+
this.minIntervalMs = deps.minSessionIntervalMs ?? MIN_SESSION_INTERVAL_MS;
|
|
154
|
+
}
|
|
155
|
+
/** Subscribes to the sessions of `cwd`. Changes are tracked from the
|
|
156
|
+
* return on. Reads no row (see {@link createWatch}). */
|
|
157
|
+
async subscribe(cwd) {
|
|
158
|
+
if (this.disposed)
|
|
159
|
+
throw RequestError.internalError(undefined, "The connection is closed");
|
|
160
|
+
if (this.subscriptions.size >= MAX_SUBSCRIPTIONS) {
|
|
161
|
+
throw RequestError.invalidParams({ reason: "too_many_subscriptions", max: MAX_SUBSCRIPTIONS }, `At most ${MAX_SUBSCRIPTIONS} session list subscriptions per connection`);
|
|
162
|
+
}
|
|
163
|
+
const key = path.resolve(cwd);
|
|
164
|
+
let watch = this.watches.get(key);
|
|
165
|
+
if (!watch) {
|
|
166
|
+
watch = this.createWatch(key);
|
|
167
|
+
this.watches.set(key, watch);
|
|
168
|
+
this.ensureShared();
|
|
169
|
+
}
|
|
170
|
+
const subscription = {
|
|
171
|
+
id: randomUUID(),
|
|
172
|
+
watch,
|
|
173
|
+
since: ++this.seq,
|
|
174
|
+
sent: new Map(),
|
|
175
|
+
sentAt: new Map(),
|
|
176
|
+
pending: new Set(),
|
|
177
|
+
};
|
|
178
|
+
// Counted at once, so concurrent subscribes cannot pass the limit.
|
|
179
|
+
this.subscriptions.set(subscription.id, subscription);
|
|
180
|
+
watch.subscriptions.add(subscription);
|
|
181
|
+
try {
|
|
182
|
+
await watch.started;
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
this.unsubscribe(subscription.id);
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
// The connection closed meanwhile.
|
|
189
|
+
if (!this.subscriptions.has(subscription.id)) {
|
|
190
|
+
throw RequestError.internalError(undefined, "The connection is closed");
|
|
191
|
+
}
|
|
192
|
+
return { subscriptionId: subscription.id };
|
|
193
|
+
}
|
|
194
|
+
/** Idempotent: an unknown id is no error. */
|
|
195
|
+
unsubscribe(subscriptionId) {
|
|
196
|
+
const subscription = this.subscriptions.get(subscriptionId);
|
|
197
|
+
if (!subscription)
|
|
198
|
+
return;
|
|
199
|
+
this.subscriptions.delete(subscriptionId);
|
|
200
|
+
if (subscription.timer)
|
|
201
|
+
clearTimeout(subscription.timer);
|
|
202
|
+
subscription.timer = undefined;
|
|
203
|
+
const { watch } = subscription;
|
|
204
|
+
watch.subscriptions.delete(subscription);
|
|
205
|
+
if (watch.subscriptions.size === 0)
|
|
206
|
+
this.closeWatch(watch);
|
|
207
|
+
}
|
|
208
|
+
/** A session this connection runs changed its state, turn end or cost. */
|
|
209
|
+
ownSessionChanged(id) {
|
|
210
|
+
for (const watch of this.watches.values()) {
|
|
211
|
+
if (watch.ready && watch.rows.has(id)) {
|
|
212
|
+
watch.dirtyStates.add(id);
|
|
213
|
+
this.schedule(watch, "states");
|
|
214
|
+
}
|
|
215
|
+
else if (!watch.ready || watch.filesById.has(id)) {
|
|
216
|
+
// Its first event here: the row is read.
|
|
217
|
+
watch.dirtyRows.add(id);
|
|
218
|
+
this.schedule(watch, "now");
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
dispose() {
|
|
223
|
+
this.disposed = true;
|
|
224
|
+
for (const id of [...this.subscriptions.keys()])
|
|
225
|
+
this.unsubscribe(id);
|
|
226
|
+
this.stopShared();
|
|
227
|
+
}
|
|
228
|
+
/** The open watchers and armed timers, for tests and diagnostics. */
|
|
229
|
+
openHandles() {
|
|
230
|
+
let watchers = this.shared.size + this.ancestors.size;
|
|
231
|
+
let timers = [this.rescanTimer, this.registryDebounce, this.registryMaxWait].filter(Boolean).length;
|
|
232
|
+
for (const watch of this.watches.values()) {
|
|
233
|
+
for (const { watcher } of watch.dirs.values())
|
|
234
|
+
if (watcher)
|
|
235
|
+
watchers++;
|
|
236
|
+
if (watch.debounce)
|
|
237
|
+
timers++;
|
|
238
|
+
if (watch.maxWait)
|
|
239
|
+
timers++;
|
|
240
|
+
if (watch.settle)
|
|
241
|
+
timers++;
|
|
242
|
+
for (const subscription of watch.subscriptions)
|
|
243
|
+
if (subscription.timer)
|
|
244
|
+
timers++;
|
|
245
|
+
}
|
|
246
|
+
return { watches: this.watches.size, watchers, timers };
|
|
247
|
+
}
|
|
248
|
+
createWatch(key) {
|
|
249
|
+
const watch = {
|
|
250
|
+
key,
|
|
251
|
+
cwd: key,
|
|
252
|
+
paths: [],
|
|
253
|
+
dirs: new Map(),
|
|
254
|
+
files: new Map(),
|
|
255
|
+
filesById: new Map(),
|
|
256
|
+
born: new Map(),
|
|
257
|
+
rows: new Map(),
|
|
258
|
+
subscriptions: new Set(),
|
|
259
|
+
started: Promise.resolve(),
|
|
260
|
+
ready: false,
|
|
261
|
+
readyAt: 0,
|
|
262
|
+
closed: false,
|
|
263
|
+
dirtyFiles: new Set(),
|
|
264
|
+
dirtyRows: new Set(),
|
|
265
|
+
dirtyStates: new Set(),
|
|
266
|
+
rescan: false,
|
|
267
|
+
successorOf: new Map(),
|
|
268
|
+
hiddenSeeds: new Map(),
|
|
269
|
+
unreadable: new Set(),
|
|
270
|
+
resolvedPaths: "",
|
|
271
|
+
again: false,
|
|
272
|
+
againFiles: false,
|
|
273
|
+
};
|
|
274
|
+
// When subscribe returns, the watchers are open; no row is read. What it
|
|
275
|
+
// keeps, all without reading a transcript:
|
|
276
|
+
// - the stats of the transcripts, opened after the watchers: a deletion
|
|
277
|
+
// or a move out of the scope is a stat that went, the rescan reads only
|
|
278
|
+
// a transcript whose stat changed, and a session's other copies are
|
|
279
|
+
// known when its row is read;
|
|
280
|
+
// - the registry: a later change of it is an event of the sessions whose
|
|
281
|
+
// record changed.
|
|
282
|
+
watch.started = this.refreshDirs(watch).then(async () => {
|
|
283
|
+
const files = await this.deps.index.enumerateFiles(watch.paths);
|
|
284
|
+
await this.registryLoaded;
|
|
285
|
+
if (watch.closed)
|
|
286
|
+
return;
|
|
287
|
+
this.setFiles(watch, files);
|
|
288
|
+
this.learnSuccessors(watch);
|
|
289
|
+
watch.resolvedPaths = scopeKey(watch.paths);
|
|
290
|
+
// The rows of the live sessions a list read: their state ages from now.
|
|
291
|
+
const seeded = await this.liveSeeds(watch, new Set());
|
|
292
|
+
if (watch.closed)
|
|
293
|
+
return;
|
|
294
|
+
watch.ready = true;
|
|
295
|
+
watch.readyAt = this.now();
|
|
296
|
+
for (const row of seeded) {
|
|
297
|
+
watch.rows.set(row.sessionId, this.present(row, watch.readyAt));
|
|
298
|
+
}
|
|
299
|
+
// Events of the start, held until now.
|
|
300
|
+
if (watch.dirtyFiles.size > 0 ||
|
|
301
|
+
watch.dirtyRows.size > 0 ||
|
|
302
|
+
watch.dirtyStates.size > 0 ||
|
|
303
|
+
watch.rescan) {
|
|
304
|
+
this.schedule(watch, "debounced");
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
void watch.started.catch(() => this.closeWatch(watch));
|
|
308
|
+
return watch;
|
|
309
|
+
}
|
|
310
|
+
closeWatch(watch) {
|
|
311
|
+
if (watch.closed)
|
|
312
|
+
return;
|
|
313
|
+
watch.closed = true;
|
|
314
|
+
for (const subscription of watch.subscriptions) {
|
|
315
|
+
this.subscriptions.delete(subscription.id);
|
|
316
|
+
if (subscription.timer)
|
|
317
|
+
clearTimeout(subscription.timer);
|
|
318
|
+
}
|
|
319
|
+
watch.subscriptions.clear();
|
|
320
|
+
for (const { watcher } of watch.dirs.values())
|
|
321
|
+
watcher?.close();
|
|
322
|
+
watch.dirs.clear();
|
|
323
|
+
if (watch.debounce)
|
|
324
|
+
clearTimeout(watch.debounce);
|
|
325
|
+
if (watch.maxWait)
|
|
326
|
+
clearTimeout(watch.maxWait);
|
|
327
|
+
if (watch.settle)
|
|
328
|
+
clearTimeout(watch.settle);
|
|
329
|
+
watch.debounce = watch.maxWait = watch.settle = undefined;
|
|
330
|
+
watch.rows.clear();
|
|
331
|
+
watch.files.clear();
|
|
332
|
+
watch.filesById.clear();
|
|
333
|
+
watch.born.clear();
|
|
334
|
+
watch.successorOf.clear();
|
|
335
|
+
watch.hiddenSeeds.clear();
|
|
336
|
+
if (this.watches.get(watch.key) === watch)
|
|
337
|
+
this.watches.delete(watch.key);
|
|
338
|
+
if (this.watches.size === 0)
|
|
339
|
+
this.stopShared();
|
|
340
|
+
}
|
|
341
|
+
/** The registry and the projects root, watched once for all cwds, and the
|
|
342
|
+
* rescan. */
|
|
343
|
+
ensureShared() {
|
|
344
|
+
if (this.disposed || this.watches.size === 0)
|
|
345
|
+
return;
|
|
346
|
+
const watchers = [
|
|
347
|
+
[liveRegistryDir(), (filename) => this.onRegistryEvent(filename)],
|
|
348
|
+
[projectsRoot(), (filename) => this.onProjectsRootEvent(filename)],
|
|
349
|
+
];
|
|
350
|
+
const missing = [];
|
|
351
|
+
let created = false;
|
|
352
|
+
for (const [dir, onEvent] of watchers) {
|
|
353
|
+
const identity = dirIdentity(dir);
|
|
354
|
+
const existing = this.shared.get(dir);
|
|
355
|
+
if (existing && this.sharedIdentity.get(dir) === identity)
|
|
356
|
+
continue;
|
|
357
|
+
if (existing) {
|
|
358
|
+
// Replaced or gone: watch what is there now.
|
|
359
|
+
existing.close();
|
|
360
|
+
this.shared.delete(dir);
|
|
361
|
+
}
|
|
362
|
+
this.sharedIdentity.set(dir, identity);
|
|
363
|
+
const watcher = watchDir(dir, onEvent, () => {
|
|
364
|
+
// Watched again, or its ancestor, by the next rescan.
|
|
365
|
+
if (this.shared.get(dir) === watcher)
|
|
366
|
+
this.shared.delete(dir);
|
|
367
|
+
});
|
|
368
|
+
if (watcher) {
|
|
369
|
+
this.shared.set(dir, watcher);
|
|
370
|
+
created = true;
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
missing.push(dir);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
this.watchAncestors(missing);
|
|
377
|
+
if (created && this.registryLoaded) {
|
|
378
|
+
// What the new directory got before its watcher opened.
|
|
379
|
+
this.registryFull = true;
|
|
380
|
+
this.scheduleRegistry(true);
|
|
381
|
+
for (const watch of this.watches.values()) {
|
|
382
|
+
if (!watch.ready)
|
|
383
|
+
continue;
|
|
384
|
+
watch.rescan = true;
|
|
385
|
+
this.schedule(watch, "now");
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
this.registryLoaded ??= this.readRegistry(true).then(() => undefined, (error) => this.deps.logError("session list registry read failed", error));
|
|
389
|
+
if (!this.rescanTimer) {
|
|
390
|
+
this.rescanTimer = setInterval(() => this.rescanAll(), this.rescanMs);
|
|
391
|
+
this.rescanTimer.unref?.();
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/** Watches the nearest existing ancestor of each of `missing` (a config
|
|
395
|
+
* directory without `projects` or `sessions` yet): any event there checks
|
|
396
|
+
* again. */
|
|
397
|
+
watchAncestors(missing) {
|
|
398
|
+
const wanted = new Set();
|
|
399
|
+
for (const dir of missing) {
|
|
400
|
+
let ancestor = path.dirname(dir);
|
|
401
|
+
while (!fs.existsSync(ancestor) && path.dirname(ancestor) !== ancestor) {
|
|
402
|
+
ancestor = path.dirname(ancestor);
|
|
403
|
+
}
|
|
404
|
+
wanted.add(ancestor);
|
|
405
|
+
}
|
|
406
|
+
for (const [ancestor, watcher] of this.ancestors) {
|
|
407
|
+
if (wanted.has(ancestor))
|
|
408
|
+
continue;
|
|
409
|
+
watcher.close();
|
|
410
|
+
this.ancestors.delete(ancestor);
|
|
411
|
+
}
|
|
412
|
+
for (const ancestor of wanted) {
|
|
413
|
+
if (this.ancestors.has(ancestor))
|
|
414
|
+
continue;
|
|
415
|
+
const watcher = watchDir(ancestor, () => this.ensureShared(), () => {
|
|
416
|
+
if (this.ancestors.get(ancestor) === watcher)
|
|
417
|
+
this.ancestors.delete(ancestor);
|
|
418
|
+
});
|
|
419
|
+
if (watcher)
|
|
420
|
+
this.ancestors.set(ancestor, watcher);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
stopShared() {
|
|
424
|
+
for (const watcher of this.shared.values())
|
|
425
|
+
watcher.close();
|
|
426
|
+
this.shared.clear();
|
|
427
|
+
this.sharedIdentity.clear();
|
|
428
|
+
this.ownShown.clear();
|
|
429
|
+
for (const watcher of this.ancestors.values())
|
|
430
|
+
watcher.close();
|
|
431
|
+
this.ancestors.clear();
|
|
432
|
+
if (this.rescanTimer)
|
|
433
|
+
clearInterval(this.rescanTimer);
|
|
434
|
+
if (this.registryDebounce)
|
|
435
|
+
clearTimeout(this.registryDebounce);
|
|
436
|
+
if (this.registryMaxWait)
|
|
437
|
+
clearTimeout(this.registryMaxWait);
|
|
438
|
+
this.rescanTimer = this.registryDebounce = this.registryMaxWait = undefined;
|
|
439
|
+
this.registryLoaded = undefined;
|
|
440
|
+
this.registryNames.clear();
|
|
441
|
+
this.registryFull = false;
|
|
442
|
+
this.liveFiles = new Map();
|
|
443
|
+
this.live = new Map();
|
|
444
|
+
}
|
|
445
|
+
rescanAll() {
|
|
446
|
+
// Directories that did not exist before may now.
|
|
447
|
+
this.ensureShared();
|
|
448
|
+
this.registryFull = true;
|
|
449
|
+
this.scheduleRegistry(true);
|
|
450
|
+
for (const watch of this.watches.values()) {
|
|
451
|
+
if (!watch.ready)
|
|
452
|
+
continue;
|
|
453
|
+
watch.rescan = true;
|
|
454
|
+
this.schedule(watch, "now");
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
// --- Events -------------------------------------------------------------
|
|
458
|
+
onTranscriptEvent(watch, dirName, filename) {
|
|
459
|
+
if (watch.closed)
|
|
460
|
+
return;
|
|
461
|
+
if (filename === null) {
|
|
462
|
+
watch.rescan = true;
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
const id = transcriptId(filename);
|
|
466
|
+
if (id === undefined)
|
|
467
|
+
return;
|
|
468
|
+
// Before the watch is ready, held until it is.
|
|
469
|
+
watch.dirtyFiles.add(path.join(projectsRoot(), dirName, filename));
|
|
470
|
+
}
|
|
471
|
+
this.schedule(watch, "debounced");
|
|
472
|
+
}
|
|
473
|
+
/** A project directory that a scope lacked may have been created. */
|
|
474
|
+
onProjectsRootEvent(filename) {
|
|
475
|
+
if (filename === null)
|
|
476
|
+
return;
|
|
477
|
+
// A project directory of a scope was made, removed or replaced: the
|
|
478
|
+
// rescan watches what is there now.
|
|
479
|
+
for (const watch of this.watches.values()) {
|
|
480
|
+
if (watch.paths.some((cwd) => isExactProjectDir(filename, cwd) || projectDirMatches(filename, cwd))) {
|
|
481
|
+
watch.rescan = true;
|
|
482
|
+
this.schedule(watch, "debounced");
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
onRegistryEvent(filename) {
|
|
487
|
+
if (filename === null)
|
|
488
|
+
this.registryFull = true;
|
|
489
|
+
else if (RECORD_FILE.test(filename))
|
|
490
|
+
this.registryNames.add(filename);
|
|
491
|
+
else
|
|
492
|
+
return;
|
|
493
|
+
this.scheduleRegistry(false);
|
|
494
|
+
}
|
|
495
|
+
// --- Scheduling ---------------------------------------------------------
|
|
496
|
+
/**
|
|
497
|
+
* Runs the work of `watch`: `debounced` after the quiet period, `now` at
|
|
498
|
+
* once, and `states` at once for the recomputed states only, leaving the
|
|
499
|
+
* transcript events to their quiet period (a registry that changes every
|
|
500
|
+
* 200 ms then does not read the transcripts that often).
|
|
501
|
+
*/
|
|
502
|
+
schedule(watch, mode) {
|
|
503
|
+
if (watch.closed)
|
|
504
|
+
return;
|
|
505
|
+
if (mode === "states") {
|
|
506
|
+
void this.work(watch, false);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (mode === "now") {
|
|
510
|
+
this.clearWatchTimers(watch);
|
|
511
|
+
void this.work(watch, true);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
if (watch.debounce)
|
|
515
|
+
clearTimeout(watch.debounce);
|
|
516
|
+
watch.debounce = setTimeout(() => this.schedule(watch, "now"), this.debounceMs);
|
|
517
|
+
watch.debounce.unref?.();
|
|
518
|
+
if (!watch.maxWait) {
|
|
519
|
+
watch.maxWait = setTimeout(() => this.schedule(watch, "now"), this.maxWaitMs);
|
|
520
|
+
watch.maxWait.unref?.();
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
clearWatchTimers(watch) {
|
|
524
|
+
if (watch.debounce)
|
|
525
|
+
clearTimeout(watch.debounce);
|
|
526
|
+
if (watch.maxWait)
|
|
527
|
+
clearTimeout(watch.maxWait);
|
|
528
|
+
watch.debounce = watch.maxWait = undefined;
|
|
529
|
+
}
|
|
530
|
+
scheduleRegistry(now) {
|
|
531
|
+
if (this.disposed || this.watches.size === 0)
|
|
532
|
+
return;
|
|
533
|
+
const run = () => {
|
|
534
|
+
if (this.registryDebounce)
|
|
535
|
+
clearTimeout(this.registryDebounce);
|
|
536
|
+
if (this.registryMaxWait)
|
|
537
|
+
clearTimeout(this.registryMaxWait);
|
|
538
|
+
this.registryDebounce = this.registryMaxWait = undefined;
|
|
539
|
+
void this.registryWork();
|
|
540
|
+
};
|
|
541
|
+
if (now)
|
|
542
|
+
return run();
|
|
543
|
+
if (this.registryDebounce)
|
|
544
|
+
clearTimeout(this.registryDebounce);
|
|
545
|
+
this.registryDebounce = setTimeout(run, this.debounceMs);
|
|
546
|
+
this.registryDebounce.unref?.();
|
|
547
|
+
if (!this.registryMaxWait) {
|
|
548
|
+
this.registryMaxWait = setTimeout(run, this.maxWaitMs);
|
|
549
|
+
this.registryMaxWait.unref?.();
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
// --- Registry -----------------------------------------------------------
|
|
553
|
+
async registryWork() {
|
|
554
|
+
if (this.registryWorking) {
|
|
555
|
+
// The running read picks the new names up when it ends.
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
this.registryWorking = (async () => {
|
|
559
|
+
try {
|
|
560
|
+
await this.registryLoaded;
|
|
561
|
+
while (this.registryFull || this.registryNames.size > 0) {
|
|
562
|
+
const full = this.registryFull;
|
|
563
|
+
this.registryFull = false;
|
|
564
|
+
const affected = await this.readRegistry(full);
|
|
565
|
+
this.onLiveChanged(affected);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
catch (error) {
|
|
569
|
+
this.deps.logError("session list registry read failed", error);
|
|
570
|
+
}
|
|
571
|
+
finally {
|
|
572
|
+
this.registryWorking = undefined;
|
|
573
|
+
}
|
|
574
|
+
})();
|
|
575
|
+
await this.registryWorking;
|
|
576
|
+
}
|
|
577
|
+
/** Reads the changed registry records (all with `full`) and returns the
|
|
578
|
+
* ids of the sessions whose record changed. */
|
|
579
|
+
async readRegistry(full) {
|
|
580
|
+
let names;
|
|
581
|
+
if (full) {
|
|
582
|
+
this.registryNames.clear();
|
|
583
|
+
try {
|
|
584
|
+
names = await fsp.readdir(liveRegistryDir());
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
names = [];
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
names = [...this.registryNames];
|
|
592
|
+
this.registryNames.clear();
|
|
593
|
+
}
|
|
594
|
+
const read = await this.deps.registry.readFiles(names, { recentStarts: true });
|
|
595
|
+
const affected = new Set();
|
|
596
|
+
const key = (record) => record
|
|
597
|
+
? `${record.sessionId}\0${record.status}\0${record.statusUpdatedAt}\0${record.kind}\0${record.entrypoint}`
|
|
598
|
+
: "";
|
|
599
|
+
const next = full ? new Map() : new Map(this.liveFiles);
|
|
600
|
+
const before = this.liveFiles;
|
|
601
|
+
const changedNames = full ? new Set([...before.keys(), ...read.keys()]) : read.keys();
|
|
602
|
+
for (const name of changedNames) {
|
|
603
|
+
const previous = before.get(name);
|
|
604
|
+
const record = read.get(name);
|
|
605
|
+
if (record)
|
|
606
|
+
next.set(name, record);
|
|
607
|
+
else
|
|
608
|
+
next.delete(name);
|
|
609
|
+
if (key(previous) !== key(record)) {
|
|
610
|
+
if (previous)
|
|
611
|
+
affected.add(previous.sessionId);
|
|
612
|
+
if (record)
|
|
613
|
+
affected.add(record.sessionId);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
this.liveFiles = next;
|
|
617
|
+
this.live = bySession(next.values());
|
|
618
|
+
return affected;
|
|
619
|
+
}
|
|
620
|
+
onLiveChanged(affected) {
|
|
621
|
+
if (affected.size === 0)
|
|
622
|
+
return;
|
|
623
|
+
for (const watch of this.watches.values()) {
|
|
624
|
+
let states = false;
|
|
625
|
+
let rows = false;
|
|
626
|
+
for (const id of affected) {
|
|
627
|
+
if (watch.ready && watch.rows.has(id)) {
|
|
628
|
+
watch.dirtyStates.add(id);
|
|
629
|
+
states = true;
|
|
630
|
+
}
|
|
631
|
+
else if (!watch.ready || watch.filesById.has(id)) {
|
|
632
|
+
// Its first event here: the row is read.
|
|
633
|
+
watch.dirtyRows.add(id);
|
|
634
|
+
rows = true;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (states)
|
|
638
|
+
this.schedule(watch, "states");
|
|
639
|
+
if (rows)
|
|
640
|
+
this.schedule(watch, "debounced");
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
// --- Scope work ---------------------------------------------------------
|
|
644
|
+
/** Reads the scope paths and watches their project directories. */
|
|
645
|
+
async refreshDirs(watch) {
|
|
646
|
+
const paths = await this.deps.index.listedPaths(watch.cwd, true);
|
|
647
|
+
const dirs = await this.deps.index.projectDirs(paths);
|
|
648
|
+
if (watch.closed)
|
|
649
|
+
return;
|
|
650
|
+
watch.paths = paths;
|
|
651
|
+
const wanted = new Map(dirs.map(({ dirName, projectPath }) => [dirName, projectPath]));
|
|
652
|
+
let opened = false;
|
|
653
|
+
for (const [dirName, entry] of watch.dirs) {
|
|
654
|
+
if (wanted.has(dirName))
|
|
655
|
+
continue;
|
|
656
|
+
entry.watcher?.close();
|
|
657
|
+
watch.dirs.delete(dirName);
|
|
658
|
+
}
|
|
659
|
+
for (const [dirName, projectPath] of wanted) {
|
|
660
|
+
const dir = path.join(projectsRoot(), dirName);
|
|
661
|
+
const identity = dirIdentity(dir);
|
|
662
|
+
let entry = watch.dirs.get(dirName);
|
|
663
|
+
if (entry?.watcher && entry.identity === identity) {
|
|
664
|
+
entry.projectPath = projectPath;
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
if (entry?.watcher) {
|
|
668
|
+
// Replaced (renamed away and made again): watch the new one.
|
|
669
|
+
entry.watcher.close();
|
|
670
|
+
watch.dirs.delete(dirName);
|
|
671
|
+
entry = undefined;
|
|
672
|
+
}
|
|
673
|
+
const created = { projectPath, watcher: null, identity };
|
|
674
|
+
created.watcher = watchDir(dir, (filename) => this.onTranscriptEvent(watch, dirName, filename), () => {
|
|
675
|
+
// Opened again by the next rescan, when the directory is back.
|
|
676
|
+
created.watcher = null;
|
|
677
|
+
});
|
|
678
|
+
watch.dirs.set(dirName, created);
|
|
679
|
+
// Not for a watcher opened again after an error, which could repeat.
|
|
680
|
+
if (created.watcher && !entry)
|
|
681
|
+
opened = true;
|
|
682
|
+
}
|
|
683
|
+
if (opened && !watch.settle) {
|
|
684
|
+
watch.settle = setTimeout(() => {
|
|
685
|
+
watch.settle = undefined;
|
|
686
|
+
watch.rescan = true;
|
|
687
|
+
this.schedule(watch, "now");
|
|
688
|
+
}, SETTLE_MS);
|
|
689
|
+
watch.settle.unref?.();
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
setFiles(watch, files) {
|
|
693
|
+
const before = watch.filesById;
|
|
694
|
+
watch.files = new Map();
|
|
695
|
+
watch.filesById = new Map();
|
|
696
|
+
for (const file of files)
|
|
697
|
+
this.putFile(watch, file, before);
|
|
698
|
+
}
|
|
699
|
+
/** `known`: the sessions with a transcript before; another one is born. */
|
|
700
|
+
putFile(watch, file, known = watch.filesById) {
|
|
701
|
+
watch.files.set(file.filePath, file);
|
|
702
|
+
const id = file.sessionId;
|
|
703
|
+
if (watch.ready && !known.has(id))
|
|
704
|
+
watch.born.set(id, ++this.seq);
|
|
705
|
+
let paths = watch.filesById.get(id);
|
|
706
|
+
if (!paths)
|
|
707
|
+
watch.filesById.set(id, (paths = new Set()));
|
|
708
|
+
paths.add(file.filePath);
|
|
709
|
+
}
|
|
710
|
+
dropFile(watch, filePath, id) {
|
|
711
|
+
watch.files.delete(filePath);
|
|
712
|
+
const paths = watch.filesById.get(id);
|
|
713
|
+
paths?.delete(filePath);
|
|
714
|
+
if (paths?.size === 0)
|
|
715
|
+
watch.filesById.delete(id);
|
|
716
|
+
}
|
|
717
|
+
present(row, now) {
|
|
718
|
+
const own = this.deps.own(row.sessionId);
|
|
719
|
+
const id = row.sessionId;
|
|
720
|
+
if (own)
|
|
721
|
+
this.ownShown.add(id);
|
|
722
|
+
else
|
|
723
|
+
this.ownShown.delete(id);
|
|
724
|
+
const info = sessionInfoOf(row, own, this.live.get(row.sessionId), now);
|
|
725
|
+
return { row, info, signature: changeSignature(info) };
|
|
726
|
+
}
|
|
727
|
+
/** Runs one pass of the pending work of `watch`, one at a time. `files`:
|
|
728
|
+
* the transcript events and the rescan too, not only the states. */
|
|
729
|
+
async work(watch, files) {
|
|
730
|
+
// Before the transcripts are stat'ed, the events wait: the start runs a
|
|
731
|
+
// pass for them.
|
|
732
|
+
if (!watch.ready || watch.closed)
|
|
733
|
+
return;
|
|
734
|
+
if (watch.working) {
|
|
735
|
+
watch.again = true;
|
|
736
|
+
watch.againFiles ||= files;
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
const working = (async () => {
|
|
740
|
+
let withFiles = files;
|
|
741
|
+
do {
|
|
742
|
+
withFiles ||= watch.againFiles;
|
|
743
|
+
watch.again = watch.againFiles = false;
|
|
744
|
+
try {
|
|
745
|
+
await this.pass(watch, withFiles);
|
|
746
|
+
}
|
|
747
|
+
catch (error) {
|
|
748
|
+
this.deps.logError(`session list change check of ${watch.cwd} failed`, error);
|
|
749
|
+
}
|
|
750
|
+
withFiles = false;
|
|
751
|
+
} while (watch.again && !watch.closed);
|
|
752
|
+
})();
|
|
753
|
+
watch.working = working;
|
|
754
|
+
try {
|
|
755
|
+
await working;
|
|
756
|
+
}
|
|
757
|
+
finally {
|
|
758
|
+
if (watch.working === working)
|
|
759
|
+
watch.working = undefined;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
async pass(watch, files) {
|
|
763
|
+
const rescan = files && watch.rescan;
|
|
764
|
+
const dirtyFiles = files ? [...watch.dirtyFiles] : [];
|
|
765
|
+
const rowIds = new Set(files ? watch.dirtyRows : []);
|
|
766
|
+
const stateIds = new Set(watch.dirtyStates);
|
|
767
|
+
/** Sessions whose state may have aged: sent only when it changed. */
|
|
768
|
+
const agedIds = new Set();
|
|
769
|
+
/** The scope paths and the transcripts of each session read again, as
|
|
770
|
+
* they were before this pass. */
|
|
771
|
+
const priorPaths = watch.paths;
|
|
772
|
+
const priorFiles = new Map();
|
|
773
|
+
const remember = (id) => {
|
|
774
|
+
if (!priorFiles.has(id))
|
|
775
|
+
priorFiles.set(id, this.filesOf(watch, id));
|
|
776
|
+
};
|
|
777
|
+
if (files) {
|
|
778
|
+
watch.rescan = false;
|
|
779
|
+
watch.dirtyFiles.clear();
|
|
780
|
+
watch.dirtyRows.clear();
|
|
781
|
+
}
|
|
782
|
+
watch.dirtyStates.clear();
|
|
783
|
+
let resolved = new Map();
|
|
784
|
+
/** Rows of live sessions not read here, from the metadata the index
|
|
785
|
+
* holds: their state ages from now on. */
|
|
786
|
+
let seeded = [];
|
|
787
|
+
/** Sessions that may have had a row before this pass. */
|
|
788
|
+
let shownBefore = new Set();
|
|
789
|
+
try {
|
|
790
|
+
if (rescan) {
|
|
791
|
+
await this.refreshDirs(watch);
|
|
792
|
+
const files = await this.deps.index.enumerateFiles(watch.paths);
|
|
793
|
+
if (watch.closed)
|
|
794
|
+
return;
|
|
795
|
+
// The sessions of a transcript that is new, changed or gone are
|
|
796
|
+
// read again; one whose stat did not change is not read.
|
|
797
|
+
const before = new Map(watch.files);
|
|
798
|
+
for (const file of files) {
|
|
799
|
+
const old = before.get(file.filePath);
|
|
800
|
+
before.delete(file.filePath);
|
|
801
|
+
if (!old ||
|
|
802
|
+
old.mtimeMs !== file.mtimeMs ||
|
|
803
|
+
old.size !== file.size ||
|
|
804
|
+
old.ino !== file.ino) {
|
|
805
|
+
rowIds.add(file.sessionId);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
for (const gone of before.values())
|
|
809
|
+
rowIds.add(gone.sessionId);
|
|
810
|
+
for (const id of rowIds)
|
|
811
|
+
remember(id);
|
|
812
|
+
this.setFiles(watch, files);
|
|
813
|
+
this.learnSuccessors(watch);
|
|
814
|
+
if (scopeKey(watch.paths) !== watch.resolvedPaths) {
|
|
815
|
+
// A worktree came or went: a transcript that did not change may now
|
|
816
|
+
// be in the scope or out of it (another path of the same project
|
|
817
|
+
// directory). Resolved again: the sessions read here, and those whose
|
|
818
|
+
// metadata the index holds (a list read them); both without reading
|
|
819
|
+
// a transcript.
|
|
820
|
+
for (const id of watch.rows.keys())
|
|
821
|
+
rowIds.add(id);
|
|
822
|
+
for (const file of files) {
|
|
823
|
+
if (this.deps.index.isRead(file))
|
|
824
|
+
rowIds.add(file.sessionId);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
// A transcript that could not be read before.
|
|
828
|
+
for (const id of watch.unreadable)
|
|
829
|
+
rowIds.add(id);
|
|
830
|
+
watch.unreadable.clear();
|
|
831
|
+
// Only the state of a session that a live process holds depends on
|
|
832
|
+
// the time (an unfinished turn ages). One without a row read here
|
|
833
|
+
// gets it from the metadata the index holds (a list read it), not
|
|
834
|
+
// from its transcript; if it has none, the client did not list it.
|
|
835
|
+
for (const id of this.live.keys()) {
|
|
836
|
+
if (watch.rows.has(id))
|
|
837
|
+
agedIds.add(id);
|
|
838
|
+
}
|
|
839
|
+
seeded = await this.liveSeeds(watch, rowIds);
|
|
840
|
+
if (watch.closed)
|
|
841
|
+
return;
|
|
842
|
+
for (const id of this.ownShown)
|
|
843
|
+
if (watch.rows.has(id))
|
|
844
|
+
agedIds.add(id);
|
|
845
|
+
}
|
|
846
|
+
else if (dirtyFiles.length > 0) {
|
|
847
|
+
const stats = await Promise.all(dirtyFiles.map((filePath) => fsp.stat(filePath).catch(() => undefined)));
|
|
848
|
+
if (watch.closed)
|
|
849
|
+
return;
|
|
850
|
+
dirtyFiles.forEach((filePath, i) => {
|
|
851
|
+
const filename = path.basename(filePath);
|
|
852
|
+
const dirName = path.basename(path.dirname(filePath));
|
|
853
|
+
const id = transcriptId(filename);
|
|
854
|
+
rowIds.add(id);
|
|
855
|
+
remember(id);
|
|
856
|
+
const dir = watch.dirs.get(dirName);
|
|
857
|
+
const fileStats = stats[i];
|
|
858
|
+
if (!dir || !fileStats?.isFile() || fileStats.size === 0) {
|
|
859
|
+
this.dropFile(watch, filePath, id);
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
this.putFile(watch, {
|
|
863
|
+
sessionId: filename.slice(0, -6),
|
|
864
|
+
filePath,
|
|
865
|
+
dirName,
|
|
866
|
+
projectPath: dir.projectPath,
|
|
867
|
+
mtimeMs: fileStats.mtimeMs,
|
|
868
|
+
size: fileStats.size,
|
|
869
|
+
ino: fileStats.ino,
|
|
870
|
+
});
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
if (rowIds.size > 0) {
|
|
874
|
+
// A session continued in a changed one may be shown or hidden now.
|
|
875
|
+
// Which sessions those are is known from the reads here and from
|
|
876
|
+
// the metadata the index holds: a session that no list or event read
|
|
877
|
+
// was not shown.
|
|
878
|
+
const successors = new Set(rowIds);
|
|
879
|
+
for (const [id, successor] of watch.successorOf) {
|
|
880
|
+
if (successors.has(successor))
|
|
881
|
+
rowIds.add(id);
|
|
882
|
+
}
|
|
883
|
+
for (const file of watch.files.values()) {
|
|
884
|
+
const successor = this.deps.index.continuedIn(file.filePath);
|
|
885
|
+
if (successor && successors.has(successor))
|
|
886
|
+
rowIds.add(file.sessionId);
|
|
887
|
+
}
|
|
888
|
+
for (const id of rowIds)
|
|
889
|
+
remember(id);
|
|
890
|
+
// Before the read below replaces the metadata the index holds.
|
|
891
|
+
shownBefore = await this.shownBefore(watch, priorPaths, rowIds, priorFiles);
|
|
892
|
+
if (watch.closed)
|
|
893
|
+
return;
|
|
894
|
+
const read = [];
|
|
895
|
+
for (const id of rowIds) {
|
|
896
|
+
for (const filePath of watch.filesById.get(id) ?? []) {
|
|
897
|
+
const file = watch.files.get(filePath);
|
|
898
|
+
if (file)
|
|
899
|
+
read.push(file);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
const rows = await this.deps.index.rowsOf(watch.paths, read, () => [
|
|
903
|
+
...watch.files.values(),
|
|
904
|
+
]);
|
|
905
|
+
if (watch.closed)
|
|
906
|
+
return;
|
|
907
|
+
resolved = new Map(rows.map((row) => [row.sessionId, row]));
|
|
908
|
+
for (const file of read) {
|
|
909
|
+
const id = file.sessionId;
|
|
910
|
+
const successor = this.deps.index.continuedIn(file.filePath);
|
|
911
|
+
if (successor)
|
|
912
|
+
watch.successorOf.set(id, successor);
|
|
913
|
+
else if (this.deps.index.isRead(file))
|
|
914
|
+
watch.successorOf.delete(id);
|
|
915
|
+
}
|
|
916
|
+
// A transcript there whose read failed is no removal: it keeps its row
|
|
917
|
+
// until a rescan reads it.
|
|
918
|
+
for (const file of read) {
|
|
919
|
+
const id = file.sessionId;
|
|
920
|
+
if (!resolved.has(id) && !this.deps.index.isRead(file)) {
|
|
921
|
+
watch.unreadable.add(id);
|
|
922
|
+
rowIds.delete(id);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
catch (error) {
|
|
928
|
+
// What this pass took is left for the next one: the file stats may
|
|
929
|
+
// have moved on, so the sessions are resolved again by id.
|
|
930
|
+
for (const filePath of dirtyFiles)
|
|
931
|
+
watch.dirtyFiles.add(filePath);
|
|
932
|
+
for (const id of rowIds)
|
|
933
|
+
watch.dirtyRows.add(id);
|
|
934
|
+
for (const id of stateIds)
|
|
935
|
+
watch.dirtyStates.add(id);
|
|
936
|
+
if (rescan)
|
|
937
|
+
watch.rescan = true;
|
|
938
|
+
throw error;
|
|
939
|
+
}
|
|
940
|
+
if (rescan)
|
|
941
|
+
watch.resolvedPaths = scopeKey(watch.paths);
|
|
942
|
+
// From here on synchronous: the rows change and are delivered at once.
|
|
943
|
+
const now = this.now();
|
|
944
|
+
/** The changed sessions. */
|
|
945
|
+
const changed = new Set();
|
|
946
|
+
/** Sessions without a row now. */
|
|
947
|
+
const absent = new Set();
|
|
948
|
+
for (const id of rowIds) {
|
|
949
|
+
const row = resolved.get(id);
|
|
950
|
+
if (row) {
|
|
951
|
+
watch.rows.set(id, this.present(row, now));
|
|
952
|
+
changed.add(id);
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
const previous = watch.rows.get(id);
|
|
956
|
+
watch.rows.delete(id);
|
|
957
|
+
if (!previous && !shownBefore.has(id))
|
|
958
|
+
continue;
|
|
959
|
+
changed.add(id);
|
|
960
|
+
absent.add(id);
|
|
961
|
+
}
|
|
962
|
+
for (const row of seeded) {
|
|
963
|
+
const id = row.sessionId;
|
|
964
|
+
if (rowIds.has(id) || watch.rows.has(id))
|
|
965
|
+
continue;
|
|
966
|
+
// As the client listed it, about when the watch became ready: a state
|
|
967
|
+
// that aged since is sent below.
|
|
968
|
+
watch.rows.set(id, this.present(row, watch.readyAt));
|
|
969
|
+
agedIds.add(id);
|
|
970
|
+
}
|
|
971
|
+
for (const id of new Set([...stateIds, ...agedIds])) {
|
|
972
|
+
if (rowIds.has(id))
|
|
973
|
+
continue;
|
|
974
|
+
const current = watch.rows.get(id);
|
|
975
|
+
if (!current)
|
|
976
|
+
continue;
|
|
977
|
+
const next = this.present(current.row, now);
|
|
978
|
+
watch.rows.set(id, next);
|
|
979
|
+
// An event of the session, or a state that aged.
|
|
980
|
+
if (stateIds.has(id) || next.signature !== current.signature) {
|
|
981
|
+
changed.add(id);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
for (const subscription of watch.subscriptions) {
|
|
985
|
+
for (const id of changed) {
|
|
986
|
+
const born = watch.born.get(id);
|
|
987
|
+
if (absent.has(id) &&
|
|
988
|
+
!subscription.sent.has(id) &&
|
|
989
|
+
born !== undefined &&
|
|
990
|
+
born > subscription.since) {
|
|
991
|
+
// Born after the subscription and never sent: nothing to remove.
|
|
992
|
+
subscription.pending.delete(id);
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
subscription.pending.add(id);
|
|
996
|
+
}
|
|
997
|
+
this.deliver(subscription);
|
|
998
|
+
}
|
|
999
|
+
// A session without a transcript now, shown or not: a removal already
|
|
1000
|
+
// sent for it is forgotten too.
|
|
1001
|
+
for (const id of rowIds) {
|
|
1002
|
+
if (watch.filesById.has(id))
|
|
1003
|
+
continue;
|
|
1004
|
+
watch.born.delete(id);
|
|
1005
|
+
watch.successorOf.delete(id);
|
|
1006
|
+
watch.hiddenSeeds.delete(id);
|
|
1007
|
+
for (const subscription of watch.subscriptions) {
|
|
1008
|
+
if (subscription.sent.get(id) === null && !subscription.pending.has(id)) {
|
|
1009
|
+
subscription.sent.delete(id);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
/** Records which session each transcript is continued in, by the metadata
|
|
1015
|
+
* the index holds now (a list read it): kept when the index evicts it.
|
|
1016
|
+
* Reads nothing. */
|
|
1017
|
+
learnSuccessors(watch) {
|
|
1018
|
+
for (const file of watch.files.values()) {
|
|
1019
|
+
const successor = this.deps.index.continuedIn(file.filePath);
|
|
1020
|
+
if (successor)
|
|
1021
|
+
watch.successorOf.set(file.sessionId, successor);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* The rows of the live sessions of the scope without a row here (and not
|
|
1026
|
+
* in `except`), from the metadata the index holds (a list read them), not
|
|
1027
|
+
* from their transcripts; one it holds nothing for the client did not
|
|
1028
|
+
* list. Reads no transcript: a continued one only checks its successor.
|
|
1029
|
+
*/
|
|
1030
|
+
async liveSeeds(watch, except) {
|
|
1031
|
+
const read = [];
|
|
1032
|
+
for (const id of this.live.keys()) {
|
|
1033
|
+
if (!watch.rows.has(id) && !except.has(id))
|
|
1034
|
+
read.push(...this.filesOf(watch, id));
|
|
1035
|
+
}
|
|
1036
|
+
if (read.length === 0)
|
|
1037
|
+
return [];
|
|
1038
|
+
const cached = await this.deps.index.cachedRowsOf(watch.paths, read);
|
|
1039
|
+
// A continued one is shown while its successor has no history: its
|
|
1040
|
+
// cached metadata and that check, made again only when the stat of
|
|
1041
|
+
// either changed.
|
|
1042
|
+
const statOf = (file) => file ? `${file.mtimeMs}:${file.size}:${file.ino}` : "-";
|
|
1043
|
+
const continued = [];
|
|
1044
|
+
for (const file of read) {
|
|
1045
|
+
const id = file.sessionId;
|
|
1046
|
+
const successor = this.deps.index.continuedIn(file.filePath);
|
|
1047
|
+
if (!cached.unknown.has(id) || !this.deps.index.isRead(file) || !successor)
|
|
1048
|
+
continue;
|
|
1049
|
+
const successorFile = watch.files.get(path.join(path.dirname(file.filePath), `${successor}.jsonl`));
|
|
1050
|
+
const key = `${statOf(file)}|${statOf(successorFile)}`;
|
|
1051
|
+
if (watch.hiddenSeeds.get(id) !== key)
|
|
1052
|
+
continued.push({ file, key });
|
|
1053
|
+
}
|
|
1054
|
+
if (continued.length === 0)
|
|
1055
|
+
return cached.rows;
|
|
1056
|
+
const rows = await this.deps.index.rowsOf(watch.paths, continued.map(({ file }) => file), () => []);
|
|
1057
|
+
const shown = new Set(rows.map((row) => row.sessionId));
|
|
1058
|
+
for (const { file, key } of continued) {
|
|
1059
|
+
const id = file.sessionId;
|
|
1060
|
+
if (shown.has(id))
|
|
1061
|
+
watch.hiddenSeeds.delete(id);
|
|
1062
|
+
else
|
|
1063
|
+
watch.hiddenSeeds.set(id, key);
|
|
1064
|
+
}
|
|
1065
|
+
return [...cached.rows, ...rows];
|
|
1066
|
+
}
|
|
1067
|
+
/** The transcripts of a session the watch knows. */
|
|
1068
|
+
filesOf(watch, id) {
|
|
1069
|
+
const files = [];
|
|
1070
|
+
for (const filePath of watch.filesById.get(id) ?? []) {
|
|
1071
|
+
const file = watch.files.get(filePath);
|
|
1072
|
+
if (file)
|
|
1073
|
+
files.push(file);
|
|
1074
|
+
}
|
|
1075
|
+
return files;
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Which of `ids`, without a row read here, may have had a row in the scope
|
|
1079
|
+
* before: those that the metadata the index holds for their earlier
|
|
1080
|
+
* transcripts (a list read them) gives a row, and those it gives no answer
|
|
1081
|
+
* for (unknown: a removal is sent once). Not a session without a
|
|
1082
|
+
* transcript before, nor one known to have had no row (another path of
|
|
1083
|
+
* the project directory, a hidden session). Reads no transcript.
|
|
1084
|
+
*/
|
|
1085
|
+
async shownBefore(watch, paths, ids, priorFiles) {
|
|
1086
|
+
const candidates = [];
|
|
1087
|
+
for (const id of ids) {
|
|
1088
|
+
if (!watch.rows.has(id))
|
|
1089
|
+
candidates.push(...(priorFiles.get(id) ?? []));
|
|
1090
|
+
}
|
|
1091
|
+
if (candidates.length === 0)
|
|
1092
|
+
return new Set();
|
|
1093
|
+
const { rows, unknown } = await this.deps.index.cachedRowsOf(paths, candidates);
|
|
1094
|
+
for (const row of rows)
|
|
1095
|
+
unknown.add(row.sessionId);
|
|
1096
|
+
return unknown;
|
|
1097
|
+
}
|
|
1098
|
+
/** Sends the pending sessions of `subscription` that differ from what it
|
|
1099
|
+
* last sent (a session never sent differs), each at most once per
|
|
1100
|
+
* interval, in one notification. */
|
|
1101
|
+
deliver(subscription) {
|
|
1102
|
+
const { watch } = subscription;
|
|
1103
|
+
if (watch.closed)
|
|
1104
|
+
return;
|
|
1105
|
+
if (subscription.timer)
|
|
1106
|
+
clearTimeout(subscription.timer);
|
|
1107
|
+
subscription.timer = undefined;
|
|
1108
|
+
const now = this.now();
|
|
1109
|
+
const sessions = [];
|
|
1110
|
+
const removed = [];
|
|
1111
|
+
let nextAt = Infinity;
|
|
1112
|
+
for (const id of subscription.pending) {
|
|
1113
|
+
const current = watch.rows.get(id);
|
|
1114
|
+
const sent = subscription.sent.get(id);
|
|
1115
|
+
const unchanged = current ? sent === current.signature : sent === null;
|
|
1116
|
+
if (unchanged) {
|
|
1117
|
+
subscription.pending.delete(id);
|
|
1118
|
+
continue;
|
|
1119
|
+
}
|
|
1120
|
+
const dueAt = (subscription.sentAt.get(id) ?? -Infinity) + this.minIntervalMs;
|
|
1121
|
+
if (dueAt > now) {
|
|
1122
|
+
nextAt = Math.min(nextAt, dueAt);
|
|
1123
|
+
continue;
|
|
1124
|
+
}
|
|
1125
|
+
subscription.pending.delete(id);
|
|
1126
|
+
subscription.sentAt.set(id, now);
|
|
1127
|
+
if (!current) {
|
|
1128
|
+
removed.push(id);
|
|
1129
|
+
// A session with a transcript but no row (hidden, or of another
|
|
1130
|
+
// path) is remembered as removed; one without a transcript is
|
|
1131
|
+
// forgotten.
|
|
1132
|
+
if (watch.filesById.has(id)) {
|
|
1133
|
+
subscription.sent.set(id, null);
|
|
1134
|
+
}
|
|
1135
|
+
else {
|
|
1136
|
+
subscription.sent.delete(id);
|
|
1137
|
+
}
|
|
1138
|
+
continue;
|
|
1139
|
+
}
|
|
1140
|
+
sessions.push(current.info);
|
|
1141
|
+
subscription.sent.set(id, current.signature);
|
|
1142
|
+
}
|
|
1143
|
+
this.forgetOldSends(subscription, now);
|
|
1144
|
+
if (nextAt !== Infinity) {
|
|
1145
|
+
subscription.timer = setTimeout(() => this.deliver(subscription), nextAt - now);
|
|
1146
|
+
subscription.timer.unref?.();
|
|
1147
|
+
}
|
|
1148
|
+
if (sessions.length === 0 && removed.length === 0)
|
|
1149
|
+
return;
|
|
1150
|
+
void this.deps
|
|
1151
|
+
.notify({ subscriptionId: subscription.id, sessions, removed })
|
|
1152
|
+
.catch((error) => this.deps.logError("session list change notification failed", error));
|
|
1153
|
+
}
|
|
1154
|
+
/** Drops send times older than the interval: they hold nothing back. */
|
|
1155
|
+
forgetOldSends(subscription, now) {
|
|
1156
|
+
if (subscription.sentAt.size < 1024)
|
|
1157
|
+
return;
|
|
1158
|
+
for (const [id, at] of subscription.sentAt) {
|
|
1159
|
+
if (now - at >= this.minIntervalMs)
|
|
1160
|
+
subscription.sentAt.delete(id);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|