pi-squire 0.1.0 → 0.2.1

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.
@@ -0,0 +1,310 @@
1
+ // Delegation tracking: the poll that notices a delegate's window going away,
2
+ // and the `delegation_status` and `delegation_close` tools. A delegation's
3
+ // state comes from `results.ts` (`deriveState`); this module only asks tmux
4
+ // about the window and words what it is told.
5
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import { Type } from "typebox";
7
+
8
+ import {
9
+ type DelegationState,
10
+ deriveState,
11
+ findSession,
12
+ type Recorded,
13
+ type Results,
14
+ recordedName,
15
+ type WindowState,
16
+ } from "./results.ts";
17
+ import type { TmuxClient, TmuxWindow } from "./tmux.ts";
18
+
19
+ /** The hook a tick emits so the provider claims waiting replies before windows are checked. */
20
+ const SCAN = "message:scan";
21
+ /** How often the parent checks its running delegations. */
22
+ const POLL_MS = 5000;
23
+
24
+ /** What `src/index.ts` needs from tracking. */
25
+ export type Tracking = {
26
+ /** Register `delegation_status` and `delegation_close`. */
27
+ registerTools(): void;
28
+ /** Start the poll when something is running. */
29
+ startPolling(): void;
30
+ /** On `session_start`: rebuild the records and restart the poll. */
31
+ restore(ctx: ExtensionContext): void;
32
+ /** On `session_shutdown`: stop the poll, and declare nothing more. */
33
+ stop(): void;
34
+ };
35
+
36
+ /** The one result every tool returns. */
37
+ export function toolResult(
38
+ payload: string,
39
+ details: unknown = {},
40
+ ): { content: { type: "text"; text: string }[]; details: unknown } {
41
+ return { content: [{ type: "text", text: payload }], details };
42
+ }
43
+
44
+ /** A tool as registered; `execute` takes the tool's own parameters, which the registration call does not check. */
45
+ type ToolDef = {
46
+ name: string;
47
+ label: string;
48
+ description: string;
49
+ parameters: unknown;
50
+ execute: (...args: never[]) => Promise<ReturnType<typeof toolResult>>;
51
+ };
52
+
53
+ /** Every window on the server, asked once; a failed ask is `undefined`, never an empty list. */
54
+ async function listWindows(tmux: TmuxClient): Promise<TmuxWindow[] | undefined> {
55
+ try {
56
+ return await tmux.listWindows();
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ }
61
+
62
+ /** The window a delegation owns: one with both its recorded id and its recorded name, since ids restart with every tmux server. */
63
+ function ownWindow(windows: TmuxWindow[], record: Recorded): TmuxWindow | undefined {
64
+ return windows.find((window) => window.id === record.windowId && window.name === record.windowName);
65
+ }
66
+
67
+ /** What the listing says about a delegation's window; a matching window whose program exited counts as gone. */
68
+ function windowState(windows: TmuxWindow[] | undefined, record: Recorded): WindowState {
69
+ if (windows === undefined) return "unknown";
70
+ const own = ownWindow(windows, record);
71
+ return own !== undefined && !own.exited ? "open" : "gone";
72
+ }
73
+
74
+ /** Records from before auto-exit existed kept their windows open, so they read off. */
75
+ function autoExitLine(record: Recorded): string {
76
+ return record.autoExit === true
77
+ ? "on — the delegate closes its window after a normal completion unless the user took over there"
78
+ : "off — the window stays open after the result";
79
+ }
80
+
81
+ /** The state, worded for the `state:` line. */
82
+ function stateLine(state: DelegationState, record: Recorded): string {
83
+ switch (state) {
84
+ case "running":
85
+ return "running — the window is open and no result has arrived";
86
+ case "gone":
87
+ return "closed without a result — the window is gone and no reply arrived; a late reply would make it done";
88
+ case "done":
89
+ return `done — result status: ${record.result?.status ?? "unknown"}`;
90
+ case "closed":
91
+ return "closed — delegation_close recorded it";
92
+ case "window-closed":
93
+ return "closed — the window is gone";
94
+ case "unknown":
95
+ return "unknown — tmux could not be asked whether the window is open";
96
+ }
97
+ }
98
+
99
+ /** One delegation as `delegation_status` reports it; its text and details both read from this. */
100
+ type View = { record: Recorded; state: DelegationState; name: string; sessionPath: string | undefined };
101
+
102
+ /** The view of a delegation: its state and the fields the text and details share. */
103
+ function view(record: Recorded, windows: TmuxWindow[] | undefined, ctx: ExtensionContext): View {
104
+ const state = deriveState(record, windowState(windows, record));
105
+ const sessionPath = record.result?.sessionPath ?? findSession(ctx.sessionManager.getSessionDir(), record.id);
106
+ return { record, state, name: recordedName(record), sessionPath };
107
+ }
108
+
109
+ /** The text block for one delegation. */
110
+ function viewText({ record, state, name, sessionPath }: View): string {
111
+ const lines = [
112
+ `${record.id} ${record.agent} (${record.model}, thinking ${record.thinking})`,
113
+ ` name: ${name}`,
114
+ ` state: ${stateLine(state, record)}`,
115
+ ` window: ${record.windowName} (${record.windowId})`,
116
+ ` auto-exit: ${autoExitLine(record)}`,
117
+ ` session: ${sessionPath ?? `${record.id} (no session file yet)`}`,
118
+ ];
119
+ if (record.result !== undefined) lines.push(` envelope: ${record.result.envelopePath}`);
120
+ return lines.join("\n");
121
+ }
122
+
123
+ /** The structured details for one delegation. */
124
+ function viewDetails({ record, name, sessionPath }: View): Record<string, unknown> {
125
+ return {
126
+ id: record.id,
127
+ name,
128
+ agent: record.agent,
129
+ model: record.model,
130
+ thinking: record.thinking,
131
+ autoExit: record.autoExit === true,
132
+ windowId: record.windowId,
133
+ windowName: record.windowName,
134
+ closed: record.closed === true,
135
+ closedWithoutResult: record.gone === true && record.result === undefined && record.closed !== true,
136
+ status: record.result?.status,
137
+ envelopePath: record.result?.envelopePath,
138
+ sessionPath,
139
+ };
140
+ }
141
+
142
+ /** Every delegation, or the one named; throws when a named id is unknown. */
143
+ function selectRecords(results: Results, id: string | undefined): Recorded[] {
144
+ const all = results.list();
145
+ const records = id === undefined ? all : all.filter((record) => record.id === id);
146
+ if (id !== undefined && records.length === 0) {
147
+ throw new Error(
148
+ `no delegation ${JSON.stringify(id)}; known: ${all.map((record) => record.id).join(", ") || "none"}`,
149
+ );
150
+ }
151
+ return records;
152
+ }
153
+
154
+ /** The poll over the records and tmux. `arm` readies it for a new session; `stop` ends it for good. */
155
+ function createPoll(
156
+ pi: ExtensionAPI,
157
+ results: Results,
158
+ tmux: TmuxClient,
159
+ ): { start(): void; stop(): void; arm(): void } {
160
+ /** The poll timer; set only while some delegation is running. */
161
+ let poll: ReturnType<typeof setInterval> | undefined;
162
+ let ticking = false;
163
+ /** False once the session shuts down, so a tick still awaiting tmux declares nothing. */
164
+ let live = true;
165
+
166
+ function stopPolling(): void {
167
+ if (poll !== undefined) clearInterval(poll);
168
+ poll = undefined;
169
+ }
170
+
171
+ /** Record gone each running delegation whose window is missing or exited; a failed list declares nothing. */
172
+ function declareGone(windows: TmuxWindow[] | undefined): void {
173
+ if (windows === undefined) return;
174
+ for (const record of results.running()) {
175
+ if (windowState(windows, record) !== "gone" || !live) continue;
176
+ // A reply may have landed while the windows before this one were checked.
177
+ pi.events.emit(SCAN, {});
178
+ results.recordGone(record.id);
179
+ }
180
+ }
181
+
182
+ /** Claim waiting replies first, then declare gone every delegation whose window is missing or exited with still no result. */
183
+ async function tick(): Promise<void> {
184
+ if (ticking) return;
185
+ ticking = true;
186
+ try {
187
+ if (results.running().length === 0) return stopPolling();
188
+ const scan: { scanned?: boolean } = {};
189
+ pi.events.emit(SCAN, scan);
190
+ if (scan.scanned !== true) return; // no message:* provider: a waiting reply cannot be ruled out
191
+ declareGone(await listWindows(tmux)); // one ask per tick
192
+ if (results.running().length === 0) stopPolling();
193
+ } finally {
194
+ ticking = false;
195
+ }
196
+ }
197
+
198
+ /** Start the timer when something is running; it never keeps the process alive. */
199
+ function startPolling(): void {
200
+ if (poll !== undefined || results.running().length === 0) return;
201
+ poll = setInterval(() => void tick(), POLL_MS);
202
+ poll.unref?.();
203
+ }
204
+
205
+ return {
206
+ start: startPolling,
207
+ stop(): void {
208
+ live = false;
209
+ stopPolling();
210
+ },
211
+ arm(): void {
212
+ stopPolling();
213
+ live = true;
214
+ },
215
+ };
216
+ }
217
+
218
+ /** The `delegation_status` tool. */
219
+ function statusTool(results: Results, tmux: TmuxClient): ToolDef {
220
+ return {
221
+ name: "delegation_status",
222
+ label: "Delegation status",
223
+ description:
224
+ "The delegations this session started, each reported as running (the window is open and no result has arrived yet), done (with the result envelope's status) or closed (closed with delegation_close, or its window is gone). A delegation whose window went with no reply is reported closed without a result until a late reply makes it done. Names the delegation id, agent, session name, model, thinking, window, delegate session path and result envelope path. A delegate's window being open means it is connected, not that its task is unfinished or finished; only a result means done.",
225
+ parameters: Type.Object({
226
+ id: Type.Optional(Type.String({ description: "One delegation id; omit to report every delegation." })),
227
+ }),
228
+ async execute(
229
+ _toolCallId: string,
230
+ params: { id?: string },
231
+ _signal: unknown,
232
+ _onUpdate: unknown,
233
+ ctx: ExtensionContext,
234
+ ): Promise<ReturnType<typeof toolResult>> {
235
+ const records = selectRecords(results, params.id);
236
+ const windows = await listWindows(tmux);
237
+ const views = records.map((record) => view(record, windows, ctx));
238
+ const details = { delegations: views.map(viewDetails) };
239
+ if (views.length === 0) return toolResult("No delegations are recorded in this session.", details);
240
+ const heading = `${views.length} delegation${views.length === 1 ? "" : "s"}:`;
241
+ return toolResult(`${heading}\n\n${views.map(viewText).join("\n\n")}`, details);
242
+ },
243
+ };
244
+ }
245
+
246
+ /** The `delegation_close` tool. */
247
+ function closeTool(results: Results, tmux: TmuxClient): ToolDef {
248
+ return {
249
+ name: "delegation_close",
250
+ label: "Close delegation",
251
+ description:
252
+ "Close one delegation: kill its tmux window through tmux and record the close, so delegation_status reports it closed. The delegate's session file and result envelope stay on disk. An unknown or already closed id returns a message with nothing killed.",
253
+ parameters: Type.Object({
254
+ id: Type.String({ description: "The delegation id to close." }),
255
+ }),
256
+ async execute(
257
+ _toolCallId: string,
258
+ params: { id: string },
259
+ _signal: unknown,
260
+ _onUpdate: unknown,
261
+ _ctx: ExtensionContext,
262
+ ): Promise<ReturnType<typeof toolResult>> {
263
+ const [record] = selectRecords(results, params.id);
264
+ if (record.closed === true)
265
+ return toolResult(`Delegation ${record.id} is already closed.`, { id: record.id, closed: true });
266
+ let windows: TmuxWindow[];
267
+ try {
268
+ windows = await tmux.listWindows();
269
+ } catch (err) {
270
+ throw new Error(`could not ask tmux about window ${record.windowName}: ${(err as Error).message}`);
271
+ }
272
+ // Only a window with the recorded id and name is ours, exited program or not.
273
+ const alive = ownWindow(windows, record) !== undefined;
274
+ if (alive) {
275
+ try {
276
+ await tmux.kill(record.windowId);
277
+ } catch (err) {
278
+ throw new Error(`could not kill window ${record.windowName} (${record.windowId}): ${(err as Error).message}`);
279
+ }
280
+ }
281
+ results.recordClose(record.id);
282
+ const how = alive
283
+ ? `killed window ${record.windowName} (${record.windowId})`
284
+ : `its window ${record.windowName} was already gone`;
285
+ return toolResult(`Closed delegation ${record.id}: ${how}.`, {
286
+ id: record.id,
287
+ windowId: record.windowId,
288
+ killed: alive,
289
+ });
290
+ },
291
+ };
292
+ }
293
+
294
+ /** The poll and the two tools, over the extension API, the records and tmux. */
295
+ export function createTracking(pi: ExtensionAPI, results: Results, tmux: TmuxClient): Tracking {
296
+ const poll = createPoll(pi, results, tmux);
297
+ return {
298
+ registerTools(): void {
299
+ pi.registerTool(statusTool(results, tmux) as never);
300
+ pi.registerTool(closeTool(results, tmux) as never);
301
+ },
302
+ startPolling: poll.start,
303
+ restore(ctx: ExtensionContext): void {
304
+ poll.arm();
305
+ results.restore(ctx);
306
+ poll.start();
307
+ },
308
+ stop: poll.stop,
309
+ };
310
+ }
File without changes