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.
- package/CHANGELOG.md +438 -1
- package/README.md +1062 -152
- package/package.json +4 -3
- package/src/agents.ts +395 -124
- package/src/child-tasks.ts +221 -0
- package/src/child.ts +107 -62
- package/src/completion-holds.ts +104 -0
- package/src/dashboard.ts +144 -0
- package/src/git.ts +6 -1
- package/src/identity.ts +152 -0
- package/src/index.ts +585 -107
- package/src/local-state.ts +21 -0
- package/src/navigator-view.ts +186 -0
- package/src/navigator.ts +152 -107
- package/src/new-delegate.ts +17 -8
- package/src/poll.ts +1 -0
- package/src/render.ts +4 -1
- package/src/result-close.ts +37 -0
- package/src/result-restore.ts +95 -0
- package/src/result-sessions.ts +39 -0
- package/src/result-types.ts +29 -1
- package/src/results-presentation.ts +47 -0
- package/src/results.ts +176 -108
- package/src/resume-checks.ts +59 -14
- package/src/resume-picker.ts +9 -2
- package/src/resume.ts +69 -15
- package/src/session-owner.ts +279 -0
- package/src/task-replies.ts +247 -0
- package/src/task-settlement.ts +81 -0
- package/src/task-store.ts +26 -0
- package/src/tmux.ts +134 -54
- package/src/tool-ceiling.ts +125 -0
- package/src/tracking.ts +72 -65
- package/src/tree-close.ts +236 -0
- package/src/tree-fence.ts +51 -0
- package/src/tree-lock.ts +77 -0
- package/src/tree-notice.ts +124 -0
- package/src/tree-registry.ts +365 -0
- package/src/tree-settings.ts +31 -0
- package/src/tree-types.ts +32 -0
- package/src/tree-view.ts +246 -0
- package/src/trust-control.ts +214 -0
- package/src/trust-prompt.ts +163 -0
- package/src/trust-wire.ts +203 -0
- package/src/widget-activity-join.ts +24 -0
- package/src/widget-activity-runtime.ts +104 -0
- package/src/widget-activity.ts +104 -0
- package/src/widget-ancestors.ts +142 -0
- package/src/widget-current.ts +41 -0
- package/src/widget-data.ts +147 -0
- package/src/widget-focus.ts +133 -0
- package/src/widget-live-process.ts +31 -0
- package/src/widget-live.ts +356 -0
- package/src/widget-render.ts +181 -0
- package/src/widget-selection.ts +58 -0
- package/src/widget-settings.ts +29 -0
- package/src/widget-summary.ts +51 -0
- package/src/widget-title-file.ts +127 -0
- package/src/widget-title.ts +118 -0
- package/src/widget.ts +272 -0
- package/src/window-placement.ts +30 -5
- 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
|
|
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
|
|
9
|
-
import type { TmuxClient
|
|
10
|
-
import {
|
|
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;
|
|
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 =
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
262
|
-
function show(
|
|
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)
|
|
265
|
-
new
|
|
266
|
-
items,
|
|
267
|
-
|
|
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: ()
|
|
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> =>
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
|
400
|
+
return (await env.view.items())?.map((item) => item.id) ?? [];
|
|
356
401
|
},
|
|
357
402
|
};
|
|
358
403
|
}
|
package/src/new-delegate.ts
CHANGED
|
@@ -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([
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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,
|