pi-squire 0.11.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,
@@ -74,6 +80,9 @@ const StartedSchema = Type.Object({
74
80
  resumedFrom: Type.Optional(Type.String({ description: "The delegation whose session this one reopened." })),
75
81
  windowId: Type.String(),
76
82
  windowName: Type.String(),
83
+ windowSession: Type.Optional(
84
+ Type.String({ description: "The tmux session its window is in now; absent when the window is gone." }),
85
+ ),
77
86
  cwd: Type.Optional(
78
87
  Type.String({ description: "The directory its window opened in; absent for records from before it was kept." }),
79
88
  ),
@@ -132,27 +141,6 @@ export type ToolDef = {
132
141
  execute: (...args: never[]) => Promise<ReturnType<typeof toolResult>>;
133
142
  };
134
143
 
135
- /** Every window on the server, asked once; a failed ask is `undefined`, never an empty list. */
136
- export async function listWindows(tmux: TmuxClient): Promise<TmuxWindow[] | undefined> {
137
- try {
138
- return await tmux.listWindows();
139
- } catch {
140
- return undefined;
141
- }
142
- }
143
-
144
- /** The window a delegation owns: one with both its recorded id and its recorded name, since ids restart with every tmux server. */
145
- export function ownWindow(windows: TmuxWindow[], record: StartedRecorded): TmuxWindow | undefined {
146
- return windows.find((window) => window.id === record.windowId && window.name === record.windowName);
147
- }
148
-
149
- /** What the listing says about a delegation's window; a matching window whose program exited counts as gone. */
150
- export function windowState(windows: TmuxWindow[] | undefined, record: StartedRecorded): WindowState {
151
- if (windows === undefined) return "unknown";
152
- const own = ownWindow(windows, record);
153
- return own !== undefined && !own.exited ? "open" : "gone";
154
- }
155
-
156
144
  /** One delegation as `delegation_status` reports it; its text and details both read from this. */
157
145
  type View = {
158
146
  record: StartedRecorded;
@@ -165,6 +153,8 @@ type View = {
165
153
  snapshot: WorkspaceSnapshot;
166
154
  /** The delegation that resumed this one, if any. */
167
155
  resumedAs: string | undefined;
156
+ /** The session its window is in now, looked up; undefined when the window is gone or unknown. */
157
+ windowSession: string | undefined;
168
158
  };
169
159
 
170
160
  /** The view of a delegation: its state and the fields the text and details share. */
@@ -185,11 +175,12 @@ function view(
185
175
  }
186
176
  }
187
177
  const snapshot = inspectWorkspace(record.workspace?.cwd, record.workspace?.git);
188
- return { record, state, name: recordedName(record), sessionPath, lastActive, snapshot, resumedAs };
178
+ const windowSession = windows === undefined ? undefined : ownWindow(windows, record)?.session;
179
+ return { record, state, name: recordedName(record), sessionPath, lastActive, snapshot, resumedAs, windowSession };
189
180
  }
190
181
 
191
182
  /** The text block for one delegation. */
192
- function viewText({ record, state, name, sessionPath, lastActive, snapshot, resumedAs }: View): string {
183
+ function viewText({ record, state, name, sessionPath, lastActive, snapshot, resumedAs, windowSession }: View): string {
193
184
  const [checkout, ...categories] = snapshotLines(snapshot);
194
185
  const seen = lastActive === undefined ? "" : ` (last active ${ago(Date.now() - lastActive.getTime())})`;
195
186
  const lines = [
@@ -198,7 +189,7 @@ function viewText({ record, state, name, sessionPath, lastActive, snapshot, resu
198
189
  ...(record.resumedFrom === undefined ? [] : [` resumed from ${record.resumedFrom}`]),
199
190
  ...(resumedAs === undefined ? [] : [` resumed as ${resumedAs}`]),
200
191
  ` state: ${stateLine(state, record)}${seen}`,
201
- ` window: ${record.windowName} (${record.windowId})`,
192
+ ` window: ${record.windowName} (${record.windowId})${windowSession === undefined || windowSession === "" ? "" : ` in session ${windowSession}`}`,
202
193
  ` cwd: ${cwdLine(record.cwd)}`,
203
194
  ...(record.workspace === undefined ? [] : [` workspace: ${workspaceLine(record.workspace)}`]),
204
195
  ` checkout now: ${checkout}`,
@@ -211,7 +202,15 @@ function viewText({ record, state, name, sessionPath, lastActive, snapshot, resu
211
202
  }
212
203
 
213
204
  /** The structured details for one delegation. */
214
- function viewDetails({ record, state, name, sessionPath, lastActive, snapshot }: View): Record<string, unknown> {
205
+ function viewDetails({
206
+ record,
207
+ state,
208
+ name,
209
+ sessionPath,
210
+ lastActive,
211
+ snapshot,
212
+ windowSession,
213
+ }: View): Record<string, unknown> {
215
214
  return {
216
215
  kind: "started",
217
216
  id: record.id,
@@ -224,6 +223,7 @@ function viewDetails({ record, state, name, sessionPath, lastActive, snapshot }:
224
223
  ...(record.resumedFrom === undefined ? {} : { resumedFrom: record.resumedFrom }),
225
224
  windowId: record.windowId,
226
225
  windowName: record.windowName,
226
+ ...(windowSession === undefined || windowSession === "" ? {} : { windowSession }),
227
227
  ...(record.cwd === undefined ? {} : { cwd: record.cwd }),
228
228
  ...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
229
229
  ...(record.workspace === undefined ? {} : { workspace: record.workspace }),
@@ -273,70 +273,6 @@ function selectRecords(results: Results, id: string | undefined): Recorded[] {
273
273
  return records;
274
274
  }
275
275
 
276
- /** The poll over the records and tmux. `arm` readies it for a new session; `stop` ends it for good. */
277
- function createPoll(
278
- pi: ExtensionAPI,
279
- results: Results,
280
- tmux: TmuxClient,
281
- ): { start(): void; stop(): void; arm(): void } {
282
- /** The poll timer; set only while some delegation is running. */
283
- let poll: ReturnType<typeof setInterval> | undefined;
284
- let ticking = false;
285
- /** False once the session shuts down, so a tick still awaiting tmux declares nothing. */
286
- let live = true;
287
-
288
- function stopPolling(): void {
289
- if (poll !== undefined) clearInterval(poll);
290
- poll = undefined;
291
- }
292
-
293
- /** Record gone each running delegation whose window is missing or exited; a failed list declares nothing. */
294
- function declareGone(windows: TmuxWindow[] | undefined): void {
295
- if (windows === undefined) return;
296
- for (const record of results.running()) {
297
- if (windowState(windows, record) !== "gone" || !live) continue;
298
- // A reply may have landed while the windows before this one were checked.
299
- pi.events.emit(SCAN, {});
300
- results.recordGone(record.id);
301
- }
302
- }
303
-
304
- /** Claim waiting replies first, then declare gone every delegation whose window is missing or exited with still no result. */
305
- async function tick(): Promise<void> {
306
- if (ticking) return;
307
- ticking = true;
308
- try {
309
- if (results.running().length === 0) return stopPolling();
310
- const scan: { scanned?: boolean } = {};
311
- pi.events.emit(SCAN, scan);
312
- if (scan.scanned !== true) return; // no message:* provider: a waiting reply cannot be ruled out
313
- declareGone(await listWindows(tmux)); // one ask per tick
314
- if (results.running().length === 0) stopPolling();
315
- } finally {
316
- ticking = false;
317
- }
318
- }
319
-
320
- /** Start the timer when something is running; it never keeps the process alive. */
321
- function startPolling(): void {
322
- if (poll !== undefined || results.running().length === 0) return;
323
- poll = setInterval(() => void tick(), POLL_MS);
324
- poll.unref?.();
325
- }
326
-
327
- return {
328
- start: startPolling,
329
- stop(): void {
330
- live = false;
331
- stopPolling();
332
- },
333
- arm(): void {
334
- stopPolling();
335
- live = true;
336
- },
337
- };
338
- }
339
-
340
276
  /** The `delegation_status` tool. */
341
277
  function statusTool(results: Results, tmux: TmuxClient, readingErrors: () => ReadingError[]): ToolDef {
342
278
  return {
@@ -394,6 +330,46 @@ function statusTool(results: Results, tmux: TmuxClient, readingErrors: () => Rea
394
330
  };
395
331
  }
396
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
+
397
373
  /** The `delegation_close` tool. */
398
374
  function closeTool(results: Results, tmux: TmuxClient): ToolDef {
399
375
  return {
@@ -414,41 +390,8 @@ function closeTool(results: Results, tmux: TmuxClient): ToolDef {
414
390
  _onUpdate: unknown,
415
391
  _ctx: ExtensionContext,
416
392
  ): Promise<ReturnType<typeof toolResult>> {
417
- const [record] = selectRecords(results, params.id);
418
- if (!isStarted(record)) {
419
- throw new Error(
420
- `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`,
421
- );
422
- }
423
- const closed = (alreadyClosed: boolean, killed: boolean) => ({
424
- id: record.id,
425
- windowId: record.windowId,
426
- windowName: record.windowName,
427
- closed: true,
428
- alreadyClosed,
429
- killed,
430
- });
431
- if (record.closed === true) return toolResult(`Delegation ${record.id} is already closed.`, closed(true, false));
432
- let windows: TmuxWindow[];
433
- try {
434
- windows = await tmux.listWindows();
435
- } catch (err) {
436
- throw new Error(`could not ask tmux about window ${record.windowName}: ${(err as Error).message}`);
437
- }
438
- // Only a window with the recorded id and name is ours, exited program or not.
439
- const alive = ownWindow(windows, record) !== undefined;
440
- if (alive) {
441
- try {
442
- await tmux.kill(record.windowId);
443
- } catch (err) {
444
- throw new Error(`could not kill window ${record.windowName} (${record.windowId}): ${(err as Error).message}`);
445
- }
446
- }
447
- results.recordClose(record.id);
448
- const how = alive
449
- ? `killed window ${record.windowName} (${record.windowId})`
450
- : `its window ${record.windowName} was already gone`;
451
- 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);
452
395
  },
453
396
  };
454
397
  }
@@ -467,6 +410,8 @@ export function createTracking(
467
410
  pi.registerTool(closeTool(results, tmux) as never);
468
411
  },
469
412
  startPolling: poll.start,
413
+ trackPopup: poll.track,
414
+ close: (id: string): Promise<CloseResult> => closeDelegation(results, tmux, id),
470
415
  restore(ctx: ExtensionContext): void {
471
416
  poll.arm();
472
417
  results.restore(ctx);
@@ -0,0 +1,31 @@
1
+ // Call-time choice of where delegate windows open. `companion` (the default)
2
+ // opens them in a session of their own, `<parent's session>-squire`; `current`
3
+ // opens them in the parent's own session, as before. Read from
4
+ // `<agent dir>/squire/settings.json` at every launch, like `worktreeRoot`.
5
+ import { readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+
8
+ export type WindowPlacement = "companion" | "current";
9
+
10
+ /**
11
+ * The placement for this call. An unknown value refuses with the file named.
12
+ * A file that cannot be read or parsed is left to the worktree settings,
13
+ * which report it when a launch needs them: it does not stop the launches
14
+ * that never read it, so those keep the default.
15
+ */
16
+ export function windowPlacement(agentDir: string): WindowPlacement {
17
+ const file = join(agentDir, "squire", "settings.json");
18
+ let parsed: unknown;
19
+ try {
20
+ parsed = JSON.parse(readFileSync(file, "utf8"));
21
+ } catch {
22
+ return "companion";
23
+ }
24
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return "companion";
25
+ const value = (parsed as Record<string, unknown>).windowPlacement;
26
+ if (value === undefined || value === "companion") return "companion";
27
+ if (value === "current") return "current";
28
+ throw new Error(
29
+ `invalid window placement settings ${file}: windowPlacement must be "companion" or "current", not ${JSON.stringify(value)}`,
30
+ );
31
+ }
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();