pim-agent 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/bin/pim.ts +1 -1
  2. package/package.json +2 -2
  3. package/packages/core/src/extensions/CoreExtensions.ts +19 -16
  4. package/packages/core/src/extensions/apply-patch/render.ts +16 -55
  5. package/packages/core/src/extensions/system-prompt/index.ts +29 -21
  6. package/packages/core/src/extensions/system-prompt/prompt.ts +5 -1
  7. package/packages/core/src/picker/PickerService.ts +23 -1
  8. package/packages/core/src/picker/token.ts +12 -5
  9. package/packages/core/src/session/EventLog.ts +22 -79
  10. package/packages/core/src/session/SearchIndex.ts +752 -0
  11. package/packages/core/src/session/SearchTokens.ts +97 -0
  12. package/packages/core/src/session/SessionDigest.ts +161 -0
  13. package/packages/core/src/session/SessionHost.ts +17 -1
  14. package/packages/core/src/session/SessionMeta.ts +267 -0
  15. package/packages/core/src/session/SessionName.ts +18 -0
  16. package/packages/core/src/session/SessionRegistry.ts +89 -14
  17. package/packages/core/src/session/WriteMark.ts +15 -1
  18. package/packages/core/src/shared/DiffLines.ts +1 -1
  19. package/packages/core/src/shared/DiffPatch.ts +70 -0
  20. package/packages/core/src/shared/DiffRenderer.ts +1 -1
  21. package/packages/core/src/shared/FileWatch.ts +15 -1
  22. package/packages/core/src/shared/Git.ts +483 -42
  23. package/packages/core/src/shared/GitMonitor.ts +221 -0
  24. package/packages/core/src/shared/Levenshtein.ts +54 -1
  25. package/packages/core/src/shared/Proc.ts +21 -1
  26. package/packages/core/src/shared/RepoDiff.ts +741 -0
  27. package/packages/core/src/shared/Surface.ts +2 -0
  28. package/packages/core/src/shared/Updater.ts +21 -5
  29. package/packages/core/src/view/DiffExpand.ts +214 -0
  30. package/packages/core/src/view/DiffLayout.ts +7 -7
  31. package/packages/core/src/view/DiffPairs.ts +47 -0
  32. package/packages/core/src/view/MovePath.ts +70 -0
  33. package/packages/daemon/src/WebSurface.ts +1 -2
  34. package/packages/protocol/src/Command.ts +129 -2
  35. package/packages/protocol/src/Diff.ts +15 -0
  36. package/packages/protocol/src/ServerEvent.ts +157 -2
  37. package/packages/server/src/ClientConnection.ts +15 -3
  38. package/packages/server/src/ProbeClient.ts +90 -28
  39. package/packages/server/src/SessionCatalogue.ts +292 -38
  40. package/packages/server/src/SessionStream.ts +69 -43
  41. package/packages/server/src/StaticClient.ts +95 -12
  42. package/packages/server/src/WsGateway.ts +264 -29
  43. package/packages/server/src/probe.ts +61 -7
  44. package/packages/telegram/src/Session.ts +1 -0
  45. package/packages/tui/src/extensions/command-picker/index.ts +64 -32
  46. package/packages/tui/src/extensions/footer/index.ts +19 -43
  47. package/packages/tui/src/extensions/session-lease/index.ts +8 -3
  48. package/packages/web/dist/client/assets/commit-mono-latin-300-normal-B-iV2FbL.woff2 +0 -0
  49. package/packages/web/dist/client/assets/commit-mono-latin-300-normal-kM0OTZCv.woff +0 -0
  50. package/packages/web/dist/client/assets/{core-CGdQx9la.js → core-CA0aSzPu.js} +1 -1
  51. package/packages/web/dist/client/assets/index-B9be-i9d.css +1 -0
  52. package/packages/web/dist/client/assets/index-CpinM5NK.js +45 -0
  53. package/packages/web/dist/client/index.html +2 -2
  54. package/packages/protocol/src/Protocol.ts +0 -6
  55. package/packages/web/dist/client/assets/index-BMNwleWO.js +0 -40
  56. package/packages/web/dist/client/assets/index-CqjB2RPr.css +0 -1
@@ -1,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 type { SessionRegistry } from "#core/session/SessionRegistry";
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<readonly SessionSummaryView[]> {
69
- const summaries = await this.deps.registry.list(command.cwd);
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
- await this.deps.cursors.prune(
73
- new Set([
74
- ...summaries.map((summary) => summary.sessionId),
75
- ...this.deps.liveSessionIds(),
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
- // Cut by modified time and digest only the page: ordering by settle time would read every session on disk.
80
- const page = await Promise.all(
81
- summaries
82
- .slice(0, command.limit ?? DEFAULT_SESSION_LIMIT)
83
- .map(async ({ sessionId, cwd, path, createdAt, modifiedAt }) => {
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
- return page.sort((a, b) => b.settledAt - a.settledAt);
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
- return this.deps.cursors.flush();
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?.modifiedAt === modifiedAt) {
196
- return cached;
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 { Git, type GitState } from "#core/shared/Git";
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
- const GIT_TTL_MS = 5_000;
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 git: GitState = Git.EMPTY;
70
- private gitCwd = "";
71
- private gitReadAt = 0;
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
- /** How often the file watch falls back to a poll; the default is a second. */
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 { branch, dirtyCount, ahead, behind } = this.gitState();
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: this.host.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 ? {} : { branch, dirtyCount, ahead, behind }),
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
  }