pi-squire 0.20.0 → 0.20.2

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/src/index.ts CHANGED
@@ -758,10 +758,11 @@ export default function delegate(
758
758
  pi,
759
759
  results,
760
760
  tmux,
761
- async (ctx, sessionId) => {
761
+ async (ctx, id) => {
762
762
  owner.assert();
763
- await navigator.panel(ctx, sessionId);
763
+ await navigator.openDelegate(ctx, id, "switch");
764
764
  },
765
+ (ctx, id) => navigator.closeDelegate(ctx, id),
765
766
  () => owner.assert(),
766
767
  );
767
768
 
@@ -1449,7 +1450,7 @@ export default function delegate(
1449
1450
  }
1450
1451
  }
1451
1452
 
1452
- /** What the new-delegate form needs, shared by `/delegate` and the panel's `n`. */
1453
+ /** What the no-argument `/delegate` form needs. */
1453
1454
  const newDelegateDeps: NewDelegateDeps = {
1454
1455
  unavailable: () => (tmux.insideTmux() ? undefined : NOT_IN_TMUX),
1455
1456
  roster: rosterFor,
@@ -1703,26 +1704,9 @@ export default function delegate(
1703
1704
  handler: resumeCommand,
1704
1705
  });
1705
1706
 
1706
- const navigator = createNavigator(
1707
- viewResults,
1708
- tmux,
1709
- tracking,
1710
- (ctx: ExtensionContext) => startNewDelegate(ctx, newDelegateDeps),
1711
- {
1712
- assertActive: () => owner.assert(),
1713
- close: (id, ctx) => tracking.close(id, ctx),
1714
- resume: (id, ctx) => resumePicker(ctx, id),
1715
- },
1716
- );
1717
-
1718
- pi.registerCommand("delegates", {
1719
- description: "Open the shared delegate dashboard: details, switch, peek, close, resume",
1720
- handler: async (_args: string, ctx: ExtensionContext) => navigator.panel(ctx),
1721
- });
1722
-
1723
- pi.registerShortcut("alt+shift+d", {
1724
- description: "Open the delegates panel",
1725
- handler: (ctx: ExtensionContext) => navigator.panel(ctx),
1707
+ const navigator = createNavigator(viewResults, tmux, tracking, {
1708
+ assertActive: () => owner.assert(),
1709
+ close: (id, ctx) => tracking.close(id, ctx),
1726
1710
  });
1727
1711
 
1728
1712
  pi.registerCommand("delegate-open", {
@@ -1,10 +1,8 @@
1
- // Read-only dashboard/navigation projection. Never supplies locators to close or task accounting.
2
- import type { DashboardRow } from "./dashboard.ts";
1
+ // Read-only navigation projection. Never supplies locators to close or task accounting.
3
2
  import {
4
3
  type DelegationState,
5
4
  deriveState,
6
5
  isStarted,
7
- locateSession,
8
6
  type Results,
9
7
  recordedName,
10
8
  type StartedRecorded,
@@ -13,42 +11,18 @@ import {
13
11
  import type { TmuxClient, TmuxWindow } from "./tmux.ts";
14
12
  import { verifyLiveWindow } from "./widget-ancestors.ts";
15
13
  import { LiveActivityReader, type LiveLocator } from "./widget-live.ts";
16
- import { WidgetTitleMirrors } from "./widget-title.ts";
17
- import { WidgetTitleFiles } from "./widget-title-file.ts";
18
14
  import { listWindows, ownWindow } from "./windows.ts";
19
15
 
20
16
  export type OpenDelegate = {
21
17
  id: string;
22
18
  name: string;
23
- title?: string;
24
- agent: string;
25
19
  state: DelegationState;
26
20
  window: TmuxWindow;
27
21
  navigable: boolean;
28
22
  live?: LiveLocator;
29
23
  record: StartedRecorded;
30
- resumedAs?: string;
31
24
  };
32
25
 
33
- /** Membership uses scoped records' session edges, never names, placement or a new tree index. */
34
- export function branchItems(items: OpenDelegate[], target: string | undefined): OpenDelegate[] {
35
- if (target === undefined) return items;
36
- const sessions: Set<string> = new Set([target]);
37
- let changed = true;
38
- while (changed) {
39
- changed = false;
40
- for (const item of items) {
41
- const parent: string | undefined = item.record.identity?.parent;
42
- const session: string = sessionIdOf(item.record);
43
- if (parent && sessions.has(parent) && !sessions.has(session)) {
44
- sessions.add(session);
45
- changed = true;
46
- }
47
- }
48
- }
49
- return items.filter((item) => sessions.has(sessionIdOf(item.record)));
50
- }
51
-
52
26
  function openItem(item: OpenDelegate): boolean {
53
27
  if (!item.navigable || item.record.closed || item.record.accountingOnly) return false;
54
28
  return (
@@ -67,8 +41,6 @@ export class NavigatorView {
67
41
  private readonly results: Results;
68
42
  private readonly tmux: TmuxClient;
69
43
  private readonly reader: Pick<LiveActivityReader, "locator">;
70
- private readonly titles: WidgetTitleFiles = new WidgetTitleFiles();
71
- private readonly mirrors: WidgetTitleMirrors = new WidgetTitleMirrors();
72
44
  // Once positively observed, a disappearing/replaced current runtime must not fall back to its old window.
73
45
  private readonly current: Set<string> = new Set();
74
46
 
@@ -82,19 +54,16 @@ export class NavigatorView {
82
54
  this.reader = reader;
83
55
  }
84
56
 
85
- async items(history = false): Promise<OpenDelegate[] | undefined> {
57
+ async items(): Promise<OpenDelegate[] | undefined> {
86
58
  const windows: TmuxWindow[] | undefined = await listWindows(this.tmux);
87
- if (!windows && !history) return undefined;
59
+ if (!windows) return undefined;
88
60
  const all: StartedRecorded[] = this.results.list().filter(isStarted);
89
61
  const latest: Map<string, string> = new Map(all.map((record) => [sessionIdOf(record), record.id]));
90
62
  const items: OpenDelegate[] = await Promise.all(
91
63
  all.map(async (record): Promise<OpenDelegate> => {
92
- const item: OpenDelegate = await this.item(record, latest.get(sessionIdOf(record)) === record.id, windows);
93
- item.resumedAs = all.find((next) => next.resumedFrom === record.id)?.id;
94
- return item;
64
+ return this.item(record, latest.get(sessionIdOf(record)) === record.id, windows);
95
65
  }),
96
66
  );
97
- if (history) return items;
98
67
  return items.filter(openItem).reverse();
99
68
  }
100
69
 
@@ -110,16 +79,11 @@ export class NavigatorView {
110
79
  const window: TmuxWindow | undefined = this.current.has(record.id)
111
80
  ? await this.liveWindow(live, windows)
112
81
  : historical;
113
- const path: string | undefined = live?.sessionFile ?? locateSession({ ...record }, undefined);
114
- const title: string | undefined =
115
- this.titles.read(path, sessionIdOf(record))?.name ?? this.mirrors.read(sessionIdOf(record));
116
82
  let state: import("./results.ts").WindowState = "unknown";
117
83
  if (windows) state = historical && !historical.exited ? "open" : "gone";
118
84
  return {
119
85
  id: record.id,
120
86
  name: recordedName(record),
121
- title,
122
- agent: record.displayName ?? record.agent,
123
87
  state: deriveState(record, state),
124
88
  record,
125
89
  live,
@@ -132,9 +96,11 @@ export class NavigatorView {
132
96
  live: LiveLocator | undefined,
133
97
  windows: TmuxWindow[] | undefined,
134
98
  ): Promise<TmuxWindow | undefined> {
135
- if (!live || !windows || !(await verifyLiveWindow(this.tmux, live).catch(() => false))) return undefined;
99
+ if (!live || !windows) return undefined;
100
+ const pane = await verifyLiveWindow(this.tmux, live).catch(() => undefined);
101
+ if (!pane) return undefined;
136
102
  const found: TmuxWindow | undefined = windows.find(
137
- (entry) => entry.id === live.locator.windowId && entry.name === live.locator.windowName,
103
+ (entry) => entry.id === pane.windowId && entry.name === pane.windowName,
138
104
  );
139
105
  // Exact Pi pane, not a different active pane's pane_dead.
140
106
  return found ? { ...found, exited: false } : undefined;
@@ -152,36 +118,3 @@ export class NavigatorView {
152
118
  return JSON.stringify(final) === JSON.stringify(current.live);
153
119
  }
154
120
  }
155
-
156
- function detailLines(item: OpenDelegate): string[] {
157
- const record: StartedRecorded = item.record;
158
- return [
159
- `delegation: ${record.id} · session: ${sessionIdOf(record)}`,
160
- `parent: ${record.identity?.parent ?? "this session"} · root: ${record.identity?.root ?? "this session"} · depth: ${record.identity?.depth ?? 1}`,
161
- `title: ${item.title ?? "not set"}`,
162
- `launch window: ${record.windowName} (${record.windowId})`,
163
- `current verified window: ${item.navigable ? `${item.window.name} (${item.window.id}) in ${item.window.session}` : "unavailable"}`,
164
- `cwd: ${record.cwd ?? "unknown"} · workspace: ${record.workspace?.cwd ?? "none recorded"}`,
165
- `session file: ${item.live?.sessionFile ?? locateSession({ ...record }, undefined) ?? "not found"}`,
166
- `session directory: ${record.sessionDir ?? "not recorded"}`,
167
- `result: ${record.result?.status ?? "none received"} · envelope: ${record.result?.envelopePath ?? "none"}`,
168
- ...(record.forkedFrom === undefined ? [] : [`forked from: ${record.forkedFrom}`]),
169
- `resumed from: ${record.resumedFrom ?? "none"} · resumed as: ${item.resumedAs ?? "none"} · auto-exit: ${record.autoExit === true ? "on" : "off"}`,
170
- `model: ${record.model} · thinking: ${record.thinking}`,
171
- ];
172
- }
173
-
174
- export function dashboardRows(items: readonly OpenDelegate[]): DashboardRow[] {
175
- const baseDepth: number = Math.min(...items.map((item) => item.record.identity?.depth ?? 1));
176
- return items.map((item): DashboardRow => {
177
- const where: string = item.navigable ? `${item.window.id} in ${item.window.session}` : "window unavailable";
178
- const title: string = item.title && item.title !== item.name ? ` · ${item.title}` : "";
179
- const outcome: string = item.record.result ? ` (${item.record.result.status})` : "";
180
- return {
181
- id: item.id,
182
- label: `${item.name}${title} · ${item.agent} · ${item.state}${outcome} · ${where}`,
183
- depth: Math.max(0, (item.record.identity?.depth ?? 1) - baseDepth),
184
- details: detailLines(item),
185
- };
186
- });
187
- }
package/src/navigator.ts CHANGED
@@ -1,33 +1,22 @@
1
- // The delegate navigator: the `/delegates` panel and `/delegate-open`. It lists
2
- // the descendant hierarchy, retains history and reaches a verified open window: a switch of
3
- // the user's tmux client to its window, a peek at the pane, or a close. Everything here is a command or a key; the model gets no tool that
4
- // moves the user's terminal.
5
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
2
  import { type Component, isKeyRelease, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
7
- import { Dashboard, type DashboardIntent, type DashboardPosition } from "./dashboard.ts";
8
- import { readIdentity } from "./identity.ts";
9
- import { branchItems, dashboardRows, NavigatorView, type OpenDelegate } from "./navigator-view.ts";
10
-
11
- import { type Results, sessionIdOf } from "./results.ts";
3
+ import { NavigatorView, type OpenDelegate } from "./navigator-view.ts";
4
+ import { isStarted, type Results, recordedName } from "./results.ts";
12
5
  import type { TmuxClient } from "./tmux.ts";
13
6
  import type { Tracking } from "./tracking.ts";
14
- import { descendants, readTree } from "./tree-registry.ts";
15
7
 
16
- /** What `/delegate-open` does; dashboard Enter opens details before switching. */
8
+ /** What `/delegate-open` does with a verified window. */
17
9
  export type OpenMode = "switch" | "peek";
18
10
 
19
11
  export const OPEN_USAGE = "/delegate-open <id> [--switch|--peek]";
20
12
  const OPEN_FLAGS = ["--switch", "--peek"] as const;
21
13
 
22
- /** What the panel hands back: the action the user chose on one delegation, or none. */
23
- type PanelResult = DashboardIntent;
24
-
25
14
  /** What the peek hands back: switch to the delegate, or nothing. */
26
15
  type PeekResult = { action: "switch" } | undefined;
27
16
 
28
17
  const PEEK_HINT = "r refresh · enter switch · esc back";
29
18
 
30
- /** Where a peek shows: over the panel or the editor, not in its place. */
19
+ /** A peek overlays the editor without replacing it. */
31
20
  const PEEK_OPTIONS = { overlay: true, overlayOptions: { width: "90%", maxHeight: "90%", anchor: "center" } } as const;
32
21
 
33
22
  type PanelTheme = { fg(color: string, text: string): string; bold(text: string): string };
@@ -105,10 +94,9 @@ class PeekView implements Component {
105
94
 
106
95
  /** What `src/index.ts` needs from the navigator. */
107
96
  export type Navigator = {
108
- /** The panel, looping back after a close. */
109
- panel(ctx: ExtensionContext, targetSessionId?: string): Promise<void>;
110
97
  /** One action on one open delegation, for `/delegate-open`. */
111
98
  openDelegate(ctx: ExtensionContext, id: string, mode: OpenMode): Promise<void>;
99
+ closeDelegate(ctx: ExtensionContext, id: string): Promise<void>;
112
100
  /** Ids of the delegations whose window is open, for completions. */
113
101
  openIds(): Promise<string[]>;
114
102
  };
@@ -138,19 +126,18 @@ export function openCompletions(prefix: string, ids: readonly string[]): { value
138
126
  return hits.length > 0 ? hits.map((value) => ({ value: head + value, label: value })) : null;
139
127
  }
140
128
 
141
- /** Context-bearing action seam; presentation never owns close/resume scope or result wording. */
129
+ /** Context-bearing action seam; presentation never owns close scope or result wording. */
142
130
  export type NavigatorActions = {
143
131
  assertActive(): void;
144
132
  close(
145
133
  id: string,
146
134
  ctx: ExtensionContext,
147
135
  ): Promise<{ text: string; isError?: boolean; details?: { complete: boolean } }>;
148
- resume(id: string, ctx: ExtensionContext): Promise<void>;
149
136
  };
150
137
  type Env = {
151
138
  results: Results;
152
139
  tmux: TmuxClient;
153
- tracking: Pick<Tracking, "close" | "closeAll">;
140
+ tracking: Pick<Tracking, "close">;
154
141
  actions: Partial<NavigatorActions>;
155
142
  view: NavigatorView;
156
143
  };
@@ -212,27 +199,20 @@ async function switchTo(env: Env, ctx: ExtensionContext, item: OpenDelegate): Pr
212
199
  );
213
200
  }
214
201
 
215
- function runningDescendants(item: OpenDelegate): boolean {
216
- if (!item.record.identity) return false;
217
- try {
218
- return descendants(readTree(item.record.identity.root), sessionIdOf(item.record)).some(
219
- (entry) => !entry.closed && !entry.finished && entry.phase !== "not-started",
220
- );
221
- } catch {
222
- return true; // Unavailable descendant state is not proof that closing is harmless.
223
- }
224
- }
225
-
226
- /** Close through the shared control path, confirming running/unknown descendant work. */
227
- async function close(env: Env, ctx: ExtensionContext, item: OpenDelegate): Promise<void> {
228
- if (item.state === "running" || (item.navigable && item.live !== undefined) || runningDescendants(item)) {
229
- const reason: string = item.state === "running" ? "is still running" : "may have live or unfinished work";
230
- const message = `${item.name} ${reason}. Close its entire recorded subtree and kill only verified shared-control windows? Reattached navigation windows may remain open.`;
231
- if (!(await ctx.ui.confirm("Close delegate", message))) return;
232
- }
202
+ async function close(env: Env, ctx: ExtensionContext, id: string): Promise<void> {
203
+ env.actions.assertActive?.();
204
+ const record = env.results.find(id);
205
+ if (!record || !isStarted(record))
206
+ return ctx.ui.notify("delegate: selected delegate is no longer available", "warning");
207
+ const name = recordedName(record);
208
+ const running = record.result || record.closed || record.gone ? "" : `${name} is still running. `;
209
+ if (
210
+ !(await ctx.ui.confirm("Close delegate", `${running}Close ${name} and its delegates? Worktrees and branches stay.`))
211
+ )
212
+ return;
233
213
  try {
234
214
  env.actions.assertActive?.();
235
- const result = await (env.actions.close?.(item.id, ctx) ?? env.tracking.close(item.id, ctx));
215
+ const result = await (env.actions.close?.(id, ctx) ?? env.tracking.close(id, ctx));
236
216
  env.actions.assertActive?.();
237
217
  ctx.ui.notify(
238
218
  `delegate: ${result.text}`,
@@ -243,30 +223,6 @@ async function close(env: Env, ctx: ExtensionContext, item: OpenDelegate): Promi
243
223
  }
244
224
  }
245
225
 
246
- /** Both entry points use this exact dashboard, differing only in initial focus/back destination. */
247
- function show(
248
- ctx: ExtensionContext,
249
- items: readonly OpenDelegate[],
250
- position: DashboardPosition,
251
- branch: string | undefined,
252
- ): Promise<PanelResult> {
253
- return ctx.ui.custom<PanelResult>(
254
- (tui, theme, _keybindings, done) =>
255
- new Dashboard({
256
- items: dashboardRows(items),
257
- root: readIdentity(ctx.sessionManager.getSessionId()) === undefined,
258
- position,
259
- detailBack: branch === undefined ? "list" : "exit",
260
- branch,
261
- theme,
262
- done,
263
- redraw: () => tui.requestRender(),
264
- rows: () => tui.terminal.rows,
265
- }),
266
- { blocked: { kind: "question", message: "Choose a delegate action" } },
267
- );
268
- }
269
-
270
226
  /** Peek at a delegate's pane in an overlay; true when the user chose to switch to it. */
271
227
  async function peek(env: Env, ctx: ExtensionContext, item: OpenDelegate): Promise<boolean> {
272
228
  const result = await ctx.ui.custom<PeekResult>(
@@ -290,66 +246,6 @@ async function peek(env: Env, ctx: ExtensionContext, item: OpenDelegate): Promis
290
246
  const noUi = (ctx: ExtensionContext): void => ctx.ui.notify("delegate: this needs an interactive session", "error");
291
247
  const noTmux = (ctx: ExtensionContext): void => ctx.ui.notify("delegate: could not ask tmux for its windows", "error");
292
248
 
293
- async function closeAll(env: Env, ctx: ExtensionContext): Promise<void> {
294
- if (
295
- !(await ctx.ui.confirm(
296
- "Close all delegate subtrees",
297
- "Close every recorded descendant of this root? Worktrees and branches are retained.",
298
- ))
299
- )
300
- return;
301
- try {
302
- env.actions.assertActive?.();
303
- const closed = await env.tracking.closeAll(ctx);
304
- env.actions.assertActive?.();
305
- ctx.ui.notify(`delegate: ${closed.text}`, closed.details.complete ? "info" : "error");
306
- } catch (error) {
307
- ctx.ui.notify(`delegate: ${String(error)}`, "error");
308
- }
309
- }
310
-
311
- /** Carry out what the panel returned; true when the panel should open again. */
312
- async function act(
313
- env: Env,
314
- startNew: (ctx: ExtensionContext) => Promise<void> | void,
315
- ctx: ExtensionContext,
316
- items: OpenDelegate[],
317
- result: PanelResult,
318
- ): Promise<boolean> {
319
- if (result === undefined) return false;
320
- env.actions.assertActive?.();
321
- if (result.action === "new") {
322
- await startNew(ctx);
323
- return false;
324
- }
325
- if (result.action === "close-all") {
326
- await closeAll(env, ctx);
327
- return true;
328
- }
329
- const item = items.find((candidate) => candidate.id === result.id);
330
- if (item === undefined) return false;
331
- if (result.action === "resume") {
332
- if (env.actions.resume) await env.actions.resume(item.id, ctx);
333
- else ctx.ui.notify("delegate: resume is unavailable in this dashboard", "warning");
334
- return true;
335
- }
336
- if (result.action === "close") {
337
- await close(env, ctx, item);
338
- return true;
339
- }
340
- if (result.action === "peek") {
341
- if (!item.navigable) {
342
- ctx.ui.notify("delegate: no verified open window to peek", "warning");
343
- return true;
344
- }
345
- if (!(await peek(env, ctx, item))) return true; // back to the panel
346
- await switchTo(env, ctx, item);
347
- return false;
348
- }
349
- await switchTo(env, ctx, item);
350
- return false;
351
- }
352
-
353
249
  /** One action on one open delegate, as `/delegate-open` runs it. */
354
250
  async function openOne(env: Env, ctx: ExtensionContext, id: string, mode: OpenMode): Promise<void> {
355
251
  const items = await env.view.items();
@@ -357,6 +253,11 @@ async function openOne(env: Env, ctx: ExtensionContext, id: string, mode: OpenMo
357
253
  if (items === undefined) return noTmux(ctx);
358
254
  const item = items.find((candidate) => candidate.id === id);
359
255
  if (item === undefined) {
256
+ if (env.results.find(id))
257
+ return ctx.ui.notify(
258
+ "delegate: no verified open window for this delegation; window changed or unavailable",
259
+ "error",
260
+ );
360
261
  const known = items.map((candidate) => candidate.id).join(", ") || "none";
361
262
  return ctx.ui.notify(`delegate: no open delegate ${JSON.stringify(id)}; open: ${known}`, "error");
362
263
  }
@@ -365,40 +266,20 @@ async function openOne(env: Env, ctx: ExtensionContext, id: string, mode: OpenMo
365
266
  if (await peek(env, ctx, item)) await switchTo(env, ctx, item);
366
267
  }
367
268
 
368
- function focusTarget(items: OpenDelegate[], target: string | undefined, position: DashboardPosition): boolean {
369
- if (target === undefined) return true;
370
- position.id = items.filter((item) => sessionIdOf(item.record) === target).at(-1)?.id;
371
- return position.id !== undefined;
372
- }
373
-
374
- /** The navigator over the records, tmux and tracking. `startNew` is wired by the entry. */
269
+ /** The command and compact-widget actions over the same verified navigation view. */
375
270
  export function createNavigator(
376
271
  results: Results,
377
272
  tmux: TmuxClient,
378
- tracking: Pick<Tracking, "close" | "closeAll">,
379
- startNew: (ctx: ExtensionContext) => Promise<void> | void,
273
+ tracking: Pick<Tracking, "close">,
380
274
  actions: Partial<NavigatorActions> = {},
381
275
  ): Navigator {
382
276
  const env: Env = { results, tmux, tracking, actions, view: new NavigatorView(results, tmux) };
383
277
  return {
384
- async panel(ctx: ExtensionContext, targetSessionId?: string): Promise<void> {
385
- if (ctx.mode !== "tui") return noUi(ctx);
386
- const position: DashboardPosition = { detail: targetSessionId !== undefined, offset: 0 };
387
- let initial = true;
388
- for (;;) {
389
- actions.assertActive?.();
390
- const items = branchItems((await env.view.items(true)) ?? [], targetSessionId);
391
- actions.assertActive?.();
392
- if (initial && !focusTarget(items, targetSessionId, position))
393
- return ctx.ui.notify("delegate: selected delegate is no longer available", "warning");
394
- initial = false;
395
- const result: PanelResult = await show(ctx, items, position, targetSessionId);
396
- if (!(await act(env, startNew, ctx, items, result))) return;
397
- }
398
- },
399
278
  openDelegate: (ctx: ExtensionContext, id: string, mode: OpenMode): Promise<void> => openOne(env, ctx, id, mode),
279
+ closeDelegate: (ctx: ExtensionContext, id: string): Promise<void> => close(env, ctx, id),
400
280
  async openIds(): Promise<string[]> {
401
- return (await env.view.items())?.map((item) => item.id) ?? [];
281
+ actions.assertActive?.();
282
+ return ((await env.view.items()) ?? []).map((item) => item.id);
402
283
  },
403
284
  };
404
285
  }
@@ -78,6 +78,32 @@ export function finishedModel(sessionPath: string | undefined): string | undefin
78
78
  return undefined;
79
79
  }
80
80
 
81
+ /** Approximate copied context from the last assistant request, excluding its output. */
82
+ export function sessionInputTokens(sessionPath: string): number | undefined {
83
+ let lines: string[];
84
+ try {
85
+ lines = readFileSync(sessionPath, "utf8").split("\n");
86
+ } catch {
87
+ return undefined;
88
+ }
89
+ for (let i = lines.length - 1; i >= 0; i--) {
90
+ let entry: { type?: unknown; message?: { role?: unknown; usage?: Record<string, unknown> } };
91
+ try {
92
+ entry = JSON.parse(lines[i]);
93
+ } catch {
94
+ continue;
95
+ }
96
+ if (entry?.type !== "message" || entry.message?.role !== "assistant") continue;
97
+ const usage = entry.message.usage;
98
+ const counts = [usage?.input, usage?.cacheRead, usage?.cacheWrite];
99
+ if (!counts.every((count): count is number => typeof count === "number" && Number.isFinite(count) && count >= 0))
100
+ return undefined;
101
+ const total = counts.reduce((sum, count) => sum + count, 0);
102
+ return Number.isFinite(total) && total > 0 ? total : undefined;
103
+ }
104
+ return undefined;
105
+ }
106
+
81
107
  /** One session-file line, as much of it as the branch walk reads. */
82
108
  type BranchEntry = { type?: unknown; id?: unknown; parentId?: unknown; thinkingLevel?: unknown };
83
109
 
package/src/results.ts CHANGED
@@ -164,9 +164,9 @@ export type Results = {
164
164
  /** Record a workspace attempt, or its next state; never counted as running. */
165
165
  recordAttempt(attempt: WorkspaceAttempt, ctx: ExtensionContext): void;
166
166
  /** Record a close; does nothing when there is no such delegation or it is already closed. */
167
- recordClose(id: string): void;
167
+ recordClose(id: string, wake?: boolean): void;
168
168
  /** Import only confirmed own direct-child closes; no foreign semantic writes. */
169
- syncCloses(): void;
169
+ syncCloses(wake?: boolean): void;
170
170
  /** Delegations with no result, no recorded close and no gone-window record. */
171
171
  running(): StartedRecorded[];
172
172
  /** Record that the window is gone with no result, and tell the parent once. False when it no longer applies. */
package/src/tmux.ts CHANGED
@@ -85,6 +85,12 @@ export type PaneLocator = {
85
85
  exited: boolean;
86
86
  };
87
87
 
88
+ export type PaneListing = {
89
+ server: Pick<PaneLocator, "socket" | "serverPid" | "serverStarted"> | undefined;
90
+ panes: ReadonlyMap<string, PaneLocator>;
91
+ windows: TmuxWindow[];
92
+ };
93
+
88
94
  export interface TmuxClient {
89
95
  /** Whether this process runs in a tmux pane. */
90
96
  insideTmux(): boolean;
@@ -102,6 +108,7 @@ export interface TmuxClient {
102
108
  inspectPane?(paneId?: string): Promise<PaneLocator | undefined>;
103
109
  /** Every window on the server, in one tmux call. */
104
110
  listWindows(): Promise<TmuxWindow[]>;
111
+ listPanes(): Promise<PaneListing>;
105
112
  /** Kill a window. */
106
113
  kill(windowId: string): Promise<void>;
107
114
  /** The session the parent's pane is in; undefined when it cannot be told. */
@@ -300,6 +307,34 @@ async function inspectPane(run: Run, paneId = process.env.TMUX_PANE): Promise<Pa
300
307
  };
301
308
  }
302
309
 
310
+ async function listPanes(run: Run): Promise<PaneListing> {
311
+ const format =
312
+ "#{socket_path}\t#{pid}\t#{start_time}\t#{pane_id}\t#{window_id}\t#{pane_dead}\t#{pane_active}\t#{session_name}\t#{window_name}";
313
+ const out = await run(["list-panes", "-a", "-F", format]);
314
+ const panes = new Map<string, PaneLocator>();
315
+ const windows = new Map<string, TmuxWindow>();
316
+ let server: PaneListing["server"];
317
+ for (const line of out.split("\n").filter((line) => line !== "")) {
318
+ const [socket, pid, started, paneId, windowId, dead, active, session, ...name] = line.split("\t");
319
+ if (
320
+ !socket ||
321
+ !/^\d+$/.test(pid) ||
322
+ !/^\d+$/.test(started) ||
323
+ !/^%[0-9]+$/.test(paneId) ||
324
+ !/^@[0-9]+$/.test(windowId) ||
325
+ !["0", "1"].includes(dead) ||
326
+ !["0", "1"].includes(active) ||
327
+ !name.length
328
+ )
329
+ throw new Error("tmux reported an invalid pane listing");
330
+ server = { socket, serverPid: Number(pid), serverStarted: started };
331
+ const pane = { ...server, paneId, windowId, windowName: name.join("\t"), exited: dead === "1" };
332
+ panes.set(paneId, pane);
333
+ if (active === "1") windows.set(windowId, { id: windowId, session, name: pane.windowName, exited: pane.exited });
334
+ }
335
+ return { server, panes, windows: [...windows.values()] };
336
+ }
337
+
303
338
  /**
304
339
  * The real client. A window is opened relative to the session of the pane in
305
340
  * `TMUX_PANE`, not whatever session a client happens to have attached, so it
@@ -340,6 +375,8 @@ export function createTmuxClient(options: { timeoutMs?: number } = {}): TmuxClie
340
375
 
341
376
  inspectPane: (paneId) => inspectPane(run, paneId),
342
377
 
378
+ listPanes: () => listPanes(run),
379
+
343
380
  async currentWindow() {
344
381
  const pane = process.env.TMUX_PANE;
345
382
  if (!pane) return undefined;
package/src/tracking.ts CHANGED
@@ -340,6 +340,7 @@ export async function closeDelegation(
340
340
  id: string | undefined,
341
341
  ctx: ExtensionContext,
342
342
  assertActive: () => void,
343
+ wake = true,
343
344
  ): Promise<CloseResult> {
344
345
  const local = id ? results.find(id) : undefined;
345
346
  if (local && !isStarted(local))
@@ -350,7 +351,7 @@ export async function closeDelegation(
350
351
  const root = readIdentity(actor)?.root ?? actor;
351
352
  const result = await closeTree({ root, actor, id, tmux, assertActive });
352
353
  assertActive();
353
- results.syncCloses();
354
+ results.syncCloses(wake);
354
355
  return result;
355
356
  }
356
357
 
@@ -372,7 +373,7 @@ function closeTool(results: Results, tmux: TmuxClient, assertActive: () => void)
372
373
  _onUpdate: unknown,
373
374
  ctx: ExtensionContext,
374
375
  ): Promise<ReturnType<typeof toolResult>> {
375
- const { text, details } = await closeDelegation(results, tmux, params.id, ctx, assertActive);
376
+ const { text, details } = await closeDelegation(results, tmux, params.id, ctx, assertActive, false);
376
377
  return { ...toolResult(text, details), ...(!details.complete ? { isError: true } : {}) };
377
378
  },
378
379
  };
@@ -98,6 +98,10 @@ export function treeTransaction<T>(root: string, work: (state: TreeState) => T):
98
98
  export function readTree(root: string, timeoutMs = 5000): TreeState {
99
99
  return withTreeLock(`${treePath(root)}.lock`, () => load(root), timeoutMs);
100
100
  }
101
+ /** Atomic file replacement gives display readers a whole snapshot without writer admission. */
102
+ export function readTreeDisplay(root: string): TreeState {
103
+ return load(root);
104
+ }
101
105
  export function treeCounts(state: TreeState): TreeCounts {
102
106
  const charged: TreeEntry[] = state.entries.filter((entry) => entry.phase !== "not-started");
103
107
  return {
package/src/tree-view.ts CHANGED
@@ -97,6 +97,7 @@ export function visibleRecordFacts(
97
97
  ctx: ExtensionContext,
98
98
  timeoutMs = 5000,
99
99
  notify = true,
100
+ read: typeof readTree = readTree,
100
101
  ): VisibleFacts {
101
102
  const session: string = ctx.sessionManager.getSessionId();
102
103
  const root: string = readIdentity(session)?.root ?? session;
@@ -106,7 +107,7 @@ export function visibleRecordFacts(
106
107
  let complete = true;
107
108
  let entries: TreeEntry[];
108
109
  try {
109
- entries = descendants(readTree(root, timeoutMs), session).filter((entry) => entry.phase !== "not-started");
110
+ entries = descendants(read(root, timeoutMs), session).filter((entry) => entry.phase !== "not-started");
110
111
  } catch (error) {
111
112
  if (notify)
112
113
  ctx.ui.notify(