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/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
- /** The hook a tick emits so the provider claims waiting replies before windows are checked. */
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 [record] = selectRecords(results, params.id);
433
- if (!isStarted(record)) {
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();