pi-squire 0.14.0 → 0.16.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 (62) hide show
  1. package/CHANGELOG.md +438 -1
  2. package/README.md +1062 -152
  3. package/package.json +4 -3
  4. package/src/agents.ts +395 -124
  5. package/src/child-tasks.ts +221 -0
  6. package/src/child.ts +107 -62
  7. package/src/completion-holds.ts +104 -0
  8. package/src/dashboard.ts +144 -0
  9. package/src/git.ts +6 -1
  10. package/src/identity.ts +152 -0
  11. package/src/index.ts +585 -107
  12. package/src/local-state.ts +21 -0
  13. package/src/navigator-view.ts +186 -0
  14. package/src/navigator.ts +152 -107
  15. package/src/new-delegate.ts +17 -8
  16. package/src/poll.ts +1 -0
  17. package/src/render.ts +4 -1
  18. package/src/result-close.ts +37 -0
  19. package/src/result-restore.ts +95 -0
  20. package/src/result-sessions.ts +39 -0
  21. package/src/result-types.ts +29 -1
  22. package/src/results-presentation.ts +47 -0
  23. package/src/results.ts +176 -108
  24. package/src/resume-checks.ts +59 -14
  25. package/src/resume-picker.ts +9 -2
  26. package/src/resume.ts +69 -15
  27. package/src/session-owner.ts +279 -0
  28. package/src/task-replies.ts +247 -0
  29. package/src/task-settlement.ts +81 -0
  30. package/src/task-store.ts +26 -0
  31. package/src/tmux.ts +134 -54
  32. package/src/tool-ceiling.ts +125 -0
  33. package/src/tracking.ts +72 -65
  34. package/src/tree-close.ts +236 -0
  35. package/src/tree-fence.ts +51 -0
  36. package/src/tree-lock.ts +77 -0
  37. package/src/tree-notice.ts +124 -0
  38. package/src/tree-registry.ts +365 -0
  39. package/src/tree-settings.ts +31 -0
  40. package/src/tree-types.ts +32 -0
  41. package/src/tree-view.ts +246 -0
  42. package/src/trust-control.ts +214 -0
  43. package/src/trust-prompt.ts +163 -0
  44. package/src/trust-wire.ts +203 -0
  45. package/src/widget-activity-join.ts +24 -0
  46. package/src/widget-activity-runtime.ts +104 -0
  47. package/src/widget-activity.ts +104 -0
  48. package/src/widget-ancestors.ts +142 -0
  49. package/src/widget-current.ts +41 -0
  50. package/src/widget-data.ts +147 -0
  51. package/src/widget-focus.ts +133 -0
  52. package/src/widget-live-process.ts +31 -0
  53. package/src/widget-live.ts +356 -0
  54. package/src/widget-render.ts +181 -0
  55. package/src/widget-selection.ts +58 -0
  56. package/src/widget-settings.ts +29 -0
  57. package/src/widget-summary.ts +51 -0
  58. package/src/widget-title-file.ts +127 -0
  59. package/src/widget-title.ts +118 -0
  60. package/src/widget.ts +272 -0
  61. package/src/window-placement.ts +30 -5
  62. package/src/workspace.ts +38 -90
@@ -0,0 +1,21 @@
1
+ // Atomic session-local records independent of Pi's transcript buffering.
2
+ // This is not a cross-process registry or a lock; duplicate-open arbitration
3
+ // belongs to the tree restoration layer.
4
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
5
+ import { dirname } from "node:path";
6
+
7
+ export function readState<T>(path: string): T | undefined {
8
+ try {
9
+ return JSON.parse(readFileSync(path, "utf8")) as T;
10
+ } catch (error) {
11
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
12
+ throw error;
13
+ }
14
+ }
15
+
16
+ export function writeState(path: string, state: unknown): void {
17
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
18
+ const temporary = `${path}.${process.pid}.tmp`;
19
+ writeFileSync(temporary, JSON.stringify(state), { mode: 0o600 });
20
+ renameSync(temporary, path);
21
+ }
@@ -0,0 +1,186 @@
1
+ // Read-only dashboard/navigation projection. Never supplies locators to close or task accounting.
2
+ import type { DashboardRow } from "./dashboard.ts";
3
+ import {
4
+ type DelegationState,
5
+ deriveState,
6
+ isStarted,
7
+ locateSession,
8
+ type Results,
9
+ recordedName,
10
+ type StartedRecorded,
11
+ sessionIdOf,
12
+ } from "./results.ts";
13
+ import type { TmuxClient, TmuxWindow } from "./tmux.ts";
14
+ import { verifyLiveWindow } from "./widget-ancestors.ts";
15
+ import { LiveActivityReader, type LiveLocator } from "./widget-live.ts";
16
+ import { WidgetTitleMirrors } from "./widget-title.ts";
17
+ import { WidgetTitleFiles } from "./widget-title-file.ts";
18
+ import { listWindows, ownWindow } from "./windows.ts";
19
+
20
+ export type OpenDelegate = {
21
+ id: string;
22
+ name: string;
23
+ title?: string;
24
+ agent: string;
25
+ state: DelegationState;
26
+ window: TmuxWindow;
27
+ navigable: boolean;
28
+ live?: LiveLocator;
29
+ record: StartedRecorded;
30
+ resumedAs?: string;
31
+ };
32
+
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
+ function openItem(item: OpenDelegate): boolean {
53
+ if (!item.navigable || item.record.closed || item.record.accountingOnly) return false;
54
+ return (
55
+ ["running", "done", "gone"].includes(item.state) || (item.state === "window-closed" && item.live !== undefined)
56
+ );
57
+ }
58
+
59
+ function sameTarget(a: OpenDelegate, b: OpenDelegate): boolean {
60
+ return (
61
+ a.window.id === b.window.id && a.window.name === b.window.name && JSON.stringify(a.live) === JSON.stringify(b.live)
62
+ );
63
+ }
64
+
65
+ /** Current evidence supersedes historical navigation, but never mutates a saved launch/result. */
66
+ export class NavigatorView {
67
+ private readonly results: Results;
68
+ private readonly tmux: TmuxClient;
69
+ private readonly reader: Pick<LiveActivityReader, "locator">;
70
+ private readonly titles: WidgetTitleFiles = new WidgetTitleFiles();
71
+ private readonly mirrors: WidgetTitleMirrors = new WidgetTitleMirrors();
72
+ // Once positively observed, a disappearing/replaced current runtime must not fall back to its old window.
73
+ private readonly current: Set<string> = new Set();
74
+
75
+ constructor(
76
+ results: Results,
77
+ tmux: TmuxClient,
78
+ reader: Pick<LiveActivityReader, "locator"> = new LiveActivityReader(),
79
+ ) {
80
+ this.results = results;
81
+ this.tmux = tmux;
82
+ this.reader = reader;
83
+ }
84
+
85
+ async items(history = false): Promise<OpenDelegate[] | undefined> {
86
+ const windows: TmuxWindow[] | undefined = await listWindows(this.tmux);
87
+ if (!windows && !history) return undefined;
88
+ const all: StartedRecorded[] = this.results.list().filter(isStarted);
89
+ const latest: Map<string, string> = new Map(all.map((record) => [sessionIdOf(record), record.id]));
90
+ const items: OpenDelegate[] = await Promise.all(
91
+ 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;
95
+ }),
96
+ );
97
+ if (history) return items;
98
+ return items.filter(openItem).reverse();
99
+ }
100
+
101
+ private async item(
102
+ record: StartedRecorded,
103
+ latest: boolean,
104
+ windows: TmuxWindow[] | undefined,
105
+ ): Promise<OpenDelegate> {
106
+ const historical: TmuxWindow | undefined = windows ? ownWindow(windows, record) : undefined;
107
+ const live: LiveLocator | undefined =
108
+ latest && !record.closed ? await this.reader.locator(sessionIdOf(record)) : undefined;
109
+ if (live) this.current.add(record.id);
110
+ const window: TmuxWindow | undefined = this.current.has(record.id)
111
+ ? await this.liveWindow(live, windows)
112
+ : 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
+ let state: import("./results.ts").WindowState = "unknown";
117
+ if (windows) state = historical && !historical.exited ? "open" : "gone";
118
+ return {
119
+ id: record.id,
120
+ name: recordedName(record),
121
+ title,
122
+ agent: record.displayName ?? record.agent,
123
+ state: deriveState(record, state),
124
+ record,
125
+ live,
126
+ navigable: !!window && !window.exited,
127
+ window: window ?? { id: record.windowId, name: record.windowName, exited: true, session: "" },
128
+ };
129
+ }
130
+
131
+ private async liveWindow(
132
+ live: LiveLocator | undefined,
133
+ windows: TmuxWindow[] | undefined,
134
+ ): Promise<TmuxWindow | undefined> {
135
+ if (!live || !windows || !(await verifyLiveWindow(this.tmux, live).catch(() => false))) return undefined;
136
+ const found: TmuxWindow | undefined = windows.find(
137
+ (entry) => entry.id === live.locator.windowId && entry.name === live.locator.windowName,
138
+ );
139
+ // Exact Pi pane, not a different active pane's pane_dead.
140
+ return found ? { ...found, exited: false } : undefined;
141
+ }
142
+
143
+ async verified(item: OpenDelegate): Promise<boolean> {
144
+ const all: StartedRecorded[] = this.results.list().filter(isStarted);
145
+ const record: StartedRecorded | undefined = all.find((entry) => entry.id === item.id);
146
+ if (!record || sessionIdOf(record) !== sessionIdOf(item.record)) return false;
147
+ const latest: boolean = all.filter((entry) => sessionIdOf(entry) === sessionIdOf(record)).at(-1)?.id === record.id;
148
+ const current: OpenDelegate = await this.item(record, latest, await listWindows(this.tmux));
149
+ if (!current.navigable || !sameTarget(item, current)) return false;
150
+ if (!current.live) return true;
151
+ const final: LiveLocator | undefined = await this.reader.locator(sessionIdOf(record));
152
+ return JSON.stringify(final) === JSON.stringify(current.live);
153
+ }
154
+ }
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
+ `resumed from: ${record.resumedFrom ?? "none"} · resumed as: ${item.resumedAs ?? "none"} · auto-exit: ${record.autoExit === true ? "on" : "off"}`,
169
+ `model: ${record.model} · thinking: ${record.thinking}`,
170
+ ];
171
+ }
172
+
173
+ export function dashboardRows(items: readonly OpenDelegate[]): DashboardRow[] {
174
+ const baseDepth: number = Math.min(...items.map((item) => item.record.identity?.depth ?? 1));
175
+ return items.map((item): DashboardRow => {
176
+ const where: string = item.navigable ? `${item.window.id} in ${item.window.session}` : "window unavailable";
177
+ const title: string = item.title && item.title !== item.name ? ` · ${item.title}` : "";
178
+ const outcome: string = item.record.result ? ` (${item.record.result.status})` : "";
179
+ return {
180
+ id: item.id,
181
+ label: `${item.name}${title} · ${item.agent} · ${item.state}${outcome} · ${where}`,
182
+ depth: Math.max(0, (item.record.identity?.depth ?? 1) - baseDepth),
183
+ details: detailLines(item),
184
+ };
185
+ });
186
+ }
package/src/navigator.ts CHANGED
@@ -1,37 +1,30 @@
1
1
  // The delegate navigator: the `/delegates` panel and `/delegate-open`. It lists
2
- // the delegations whose window is open and reaches one of them: a switch of
2
+ // the descendant hierarchy, retains history and reaches a verified open window: a switch of
3
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
4
  // moves the user's terminal.
5
5
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
- import { type Component, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
6
+ 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";
7
10
 
8
- import { type DelegationState, deriveState, isStarted, type Results, recordedName } from "./results.ts";
9
- import type { TmuxClient, TmuxWindow } from "./tmux.ts";
10
- import { listWindows, ownWindow, type Tracking } from "./tracking.ts";
11
+ import { type Results, sessionIdOf } from "./results.ts";
12
+ import type { TmuxClient } from "./tmux.ts";
13
+ import type { Tracking } from "./tracking.ts";
14
+ import { descendants, readTree } from "./tree-registry.ts";
11
15
 
12
- /** What `/delegate-open` does; switch is what enter does in the panel. */
16
+ /** What `/delegate-open` does; dashboard Enter opens details before switching. */
13
17
  export type OpenMode = "switch" | "peek";
14
18
 
15
19
  export const OPEN_USAGE = "/delegate-open <id> [--switch|--peek]";
16
20
  const OPEN_FLAGS = ["--switch", "--peek"] as const;
17
21
 
18
- /** One delegation whose window is open. */
19
- type OpenDelegate = {
20
- id: string;
21
- name: string;
22
- agent: string;
23
- state: DelegationState;
24
- window: TmuxWindow;
25
- };
26
-
27
22
  /** What the panel hands back: the action the user chose on one delegation, or none. */
28
- type PanelResult = { action: "switch" | "close" | "peek"; id: string } | { action: "new" } | undefined;
23
+ type PanelResult = DashboardIntent;
29
24
 
30
25
  /** What the peek hands back: switch to the delegate, or nothing. */
31
26
  type PeekResult = { action: "switch" } | undefined;
32
27
 
33
- const HINT = "↑↓ select · enter switch · p peek · x close · n new · esc leave";
34
- const EMPTY = "No open delegates · n new · esc close";
35
28
  const PEEK_HINT = "r refresh · enter switch · esc back";
36
29
 
37
30
  /** Where a peek shows: over the panel or the editor, not in its place. */
@@ -51,54 +44,6 @@ function peekBody(peek: Capture, rows: number): string[] {
51
44
  .slice(-Math.max(5, rows - 8));
52
45
  }
53
46
 
54
- /** The panel's list of open delegates, as a pi-tui component. */
55
- class Panel implements Component {
56
- private selected = 0;
57
- private readonly items: readonly OpenDelegate[];
58
- private readonly theme: PanelTheme;
59
- private readonly done: (result: PanelResult) => void;
60
- private readonly redraw: () => void;
61
-
62
- constructor(parts: {
63
- items: readonly OpenDelegate[];
64
- theme: PanelTheme;
65
- done: (result: PanelResult) => void;
66
- redraw: () => void;
67
- }) {
68
- this.items = parts.items;
69
- this.theme = parts.theme;
70
- this.done = parts.done;
71
- this.redraw = parts.redraw;
72
- }
73
-
74
- invalidate(): void {}
75
-
76
- handleInput(data: string): void {
77
- const item = this.items[this.selected];
78
- const count = this.items.length;
79
- if (matchesKey(data, "escape")) this.done(undefined);
80
- else if (matchesKey(data, "n")) this.done({ action: "new" });
81
- else if (item === undefined) return;
82
- else if (matchesKey(data, "up")) this.selected = (this.selected + count - 1) % count;
83
- else if (matchesKey(data, "down")) this.selected = (this.selected + 1) % count;
84
- else if (matchesKey(data, "enter")) this.done({ action: "switch", id: item.id });
85
- else if (matchesKey(data, "p")) this.done({ action: "peek", id: item.id });
86
- else if (matchesKey(data, "x")) this.done({ action: "close", id: item.id });
87
- this.redraw();
88
- }
89
-
90
- render(width: number): string[] {
91
- const fit = (text: string): string => truncateToWidth(text, Math.max(1, width));
92
- if (this.items.length === 0) return [this.theme.bold("Delegates"), "", EMPTY].map(fit);
93
- const lines = this.items.map((item, index) => {
94
- const where = item.window.session === "" ? item.window.id : `${item.window.id} in ${item.window.session}`;
95
- const row = `${item.name} · ${item.agent} · ${item.state} · ${where}`;
96
- return index === this.selected ? this.theme.fg("accent", `▸ ${row}`) : ` ${row}`;
97
- });
98
- return [this.theme.bold("Delegates"), "", ...lines, "", this.theme.fg("dim", HINT)].map(fit);
99
- }
100
- }
101
-
102
47
  /** A snapshot of one delegate's pane, shown as an overlay: `r` refreshes, enter switches, esc returns. */
103
48
  class PeekView implements Component {
104
49
  private capture: Capture = { text: "", error: undefined, loading: true };
@@ -143,6 +88,7 @@ class PeekView implements Component {
143
88
  }
144
89
 
145
90
  handleInput(data: string): void {
91
+ if (isKeyRelease(data)) return;
146
92
  if (matchesKey(data, "escape")) this.done(undefined);
147
93
  else if (matchesKey(data, "r")) this.refresh();
148
94
  else if (matchesKey(data, "enter")) this.done({ action: "switch" });
@@ -160,7 +106,7 @@ class PeekView implements Component {
160
106
  /** What `src/index.ts` needs from the navigator. */
161
107
  export type Navigator = {
162
108
  /** The panel, looping back after a close. */
163
- panel(ctx: ExtensionContext): Promise<void>;
109
+ panel(ctx: ExtensionContext, targetSessionId?: string): Promise<void>;
164
110
  /** One action on one open delegation, for `/delegate-open`. */
165
111
  openDelegate(ctx: ExtensionContext, id: string, mode: OpenMode): Promise<void>;
166
112
  /** Ids of the delegations whose window is open, for completions. */
@@ -192,25 +138,31 @@ export function openCompletions(prefix: string, ids: readonly string[]): { value
192
138
  return hits.length > 0 ? hits.map((value) => ({ value: head + value, label: value })) : null;
193
139
  }
194
140
 
195
- type Env = { results: Results; tmux: TmuxClient; tracking: Pick<Tracking, "close"> };
196
-
197
- /** The open delegations, newest first, from the records and one listing; undefined when tmux cannot be asked. */
198
- async function openDelegates({ results, tmux }: Env): Promise<OpenDelegate[] | undefined> {
199
- const windows = await listWindows(tmux);
200
- if (windows === undefined) return undefined;
201
- const found: OpenDelegate[] = [];
202
- for (const record of results.list()) {
203
- if (!isStarted(record)) continue;
204
- const window = ownWindow(windows, record);
205
- if (window === undefined || window.exited) continue;
206
- const state = deriveState(record, "open");
207
- if (state !== "running" && state !== "done") continue;
208
- found.push({ id: record.id, name: recordedName(record), agent: record.agent, state, window });
209
- }
210
- return found.reverse();
141
+ /** Context-bearing action seam; presentation never owns close/resume scope or result wording. */
142
+ export type NavigatorActions = {
143
+ assertActive(): void;
144
+ close(
145
+ id: string,
146
+ ctx: ExtensionContext,
147
+ ): Promise<{ text: string; isError?: boolean; details?: { complete: boolean } }>;
148
+ resume(id: string, ctx: ExtensionContext): Promise<void>;
149
+ };
150
+ type Env = {
151
+ results: Results;
152
+ tmux: TmuxClient;
153
+ tracking: Pick<Tracking, "close" | "closeAll">;
154
+ actions: Partial<NavigatorActions>;
155
+ view: NavigatorView;
156
+ };
157
+
158
+ async function verifiedWindow(env: Env, item: OpenDelegate): Promise<boolean> {
159
+ env.actions.assertActive?.();
160
+ const verified: boolean = await env.view.verified(item);
161
+ env.actions.assertActive?.();
162
+ return verified;
211
163
  }
212
164
 
213
- /** The parent's own session name, or undefined when tmux cannot say. */
165
+ /** The parent\'s own session name, or undefined when tmux cannot say. */
214
166
  async function ownSessionName(tmux: TmuxClient): Promise<string | undefined> {
215
167
  try {
216
168
  return (await tmux.ownSession())?.name;
@@ -232,41 +184,84 @@ async function clientOf(tmux: TmuxClient): Promise<{ client: string } | { refusa
232
184
  }
233
185
 
234
186
  async function switchTo(env: Env, ctx: ExtensionContext, item: OpenDelegate): Promise<void> {
187
+ env.actions.assertActive?.();
235
188
  const found = await clientOf(env.tmux);
236
189
  if ("refusal" in found) return ctx.ui.notify(`delegate: cannot switch: ${found.refusal}`, "warning");
237
190
  try {
191
+ env.actions.assertActive?.();
192
+ if (!(await verifiedWindow(env, item)))
193
+ return ctx.ui.notify(
194
+ "delegate: no verified open window for this delegation; window changed or unavailable",
195
+ "error",
196
+ );
238
197
  await env.tmux.switchTo(found.client, item.window.id);
239
198
  } catch (err) {
240
199
  return ctx.ui.notify(`delegate: could not switch: ${(err as Error).message}`, "error");
241
200
  }
201
+ try {
202
+ env.actions.assertActive?.();
203
+ } catch (err) {
204
+ return ctx.ui.notify(
205
+ `delegate: switched to ${item.name} (${item.window.id}); session ownership lost: ${(err as Error).message}`,
206
+ "warning",
207
+ );
208
+ }
242
209
  ctx.ui.notify(
243
210
  `delegate: switched to ${item.name} (${item.window.id}) · your tmux last-session key returns you`,
244
211
  "info",
245
212
  );
246
213
  }
247
214
 
248
- /** Close through the `delegation_close` path, asking first only while the delegate runs. */
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. */
249
227
  async function close(env: Env, ctx: ExtensionContext, item: OpenDelegate): Promise<void> {
250
- if (item.state === "running") {
251
- const message = `${item.name} is still running. Close it and kill its window?`;
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.`;
252
231
  if (!(await ctx.ui.confirm("Close delegate", message))) return;
253
232
  }
254
233
  try {
255
- ctx.ui.notify(`delegate: ${(await env.tracking.close(item.id)).text}`, "info");
234
+ env.actions.assertActive?.();
235
+ const result = await (env.actions.close?.(item.id, ctx) ?? env.tracking.close(item.id, ctx));
236
+ env.actions.assertActive?.();
237
+ ctx.ui.notify(
238
+ `delegate: ${result.text}`,
239
+ ("isError" in result && result.isError) || result.details?.complete === false ? "error" : "info",
240
+ );
256
241
  } catch (err) {
257
242
  ctx.ui.notify(`delegate: ${(err as Error).message}`, "error");
258
243
  }
259
244
  }
260
245
 
261
- /** Show the panel; resolves with what the user chose. */
262
- function show(ctx: ExtensionContext, items: readonly OpenDelegate[]): Promise<PanelResult> {
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> {
263
253
  return ctx.ui.custom<PanelResult>(
264
- (tui, theme, _keybindings, done): Panel =>
265
- new Panel({
266
- items,
267
- theme: theme as unknown as PanelTheme,
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,
268
262
  done,
269
- redraw: (): void => tui.requestRender(),
263
+ redraw: () => tui.requestRender(),
264
+ rows: () => tui.terminal.rows,
270
265
  }),
271
266
  );
272
267
  }
@@ -278,7 +273,10 @@ async function peek(env: Env, ctx: ExtensionContext, item: OpenDelegate): Promis
278
273
  new PeekView({
279
274
  item,
280
275
  theme: theme as unknown as PanelTheme,
281
- read: (windowId: string): Promise<string> => env.tmux.capture(windowId),
276
+ read: async (windowId: string): Promise<string> => {
277
+ if (!(await verifiedWindow(env, item))) throw new Error("no verified open window");
278
+ return env.tmux.capture(item.live?.locator.paneId ?? windowId);
279
+ },
282
280
  done,
283
281
  redraw: (): void => tui.requestRender(),
284
282
  rows: (): number => tui.terminal.rows,
@@ -291,6 +289,24 @@ async function peek(env: Env, ctx: ExtensionContext, item: OpenDelegate): Promis
291
289
  const noUi = (ctx: ExtensionContext): void => ctx.ui.notify("delegate: this needs an interactive session", "error");
292
290
  const noTmux = (ctx: ExtensionContext): void => ctx.ui.notify("delegate: could not ask tmux for its windows", "error");
293
291
 
292
+ async function closeAll(env: Env, ctx: ExtensionContext): Promise<void> {
293
+ if (
294
+ !(await ctx.ui.confirm(
295
+ "Close all delegate subtrees",
296
+ "Close every recorded descendant of this root? Worktrees and branches are retained.",
297
+ ))
298
+ )
299
+ return;
300
+ try {
301
+ env.actions.assertActive?.();
302
+ const closed = await env.tracking.closeAll(ctx);
303
+ env.actions.assertActive?.();
304
+ ctx.ui.notify(`delegate: ${closed.text}`, closed.details.complete ? "info" : "error");
305
+ } catch (error) {
306
+ ctx.ui.notify(`delegate: ${String(error)}`, "error");
307
+ }
308
+ }
309
+
294
310
  /** Carry out what the panel returned; true when the panel should open again. */
295
311
  async function act(
296
312
  env: Env,
@@ -300,17 +316,31 @@ async function act(
300
316
  result: PanelResult,
301
317
  ): Promise<boolean> {
302
318
  if (result === undefined) return false;
319
+ env.actions.assertActive?.();
303
320
  if (result.action === "new") {
304
321
  await startNew(ctx);
305
322
  return false;
306
323
  }
324
+ if (result.action === "close-all") {
325
+ await closeAll(env, ctx);
326
+ return true;
327
+ }
307
328
  const item = items.find((candidate) => candidate.id === result.id);
308
329
  if (item === undefined) return false;
330
+ if (result.action === "resume") {
331
+ if (env.actions.resume) await env.actions.resume(item.id, ctx);
332
+ else ctx.ui.notify("delegate: resume is unavailable in this dashboard", "warning");
333
+ return true;
334
+ }
309
335
  if (result.action === "close") {
310
336
  await close(env, ctx, item);
311
337
  return true;
312
338
  }
313
339
  if (result.action === "peek") {
340
+ if (!item.navigable) {
341
+ ctx.ui.notify("delegate: no verified open window to peek", "warning");
342
+ return true;
343
+ }
314
344
  if (!(await peek(env, ctx, item))) return true; // back to the panel
315
345
  await switchTo(env, ctx, item);
316
346
  return false;
@@ -321,7 +351,8 @@ async function act(
321
351
 
322
352
  /** One action on one open delegate, as `/delegate-open` runs it. */
323
353
  async function openOne(env: Env, ctx: ExtensionContext, id: string, mode: OpenMode): Promise<void> {
324
- const items = await openDelegates(env);
354
+ const items = await env.view.items();
355
+ env.actions.assertActive?.();
325
356
  if (items === undefined) return noTmux(ctx);
326
357
  const item = items.find((candidate) => candidate.id === id);
327
358
  if (item === undefined) {
@@ -329,30 +360,44 @@ async function openOne(env: Env, ctx: ExtensionContext, id: string, mode: OpenMo
329
360
  return ctx.ui.notify(`delegate: no open delegate ${JSON.stringify(id)}; open: ${known}`, "error");
330
361
  }
331
362
  if (mode === "switch") return switchTo(env, ctx, item);
332
- if (!ctx.hasUI) return noUi(ctx);
363
+ if (ctx.mode !== "tui") return noUi(ctx);
333
364
  if (await peek(env, ctx, item)) await switchTo(env, ctx, item);
334
365
  }
335
366
 
336
- /** The navigator over the records, tmux and tracking. `startNew` is the new-delegate flow, wired by the entry. */
367
+ function focusTarget(items: OpenDelegate[], target: string | undefined, position: DashboardPosition): boolean {
368
+ if (target === undefined) return true;
369
+ position.id = items.filter((item) => sessionIdOf(item.record) === target).at(-1)?.id;
370
+ return position.id !== undefined;
371
+ }
372
+
373
+ /** The navigator over the records, tmux and tracking. `startNew` is wired by the entry. */
337
374
  export function createNavigator(
338
375
  results: Results,
339
376
  tmux: TmuxClient,
340
- tracking: Pick<Tracking, "close">,
377
+ tracking: Pick<Tracking, "close" | "closeAll">,
341
378
  startNew: (ctx: ExtensionContext) => Promise<void> | void,
379
+ actions: Partial<NavigatorActions> = {},
342
380
  ): Navigator {
343
- const env: Env = { results, tmux, tracking };
381
+ const env: Env = { results, tmux, tracking, actions, view: new NavigatorView(results, tmux) };
344
382
  return {
345
- async panel(ctx: ExtensionContext): Promise<void> {
346
- if (!ctx.hasUI) return noUi(ctx);
383
+ async panel(ctx: ExtensionContext, targetSessionId?: string): Promise<void> {
384
+ if (ctx.mode !== "tui") return noUi(ctx);
385
+ const position: DashboardPosition = { detail: targetSessionId !== undefined, offset: 0 };
386
+ let initial = true;
347
387
  for (;;) {
348
- const items = await openDelegates(env);
349
- if (items === undefined) return noTmux(ctx);
350
- if (!(await act(env, startNew, ctx, items, await show(ctx, items)))) return;
388
+ actions.assertActive?.();
389
+ const items = branchItems((await env.view.items(true)) ?? [], targetSessionId);
390
+ actions.assertActive?.();
391
+ if (initial && !focusTarget(items, targetSessionId, position))
392
+ return ctx.ui.notify("delegate: selected delegate is no longer available", "warning");
393
+ initial = false;
394
+ const result: PanelResult = await show(ctx, items, position, targetSessionId);
395
+ if (!(await act(env, startNew, ctx, items, result))) return;
351
396
  }
352
397
  },
353
398
  openDelegate: (ctx: ExtensionContext, id: string, mode: OpenMode): Promise<void> => openOne(env, ctx, id, mode),
354
399
  async openIds(): Promise<string[]> {
355
- return (await openDelegates(env))?.map((item) => item.id) ?? [];
400
+ return (await env.view.items())?.map((item) => item.id) ?? [];
356
401
  },
357
402
  };
358
403
  }
@@ -47,6 +47,8 @@ export type NewDelegateDeps = {
47
47
  roster(ctx: ExtensionContext): Roster;
48
48
  /** The refusal text for an empty roster. */
49
49
  noAgents(): string;
50
+ /** The model and thinking `agent` runs on when the request names none: its own, else the parent's. */
51
+ defaults(agent: Agent, ctx: ExtensionContext): { model?: string; thinking: string };
50
52
  /** Why `value` is not a usable label, or undefined; the check `--label` gets. */
51
53
  labelProblem(value: string): string | undefined;
52
54
  /** Start the delegate as `/delegate` would, notifying the start or the refusal. */
@@ -207,12 +209,12 @@ export class SettingsForm implements Component {
207
209
  this.options = options;
208
210
  const { agent, models, style } = options;
209
211
  for (const model of models) this.byId.set(`${model.provider}/${model.id}`, model);
210
- this.model = agent.model;
211
- this.thinking = agent.thinking;
212
+ this.model = agent.model ?? "";
213
+ this.thinking = agent.thinking ?? "";
212
214
  this.autoExit = agent.autoExit;
213
215
  this.cwd = options.cwd;
214
216
  this.clampInitialThinking();
215
- const modelItems: SelectItem[] = [...new Set([agent.model, ...this.byId.keys()])].map((value) => ({
217
+ const modelItems: SelectItem[] = [...new Set([this.model, ...this.byId.keys()])].map((value) => ({
216
218
  value,
217
219
  label: value,
218
220
  }));
@@ -360,8 +362,8 @@ export class SettingsForm implements Component {
360
362
  return;
361
363
  }
362
364
  this.options.done({
363
- ...(this.model === this.options.agent.model ? {} : { model: this.model }),
364
- ...(this.thinking === this.options.agent.thinking ? {} : { thinking: this.thinking }),
365
+ ...(this.model === (this.options.agent.model ?? "") ? {} : { model: this.model }),
366
+ ...(this.thinking === (this.options.agent.thinking ?? "") ? {} : { thinking: this.thinking }),
365
367
  ...(this.autoExit === this.options.agent.autoExit ? {} : { autoExit: this.autoExit }),
366
368
  ...(this.cwd === this.options.cwd ? {} : { cwd: this.cwd }),
367
369
  ...(this.worktree === "" ? {} : { worktree: this.worktree }),
@@ -410,14 +412,20 @@ export async function startNewDelegate(ctx: ExtensionContext, deps: NewDelegateD
410
412
  if (unavailable !== undefined) return fail(unavailable);
411
413
  const roster = deps.roster(ctx);
412
414
  if (roster.agents.length === 0) return fail(deps.noAgents());
413
- const options = roster.agents.map((agent) => `${agent.name} — ${agent.description}`);
415
+ const options = roster.agents.map(
416
+ (agent) =>
417
+ `${agent.name}${agent.displayName === undefined ? "" : ` (${agent.displayName})`} — ${agent.description}`,
418
+ );
414
419
  const picked = await ctx.ui.select("New delegate: agent", options);
415
420
  const agent = roster.agents[options.indexOf(picked ?? "")];
416
421
  if (agent === undefined) return;
417
422
  const task = await ctx.ui.editor(`Task for ${agent.name}`);
418
423
  if (task === undefined) return;
419
424
  if (task.trim() === "") return fail("a task is required");
420
- const typed = await ctx.ui.input(`Label (optional; empty for ${agent.name}-<id>)`, "letters, digits, _ or -");
425
+ const typed = await ctx.ui.input(
426
+ `Label (optional; empty for ${agent.displayName ?? agent.name}-<id>)`,
427
+ "letters, digits, _ or -",
428
+ );
421
429
  if (typed === undefined) return;
422
430
  const label = typed.trim() === "" ? undefined : typed.trim();
423
431
  const badLabel = label === undefined ? undefined : deps.labelProblem(label);
@@ -425,7 +433,8 @@ export async function startNewDelegate(ctx: ExtensionContext, deps: NewDelegateD
425
433
  const chosen = await ctx.ui.custom<FormResult | undefined>(
426
434
  (_tui, theme, _keys, done): Component =>
427
435
  new SettingsForm({
428
- agent,
436
+ // The rows start at what a request naming nothing runs on.
437
+ agent: { ...agent, ...deps.defaults(agent, ctx) } as Agent,
429
438
  models: ctx.modelRegistry.getAvailable() as Model<never>[],
430
439
  cwd: ctx.cwd,
431
440
  style: theme,