pi-squire 0.20.1 → 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/CHANGELOG.md +115 -61
- package/README.md +55 -89
- package/package.json +2 -2
- package/src/agents.ts +1 -1
- package/src/index.ts +7 -23
- package/src/navigator-view.ts +8 -75
- package/src/navigator.ts +29 -148
- package/src/results.ts +2 -2
- package/src/tmux.ts +37 -0
- package/src/tracking.ts +3 -2
- package/src/tree-registry.ts +4 -0
- package/src/tree-view.ts +2 -1
- package/src/widget-activity-runtime.ts +10 -27
- package/src/widget-ancestors.ts +53 -41
- package/src/widget-current.ts +21 -20
- package/src/widget-data.ts +35 -2
- package/src/widget-focus.ts +21 -11
- package/src/widget-live.ts +7 -16
- package/src/widget-render.ts +72 -71
- package/src/widget-selection.ts +16 -7
- package/src/widget.ts +94 -53
- package/src/windows.ts +9 -1
- package/src/dashboard.ts +0 -144
- package/src/widget-live-process.ts +0 -31
- package/src/widget-summary.ts +0 -51
package/src/navigator-view.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
// Read-only
|
|
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(
|
|
57
|
+
async items(): Promise<OpenDelegate[] | undefined> {
|
|
86
58
|
const windows: TmuxWindow[] | undefined = await listWindows(this.tmux);
|
|
87
|
-
if (!windows
|
|
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
|
-
|
|
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
|
|
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 ===
|
|
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 {
|
|
8
|
-
import {
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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"
|
|
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
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
222
|
-
|
|
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?.(
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
281
|
+
actions.assertActive?.();
|
|
282
|
+
return ((await env.view.items()) ?? []).map((item) => item.id);
|
|
402
283
|
},
|
|
403
284
|
};
|
|
404
285
|
}
|
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
|
};
|
package/src/tree-registry.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
@@ -4,33 +4,20 @@ import type { TmuxClient } from "./tmux.ts";
|
|
|
4
4
|
import { ActivityCollector } from "./widget-activity.ts";
|
|
5
5
|
import { LIVE_LAUNCH_ENV, LiveActivityPublisher, liveLaunch } from "./widget-live.ts";
|
|
6
6
|
|
|
7
|
-
function observeLocator(
|
|
7
|
+
async function observeLocator(
|
|
8
8
|
publisher: LiveActivityPublisher,
|
|
9
9
|
id: string,
|
|
10
10
|
token: string,
|
|
11
11
|
tmux: TmuxClient,
|
|
12
12
|
ctx: ExtensionContext,
|
|
13
|
-
):
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
if (
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
publisher.publishLocator(locator, token, ctx.sessionManager.getSessionFile());
|
|
22
|
-
} catch {
|
|
23
|
-
/* Optional observation cannot block task processing. */
|
|
24
|
-
} finally {
|
|
25
|
-
reading = false;
|
|
26
|
-
}
|
|
27
|
-
};
|
|
28
|
-
void refresh();
|
|
29
|
-
const timer: ReturnType<typeof setInterval> = setInterval(() => {
|
|
30
|
-
void refresh();
|
|
31
|
-
}, 1000);
|
|
32
|
-
timer.unref();
|
|
33
|
-
return timer;
|
|
13
|
+
): Promise<void> {
|
|
14
|
+
try {
|
|
15
|
+
const locator = await tmux.inspectPane?.().catch(() => undefined);
|
|
16
|
+
if (matchesSessionOwner(id, process.pid, token))
|
|
17
|
+
publisher.publishLocator(locator, token, ctx.sessionManager.getSessionFile());
|
|
18
|
+
} catch {
|
|
19
|
+
/* Optional observation cannot block task processing. */
|
|
20
|
+
}
|
|
34
21
|
}
|
|
35
22
|
|
|
36
23
|
/** Observe raw lifecycle after a positive startup owner gate; never grants task/action authority. */
|
|
@@ -43,12 +30,8 @@ export function registerActivityRuntime(
|
|
|
43
30
|
const seed: string | undefined = process.env[LIVE_LAUNCH_ENV];
|
|
44
31
|
let collector: ActivityCollector | undefined;
|
|
45
32
|
let publisher: LiveActivityPublisher | undefined;
|
|
46
|
-
let timer: ReturnType<typeof setInterval> | undefined;
|
|
47
33
|
|
|
48
34
|
const stop = (): void => {
|
|
49
|
-
if (timer) clearInterval(timer);
|
|
50
|
-
timer = undefined;
|
|
51
|
-
|
|
52
35
|
collector?.clear();
|
|
53
36
|
collector = undefined;
|
|
54
37
|
publisher?.stop();
|
|
@@ -66,7 +49,7 @@ export function registerActivityRuntime(
|
|
|
66
49
|
collector = new ActivityCollector();
|
|
67
50
|
publisher = new LiveActivityPublisher(id, liveLaunch(seed, id));
|
|
68
51
|
publish();
|
|
69
|
-
if (tmux && ownerToken)
|
|
52
|
+
if (tmux && ownerToken) void observeLocator(publisher, id, ownerToken, tmux, ctx);
|
|
70
53
|
});
|
|
71
54
|
pi.on("tool_execution_start", (event) => {
|
|
72
55
|
collector?.start(event.toolCallId, event.toolName, event.parentToolCallId);
|