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.
@@ -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,25 @@
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 { 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";
26
+ import type { WindowPlacement } from "./window-placement.ts";
27
27
  import type { Workspace } from "./workspace.ts";
28
28
 
29
29
  /** What a resume asks for; the fields after `task` are optional overrides. */
@@ -49,6 +49,8 @@ export type ResumeDeps = {
49
49
  /** The refusal text for a session outside tmux. */
50
50
  notInTmux: string;
51
51
  startPolling(): void;
52
+ /** The window placement for this call; throws on an invalid setting. */
53
+ placement(): WindowPlacement;
52
54
  roster(ctx: ExtensionContext): Roster;
53
55
  chooseModel(
54
56
  explicit: string | undefined,
@@ -65,7 +67,7 @@ export type ResumeDeps = {
65
67
  ): Promise<{ workspace: Workspace; trusted: boolean }>;
66
68
  /** Mail the task to a session id; returns the request id. */
67
69
  sendTask(to: string, task: string): string;
68
- /** Open the window with `--session <path>`; returns its id. */
70
+ /** Open the window with `--session <path>`; returns its id and session. */
69
71
  open(input: {
70
72
  id: string;
71
73
  name: string;
@@ -77,7 +79,8 @@ export type ResumeDeps = {
77
79
  start: { agent: Agent; model: string; rest: string[]; thinking: string; autoExit: boolean };
78
80
  toolCallId: string | undefined;
79
81
  ctx: ExtensionContext;
80
- }): Promise<string>;
82
+ placement: WindowPlacement;
83
+ }): Promise<OpenedWindow>;
81
84
  };
82
85
 
83
86
  /** A started resume: what the tool and the command report. */
@@ -92,6 +95,8 @@ export type Resumed = {
92
95
  autoExit: boolean;
93
96
  windowId: string;
94
97
  windowName: string;
98
+ /** The session the window opened in, when tmux could say. */
99
+ windowSession?: string;
95
100
  cwd: string;
96
101
  sessionDir: string;
97
102
  workspace: Workspace;
@@ -136,28 +141,10 @@ function newestStarted(results: Results, id: string): StartedRecorded {
136
141
  return record;
137
142
  }
138
143
 
139
- /**
140
- * Steps 3 and 4: no process of this delegate is left. A running delegate is
141
- * mailed instead; tmux that cannot be asked proves nothing; a window with the
142
- * record's id and name that still runs its program is refused, and one whose
143
- * program exited is killed first.
144
- */
144
+ /** Steps 3 and 4, then the kill of an exited window; `windowCheck` is the part the picker shares. */
145
145
  async function ensureExited(record: StartedRecorded, tmux: TmuxClient): Promise<void> {
146
- const windows = await listWindows(tmux);
147
- const state = deriveState(record, windowState(windows, record));
148
- if (state === "running") {
149
- throw new Error(`delegation ${record.id} is running; send it a message instead of resuming it`);
150
- }
151
- if (windows === undefined || state === "unknown") {
152
- throw new Error(`could not ask tmux whether delegation ${record.id}'s window is open, so it is not resumed`);
153
- }
154
- const own = ownWindow(windows, record);
146
+ const own = windowCheck(record, await listWindows(tmux));
155
147
  if (own === undefined) return;
156
- if (!own.exited) {
157
- throw new Error(
158
- `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`,
159
- );
160
- }
161
148
  try {
162
149
  await tmux.kill(own.id);
163
150
  } catch (err) {
@@ -167,52 +154,6 @@ async function ensureExited(record: StartedRecorded, tmux: TmuxClient): Promise<
167
154
  }
168
155
  }
169
156
 
170
- /** Whether `path` is an existing directory. */
171
- function isDirectory(path: string): boolean {
172
- try {
173
- return statSync(path).isDirectory();
174
- } catch {
175
- return false;
176
- }
177
- }
178
-
179
- /** A delegate's session file, the directory its header says Pi runs it in, and the session directory to pass. */
180
- type SessionFile = { path: string; cwd: string; dir: string };
181
-
182
- /** Step 5: the session file, and the directory its header says Pi will run it in. */
183
- function sessionFor(record: StartedRecorded, ctx: ExtensionContext): SessionFile {
184
- const dir = record.sessionDir ?? ctx.sessionManager.getSessionDir();
185
- const path = locateSession(record, ctx.sessionManager.getSessionDir());
186
- if (path === undefined) {
187
- throw new Error(
188
- `no session file for session ${sessionIdOf(record)} was found in ${dir}, so delegation ${record.id} is not resumable`,
189
- );
190
- }
191
- if (!isAbsolute(path) || !isAbsolute(dir)) {
192
- throw new Error(
193
- `the session file ${path} or its directory ${dir} is not an absolute path, so delegation ${record.id} is not resumable`,
194
- );
195
- }
196
- const cwd = sessionHeaderCwd(path);
197
- if (cwd === undefined) {
198
- throw new Error(
199
- `could not read a working directory from the header of ${path}, so delegation ${record.id} is not resumable`,
200
- );
201
- }
202
- if (!isDirectory(cwd)) {
203
- throw new Error(
204
- `delegation ${record.id}'s session runs in ${cwd}, which no longer exists, so it is not resumable (a removed worktree cannot be resumed)`,
205
- );
206
- }
207
- return { path, cwd, dir };
208
- }
209
-
210
- /** The model choices: an explicit model alone, else where the delegate ended, then the agent's fallbacks. */
211
- function preferredModels(record: StartedRecorded, agent: Agent, sessionPath: string): string[] {
212
- const ended = finishedModel(sessionPath) ?? record.result?.finishedModel ?? record.model;
213
- return [...new Set([ended, ...agent.fallback])];
214
- }
215
-
216
157
  /** The tool's result: the `delegate` tool's plus the delegation it resumed. */
217
158
  function resultSchema(delegateSchema: TObject): TObject {
218
159
  return Type.Object({
@@ -237,11 +178,7 @@ async function planResume(
237
178
  signal: AbortSignal | undefined,
238
179
  ): Promise<Plan> {
239
180
  const { record, agent, session, request } = input;
240
- const thinking = request.thinking ?? record.thinking;
241
- const badThinking = deps.thinkingProblem(thinking);
242
- if (badThinking !== undefined) throw new Error(badThinking);
243
- const preferred = preferredModels(record, agent, session.path);
244
- const chosen = await deps.chooseModel(request.model, agent, ctx.modelRegistry, preferred);
181
+ const { thinking, chosen } = await settleModel(deps, input, ctx);
245
182
  const autoExit = request.autoExit ?? record.autoExit === true;
246
183
  const prepared = await deps.prepareDirectory(session.cwd, ctx, signal);
247
184
  // A managed worktree keeps its launch facts while the session still runs in it.
@@ -273,15 +210,22 @@ async function mailTask(deps: ResumeDeps, windowId: string, sessionId: string, t
273
210
 
274
211
  async function startResume(
275
212
  deps: ResumeDeps,
276
- input: { record: StartedRecorded; session: SessionFile; plan: Plan; task: string; toolCallId: string | undefined },
213
+ input: {
214
+ record: StartedRecorded;
215
+ session: SessionFile;
216
+ plan: Plan;
217
+ task: string;
218
+ toolCallId: string | undefined;
219
+ placement: WindowPlacement;
220
+ },
277
221
  ctx: ExtensionContext,
278
222
  ): Promise<Resumed> {
279
- const { record, session, plan, task, toolCallId } = input;
223
+ const { record, session, plan, task, toolCallId, placement } = input;
280
224
  const { start, chosen, workspace } = plan;
281
225
  const id = randomUUID();
282
226
  const sessionId = sessionIdOf(record);
283
227
  const name = recordedName(record);
284
- const windowId = await deps.open({
228
+ const { id: windowId, session: windowSession } = await deps.open({
285
229
  id,
286
230
  name,
287
231
  sessionPath: session.path,
@@ -292,6 +236,7 @@ async function startResume(
292
236
  start,
293
237
  toolCallId,
294
238
  ctx,
239
+ placement,
295
240
  });
296
241
  const requestId = await mailTask(deps, windowId, sessionId, task);
297
242
  deps.results.recordStart(
@@ -325,6 +270,7 @@ async function startResume(
325
270
  autoExit: start.autoExit,
326
271
  windowId,
327
272
  windowName: name,
273
+ ...(windowSession === undefined ? {} : { windowSession }),
328
274
  cwd: workspace.cwd,
329
275
  sessionDir: session.dir,
330
276
  workspace,
@@ -335,9 +281,6 @@ async function startResume(
335
281
  };
336
282
  }
337
283
 
338
- /** The delegation ids with a resume under way in this process. */
339
- const inFlight = new Set<string>();
340
-
341
284
  /** Resume `request.id`: every eligibility check in order, then the launch. */
342
285
  async function resume(
343
286
  deps: ResumeDeps,
@@ -345,30 +288,49 @@ async function resume(
345
288
  ctx: ExtensionContext,
346
289
  signal: AbortSignal | undefined,
347
290
  ): Promise<Resumed> {
348
- if (!deps.tmux.insideTmux()) throw new Error(deps.notInTmux);
291
+ const placement = await preflight(deps); // read once, before the exited window is killed or anything is mailed
349
292
  if (request.task.trim() === "") throw new Error("a task is required");
350
293
  const record = newestStarted(deps.results, request.id);
351
294
  await ensureExited(record, deps.tmux);
352
- const session = sessionFor(record, ctx);
353
- const agent = deps.roster(ctx).agents.find((item) => item.name === record.agent);
354
- if (agent === undefined) {
355
- throw new Error(
356
- `agent ${JSON.stringify(record.agent)} is no longer in the roster, so delegation ${record.id} is not resumed`,
357
- );
358
- }
359
- if (inFlight.has(record.id)) throw new Error(`delegation ${record.id} is already being resumed`);
295
+ const { session, agent } = recordCheck(deps, record, ctx);
360
296
  inFlight.add(record.id);
361
297
  try {
362
298
  const plan = await planResume(deps, { record, agent, session, request }, ctx, signal);
363
- return await startResume(deps, { record, session, plan, task: request.task, toolCallId: request.toolCallId }, ctx);
299
+ return await startResume(
300
+ deps,
301
+ { record, session, plan, task: request.task, toolCallId: request.toolCallId, placement },
302
+ ctx,
303
+ );
364
304
  } finally {
365
305
  inFlight.delete(record.id);
366
306
  }
367
307
  }
368
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
+
328
+ /** The session a window opened in, for the start text. */
329
+ const inSession = (session: string | undefined): string => (session === undefined ? "" : ` in session ${session}`);
330
+
369
331
  /** The one line the command and the tool tell the user. */
370
332
  export function startedLine(resumed: Resumed): string {
371
- return `delegate: resumed ${resumed.resumedFrom} as ${resumed.id}: ${resumed.agent} in window ${resumed.windowName}${resumed.fallbackNote === "" ? "" : `. ${resumed.fallbackNote}`}${resumed.notices.map((line) => `. ${line}`).join("")}`;
333
+ return `delegate: resumed ${resumed.resumedFrom} as ${resumed.id}: ${resumed.agent} in window ${resumed.windowName}${inSession(resumed.windowSession)}${resumed.fallbackNote === "" ? "" : `. ${resumed.fallbackNote}`}${resumed.notices.map((line) => `. ${line}`).join("")}`;
372
334
  }
373
335
 
374
336
  /** The `delegation_resume` tool. */
@@ -410,7 +372,7 @@ function resumeTool(deps: ResumeDeps): ToolDef {
410
372
  const { fallbackNote, ...details } = resumed;
411
373
  ctx.ui.notify(startedLine(resumed), "info");
412
374
  return toolResult(
413
- `Resumed delegation ${resumed.resumedFrom} as ${resumed.id}: ${resumed.agent} (${resumed.model}, thinking ${resumed.thinking}) in window ${resumed.windowName}. It runs in the background, and its result arrives as a message that starts your next turn on its own; delegation_close ends it.${fallbackNote === "" ? "" : ` ${fallbackNote}`}${resumed.notices.map((line) => ` ${line}`).join("")}`,
375
+ `Resumed delegation ${resumed.resumedFrom} as ${resumed.id}: ${resumed.agent} (${resumed.model}, thinking ${resumed.thinking}) in window ${resumed.windowName}${inSession(resumed.windowSession)}. It runs in the background, and its result arrives as a message that starts your next turn on its own; delegation_close ends it.${fallbackNote === "" ? "" : ` ${fallbackNote}`}${resumed.notices.map((line) => ` ${line}`).join("")}`,
414
376
  { outcome: "started", ...details },
415
377
  );
416
378
  },
@@ -439,9 +401,12 @@ function requestFrom(params: {
439
401
  /** Resuming as a function for the command, and the tool for the model. */
440
402
  export function createResume(deps: ResumeDeps): {
441
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[]>;
442
406
  tool(): ToolDef;
443
407
  } {
444
408
  return {
409
+ eligible: (ctx: ExtensionContext) => eligible(deps, ctx),
445
410
  resume: (request: ResumeRequest, ctx: ExtensionContext, signal?: AbortSignal) => resume(deps, request, ctx, signal),
446
411
  tool: () => resumeTool(deps),
447
412
  };