pim-agent 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/pim.ts +1 -1
- package/package.json +2 -2
- 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/system-prompt/index.ts +29 -21
- package/packages/core/src/extensions/system-prompt/prompt.ts +5 -1
- 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 +17 -1
- 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 +89 -14
- 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/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/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 +129 -2
- package/packages/protocol/src/Diff.ts +15 -0
- package/packages/protocol/src/ServerEvent.ts +157 -2
- package/packages/server/src/ClientConnection.ts +15 -3
- package/packages/server/src/ProbeClient.ts +90 -28
- package/packages/server/src/SessionCatalogue.ts +292 -38
- package/packages/server/src/SessionStream.ts +69 -43
- package/packages/server/src/StaticClient.ts +95 -12
- package/packages/server/src/WsGateway.ts +264 -29
- package/packages/server/src/probe.ts +61 -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/commit-mono-latin-300-normal-B-iV2FbL.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-300-normal-kM0OTZCv.woff +0 -0
- package/packages/web/dist/client/assets/{core-CGdQx9la.js → core-CA0aSzPu.js} +1 -1
- package/packages/web/dist/client/assets/index-B9be-i9d.css +1 -0
- package/packages/web/dist/client/assets/index-CpinM5NK.js +45 -0
- package/packages/web/dist/client/index.html +2 -2
- package/packages/protocol/src/Protocol.ts +0 -6
- package/packages/web/dist/client/assets/index-BMNwleWO.js +0 -40
- package/packages/web/dist/client/assets/index-CqjB2RPr.css +0 -1
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
import { EventLog } from "#core/session/EventLog";
|
|
2
2
|
import type { SessionDigest } from "#core/session/EventLog";
|
|
3
3
|
import type { ReadCursors } from "#core/session/ReadCursors";
|
|
4
|
-
import
|
|
4
|
+
import { SearchIndex } from "#core/session/SearchIndex";
|
|
5
|
+
import type { SearchHit } from "#core/session/SearchIndex";
|
|
6
|
+
import type {
|
|
7
|
+
Pinning,
|
|
8
|
+
SessionEntry,
|
|
9
|
+
SessionMeta,
|
|
10
|
+
} from "#core/session/SessionMeta";
|
|
11
|
+
import type {
|
|
12
|
+
SessionRegistry,
|
|
13
|
+
SessionSummary,
|
|
14
|
+
} from "#core/session/SessionRegistry";
|
|
5
15
|
import { FileWatch } from "#core/shared/FileWatch";
|
|
16
|
+
import { Pool } from "#core/shared/Pool";
|
|
6
17
|
import type { Command } from "#protocol/Command";
|
|
7
18
|
import type {
|
|
19
|
+
ProjectView,
|
|
20
|
+
SearchHitView,
|
|
8
21
|
ServerEvent,
|
|
22
|
+
SessionListing,
|
|
23
|
+
SessionSearch,
|
|
9
24
|
SessionStatus,
|
|
10
25
|
SessionSummaryView,
|
|
11
26
|
} from "#protocol/ServerEvent";
|
|
@@ -14,6 +29,8 @@ export type SessionCatalogueDeps = {
|
|
|
14
29
|
readonly registry: SessionRegistry;
|
|
15
30
|
/** Which sessions have been read, shared by every client. */
|
|
16
31
|
readonly cursors: ReadCursors;
|
|
32
|
+
/** Archived, held-unread and pinned, shared by every client. */
|
|
33
|
+
readonly meta: SessionMeta;
|
|
17
34
|
readonly liveStatus: (sessionId: string) => SessionStatus | undefined;
|
|
18
35
|
readonly liveSessionIds: () => Iterable<string>;
|
|
19
36
|
readonly isBeingRead: (sessionId: string) => boolean;
|
|
@@ -22,22 +39,37 @@ export type SessionCatalogueDeps = {
|
|
|
22
39
|
|
|
23
40
|
const DEFAULT_SESSION_LIMIT = 50;
|
|
24
41
|
|
|
42
|
+
/** A digest is a whole-file read; a page of them at once is a page of files in memory at once. */
|
|
43
|
+
const DIGEST_READS = 16;
|
|
44
|
+
|
|
25
45
|
/** Long enough that a turn's entries are one announcement, short enough that a new session appears while the user is still looking. */
|
|
26
46
|
const ANNOUNCE_MS = 500;
|
|
27
47
|
|
|
28
48
|
type CachedDigest = SessionDigest & { readonly modifiedAt: number };
|
|
29
49
|
|
|
50
|
+
type Overrides = ReadonlyMap<string, SessionEntry>;
|
|
51
|
+
|
|
52
|
+
type PageScope = {
|
|
53
|
+
readonly overrides: Overrides;
|
|
54
|
+
/** True while listing the archived sessions rather than the live ones. */
|
|
55
|
+
readonly archived: boolean;
|
|
56
|
+
readonly limit: number;
|
|
57
|
+
readonly perProject: number;
|
|
58
|
+
};
|
|
59
|
+
|
|
30
60
|
export class SessionCatalogue {
|
|
31
61
|
private readonly deps: SessionCatalogueDeps;
|
|
32
62
|
private readonly digests = new Map<string, CachedDigest>();
|
|
33
63
|
private readonly activity = new Map<string, SessionStatus>();
|
|
34
64
|
private readonly settled = new Map<string, number>();
|
|
35
65
|
private readonly watches = new Map<string, () => void>();
|
|
66
|
+
private index: SearchIndex;
|
|
36
67
|
private watching = false;
|
|
37
68
|
private announcing: ReturnType<typeof setTimeout> | undefined;
|
|
38
69
|
|
|
39
70
|
public constructor(deps: SessionCatalogueDeps) {
|
|
40
71
|
this.deps = deps;
|
|
72
|
+
this.index = this.newIndex();
|
|
41
73
|
}
|
|
42
74
|
|
|
43
75
|
/**
|
|
@@ -65,41 +97,69 @@ export class SessionCatalogue {
|
|
|
65
97
|
|
|
66
98
|
public async list(
|
|
67
99
|
command: Command & { readonly type: "list_sessions" }
|
|
68
|
-
): Promise<
|
|
69
|
-
const summaries = await
|
|
100
|
+
): Promise<SessionListing> {
|
|
101
|
+
const [summaries, overrides, pinning] = await Promise.all([
|
|
102
|
+
this.deps.registry.list(command.cwd),
|
|
103
|
+
this.deps.meta.sessions(),
|
|
104
|
+
this.deps.meta.pinning(),
|
|
105
|
+
]);
|
|
70
106
|
// Prune only on an unfiltered listing: a cwd-filtered one would forget every other directory.
|
|
71
107
|
if (command.cwd === undefined) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
108
|
+
const alive = new Set([
|
|
109
|
+
...summaries.map((summary) => summary.sessionId),
|
|
110
|
+
...this.deps.liveSessionIds(),
|
|
111
|
+
]);
|
|
112
|
+
await Promise.all([
|
|
113
|
+
this.deps.cursors.prune(alive),
|
|
114
|
+
this.deps.meta.prune(alive),
|
|
115
|
+
]);
|
|
78
116
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
.
|
|
84
|
-
const { title, settledAt } = await this.digestOf(path, modifiedAt);
|
|
85
|
-
const status = this.deps.liveStatus(sessionId);
|
|
86
|
-
const answeredAt = this.answerTime(sessionId, status, settledAt);
|
|
87
|
-
const unread = await this.deps.cursors.isUnread(
|
|
88
|
-
sessionId,
|
|
89
|
-
answeredAt
|
|
90
|
-
);
|
|
91
|
-
return {
|
|
92
|
-
sessionId,
|
|
93
|
-
cwd,
|
|
94
|
-
createdAt,
|
|
95
|
-
settledAt: answeredAt ?? settledAt ?? createdAt,
|
|
96
|
-
...(title === undefined ? {} : { title }),
|
|
97
|
-
...(status === undefined || status === "idle" ? {} : { status }),
|
|
98
|
-
...(unread ? { unread: true } : {}),
|
|
99
|
-
};
|
|
100
|
-
})
|
|
117
|
+
const scope = command.archived === true;
|
|
118
|
+
// Before the cut: an archived row that ate the page budget would push a live one off the end.
|
|
119
|
+
const inScope = summaries.filter(
|
|
120
|
+
(summary) =>
|
|
121
|
+
(overrides.get(summary.sessionId)?.archived === true) === scope
|
|
101
122
|
);
|
|
102
|
-
|
|
123
|
+
const limit = command.limit ?? DEFAULT_SESSION_LIMIT;
|
|
124
|
+
return {
|
|
125
|
+
sessions: await this.page(inScope, {
|
|
126
|
+
overrides,
|
|
127
|
+
archived: scope,
|
|
128
|
+
limit,
|
|
129
|
+
// Absent, a project may fill the page; it is the page that bounds it either way.
|
|
130
|
+
perProject: command.perProject ?? limit,
|
|
131
|
+
}),
|
|
132
|
+
// Counted before the cut, and over the whole scope: a collapsed group says how many it holds, and a row the cut dropped is one a client can still ask for.
|
|
133
|
+
projects: projectsOf(inScope, pinning),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The index cannot see the sidecar, so the archived scope is a predicate it
|
|
139
|
+
* calls before its own limit, and the badge is a `Map.get` over the one read
|
|
140
|
+
* of `sessions.json` this query makes.
|
|
141
|
+
*/
|
|
142
|
+
public async search(
|
|
143
|
+
command: Command & { readonly type: "search_sessions" }
|
|
144
|
+
): Promise<SessionSearch> {
|
|
145
|
+
const overrides = await this.deps.meta.sessions();
|
|
146
|
+
const archivedOf = (sessionId: string): boolean =>
|
|
147
|
+
overrides.get(sessionId)?.archived === true;
|
|
148
|
+
const { hits, dropped, scanned } = await this.index.search(command.query, {
|
|
149
|
+
...(command.limit === undefined ? {} : { limit: command.limit }),
|
|
150
|
+
...(command.cwd === undefined ? {} : { cwd: command.cwd }),
|
|
151
|
+
...(command.archived === undefined
|
|
152
|
+
? {}
|
|
153
|
+
: {
|
|
154
|
+
accept: (sessionId: string) =>
|
|
155
|
+
archivedOf(sessionId) === command.archived,
|
|
156
|
+
}),
|
|
157
|
+
});
|
|
158
|
+
return {
|
|
159
|
+
hits: hits.map((hit) => hitOf(hit, archivedOf(hit.sessionId))),
|
|
160
|
+
dropped,
|
|
161
|
+
scanned,
|
|
162
|
+
};
|
|
103
163
|
}
|
|
104
164
|
|
|
105
165
|
/** Announces a status edge to every client; the announcement must precede the read mark. */
|
|
@@ -118,20 +178,99 @@ export class SessionCatalogue {
|
|
|
118
178
|
this.activity.set(sessionId, status);
|
|
119
179
|
}
|
|
120
180
|
|
|
121
|
-
/** Moves a session's read cursor to now and says so to every client. */
|
|
181
|
+
/** Moves a session's read cursor to now, drops the mark a reader left on it by hand, and says so to every client. */
|
|
122
182
|
public async markRead(sessionId: string): Promise<void> {
|
|
123
183
|
await this.deps.cursors.mark(sessionId);
|
|
184
|
+
// The only place the sticky flag is cleared; a listing must never clear it.
|
|
185
|
+
if ((await this.deps.meta.of(sessionId)).unread === true) {
|
|
186
|
+
await this.deps.meta.setUnread(sessionId, false);
|
|
187
|
+
}
|
|
124
188
|
this.deps.announce({ type: "session_read", sessionId });
|
|
125
189
|
}
|
|
126
190
|
|
|
127
|
-
public flush(): Promise<void> {
|
|
128
|
-
|
|
191
|
+
public async flush(): Promise<void> {
|
|
192
|
+
await Promise.all([this.deps.cursors.flush(), this.deps.meta.flush()]);
|
|
129
193
|
}
|
|
130
194
|
|
|
131
195
|
public clear(): void {
|
|
132
196
|
this.digests.clear();
|
|
133
197
|
this.activity.clear();
|
|
134
198
|
this.settled.clear();
|
|
199
|
+
this.index = this.newIndex();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** One index per catalogue, and so one per session root; the registry's listing is the tree it follows. */
|
|
203
|
+
private newIndex(): SearchIndex {
|
|
204
|
+
return new SearchIndex({ list: () => this.deps.registry.list() });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Selects by modified time under a per-project budget and digests only what
|
|
209
|
+
* it selects: ordering by settle time would read every session on disk. A
|
|
210
|
+
* session with nothing to call itself is not a row, and the budget it spent
|
|
211
|
+
* goes back to its project, so a directory of abandoned files still shows
|
|
212
|
+
* the sessions underneath them.
|
|
213
|
+
*/
|
|
214
|
+
private async page(
|
|
215
|
+
candidates: readonly SessionSummary[],
|
|
216
|
+
scope: PageScope
|
|
217
|
+
): Promise<readonly SessionSummaryView[]> {
|
|
218
|
+
const rows: SessionSummaryView[] = [];
|
|
219
|
+
const taken = new Map<string, number>();
|
|
220
|
+
const consumed = new Set<SessionSummary>();
|
|
221
|
+
for (;;) {
|
|
222
|
+
const batch = choose(candidates, consumed, taken, {
|
|
223
|
+
room: scope.limit - rows.length,
|
|
224
|
+
perProject: scope.perProject,
|
|
225
|
+
});
|
|
226
|
+
if (batch.length === 0) {
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
const built = await Pool.mapPooled(batch, DIGEST_READS, (summary) =>
|
|
230
|
+
this.row(summary, scope)
|
|
231
|
+
);
|
|
232
|
+
for (const [index, row] of built.entries()) {
|
|
233
|
+
if (row === undefined) {
|
|
234
|
+
const { cwd } = batch[index]!;
|
|
235
|
+
taken.set(cwd, (taken.get(cwd) ?? 1) - 1);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
rows.push(row);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return rows.sort((a, b) => b.settledAt - a.settledAt);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Nothing for a session that never asked anything and was never named: the row it would draw is a truncated UUID. */
|
|
245
|
+
private async row(
|
|
246
|
+
{ sessionId, cwd, path, createdAt, modifiedAt }: SessionSummary,
|
|
247
|
+
{ overrides, archived }: PageScope
|
|
248
|
+
): Promise<SessionSummaryView | undefined> {
|
|
249
|
+
const status = this.deps.liveStatus(sessionId);
|
|
250
|
+
const { title, named, settledAt } = await this.digestOf(
|
|
251
|
+
sessionId,
|
|
252
|
+
path,
|
|
253
|
+
modifiedAt,
|
|
254
|
+
status
|
|
255
|
+
);
|
|
256
|
+
if (title === undefined) {
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
const answeredAt = this.answerTime(sessionId, status, settledAt);
|
|
260
|
+
const unread =
|
|
261
|
+
overrides.get(sessionId)?.unread === true ||
|
|
262
|
+
(await this.deps.cursors.isUnread(sessionId, answeredAt));
|
|
263
|
+
return {
|
|
264
|
+
sessionId,
|
|
265
|
+
cwd,
|
|
266
|
+
createdAt,
|
|
267
|
+
settledAt: answeredAt ?? settledAt ?? createdAt,
|
|
268
|
+
title,
|
|
269
|
+
...(named === true ? { named } : {}),
|
|
270
|
+
...(archived ? { archived: true as const } : {}),
|
|
271
|
+
...(status === undefined || status === "idle" ? {} : { status }),
|
|
272
|
+
...(unread ? { unread: true } : {}),
|
|
273
|
+
};
|
|
135
274
|
}
|
|
136
275
|
|
|
137
276
|
// One watch per directory, never a recursive one: `fs.watch` recursion is unsupported on some platforms and silent on others.
|
|
@@ -188,15 +327,130 @@ export class SessionCatalogue {
|
|
|
188
327
|
}
|
|
189
328
|
|
|
190
329
|
private async digestOf(
|
|
330
|
+
sessionId: string,
|
|
191
331
|
path: string,
|
|
192
|
-
modifiedAt: number
|
|
332
|
+
modifiedAt: number,
|
|
333
|
+
status: SessionStatus | undefined
|
|
193
334
|
): Promise<SessionDigest> {
|
|
335
|
+
const name = this.liveName(sessionId);
|
|
336
|
+
const digest =
|
|
337
|
+
this.reusable(path, modifiedAt, status, name) ??
|
|
338
|
+
(await this.readDigest(path, modifiedAt));
|
|
339
|
+
return renamed(digest, name);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Mid-turn the file is appended to between listings, so its digest never hits
|
|
344
|
+
* the cache and the whole-file read lands on the busiest session there is.
|
|
345
|
+
* Nothing it answers can have moved: the opening message was written long
|
|
346
|
+
* ago, the settle time is deliberately not read from the file while a turn
|
|
347
|
+
* runs, and the name is the live session's own to say.
|
|
348
|
+
*/
|
|
349
|
+
private reusable(
|
|
350
|
+
path: string,
|
|
351
|
+
modifiedAt: number,
|
|
352
|
+
status: SessionStatus | undefined,
|
|
353
|
+
name: string | null | undefined
|
|
354
|
+
): CachedDigest | undefined {
|
|
194
355
|
const cached = this.digests.get(path);
|
|
195
|
-
if (cached
|
|
196
|
-
return
|
|
356
|
+
if (cached === undefined) {
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
// Except a name the live session has since cleared: the opening message a
|
|
360
|
+
// nameless row falls back to is in the file and nowhere else.
|
|
361
|
+
if (name === null && cached.named === true) {
|
|
362
|
+
return undefined;
|
|
197
363
|
}
|
|
364
|
+
const working = status !== undefined && status !== "idle";
|
|
365
|
+
return cached.modifiedAt === modifiedAt || working ? cached : undefined;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private async readDigest(
|
|
369
|
+
path: string,
|
|
370
|
+
modifiedAt: number
|
|
371
|
+
): Promise<SessionDigest> {
|
|
198
372
|
const digest = await new EventLog(path).digest();
|
|
199
373
|
this.digests.set(path, { ...digest, modifiedAt });
|
|
200
374
|
return digest;
|
|
201
375
|
}
|
|
376
|
+
|
|
377
|
+
/** What a live session calls itself, without reading its file; `null` is live and unnamed. */
|
|
378
|
+
private liveName(sessionId: string): string | null | undefined {
|
|
379
|
+
const agent = this.deps.registry.peek(sessionId)?.agentSession;
|
|
380
|
+
return agent === undefined
|
|
381
|
+
? undefined
|
|
382
|
+
: (agent.sessionManager.getSessionName() ?? null);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** The row the wire carries: the index's hit, plus the one thing about it only the sidecar knows. */
|
|
387
|
+
function hitOf(hit: SearchHit, archived: boolean): SearchHitView {
|
|
388
|
+
return {
|
|
389
|
+
sessionId: hit.sessionId,
|
|
390
|
+
cwd: hit.cwd,
|
|
391
|
+
...(hit.title === undefined ? {} : { title: hit.title }),
|
|
392
|
+
titleRanges: hit.titleRanges,
|
|
393
|
+
...(hit.opening === undefined ? {} : { opening: hit.opening }),
|
|
394
|
+
settledAt: hit.settledAt,
|
|
395
|
+
...(archived ? { archived: true as const } : {}),
|
|
396
|
+
snippets: hit.snippets,
|
|
397
|
+
total: hit.total,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** A live session's own name wins over the file's: a rename lands on the row before the digest behind it expires. */
|
|
402
|
+
function renamed(
|
|
403
|
+
digest: SessionDigest,
|
|
404
|
+
name: string | null | undefined
|
|
405
|
+
): SessionDigest {
|
|
406
|
+
return typeof name === "string"
|
|
407
|
+
? { ...digest, title: name, named: true }
|
|
408
|
+
: digest;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** The next sessions to digest, newest first, skipping the projects that have spent their budget and the candidates an earlier round already took. */
|
|
412
|
+
function choose(
|
|
413
|
+
candidates: readonly SessionSummary[],
|
|
414
|
+
consumed: Set<SessionSummary>,
|
|
415
|
+
taken: Map<string, number>,
|
|
416
|
+
budget: { readonly room: number; readonly perProject: number }
|
|
417
|
+
): readonly SessionSummary[] {
|
|
418
|
+
const batch: SessionSummary[] = [];
|
|
419
|
+
for (const summary of candidates) {
|
|
420
|
+
if (batch.length >= budget.room) {
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
const used = taken.get(summary.cwd) ?? 0;
|
|
424
|
+
if (consumed.has(summary) || used >= budget.perProject) {
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
taken.set(summary.cwd, used + 1);
|
|
428
|
+
consumed.add(summary);
|
|
429
|
+
batch.push(summary);
|
|
430
|
+
}
|
|
431
|
+
return batch;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Every working directory the scope holds sessions in, newest first; the count is the header scan's, so it owes nothing to the page. */
|
|
435
|
+
function projectsOf(
|
|
436
|
+
summaries: readonly SessionSummary[],
|
|
437
|
+
{ projects, order }: Pinning
|
|
438
|
+
): readonly ProjectView[] {
|
|
439
|
+
const counts = new Map<string, number>();
|
|
440
|
+
for (const { cwd } of summaries) {
|
|
441
|
+
counts.set(cwd, (counts.get(cwd) ?? 0) + 1);
|
|
442
|
+
}
|
|
443
|
+
const ranks = new Map(order.map((cwd, rank) => [cwd, rank]));
|
|
444
|
+
return [...counts].map(([cwd, count]) => {
|
|
445
|
+
const entry = projects.get(cwd);
|
|
446
|
+
return {
|
|
447
|
+
cwd,
|
|
448
|
+
count,
|
|
449
|
+
...(entry?.pinned === true
|
|
450
|
+
? { pinned: true as const, pinRank: ranks.get(cwd) ?? 0 }
|
|
451
|
+
: {}),
|
|
452
|
+
...(entry?.expanded === true ? { expanded: true as const } : {}),
|
|
453
|
+
...(entry?.label === undefined ? {} : { label: entry.label }),
|
|
454
|
+
};
|
|
455
|
+
});
|
|
202
456
|
}
|
|
@@ -5,7 +5,7 @@ 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
7
|
import { FileWatch } from "#core/shared/FileWatch";
|
|
8
|
-
import {
|
|
8
|
+
import { GitMonitor } from "#core/shared/GitMonitor";
|
|
9
9
|
import { Tools } from "#core/shared/Tools";
|
|
10
10
|
import type { ToolView } from "#core/view/ViewBlock";
|
|
11
11
|
import type {
|
|
@@ -35,7 +35,18 @@ type LiveMessage = {
|
|
|
35
35
|
readonly tools: LiveTool[];
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
export type SessionStreamDeps = {
|
|
39
|
+
/** How often the file watch falls back to a poll; the default is a second. */
|
|
40
|
+
readonly pollMs?: number;
|
|
41
|
+
/** Shared with every other session in the same directory; one is made here when none is given. */
|
|
42
|
+
readonly git?: GitMonitor;
|
|
43
|
+
/**
|
|
44
|
+
* Whether a session in this stream's directory is mid-turn, this one
|
|
45
|
+
* included. Only the gateway can see the others, so a stream standing alone
|
|
46
|
+
* reports nothing rather than guessing from itself.
|
|
47
|
+
*/
|
|
48
|
+
readonly repoBusy?: () => boolean;
|
|
49
|
+
};
|
|
39
50
|
|
|
40
51
|
function sameLease(a: LeaseState, b: LeaseState): boolean {
|
|
41
52
|
return (
|
|
@@ -54,6 +65,8 @@ export class SessionStream {
|
|
|
54
65
|
private readonly projection: SessionProjection;
|
|
55
66
|
private readonly listeners = new Set<StreamListener>();
|
|
56
67
|
private readonly pollMs: number | undefined;
|
|
68
|
+
private readonly git: GitMonitor;
|
|
69
|
+
private readonly repoBusy: (() => boolean) | undefined;
|
|
57
70
|
private liveTurn: LiveMessage[] = [];
|
|
58
71
|
private unsubscribe: (() => void) | undefined;
|
|
59
72
|
private unsubscribeLease: (() => void) | undefined;
|
|
@@ -66,22 +79,22 @@ export class SessionStream {
|
|
|
66
79
|
private sentSeq = 0;
|
|
67
80
|
private liveMessageId = 0;
|
|
68
81
|
private turnStartedAt = 0;
|
|
69
|
-
private
|
|
70
|
-
private
|
|
71
|
-
private
|
|
72
|
-
private gitInFlight = false;
|
|
82
|
+
private gitCwd: string | undefined;
|
|
83
|
+
private gitStop: (() => void) | undefined;
|
|
84
|
+
private gitBranch: string | null = null;
|
|
73
85
|
|
|
74
86
|
public constructor(
|
|
75
87
|
sessionId: string,
|
|
76
88
|
host: SessionHost,
|
|
77
89
|
sessionPath: string,
|
|
78
|
-
|
|
79
|
-
pollMs?: number
|
|
90
|
+
deps: SessionStreamDeps = {}
|
|
80
91
|
) {
|
|
81
92
|
this.sessionId = sessionId;
|
|
82
93
|
this.host = host;
|
|
83
94
|
this.sessionPath = sessionPath;
|
|
84
|
-
this.pollMs = pollMs;
|
|
95
|
+
this.pollMs = deps.pollMs;
|
|
96
|
+
this.git = deps.git ?? new GitMonitor();
|
|
97
|
+
this.repoBusy = deps.repoBusy;
|
|
85
98
|
this.projection = new SessionProjection(sessionPath, () => host.cwd);
|
|
86
99
|
this.picker = new PickerService({
|
|
87
100
|
cwd: () => host.cwd,
|
|
@@ -120,6 +133,7 @@ export class SessionStream {
|
|
|
120
133
|
if (!active) {
|
|
121
134
|
this.unwatch?.();
|
|
122
135
|
this.unwatch = undefined;
|
|
136
|
+
this.syncGit();
|
|
123
137
|
return;
|
|
124
138
|
}
|
|
125
139
|
const stops = [
|
|
@@ -139,6 +153,37 @@ export class SessionStream {
|
|
|
139
153
|
stop();
|
|
140
154
|
}
|
|
141
155
|
};
|
|
156
|
+
this.syncGit();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Re-points the git watch after the session moves; the monitor is shared, so a repeat is free. */
|
|
160
|
+
public syncGit(): void {
|
|
161
|
+
const wanted = this.unwatch === undefined ? undefined : this.host.cwd;
|
|
162
|
+
if (wanted === this.gitCwd) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
this.gitStop?.();
|
|
166
|
+
this.gitStop = undefined;
|
|
167
|
+
this.gitCwd = wanted;
|
|
168
|
+
if (wanted === undefined) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
this.gitStop = this.git.watch(wanted, (state) => {
|
|
172
|
+
// Whoever moved it — this client, the terminal, another window — every
|
|
173
|
+
// path the pickers hold is from the branch that just left. The first
|
|
174
|
+
// reading is a discovery rather than a move, and drops nothing.
|
|
175
|
+
const moved = this.gitBranch !== null && state.branch !== this.gitBranch;
|
|
176
|
+
this.gitBranch = state.branch;
|
|
177
|
+
if (moved) {
|
|
178
|
+
this.invalidatePickers("files");
|
|
179
|
+
}
|
|
180
|
+
this.emit(this.sessionState());
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Reads the repository again for a client that has reason to think its picture is old. */
|
|
185
|
+
public async refreshGit(fetch: boolean): Promise<void> {
|
|
186
|
+
await this.git.refresh(this.host.cwd, { fetch });
|
|
142
187
|
}
|
|
143
188
|
|
|
144
189
|
/** Project whatever pi has appended since the last read, telling every client; returns the head. */
|
|
@@ -154,6 +199,11 @@ export class SessionStream {
|
|
|
154
199
|
};
|
|
155
200
|
}
|
|
156
201
|
|
|
202
|
+
/** Whether `callId` names a tool this session is still running, log written or not. */
|
|
203
|
+
public isRunning(callId: string): boolean {
|
|
204
|
+
return this.findTool(callId)?.done === false;
|
|
205
|
+
}
|
|
206
|
+
|
|
157
207
|
/** Everything a client at `fromSeq` has not seen: the durable tail, the in-flight turn coalesced, then state. */
|
|
158
208
|
public async replay(fromSeq: number): Promise<readonly StreamEvent[]> {
|
|
159
209
|
await Promise.all([this.flushDurable(), this.readLease()]);
|
|
@@ -211,7 +261,9 @@ export class SessionStream {
|
|
|
211
261
|
public sessionState(): EphemeralEvent {
|
|
212
262
|
const tps = this.host.tps;
|
|
213
263
|
const usage = this.host.usage();
|
|
214
|
-
const
|
|
264
|
+
const cwd = this.host.cwd;
|
|
265
|
+
const { branch, dirtyCount, ahead, behind, revision } =
|
|
266
|
+
this.git.stateOf(cwd);
|
|
215
267
|
const modelLabel = this.host.currentModelLabel;
|
|
216
268
|
const turnElapsedMs =
|
|
217
269
|
this.host.status === "idle" || this.turnStartedAt === 0
|
|
@@ -219,13 +271,14 @@ export class SessionStream {
|
|
|
219
271
|
: Date.now() - this.turnStartedAt;
|
|
220
272
|
return {
|
|
221
273
|
type: "session_state",
|
|
222
|
-
cwd
|
|
274
|
+
cwd,
|
|
223
275
|
model: this.host.currentModelId ?? "",
|
|
224
276
|
...(modelLabel === undefined ? {} : { modelLabel }),
|
|
225
277
|
thinking: this.host.currentThinkingLevel,
|
|
226
278
|
cost: this.host.settings.cumulativeCost ?? 0,
|
|
227
279
|
status: this.host.status,
|
|
228
280
|
...this.leaseState(),
|
|
281
|
+
...(this.repoBusy?.() === true ? { repoBusy: true } : {}),
|
|
229
282
|
...(tps === undefined ? {} : { tps }),
|
|
230
283
|
...(turnElapsedMs === undefined ? {} : { turnElapsedMs }),
|
|
231
284
|
...(usage?.percent === null || usage === undefined
|
|
@@ -234,7 +287,9 @@ export class SessionStream {
|
|
|
234
287
|
contextPercent: usage.percent,
|
|
235
288
|
contextWindow: usage.contextWindow,
|
|
236
289
|
}),
|
|
237
|
-
...(branch === null
|
|
290
|
+
...(branch === null
|
|
291
|
+
? {}
|
|
292
|
+
: { branch, dirtyCount, ahead, behind, repoRevision: revision }),
|
|
238
293
|
};
|
|
239
294
|
}
|
|
240
295
|
|
|
@@ -283,37 +338,6 @@ export class SessionStream {
|
|
|
283
338
|
}
|
|
284
339
|
}
|
|
285
340
|
|
|
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
341
|
public dispose(): void {
|
|
318
342
|
this.unsubscribe?.();
|
|
319
343
|
this.unsubscribe = undefined;
|
|
@@ -413,6 +437,8 @@ export class SessionStream {
|
|
|
413
437
|
});
|
|
414
438
|
if (Tools.effectOf(event.toolName)?.kind !== "readOnly") {
|
|
415
439
|
this.invalidatePickers("files");
|
|
440
|
+
// The tool just wrote the worktree, which `.git` never reports.
|
|
441
|
+
void this.git.refresh(this.host.cwd);
|
|
416
442
|
}
|
|
417
443
|
return;
|
|
418
444
|
}
|