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.
@@ -0,0 +1,159 @@
1
+ // The checks a resume makes before it launches anything, shared by `resume`
2
+ // and the resume picker so both accept the same delegations: the call-wide
3
+ // prerequisites once, then per record the window, session file, agent, thinking
4
+ // level and model. None of them changes anything; killing an exited window and
5
+ // deciding trust stay with the resume itself.
6
+ import { statSync } from "node:fs";
7
+ import { isAbsolute } from "node:path";
8
+
9
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+
11
+ import type { Agent } from "./agents.ts";
12
+ import { sessionHeaderCwd, sessionIdOf } from "./result-sessions.ts";
13
+ import { deriveState, finishedModel, locateSession, type StartedRecorded } from "./results.ts";
14
+ import type { ResumeDeps, ResumeRequest } from "./resume.ts";
15
+ import { requireTmuxVersion, type TmuxWindow } from "./tmux.ts";
16
+ import { ownWindow, windowState } from "./tracking.ts";
17
+ import type { WindowPlacement } from "./window-placement.ts";
18
+
19
+ /**
20
+ * Steps 3 and 4: no process of this delegate is left. A running delegate is
21
+ * mailed instead; tmux that cannot be asked proves nothing; a window with the
22
+ * record's id and name that still runs its program is refused, and one whose
23
+ * program exited is killed first.
24
+ */
25
+ export function windowCheck(record: StartedRecorded, windows: TmuxWindow[] | undefined): TmuxWindow | undefined {
26
+ const state = deriveState(record, windowState(windows, record));
27
+ if (state === "running") {
28
+ throw new Error(`delegation ${record.id} is running; send it a message instead of resuming it`);
29
+ }
30
+ if (windows === undefined || state === "unknown") {
31
+ throw new Error(`could not ask tmux whether delegation ${record.id}'s window is open, so it is not resumed`);
32
+ }
33
+ const own = ownWindow(windows, record);
34
+ if (own !== undefined && !own.exited) {
35
+ throw new Error(
36
+ `delegation ${record.id}'s window ${record.windowName} (${record.windowId}) is still open; its Pi may still be running, so it is not resumed. Close it with delegation_close, or send it a message`,
37
+ );
38
+ }
39
+ return own;
40
+ }
41
+
42
+ /** Whether `path` is an existing directory. */
43
+ export function isDirectory(path: string): boolean {
44
+ try {
45
+ return statSync(path).isDirectory();
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ /** A delegate's session file, the directory its header says Pi runs it in, and the session directory to pass. */
52
+ export type SessionFile = { path: string; cwd: string; dir: string };
53
+
54
+ /** Step 5: the session file, and the directory its header says Pi will run it in. */
55
+ export function sessionFor(record: StartedRecorded, ctx: ExtensionContext): SessionFile {
56
+ const dir = record.sessionDir ?? ctx.sessionManager.getSessionDir();
57
+ const path = locateSession(record, ctx.sessionManager.getSessionDir());
58
+ if (path === undefined) {
59
+ throw new Error(
60
+ `no session file for session ${sessionIdOf(record)} was found in ${dir}, so delegation ${record.id} is not resumable`,
61
+ );
62
+ }
63
+ if (!isAbsolute(path) || !isAbsolute(dir)) {
64
+ throw new Error(
65
+ `the session file ${path} or its directory ${dir} is not an absolute path, so delegation ${record.id} is not resumable`,
66
+ );
67
+ }
68
+ const cwd = sessionHeaderCwd(path);
69
+ if (cwd === undefined) {
70
+ throw new Error(
71
+ `could not read a working directory from the header of ${path}, so delegation ${record.id} is not resumable`,
72
+ );
73
+ }
74
+ if (!isDirectory(cwd)) {
75
+ throw new Error(
76
+ `delegation ${record.id}'s session runs in ${cwd}, which no longer exists, so it is not resumable (a removed worktree cannot be resumed)`,
77
+ );
78
+ }
79
+ return { path, cwd, dir };
80
+ }
81
+
82
+ /** The model choices: an explicit model alone, else where the delegate ended, then the agent's fallbacks. */
83
+ export function preferredModels(record: StartedRecorded, agent: Agent, sessionPath: string): string[] {
84
+ const ended = finishedModel(sessionPath) ?? record.result?.finishedModel ?? record.model;
85
+ return [...new Set([ended, ...agent.fallback])];
86
+ }
87
+
88
+ /** Steps 5 and 6: the session file and the agent, and no resume of the delegation already under way. */
89
+ export function recordCheck(
90
+ deps: ResumeDeps,
91
+ record: StartedRecorded,
92
+ ctx: ExtensionContext,
93
+ ): { session: SessionFile; agent: Agent } {
94
+ const session = sessionFor(record, ctx);
95
+ const agent = deps.roster(ctx).agents.find((item) => item.name === record.agent);
96
+ if (agent === undefined) {
97
+ throw new Error(
98
+ `agent ${JSON.stringify(record.agent)} is no longer in the roster, so delegation ${record.id} is not resumed`,
99
+ );
100
+ }
101
+ if (inFlight.has(record.id)) throw new Error(`delegation ${record.id} is already being resumed`);
102
+ return { session, agent };
103
+ }
104
+
105
+ /** The thinking level and model a resume runs with; throws when the level is invalid or no model can be used. */
106
+ export async function settleModel(
107
+ deps: ResumeDeps,
108
+ input: {
109
+ record: StartedRecorded;
110
+ agent: Agent;
111
+ session: SessionFile;
112
+ request: Pick<ResumeRequest, "model" | "thinking">;
113
+ },
114
+ ctx: ExtensionContext,
115
+ ): Promise<{ thinking: string; chosen: Awaited<ReturnType<ResumeDeps["chooseModel"]>> }> {
116
+ const { record, agent, session, request } = input;
117
+ const thinking = request.thinking ?? record.thinking;
118
+ const badThinking = deps.thinkingProblem(thinking);
119
+ if (badThinking !== undefined) throw new Error(badThinking);
120
+ const preferred = preferredModels(record, agent, session.path);
121
+ return { thinking, chosen: await deps.chooseModel(request.model, agent, ctx.modelRegistry, preferred) };
122
+ }
123
+
124
+ /** The call-wide prerequisites of a resume: inside tmux, a new enough tmux, a valid placement setting (read once). */
125
+ export async function preflight(deps: ResumeDeps): Promise<WindowPlacement> {
126
+ if (!deps.tmux.insideTmux()) throw new Error(deps.notInTmux);
127
+ await requireTmuxVersion(deps.tmux);
128
+ return deps.placement();
129
+ }
130
+
131
+ /** The delegation ids with a resume under way in this process. */
132
+ export const inFlight = new Set<string>();
133
+
134
+ /** Whether `record` passes every check a resume makes that changes nothing; the resume's own kill of an exited window and trust prompt are left to it. */
135
+ export async function passes(
136
+ deps: ResumeDeps,
137
+ record: StartedRecorded,
138
+ windows: TmuxWindow[],
139
+ ctx: ExtensionContext,
140
+ models: Map<string, boolean>,
141
+ ): Promise<boolean> {
142
+ try {
143
+ windowCheck(record, windows);
144
+ const { session, agent } = recordCheck(deps, record, ctx);
145
+ // Model choice depends on the agent and where the delegate ended, so records alike share one answer.
146
+ const key = JSON.stringify([record.thinking, agent.name, preferredModels(record, agent, session.path)]);
147
+ let ok = models.get(key);
148
+ if (ok === undefined) {
149
+ ok = await settleModel(deps, { record, agent, session, request: {} }, ctx).then(
150
+ () => true,
151
+ () => false,
152
+ );
153
+ models.set(key, ok);
154
+ }
155
+ return ok;
156
+ } catch {
157
+ return false;
158
+ }
159
+ }
@@ -0,0 +1,50 @@
1
+ // `/delegate-resume` with no arguments: pick one of the delegations a resume
2
+ // would accept, write the follow-up in the multi-line editor, then resume it
3
+ // the way the command does.
4
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+
6
+ import { recordedName, type StartedRecorded } from "./results.ts";
7
+
8
+ /** What the picker reads from the resume path in `index.ts`. */
9
+ export type ResumePickerDeps = {
10
+ /** Why nothing can resume now (not inside tmux), or undefined. */
11
+ unavailable(): string | undefined;
12
+ /** The delegations a resume accepts now, newest first. */
13
+ eligible(ctx: ExtensionContext): Promise<StartedRecorded[]>;
14
+ /** Resume as the command does, notifying the start or the refusal. */
15
+ resume(request: { id: string; task: string }, ctx: ExtensionContext): Promise<void>;
16
+ };
17
+
18
+ /** One picker line; the id prefix keeps two delegations with one name apart. */
19
+ const optionFor = (record: StartedRecorded): string =>
20
+ `${recordedName(record)} · ${record.agent} · ${record.id.slice(0, 8)}`;
21
+
22
+ export async function pickResume(ctx: ExtensionContext, deps: ResumePickerDeps): Promise<void> {
23
+ const unavailable = deps.unavailable();
24
+ if (unavailable !== undefined) {
25
+ ctx.ui.notify(`delegate: ${unavailable}`, "error");
26
+ return;
27
+ }
28
+ let records: StartedRecorded[];
29
+ try {
30
+ records = await deps.eligible(ctx);
31
+ } catch (err) {
32
+ ctx.ui.notify(`delegate: ${(err as Error).message}`, "error");
33
+ return;
34
+ }
35
+ if (records.length === 0) {
36
+ ctx.ui.notify("delegate: no delegation can be resumed now", "info");
37
+ return;
38
+ }
39
+ const options = records.map(optionFor);
40
+ const picked = await ctx.ui.select("Resume which delegation?", options);
41
+ const record = records[options.indexOf(picked ?? "")];
42
+ if (record === undefined) return;
43
+ const task = await ctx.ui.editor(`Follow-up for ${recordedName(record)}`);
44
+ if (task === undefined) return;
45
+ if (task.trim() === "") {
46
+ ctx.ui.notify("delegate: a task is required", "error");
47
+ return;
48
+ }
49
+ await deps.resume({ id: record.id, task }, ctx);
50
+ }
package/src/resume.ts CHANGED
@@ -5,25 +5,24 @@
5
5
  // `index.ts` supplies the pieces it shares with a launch: model choice, trust,
6
6
  // the task mail and the window.
7
7
  import { randomUUID } from "node:crypto";
8
- import { statSync } from "node:fs";
9
- import { isAbsolute } from "node:path";
10
8
 
11
9
  import type { ExtensionContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
12
10
  import { type TObject, Type } from "typebox";
13
11
 
14
12
  import { type Agent, type Roster, THINKING_LEVELS } from "./agents.ts";
15
- import { sessionHeaderCwd, sessionIdOf } from "./result-sessions.ts";
13
+ import { sessionIdOf } from "./result-sessions.ts";
14
+ import { isStarted, type Results, recordedName, type StartedRecorded } from "./results.ts";
16
15
  import {
17
- deriveState,
18
- finishedModel,
19
- isStarted,
20
- locateSession,
21
- type Results,
22
- recordedName,
23
- type StartedRecorded,
24
- } from "./results.ts";
25
- import { type OpenedWindow, requireTmuxVersion, type TmuxClient } from "./tmux.ts";
26
- import { listWindows, ownWindow, type ToolDef, toolResult, windowState } from "./tracking.ts";
16
+ inFlight,
17
+ passes,
18
+ preflight,
19
+ recordCheck,
20
+ type SessionFile,
21
+ settleModel,
22
+ windowCheck,
23
+ } from "./resume-checks.ts";
24
+ import type { OpenedWindow, TmuxClient } from "./tmux.ts";
25
+ import { listWindows, type ToolDef, toolResult } from "./tracking.ts";
27
26
  import type { WindowPlacement } from "./window-placement.ts";
28
27
  import type { Workspace } from "./workspace.ts";
29
28
 
@@ -142,28 +141,10 @@ function newestStarted(results: Results, id: string): StartedRecorded {
142
141
  return record;
143
142
  }
144
143
 
145
- /**
146
- * Steps 3 and 4: no process of this delegate is left. A running delegate is
147
- * mailed instead; tmux that cannot be asked proves nothing; a window with the
148
- * record's id and name that still runs its program is refused, and one whose
149
- * program exited is killed first.
150
- */
144
+ /** Steps 3 and 4, then the kill of an exited window; `windowCheck` is the part the picker shares. */
151
145
  async function ensureExited(record: StartedRecorded, tmux: TmuxClient): Promise<void> {
152
- const windows = await listWindows(tmux);
153
- const state = deriveState(record, windowState(windows, record));
154
- if (state === "running") {
155
- throw new Error(`delegation ${record.id} is running; send it a message instead of resuming it`);
156
- }
157
- if (windows === undefined || state === "unknown") {
158
- throw new Error(`could not ask tmux whether delegation ${record.id}'s window is open, so it is not resumed`);
159
- }
160
- const own = ownWindow(windows, record);
146
+ const own = windowCheck(record, await listWindows(tmux));
161
147
  if (own === undefined) return;
162
- if (!own.exited) {
163
- throw new Error(
164
- `delegation ${record.id}'s window ${record.windowName} (${record.windowId}) is still open; its Pi may still be running, so it is not resumed. Close it with delegation_close, or send it a message`,
165
- );
166
- }
167
148
  try {
168
149
  await tmux.kill(own.id);
169
150
  } catch (err) {
@@ -173,52 +154,6 @@ async function ensureExited(record: StartedRecorded, tmux: TmuxClient): Promise<
173
154
  }
174
155
  }
175
156
 
176
- /** Whether `path` is an existing directory. */
177
- function isDirectory(path: string): boolean {
178
- try {
179
- return statSync(path).isDirectory();
180
- } catch {
181
- return false;
182
- }
183
- }
184
-
185
- /** A delegate's session file, the directory its header says Pi runs it in, and the session directory to pass. */
186
- type SessionFile = { path: string; cwd: string; dir: string };
187
-
188
- /** Step 5: the session file, and the directory its header says Pi will run it in. */
189
- function sessionFor(record: StartedRecorded, ctx: ExtensionContext): SessionFile {
190
- const dir = record.sessionDir ?? ctx.sessionManager.getSessionDir();
191
- const path = locateSession(record, ctx.sessionManager.getSessionDir());
192
- if (path === undefined) {
193
- throw new Error(
194
- `no session file for session ${sessionIdOf(record)} was found in ${dir}, so delegation ${record.id} is not resumable`,
195
- );
196
- }
197
- if (!isAbsolute(path) || !isAbsolute(dir)) {
198
- throw new Error(
199
- `the session file ${path} or its directory ${dir} is not an absolute path, so delegation ${record.id} is not resumable`,
200
- );
201
- }
202
- const cwd = sessionHeaderCwd(path);
203
- if (cwd === undefined) {
204
- throw new Error(
205
- `could not read a working directory from the header of ${path}, so delegation ${record.id} is not resumable`,
206
- );
207
- }
208
- if (!isDirectory(cwd)) {
209
- throw new Error(
210
- `delegation ${record.id}'s session runs in ${cwd}, which no longer exists, so it is not resumable (a removed worktree cannot be resumed)`,
211
- );
212
- }
213
- return { path, cwd, dir };
214
- }
215
-
216
- /** The model choices: an explicit model alone, else where the delegate ended, then the agent's fallbacks. */
217
- function preferredModels(record: StartedRecorded, agent: Agent, sessionPath: string): string[] {
218
- const ended = finishedModel(sessionPath) ?? record.result?.finishedModel ?? record.model;
219
- return [...new Set([ended, ...agent.fallback])];
220
- }
221
-
222
157
  /** The tool's result: the `delegate` tool's plus the delegation it resumed. */
223
158
  function resultSchema(delegateSchema: TObject): TObject {
224
159
  return Type.Object({
@@ -243,11 +178,7 @@ async function planResume(
243
178
  signal: AbortSignal | undefined,
244
179
  ): Promise<Plan> {
245
180
  const { record, agent, session, request } = input;
246
- const thinking = request.thinking ?? record.thinking;
247
- const badThinking = deps.thinkingProblem(thinking);
248
- if (badThinking !== undefined) throw new Error(badThinking);
249
- const preferred = preferredModels(record, agent, session.path);
250
- const chosen = await deps.chooseModel(request.model, agent, ctx.modelRegistry, preferred);
181
+ const { thinking, chosen } = await settleModel(deps, input, ctx);
251
182
  const autoExit = request.autoExit ?? record.autoExit === true;
252
183
  const prepared = await deps.prepareDirectory(session.cwd, ctx, signal);
253
184
  // A managed worktree keeps its launch facts while the session still runs in it.
@@ -350,9 +281,6 @@ async function startResume(
350
281
  };
351
282
  }
352
283
 
353
- /** The delegation ids with a resume under way in this process. */
354
- const inFlight = new Set<string>();
355
-
356
284
  /** Resume `request.id`: every eligibility check in order, then the launch. */
357
285
  async function resume(
358
286
  deps: ResumeDeps,
@@ -360,20 +288,11 @@ async function resume(
360
288
  ctx: ExtensionContext,
361
289
  signal: AbortSignal | undefined,
362
290
  ): Promise<Resumed> {
363
- if (!deps.tmux.insideTmux()) throw new Error(deps.notInTmux);
364
- await requireTmuxVersion(deps.tmux);
365
- const placement = deps.placement(); // read once, before the exited window is killed or anything is mailed
291
+ const placement = await preflight(deps); // read once, before the exited window is killed or anything is mailed
366
292
  if (request.task.trim() === "") throw new Error("a task is required");
367
293
  const record = newestStarted(deps.results, request.id);
368
294
  await ensureExited(record, deps.tmux);
369
- const session = sessionFor(record, ctx);
370
- const agent = deps.roster(ctx).agents.find((item) => item.name === record.agent);
371
- if (agent === undefined) {
372
- throw new Error(
373
- `agent ${JSON.stringify(record.agent)} is no longer in the roster, so delegation ${record.id} is not resumed`,
374
- );
375
- }
376
- if (inFlight.has(record.id)) throw new Error(`delegation ${record.id} is already being resumed`);
295
+ const { session, agent } = recordCheck(deps, record, ctx);
377
296
  inFlight.add(record.id);
378
297
  try {
379
298
  const plan = await planResume(deps, { record, agent, session, request }, ctx, signal);
@@ -387,6 +306,25 @@ async function resume(
387
306
  }
388
307
  }
389
308
 
309
+ /**
310
+ * The delegations that pass every check `resume` makes, newest first, read
311
+ * with one `listWindows` and changing nothing: an exited window is left for
312
+ * the resume itself to kill and trust is left for its prompt. The call-wide
313
+ * prerequisites are checked once and refuse as `resume` does.
314
+ */
315
+ async function eligible(deps: ResumeDeps, ctx: ExtensionContext): Promise<StartedRecorded[]> {
316
+ await preflight(deps);
317
+ const windows = await listWindows(deps.tmux);
318
+ if (windows === undefined) return [];
319
+ const ids = new Set(resumableIds(deps.results));
320
+ const models = new Map<string, boolean>();
321
+ const found: StartedRecorded[] = [];
322
+ for (const record of startedRecords(deps.results)) {
323
+ if (ids.has(record.id) && (await passes(deps, record, windows, ctx, models))) found.push(record);
324
+ }
325
+ return found.reverse();
326
+ }
327
+
390
328
  /** The session a window opened in, for the start text. */
391
329
  const inSession = (session: string | undefined): string => (session === undefined ? "" : ` in session ${session}`);
392
330
 
@@ -463,9 +401,12 @@ function requestFrom(params: {
463
401
  /** Resuming as a function for the command, and the tool for the model. */
464
402
  export function createResume(deps: ResumeDeps): {
465
403
  resume(request: ResumeRequest, ctx: ExtensionContext, signal?: AbortSignal): Promise<Resumed>;
404
+ /** The delegations a resume would accept now, newest first. */
405
+ eligible(ctx: ExtensionContext): Promise<StartedRecorded[]>;
466
406
  tool(): ToolDef;
467
407
  } {
468
408
  return {
409
+ eligible: (ctx: ExtensionContext) => eligible(deps, ctx),
469
410
  resume: (request: ResumeRequest, ctx: ExtensionContext, signal?: AbortSignal) => resume(deps, request, ctx, signal),
470
411
  tool: () => resumeTool(deps),
471
412
  };
package/src/tmux.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // The tmux operations `delegate` needs, behind an interface so tests can use a
2
2
  // fake. This client talks to tmux with `execFile` and no shell.
3
- import { execFile } from "node:child_process";
3
+ import { execFile, spawn } from "node:child_process";
4
4
 
5
5
  import type { WindowPlacement } from "./window-placement.ts";
6
6
 
@@ -62,6 +62,12 @@ export class WindowOpenError extends Error {
62
62
  }
63
63
  }
64
64
 
65
+ /** A window as the popup needs it: its id and its name, which titles the popup. */
66
+ export type PopupWindow = { id: string; name: string };
67
+
68
+ /** The session the parent's pane is in: its id (`$N`) and name. */
69
+ export type OwnSession = { id: string; name: string };
70
+
65
71
  export interface TmuxClient {
66
72
  /** Whether this process runs in a tmux pane. */
67
73
  insideTmux(): boolean;
@@ -77,6 +83,24 @@ export interface TmuxClient {
77
83
  listWindows(): Promise<TmuxWindow[]>;
78
84
  /** Kill a window. */
79
85
  kill(windowId: string): Promise<void>;
86
+ /** The session the parent's pane is in; undefined when it cannot be told. */
87
+ ownSession(): Promise<OwnSession | undefined>;
88
+ /** The most recently active client attached to the session named `session`; undefined when none is. */
89
+ clientFor(session: string): Promise<string | undefined>;
90
+ /** Show the window to a client: `switch-client -c <client> -t @N`. */
91
+ switchTo(client: string, windowId: string): Promise<void>;
92
+ /**
93
+ * Open a popup over a client that attaches to the window's session at the
94
+ * window. `display-popup` returns only when the popup closes, so this
95
+ * resolves once the popup was launched, not when it ends, and a launch that
96
+ * fails at once rejects. `onExit` runs when the popup's process ends, however
97
+ * the popup was closed, and not when the launch failed.
98
+ */
99
+ popup(client: string, window: PopupWindow, onExit: () => void): Promise<void>;
100
+ /** Close the popup on a client (`display-popup -C`). */
101
+ closePopup(client: string): Promise<void>;
102
+ /** The window's visible pane as text (`capture-pane -p`). */
103
+ capture(windowId: string): Promise<string>;
80
104
  }
81
105
 
82
106
  /** Run one tmux command and return its stdout; never a shell. */
@@ -115,6 +139,48 @@ async function parentSession(run: Run): Promise<Parent | undefined> {
115
139
  return id.trim() === "" ? undefined : { id: id.trim(), name: name.join("\t"), companion: marker === "1" };
116
140
  }
117
141
 
142
+ /** How long a launched popup may take to fail before it counts as shown. */
143
+ const POPUP_SETTLE_MS = 300;
144
+
145
+ /**
146
+ * Launch `display-popup`, which blocks until the popup closes, detached and
147
+ * outside `runTmux`'s timeout. Resolves once it is up; rejects on a spawn
148
+ * error or an exit within the settle time. Its stderr is read only until then
149
+ * and then released, so the open popup keeps nothing of this process alive;
150
+ * `onExit` runs when the popup's process ends, however it was closed.
151
+ */
152
+ function launchPopup(args: string[], onExit: () => void): Promise<void> {
153
+ return new Promise((resolve, reject) => {
154
+ let settled = false;
155
+ let stderr = "";
156
+ const child = spawn("tmux", args, { detached: true, stdio: ["ignore", "ignore", "pipe"] });
157
+ const onData = (chunk: Buffer): void => {
158
+ if (stderr.length < 4096) stderr += chunk.toString();
159
+ };
160
+ child.stderr?.on("data", onData);
161
+ const settle = (error?: Error): void => {
162
+ if (settled) return;
163
+ settled = true;
164
+ clearTimeout(timer);
165
+ child.stderr?.off("data", onData);
166
+ child.stderr?.destroy();
167
+ if (error === undefined) resolve();
168
+ else reject(error);
169
+ };
170
+ const timer = setTimeout(() => settle(), POPUP_SETTLE_MS);
171
+ child.on("error", (err) => settle(err));
172
+ child.on("close", (code) => {
173
+ if (!settled && code !== 0) {
174
+ settle(new Error(stderr.trim() !== "" ? stderr.trim() : `tmux display-popup exited with ${code}`));
175
+ return;
176
+ }
177
+ settle();
178
+ onExit();
179
+ });
180
+ child.unref();
181
+ });
182
+ }
183
+
118
184
  const envArgs = (spec: WindowSpec): string[] =>
119
185
  Object.entries(spec.env).flatMap(([key, value]) => ["-e", `${key}=${value}`]);
120
186
 
@@ -184,6 +250,54 @@ function openCurrent(run: Run, spec: WindowSpec, parent: Parent | undefined): Pr
184
250
  return create(run, args);
185
251
  }
186
252
 
253
+ /** The methods that reach a delegate's window from the user's terminal: clients, switch, popup, capture. */
254
+ function navigation(
255
+ run: Run,
256
+ ): Pick<TmuxClient, "ownSession" | "clientFor" | "switchTo" | "popup" | "closePopup" | "capture"> {
257
+ return {
258
+ async ownSession() {
259
+ const parent = await parentSession(run);
260
+ return parent === undefined || parent.name === "" ? undefined : { id: parent.id, name: parent.name };
261
+ },
262
+
263
+ async clientFor(session) {
264
+ const out = await run(["list-clients", "-t", `=${session}:`, "-F", "#{client_activity}\t#{client_name}"]);
265
+ let best: { activity: number; name: string } | undefined;
266
+ for (const line of out.split("\n")) {
267
+ const [activity, ...name] = line.split("\t");
268
+ const at = Number(activity);
269
+ if (name.length === 0 || Number.isNaN(at)) continue;
270
+ if (best === undefined || at > best.activity) best = { activity: at, name: name.join("\t") };
271
+ }
272
+ return best?.name;
273
+ },
274
+
275
+ async switchTo(client, windowId) {
276
+ await run(["switch-client", "-c", client, "-t", windowId]);
277
+ },
278
+
279
+ async popup(client, window, onExit) {
280
+ if (!/^@[0-9]+$/.test(window.id)) throw new Error(`${window.id} is not a tmux window id`);
281
+ const sessionId = (await run(["display-message", "-p", "-t", window.id, "-F", "#{session_id}"])).trim();
282
+ if (!/^\$[0-9]+$/.test(sessionId)) throw new Error(`tmux reported an invalid session id for ${window.id}`);
283
+ // Ids are `@N` and `$N` only; quoted so the popup's shell does not expand `$N`.
284
+ const command = `tmux select-window -t '${window.id}' \\; attach-session -t '${sessionId}'`;
285
+ await launchPopup(
286
+ ["display-popup", "-c", client, "-E", "-w", "90%", "-h", "90%", "-T", window.name, command],
287
+ onExit,
288
+ );
289
+ },
290
+
291
+ async closePopup(client) {
292
+ await run(["display-popup", "-C", "-c", client]);
293
+ },
294
+
295
+ async capture(windowId) {
296
+ return run(["capture-pane", "-p", "-t", windowId]);
297
+ },
298
+ };
299
+ }
300
+
187
301
  /**
188
302
  * The real client. A window is opened relative to the session of the pane in
189
303
  * `TMUX_PANE`, not whatever session a client happens to have attached, so it
@@ -239,5 +353,7 @@ export function createTmuxClient(options: { timeoutMs?: number } = {}): TmuxClie
239
353
  async kill(windowId) {
240
354
  await run(["kill-window", "-t", windowId]);
241
355
  },
356
+
357
+ ...navigation(run),
242
358
  };
243
359
  }