pi-squire 0.12.0 → 0.13.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 +24 -1
- package/README.md +56 -1
- package/package.json +3 -1
- package/src/index.ts +82 -22
- package/src/navigator.ts +382 -0
- package/src/new-delegate.ts +438 -0
- package/src/poll.ts +111 -0
- package/src/popups.ts +49 -0
- package/src/resume-checks.ts +159 -0
- package/src/resume-picker.ts +50 -0
- package/src/resume.ts +39 -98
- package/src/tmux.ts +117 -1
- package/src/tracking.ts +57 -127
- package/src/windows.ts +24 -0
- package/src/workspace.ts +1 -1
package/src/tracking.ts
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
import { statSync } from "node:fs";
|
|
6
6
|
|
|
7
7
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import { Type } from "typebox";
|
|
8
|
+
import { type Static, Type } from "typebox";
|
|
9
9
|
|
|
10
10
|
import { type Mark, readMarks } from "./limits.ts";
|
|
11
|
+
import { createPoll } from "./poll.ts";
|
|
12
|
+
import type { Popup } from "./popups.ts";
|
|
11
13
|
import { attemptLines, cwdLine, modelLabel, snapshotLines, workspaceLine } from "./render.ts";
|
|
12
14
|
import {
|
|
13
15
|
type DelegationState,
|
|
@@ -19,7 +21,6 @@ import {
|
|
|
19
21
|
recordedName,
|
|
20
22
|
type StartedRecorded,
|
|
21
23
|
sessionIdOf,
|
|
22
|
-
type WindowState,
|
|
23
24
|
type WorkspaceAttempt,
|
|
24
25
|
} from "./results.ts";
|
|
25
26
|
import { inspectWorkspace, type WorkspaceSnapshot, WorkspaceSnapshotSchema } from "./snapshot.ts";
|
|
@@ -27,25 +28,30 @@ import type { TmuxClient, TmuxWindow } from "./tmux.ts";
|
|
|
27
28
|
import { AttemptSchema } from "./tracking-attempt.ts";
|
|
28
29
|
import { ago, autoExitLine, stateLine } from "./tracking-text.ts";
|
|
29
30
|
import type { ReadingError } from "./usage.ts";
|
|
31
|
+
import { listWindows, ownWindow, windowState } from "./windows.ts";
|
|
30
32
|
import { WorkspaceSchema } from "./workspace.ts";
|
|
31
33
|
|
|
32
|
-
|
|
33
|
-
const SCAN = "message:scan";
|
|
34
|
-
/** How often the parent checks its running delegations. */
|
|
35
|
-
const POLL_MS = 5000;
|
|
34
|
+
export { listWindows, ownWindow, windowState };
|
|
36
35
|
|
|
37
36
|
/** What `src/index.ts` needs from tracking. */
|
|
38
37
|
export type Tracking = {
|
|
39
38
|
/** Register `delegation_status` and `delegation_close`. */
|
|
40
39
|
registerTools(): void;
|
|
41
|
-
/** Start the poll when something is running. */
|
|
40
|
+
/** Start the poll when something is running or a popup is open. */
|
|
42
41
|
startPolling(): void;
|
|
42
|
+
/** Remember a popup the parent opened; the poll closes it once its window is gone. Returns the call that forgets it when its process ends. */
|
|
43
|
+
trackPopup(popup: Popup): () => void;
|
|
44
|
+
/** Close one delegation as `delegation_close` does; throws as the tool does. */
|
|
45
|
+
close(id: string): Promise<CloseResult>;
|
|
43
46
|
/** On `session_start`: rebuild the records and restart the poll. */
|
|
44
47
|
restore(ctx: ExtensionContext): void;
|
|
45
48
|
/** On `session_shutdown`: stop the poll, and declare nothing more. */
|
|
46
49
|
stop(): void;
|
|
47
50
|
};
|
|
48
51
|
|
|
52
|
+
/** What closing one delegation did. */
|
|
53
|
+
export type CloseResult = { text: string; details: Static<typeof CloseSchema> };
|
|
54
|
+
|
|
49
55
|
/** The one result every tool returns. `details` doubles as `structuredContent`, so it must match the tool's `outputSchema`; Pi does not check it. */
|
|
50
56
|
export function toolResult(
|
|
51
57
|
payload: string,
|
|
@@ -135,27 +141,6 @@ export type ToolDef = {
|
|
|
135
141
|
execute: (...args: never[]) => Promise<ReturnType<typeof toolResult>>;
|
|
136
142
|
};
|
|
137
143
|
|
|
138
|
-
/** Every window on the server, asked once; a failed ask is `undefined`, never an empty list. */
|
|
139
|
-
export async function listWindows(tmux: TmuxClient): Promise<TmuxWindow[] | undefined> {
|
|
140
|
-
try {
|
|
141
|
-
return await tmux.listWindows();
|
|
142
|
-
} catch {
|
|
143
|
-
return undefined;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** The window a delegation owns: one with both its recorded id and its recorded name, since ids restart with every tmux server. */
|
|
148
|
-
export function ownWindow(windows: TmuxWindow[], record: StartedRecorded): TmuxWindow | undefined {
|
|
149
|
-
return windows.find((window) => window.id === record.windowId && window.name === record.windowName);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** What the listing says about a delegation's window; a matching window whose program exited counts as gone. */
|
|
153
|
-
export function windowState(windows: TmuxWindow[] | undefined, record: StartedRecorded): WindowState {
|
|
154
|
-
if (windows === undefined) return "unknown";
|
|
155
|
-
const own = ownWindow(windows, record);
|
|
156
|
-
return own !== undefined && !own.exited ? "open" : "gone";
|
|
157
|
-
}
|
|
158
|
-
|
|
159
144
|
/** One delegation as `delegation_status` reports it; its text and details both read from this. */
|
|
160
145
|
type View = {
|
|
161
146
|
record: StartedRecorded;
|
|
@@ -288,70 +273,6 @@ function selectRecords(results: Results, id: string | undefined): Recorded[] {
|
|
|
288
273
|
return records;
|
|
289
274
|
}
|
|
290
275
|
|
|
291
|
-
/** The poll over the records and tmux. `arm` readies it for a new session; `stop` ends it for good. */
|
|
292
|
-
function createPoll(
|
|
293
|
-
pi: ExtensionAPI,
|
|
294
|
-
results: Results,
|
|
295
|
-
tmux: TmuxClient,
|
|
296
|
-
): { start(): void; stop(): void; arm(): void } {
|
|
297
|
-
/** The poll timer; set only while some delegation is running. */
|
|
298
|
-
let poll: ReturnType<typeof setInterval> | undefined;
|
|
299
|
-
let ticking = false;
|
|
300
|
-
/** False once the session shuts down, so a tick still awaiting tmux declares nothing. */
|
|
301
|
-
let live = true;
|
|
302
|
-
|
|
303
|
-
function stopPolling(): void {
|
|
304
|
-
if (poll !== undefined) clearInterval(poll);
|
|
305
|
-
poll = undefined;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/** Record gone each running delegation whose window is missing or exited; a failed list declares nothing. */
|
|
309
|
-
function declareGone(windows: TmuxWindow[] | undefined): void {
|
|
310
|
-
if (windows === undefined) return;
|
|
311
|
-
for (const record of results.running()) {
|
|
312
|
-
if (windowState(windows, record) !== "gone" || !live) continue;
|
|
313
|
-
// A reply may have landed while the windows before this one were checked.
|
|
314
|
-
pi.events.emit(SCAN, {});
|
|
315
|
-
results.recordGone(record.id);
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/** Claim waiting replies first, then declare gone every delegation whose window is missing or exited with still no result. */
|
|
320
|
-
async function tick(): Promise<void> {
|
|
321
|
-
if (ticking) return;
|
|
322
|
-
ticking = true;
|
|
323
|
-
try {
|
|
324
|
-
if (results.running().length === 0) return stopPolling();
|
|
325
|
-
const scan: { scanned?: boolean } = {};
|
|
326
|
-
pi.events.emit(SCAN, scan);
|
|
327
|
-
if (scan.scanned !== true) return; // no message:* provider: a waiting reply cannot be ruled out
|
|
328
|
-
declareGone(await listWindows(tmux)); // one ask per tick
|
|
329
|
-
if (results.running().length === 0) stopPolling();
|
|
330
|
-
} finally {
|
|
331
|
-
ticking = false;
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/** Start the timer when something is running; it never keeps the process alive. */
|
|
336
|
-
function startPolling(): void {
|
|
337
|
-
if (poll !== undefined || results.running().length === 0) return;
|
|
338
|
-
poll = setInterval(() => void tick(), POLL_MS);
|
|
339
|
-
poll.unref?.();
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
return {
|
|
343
|
-
start: startPolling,
|
|
344
|
-
stop(): void {
|
|
345
|
-
live = false;
|
|
346
|
-
stopPolling();
|
|
347
|
-
},
|
|
348
|
-
arm(): void {
|
|
349
|
-
stopPolling();
|
|
350
|
-
live = true;
|
|
351
|
-
},
|
|
352
|
-
};
|
|
353
|
-
}
|
|
354
|
-
|
|
355
276
|
/** The `delegation_status` tool. */
|
|
356
277
|
function statusTool(results: Results, tmux: TmuxClient, readingErrors: () => ReadingError[]): ToolDef {
|
|
357
278
|
return {
|
|
@@ -409,6 +330,46 @@ function statusTool(results: Results, tmux: TmuxClient, readingErrors: () => Rea
|
|
|
409
330
|
};
|
|
410
331
|
}
|
|
411
332
|
|
|
333
|
+
/** Close one delegation: kill its window when it is open and record the close; throws when it cannot. */
|
|
334
|
+
export async function closeDelegation(results: Results, tmux: TmuxClient, id: string): Promise<CloseResult> {
|
|
335
|
+
const [record] = selectRecords(results, id);
|
|
336
|
+
if (!isStarted(record)) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
`delegation ${record.id} is a workspace attempt (${record.attempt.phase}): no verified window id was recorded; a window may still exist, so close is unavailable`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
const closed = (alreadyClosed: boolean, killed: boolean): CloseResult["details"] => ({
|
|
342
|
+
id: record.id,
|
|
343
|
+
windowId: record.windowId,
|
|
344
|
+
windowName: record.windowName,
|
|
345
|
+
closed: true as const,
|
|
346
|
+
alreadyClosed,
|
|
347
|
+
killed,
|
|
348
|
+
});
|
|
349
|
+
if (record.closed === true)
|
|
350
|
+
return { text: `Delegation ${record.id} is already closed.`, details: closed(true, false) };
|
|
351
|
+
let windows: TmuxWindow[];
|
|
352
|
+
try {
|
|
353
|
+
windows = await tmux.listWindows();
|
|
354
|
+
} catch (err) {
|
|
355
|
+
throw new Error(`could not ask tmux about window ${record.windowName}: ${(err as Error).message}`);
|
|
356
|
+
}
|
|
357
|
+
// Only a window with the recorded id and name is ours, exited program or not.
|
|
358
|
+
const alive = ownWindow(windows, record) !== undefined;
|
|
359
|
+
if (alive) {
|
|
360
|
+
try {
|
|
361
|
+
await tmux.kill(record.windowId);
|
|
362
|
+
} catch (err) {
|
|
363
|
+
throw new Error(`could not kill window ${record.windowName} (${record.windowId}): ${(err as Error).message}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
results.recordClose(record.id);
|
|
367
|
+
const how = alive
|
|
368
|
+
? `killed window ${record.windowName} (${record.windowId})`
|
|
369
|
+
: `its window ${record.windowName} was already gone`;
|
|
370
|
+
return { text: `Closed delegation ${record.id}: ${how}.`, details: closed(false, alive) };
|
|
371
|
+
}
|
|
372
|
+
|
|
412
373
|
/** The `delegation_close` tool. */
|
|
413
374
|
function closeTool(results: Results, tmux: TmuxClient): ToolDef {
|
|
414
375
|
return {
|
|
@@ -429,41 +390,8 @@ function closeTool(results: Results, tmux: TmuxClient): ToolDef {
|
|
|
429
390
|
_onUpdate: unknown,
|
|
430
391
|
_ctx: ExtensionContext,
|
|
431
392
|
): Promise<ReturnType<typeof toolResult>> {
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
throw new Error(
|
|
435
|
-
`delegation ${record.id} is a workspace attempt (${record.attempt.phase}): no verified window id was recorded; a window may still exist, so close is unavailable`,
|
|
436
|
-
);
|
|
437
|
-
}
|
|
438
|
-
const closed = (alreadyClosed: boolean, killed: boolean) => ({
|
|
439
|
-
id: record.id,
|
|
440
|
-
windowId: record.windowId,
|
|
441
|
-
windowName: record.windowName,
|
|
442
|
-
closed: true,
|
|
443
|
-
alreadyClosed,
|
|
444
|
-
killed,
|
|
445
|
-
});
|
|
446
|
-
if (record.closed === true) return toolResult(`Delegation ${record.id} is already closed.`, closed(true, false));
|
|
447
|
-
let windows: TmuxWindow[];
|
|
448
|
-
try {
|
|
449
|
-
windows = await tmux.listWindows();
|
|
450
|
-
} catch (err) {
|
|
451
|
-
throw new Error(`could not ask tmux about window ${record.windowName}: ${(err as Error).message}`);
|
|
452
|
-
}
|
|
453
|
-
// Only a window with the recorded id and name is ours, exited program or not.
|
|
454
|
-
const alive = ownWindow(windows, record) !== undefined;
|
|
455
|
-
if (alive) {
|
|
456
|
-
try {
|
|
457
|
-
await tmux.kill(record.windowId);
|
|
458
|
-
} catch (err) {
|
|
459
|
-
throw new Error(`could not kill window ${record.windowName} (${record.windowId}): ${(err as Error).message}`);
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
results.recordClose(record.id);
|
|
463
|
-
const how = alive
|
|
464
|
-
? `killed window ${record.windowName} (${record.windowId})`
|
|
465
|
-
: `its window ${record.windowName} was already gone`;
|
|
466
|
-
return toolResult(`Closed delegation ${record.id}: ${how}.`, closed(false, alive));
|
|
393
|
+
const { text, details } = await closeDelegation(results, tmux, params.id);
|
|
394
|
+
return toolResult(text, details);
|
|
467
395
|
},
|
|
468
396
|
};
|
|
469
397
|
}
|
|
@@ -482,6 +410,8 @@ export function createTracking(
|
|
|
482
410
|
pi.registerTool(closeTool(results, tmux) as never);
|
|
483
411
|
},
|
|
484
412
|
startPolling: poll.start,
|
|
413
|
+
trackPopup: poll.track,
|
|
414
|
+
close: (id: string): Promise<CloseResult> => closeDelegation(results, tmux, id),
|
|
485
415
|
restore(ctx: ExtensionContext): void {
|
|
486
416
|
poll.arm();
|
|
487
417
|
results.restore(ctx);
|
package/src/windows.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Reading tmux's window list against the delegation records.
|
|
2
|
+
import type { StartedRecorded, WindowState } from "./results.ts";
|
|
3
|
+
import type { TmuxClient, TmuxWindow } from "./tmux.ts";
|
|
4
|
+
|
|
5
|
+
/** Every window on the server, asked once; a failed ask is `undefined`, never an empty list. */
|
|
6
|
+
export async function listWindows(tmux: TmuxClient): Promise<TmuxWindow[] | undefined> {
|
|
7
|
+
try {
|
|
8
|
+
return await tmux.listWindows();
|
|
9
|
+
} catch {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The window a delegation owns: one with both its recorded id and its recorded name, since ids restart with every tmux server. */
|
|
15
|
+
export function ownWindow(windows: TmuxWindow[], record: StartedRecorded): TmuxWindow | undefined {
|
|
16
|
+
return windows.find((window) => window.id === record.windowId && window.name === record.windowName);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** What the listing says about a delegation's window; a matching window whose program exited counts as gone. */
|
|
20
|
+
export function windowState(windows: TmuxWindow[] | undefined, record: StartedRecorded): WindowState {
|
|
21
|
+
if (windows === undefined) return "unknown";
|
|
22
|
+
const own = ownWindow(windows, record);
|
|
23
|
+
return own !== undefined && !own.exited ? "open" : "gone";
|
|
24
|
+
}
|
package/src/workspace.ts
CHANGED
|
@@ -119,7 +119,7 @@ export function normalizeWorkspaceValue(value: unknown, name: string): string |
|
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
/** The real path of an existing, accessible directory named by `value`, relative to `base`. */
|
|
122
|
-
function resolveDirectory(value: string, base: string): string {
|
|
122
|
+
export function resolveDirectory(value: string, base: string): string {
|
|
123
123
|
// Only a leading `~` or `~/`: no variables, globs or `~user`.
|
|
124
124
|
let expanded = value;
|
|
125
125
|
if (value === "~") expanded = homedir();
|