pi-squire 0.10.0 → 0.11.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/resume.ts ADDED
@@ -0,0 +1,448 @@
1
+ // Resuming an exited delegate: the eligibility checks, the launch that reopens
2
+ // its own session file, and the `delegation_resume` tool. A resume is recorded
3
+ // as a new delegation (`resumedFrom` the old id) that keeps the old session id
4
+ // (`sessionId`), so mail, replies and usage-limit marks still find the session.
5
+ // `index.ts` supplies the pieces it shares with a launch: model choice, trust,
6
+ // the task mail and the window.
7
+ import { randomUUID } from "node:crypto";
8
+ import { statSync } from "node:fs";
9
+ import { isAbsolute } from "node:path";
10
+
11
+ import type { ExtensionContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
12
+ import { type TObject, Type } from "typebox";
13
+
14
+ import { type Agent, type Roster, THINKING_LEVELS } from "./agents.ts";
15
+ import { sessionHeaderCwd, sessionIdOf } from "./result-sessions.ts";
16
+ 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";
27
+ import type { Workspace } from "./workspace.ts";
28
+
29
+ /** What a resume asks for; the fields after `task` are optional overrides. */
30
+ export type ResumeRequest = {
31
+ id: string;
32
+ task: string;
33
+ model?: string;
34
+ thinking?: string;
35
+ autoExit?: boolean;
36
+ /** The `delegation_resume` tool call that asked for it; undefined from the command. */
37
+ toolCallId?: string;
38
+ };
39
+
40
+ /** A candidate model passed over, as `chooseModel` reports it. */
41
+ type Skipped = { model: string; why: string; reason: string };
42
+
43
+ /** What a resume reads from the launch path in `index.ts`. */
44
+ export type ResumeDeps = {
45
+ results: Results;
46
+ tmux: TmuxClient;
47
+ /** The `delegate` tool's result schema, which a resume's result extends. */
48
+ delegateSchema: TObject;
49
+ /** The refusal text for a session outside tmux. */
50
+ notInTmux: string;
51
+ startPolling(): void;
52
+ roster(ctx: ExtensionContext): Roster;
53
+ chooseModel(
54
+ explicit: string | undefined,
55
+ agent: Agent,
56
+ models: ModelRegistry,
57
+ preferred: readonly string[],
58
+ ): Promise<{ model: string; skipped: Skipped[]; notices: string[]; rest: string[] }>;
59
+ thinkingProblem(value: string): string | undefined;
60
+ /** The workspace and trust for an existing directory, deciding trust again. */
61
+ prepareDirectory(
62
+ cwd: string,
63
+ ctx: ExtensionContext,
64
+ signal: AbortSignal | undefined,
65
+ ): Promise<{ workspace: Workspace; trusted: boolean }>;
66
+ /** Mail the task to a session id; returns the request id. */
67
+ sendTask(to: string, task: string): string;
68
+ /** Open the window with `--session <path>`; returns its id. */
69
+ open(input: {
70
+ id: string;
71
+ name: string;
72
+ sessionPath: string;
73
+ workspace: Workspace;
74
+ trusted: boolean;
75
+ cwd: string;
76
+ sessionDir: string;
77
+ start: { agent: Agent; model: string; rest: string[]; thinking: string; autoExit: boolean };
78
+ toolCallId: string | undefined;
79
+ ctx: ExtensionContext;
80
+ }): Promise<string>;
81
+ };
82
+
83
+ /** A started resume: what the tool and the command report. */
84
+ export type Resumed = {
85
+ id: string;
86
+ resumedFrom: string;
87
+ agent: string;
88
+ model: string;
89
+ skipped: { model: string; reason: string }[];
90
+ notices: string[];
91
+ thinking: string;
92
+ autoExit: boolean;
93
+ windowId: string;
94
+ windowName: string;
95
+ cwd: string;
96
+ sessionDir: string;
97
+ workspace: Workspace;
98
+ /** The sentence naming a fallback model, or "". */
99
+ fallbackNote: string;
100
+ };
101
+
102
+ /** The started records, in the order recorded. */
103
+ function startedRecords(results: Results): StartedRecorded[] {
104
+ return results.list().filter(isStarted);
105
+ }
106
+
107
+ /** The record a `/delegate-resume` id can name: started, with no later resume of it. */
108
+ export function resumableIds(results: Results): string[] {
109
+ const started = startedRecords(results);
110
+ return started.filter((record) => !started.some((item) => item.resumedFrom === record.id)).map(({ id }) => id);
111
+ }
112
+
113
+ /** Steps 1 and 2: the id names a started delegation that nothing resumed. */
114
+ function newestStarted(results: Results, id: string): StartedRecorded {
115
+ const record = results.find(id);
116
+ if (record === undefined) {
117
+ const known = results.list().map((item) => item.id);
118
+ throw new Error(`no delegation ${JSON.stringify(id)}; known: ${known.join(", ") || "none"}`);
119
+ }
120
+ if (!isStarted(record)) {
121
+ throw new Error(
122
+ `delegation ${id} is a workspace attempt (${record.attempt.phase}), not a started delegate, so there is no session to resume`,
123
+ );
124
+ }
125
+ const started = startedRecords(results);
126
+ let newest = record;
127
+ for (let next = started.find((item) => item.resumedFrom === newest.id); next !== undefined; ) {
128
+ newest = next;
129
+ next = started.find((item) => item.resumedFrom === newest.id);
130
+ }
131
+ if (newest !== record) {
132
+ throw new Error(
133
+ `delegation ${id} was already resumed; resume its newest delegation ${newest.id} instead (it continues the same session)`,
134
+ );
135
+ }
136
+ return record;
137
+ }
138
+
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
+ */
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);
155
+ 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
+ try {
162
+ await tmux.kill(own.id);
163
+ } catch (err) {
164
+ throw new Error(
165
+ `could not kill the exited window ${record.windowName} (${record.windowId}): ${(err as Error).message}`,
166
+ );
167
+ }
168
+ }
169
+
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
+ /** The tool's result: the `delegate` tool's plus the delegation it resumed. */
217
+ function resultSchema(delegateSchema: TObject): TObject {
218
+ return Type.Object({
219
+ ...delegateSchema.properties,
220
+ resumedFrom: Type.String({ description: "The delegation whose session this one reopened." }),
221
+ });
222
+ }
223
+
224
+ /** The model, thinking and auto-exit a resume runs with, and the directory and workspace it opens in. */
225
+ type Plan = {
226
+ start: { agent: Agent; model: string; rest: string[]; thinking: string; autoExit: boolean };
227
+ chosen: Awaited<ReturnType<ResumeDeps["chooseModel"]>>;
228
+ workspace: Workspace;
229
+ trusted: boolean;
230
+ };
231
+
232
+ /** Settle the request's overrides against the record, and decide trust again for the session's directory. */
233
+ async function planResume(
234
+ deps: ResumeDeps,
235
+ input: { record: StartedRecorded; agent: Agent; session: SessionFile; request: ResumeRequest },
236
+ ctx: ExtensionContext,
237
+ signal: AbortSignal | undefined,
238
+ ): Promise<Plan> {
239
+ 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);
245
+ const autoExit = request.autoExit ?? record.autoExit === true;
246
+ const prepared = await deps.prepareDirectory(session.cwd, ctx, signal);
247
+ // A managed worktree keeps its launch facts while the session still runs in it.
248
+ const workspace = record.workspace?.cwd === prepared.workspace.cwd ? record.workspace : prepared.workspace;
249
+ return {
250
+ start: { agent, model: chosen.model, rest: chosen.rest, thinking, autoExit },
251
+ chosen,
252
+ workspace,
253
+ trusted: prepared.trusted,
254
+ };
255
+ }
256
+
257
+ /** Mail the task, open the window on the session file and record the new delegation. */
258
+ /**
259
+ * Mail the task to a window already open. Unlike a launch, a resume mails
260
+ * after the window opens: the session's inbox outlives a failed attempt, so
261
+ * mail sent for a window that never opened would reach the next resume as a
262
+ * second task. The reopened session's provider still delivers mail that
263
+ * arrives after it starts. A failed send kills the window, which has no task.
264
+ */
265
+ async function mailTask(deps: ResumeDeps, windowId: string, sessionId: string, task: string): Promise<string> {
266
+ try {
267
+ return deps.sendTask(sessionId, task);
268
+ } catch (err) {
269
+ await deps.tmux.kill(windowId).catch(() => {});
270
+ throw err;
271
+ }
272
+ }
273
+
274
+ async function startResume(
275
+ deps: ResumeDeps,
276
+ input: { record: StartedRecorded; session: SessionFile; plan: Plan; task: string; toolCallId: string | undefined },
277
+ ctx: ExtensionContext,
278
+ ): Promise<Resumed> {
279
+ const { record, session, plan, task, toolCallId } = input;
280
+ const { start, chosen, workspace } = plan;
281
+ const id = randomUUID();
282
+ const sessionId = sessionIdOf(record);
283
+ const name = recordedName(record);
284
+ const windowId = await deps.open({
285
+ id,
286
+ name,
287
+ sessionPath: session.path,
288
+ workspace,
289
+ trusted: plan.trusted,
290
+ cwd: workspace.cwd,
291
+ sessionDir: session.dir,
292
+ start,
293
+ toolCallId,
294
+ ctx,
295
+ });
296
+ const requestId = await mailTask(deps, windowId, sessionId, task);
297
+ deps.results.recordStart(
298
+ {
299
+ id,
300
+ agent: start.agent.name,
301
+ model: start.model,
302
+ thinking: start.thinking,
303
+ name,
304
+ windowId,
305
+ windowName: name,
306
+ requestId,
307
+ autoExit: start.autoExit,
308
+ cwd: workspace.cwd,
309
+ sessionDir: session.dir,
310
+ workspace,
311
+ resumedFrom: record.id,
312
+ sessionId,
313
+ },
314
+ ctx,
315
+ );
316
+ deps.startPolling();
317
+ return {
318
+ id,
319
+ resumedFrom: record.id,
320
+ agent: start.agent.name,
321
+ model: start.model,
322
+ skipped: chosen.skipped.map(({ model, reason }) => ({ model, reason })),
323
+ notices: chosen.notices,
324
+ thinking: start.thinking,
325
+ autoExit: start.autoExit,
326
+ windowId,
327
+ windowName: name,
328
+ cwd: workspace.cwd,
329
+ sessionDir: session.dir,
330
+ workspace,
331
+ fallbackNote:
332
+ chosen.skipped.length === 0
333
+ ? ""
334
+ : `Used fallback ${chosen.model} because ${chosen.skipped.map((item) => `${item.model} is ${item.why}`).join("; ")}.`,
335
+ };
336
+ }
337
+
338
+ /** The delegation ids with a resume under way in this process. */
339
+ const inFlight = new Set<string>();
340
+
341
+ /** Resume `request.id`: every eligibility check in order, then the launch. */
342
+ async function resume(
343
+ deps: ResumeDeps,
344
+ request: ResumeRequest,
345
+ ctx: ExtensionContext,
346
+ signal: AbortSignal | undefined,
347
+ ): Promise<Resumed> {
348
+ if (!deps.tmux.insideTmux()) throw new Error(deps.notInTmux);
349
+ if (request.task.trim() === "") throw new Error("a task is required");
350
+ const record = newestStarted(deps.results, request.id);
351
+ 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`);
360
+ inFlight.add(record.id);
361
+ try {
362
+ 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);
364
+ } finally {
365
+ inFlight.delete(record.id);
366
+ }
367
+ }
368
+
369
+ /** The one line the command and the tool tell the user. */
370
+ 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("")}`;
372
+ }
373
+
374
+ /** The `delegation_resume` tool. */
375
+ function resumeTool(deps: ResumeDeps): ToolDef {
376
+ return {
377
+ name: "delegation_resume",
378
+ label: "Resume delegation",
379
+ description:
380
+ "Continue an exited delegate with a follow-up task: reopen its own session, so it keeps the context it built, in a new window. The follow-up is recorded as a new delegation, and its result arrives as a message on its own, like a delegate's. Use it when the next task needs what the delegate already read or decided; for unrelated work, use delegate. It refuses a delegation that is still running (send it a message instead), one whose window is still open, an older delegation of a chain that was already resumed (it names the newest), and one whose working directory is gone, a removed worktree included. A delegate whose window was killed mid-turn can be resumed.",
381
+ parameters: Type.Object({
382
+ id: Type.String({ description: "The delegation to resume, from a delegate result or delegation_status." }),
383
+ task: Type.String({
384
+ description: "The follow-up task. The delegate sees its earlier conversation plus this.",
385
+ }),
386
+ model: Type.Optional(
387
+ Type.String({
388
+ description:
389
+ "provider/id to resume on; used as given. Omit to continue on the model the delegate finished on, or its agent's next fallback when that one is unavailable.",
390
+ }),
391
+ ),
392
+ thinking: Type.Optional(
393
+ Type.String({ description: `Thinking level (${THINKING_LEVELS.join(", ")}); defaults to the delegation's.` }),
394
+ ),
395
+ auto_exit: Type.Optional(
396
+ Type.Boolean({ description: "Close the window once it finishes; defaults to the delegation's setting." }),
397
+ ),
398
+ }),
399
+ outputSchema: resultSchema(deps.delegateSchema),
400
+ // Opens a window and removes nothing but an exited window of the old run; each call starts another run.
401
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
402
+ async execute(
403
+ toolCallId: string,
404
+ params: { id: string; task: string; model?: string | null; thinking?: string | null; auto_exit?: boolean | null },
405
+ signal: AbortSignal | undefined,
406
+ _onUpdate: unknown,
407
+ ctx: ExtensionContext,
408
+ ): Promise<ReturnType<typeof toolResult>> {
409
+ const resumed = await resume(deps, { ...requestFrom(params), toolCallId }, ctx, signal);
410
+ const { fallbackNote, ...details } = resumed;
411
+ ctx.ui.notify(startedLine(resumed), "info");
412
+ 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("")}`,
414
+ { outcome: "started", ...details },
415
+ );
416
+ },
417
+ };
418
+ }
419
+
420
+ /** The request a tool call makes; some models fill every optional field with null or the string "null", and both mean unset. */
421
+ function requestFrom(params: {
422
+ id: string;
423
+ task: string;
424
+ model?: string | null;
425
+ thinking?: string | null;
426
+ auto_exit?: boolean | null;
427
+ }): ResumeRequest {
428
+ const given = (value: string | null | undefined): string | undefined =>
429
+ value == null || value === "null" ? undefined : value;
430
+ return {
431
+ id: params.id,
432
+ task: params.task ?? "",
433
+ model: given(params.model),
434
+ thinking: given(params.thinking),
435
+ autoExit: typeof params.auto_exit === "boolean" ? params.auto_exit : undefined,
436
+ };
437
+ }
438
+
439
+ /** Resuming as a function for the command, and the tool for the model. */
440
+ export function createResume(deps: ResumeDeps): {
441
+ resume(request: ResumeRequest, ctx: ExtensionContext, signal?: AbortSignal): Promise<Resumed>;
442
+ tool(): ToolDef;
443
+ } {
444
+ return {
445
+ resume: (request: ResumeRequest, ctx: ExtensionContext, signal?: AbortSignal) => resume(deps, request, ctx, signal),
446
+ tool: () => resumeTool(deps),
447
+ };
448
+ }
@@ -0,0 +1,41 @@
1
+ // How `delegation_status` words a delegation's state, auto-exit and age.
2
+ import type { DelegationState, StartedRecorded } from "./results.ts";
3
+
4
+ /** Records from before auto-exit existed kept their windows open, so they read off. */
5
+ export function autoExitLine(record: StartedRecorded): string {
6
+ return record.autoExit === true
7
+ ? "on — the delegate closes its window after a normal completion unless the user took over there"
8
+ : "off — the window stays open after the result";
9
+ }
10
+
11
+ /** The state, worded for the `state:` line. */
12
+ export function stateLine(state: DelegationState, record: StartedRecorded): string {
13
+ switch (state) {
14
+ case "running":
15
+ return "running — the window is open and no result has arrived";
16
+ case "gone":
17
+ return "closed without a result — the window is gone and no reply arrived; a late reply would make it done";
18
+ case "done":
19
+ return `done — result status: ${record.result?.status ?? "unknown"}; the result reaches this session as a [delegate] message on its own`;
20
+ case "closed":
21
+ return "closed — delegation_close recorded it";
22
+ case "window-closed":
23
+ return "closed — the window is gone";
24
+ case "unknown":
25
+ return "unknown — tmux could not be asked whether the window is open";
26
+ case "planned":
27
+ case "created":
28
+ case "rolled-back":
29
+ case "retained":
30
+ case "interrupted":
31
+ return `${state} — no window id/start was recorded; a window may still exist`;
32
+ }
33
+ }
34
+
35
+ /** How long ago, worded for the `state:` line: `just now`, `N min ago` or `N h M min ago`. */
36
+ export function ago(ms: number): string {
37
+ const minutes = Math.floor(ms / 60_000);
38
+ if (minutes < 1) return "just now";
39
+ if (minutes < 60) return `${minutes} min ago`;
40
+ return `${Math.floor(minutes / 60)} h ${minutes % 60} min ago`;
41
+ }
package/src/tracking.ts CHANGED
@@ -18,12 +18,14 @@ import {
18
18
  type Results,
19
19
  recordedName,
20
20
  type StartedRecorded,
21
+ sessionIdOf,
21
22
  type WindowState,
22
23
  type WorkspaceAttempt,
23
24
  } from "./results.ts";
24
25
  import { inspectWorkspace, type WorkspaceSnapshot, WorkspaceSnapshotSchema } from "./snapshot.ts";
25
26
  import type { TmuxClient, TmuxWindow } from "./tmux.ts";
26
27
  import { AttemptSchema } from "./tracking-attempt.ts";
28
+ import { ago, autoExitLine, stateLine } from "./tracking-text.ts";
27
29
  import type { ReadingError } from "./usage.ts";
28
30
  import { WorkspaceSchema } from "./workspace.ts";
29
31
 
@@ -69,6 +71,7 @@ const StartedSchema = Type.Object({
69
71
  ),
70
72
  thinking: Type.String(),
71
73
  autoExit: Type.Boolean(),
74
+ resumedFrom: Type.Optional(Type.String({ description: "The delegation whose session this one reopened." })),
72
75
  windowId: Type.String(),
73
76
  windowName: Type.String(),
74
77
  cwd: Type.Optional(
@@ -81,7 +84,7 @@ const StartedSchema = Type.Object({
81
84
  STARTED_STATES.map((state) => Type.Literal(state)),
82
85
  {
83
86
  description:
84
- "running: window open, no result. done: a result arrived (see status, envelopePath). gone: the window went with no result; a late reply makes it done. closed: delegation_close recorded it. window-closed: the window is gone. unknown: tmux could not be asked. done and closed are final; gone and window-closed turn done if a late reply arrives.",
87
+ "running: window open, no result. done: a result arrived (see status, envelopePath). gone: the window went with no result; a late reply makes it done. closed: delegation_close recorded it. window-closed: the window is gone. unknown: tmux could not be asked. done and closed are final, though a later delegation_resume continues one as a new delegation; gone and window-closed turn done if a late reply arrives.",
85
88
  },
86
89
  ),
87
90
  closed: Type.Boolean(),
@@ -119,7 +122,7 @@ export const CloseSchema = Type.Object({
119
122
  });
120
123
 
121
124
  /** A tool as registered; `execute` takes the tool's own parameters, which the registration call does not check. */
122
- type ToolDef = {
125
+ export type ToolDef = {
123
126
  name: string;
124
127
  label: string;
125
128
  description: string;
@@ -130,7 +133,7 @@ type ToolDef = {
130
133
  };
131
134
 
132
135
  /** Every window on the server, asked once; a failed ask is `undefined`, never an empty list. */
133
- async function listWindows(tmux: TmuxClient): Promise<TmuxWindow[] | undefined> {
136
+ export async function listWindows(tmux: TmuxClient): Promise<TmuxWindow[] | undefined> {
134
137
  try {
135
138
  return await tmux.listWindows();
136
139
  } catch {
@@ -139,48 +142,17 @@ async function listWindows(tmux: TmuxClient): Promise<TmuxWindow[] | undefined>
139
142
  }
140
143
 
141
144
  /** The window a delegation owns: one with both its recorded id and its recorded name, since ids restart with every tmux server. */
142
- function ownWindow(windows: TmuxWindow[], record: StartedRecorded): TmuxWindow | undefined {
145
+ export function ownWindow(windows: TmuxWindow[], record: StartedRecorded): TmuxWindow | undefined {
143
146
  return windows.find((window) => window.id === record.windowId && window.name === record.windowName);
144
147
  }
145
148
 
146
149
  /** What the listing says about a delegation's window; a matching window whose program exited counts as gone. */
147
- function windowState(windows: TmuxWindow[] | undefined, record: StartedRecorded): WindowState {
150
+ export function windowState(windows: TmuxWindow[] | undefined, record: StartedRecorded): WindowState {
148
151
  if (windows === undefined) return "unknown";
149
152
  const own = ownWindow(windows, record);
150
153
  return own !== undefined && !own.exited ? "open" : "gone";
151
154
  }
152
155
 
153
- /** Records from before auto-exit existed kept their windows open, so they read off. */
154
- function autoExitLine(record: StartedRecorded): string {
155
- return record.autoExit === true
156
- ? "on — the delegate closes its window after a normal completion unless the user took over there"
157
- : "off — the window stays open after the result";
158
- }
159
-
160
- /** The state, worded for the `state:` line. */
161
- function stateLine(state: DelegationState, record: StartedRecorded): string {
162
- switch (state) {
163
- case "running":
164
- return "running — the window is open and no result has arrived";
165
- case "gone":
166
- return "closed without a result — the window is gone and no reply arrived; a late reply would make it done";
167
- case "done":
168
- return `done — result status: ${record.result?.status ?? "unknown"}; the result reaches this session as a [delegate] message on its own`;
169
- case "closed":
170
- return "closed — delegation_close recorded it";
171
- case "window-closed":
172
- return "closed — the window is gone";
173
- case "unknown":
174
- return "unknown — tmux could not be asked whether the window is open";
175
- case "planned":
176
- case "created":
177
- case "rolled-back":
178
- case "retained":
179
- case "interrupted":
180
- return `${state} — no window id/start was recorded; a window may still exist`;
181
- }
182
- }
183
-
184
156
  /** One delegation as `delegation_status` reports it; its text and details both read from this. */
185
157
  type View = {
186
158
  record: StartedRecorded;
@@ -191,18 +163,17 @@ type View = {
191
163
  lastActive: Date | undefined;
192
164
  /** The checkout observed for this status call; the recorded result keeps its own. */
193
165
  snapshot: WorkspaceSnapshot;
166
+ /** The delegation that resumed this one, if any. */
167
+ resumedAs: string | undefined;
194
168
  };
195
169
 
196
- /** How long ago, worded for the `state:` line: `just now`, `N min ago` or `N h M min ago`. */
197
- function ago(ms: number): string {
198
- const minutes = Math.floor(ms / 60_000);
199
- if (minutes < 1) return "just now";
200
- if (minutes < 60) return `${minutes} min ago`;
201
- return `${Math.floor(minutes / 60)} h ${minutes % 60} min ago`;
202
- }
203
-
204
170
  /** The view of a delegation: its state and the fields the text and details share. */
205
- function view(record: StartedRecorded, windows: TmuxWindow[] | undefined, ctx: ExtensionContext): View {
171
+ function view(
172
+ record: StartedRecorded,
173
+ windows: TmuxWindow[] | undefined,
174
+ ctx: ExtensionContext,
175
+ resumedAs: string | undefined,
176
+ ): View {
206
177
  const state = deriveState(record, windowState(windows, record));
207
178
  const sessionPath = locateSession(record, ctx.sessionManager.getSessionDir());
208
179
  let lastActive: Date | undefined;
@@ -214,16 +185,18 @@ function view(record: StartedRecorded, windows: TmuxWindow[] | undefined, ctx: E
214
185
  }
215
186
  }
216
187
  const snapshot = inspectWorkspace(record.workspace?.cwd, record.workspace?.git);
217
- return { record, state, name: recordedName(record), sessionPath, lastActive, snapshot };
188
+ return { record, state, name: recordedName(record), sessionPath, lastActive, snapshot, resumedAs };
218
189
  }
219
190
 
220
191
  /** The text block for one delegation. */
221
- function viewText({ record, state, name, sessionPath, lastActive, snapshot }: View): string {
192
+ function viewText({ record, state, name, sessionPath, lastActive, snapshot, resumedAs }: View): string {
222
193
  const [checkout, ...categories] = snapshotLines(snapshot);
223
194
  const seen = lastActive === undefined ? "" : ` (last active ${ago(Date.now() - lastActive.getTime())})`;
224
195
  const lines = [
225
196
  `${record.id} ${record.agent} (${modelLabel(record.model, record.result?.finishedModel)}, thinking ${record.thinking})`,
226
197
  ` name: ${name}`,
198
+ ...(record.resumedFrom === undefined ? [] : [` resumed from ${record.resumedFrom}`]),
199
+ ...(resumedAs === undefined ? [] : [` resumed as ${resumedAs}`]),
227
200
  ` state: ${stateLine(state, record)}${seen}`,
228
201
  ` window: ${record.windowName} (${record.windowId})`,
229
202
  ` cwd: ${cwdLine(record.cwd)}`,
@@ -231,7 +204,7 @@ function viewText({ record, state, name, sessionPath, lastActive, snapshot }: Vi
231
204
  ` checkout now: ${checkout}`,
232
205
  ...categories.map((line) => ` ${line}`),
233
206
  ` auto-exit: ${autoExitLine(record)}`,
234
- ` session: ${sessionPath ?? `${record.id} (no session file yet)`}`,
207
+ ` session: ${sessionPath ?? `${sessionIdOf(record)} (no session file yet)`}`,
235
208
  ];
236
209
  if (record.result !== undefined) lines.push(` envelope: ${record.result.envelopePath}`);
237
210
  return lines.join("\n");
@@ -248,6 +221,7 @@ function viewDetails({ record, state, name, sessionPath, lastActive, snapshot }:
248
221
  ...(record.result?.finishedModel === undefined ? {} : { finishedModel: record.result.finishedModel }),
249
222
  thinking: record.thinking,
250
223
  autoExit: record.autoExit === true,
224
+ ...(record.resumedFrom === undefined ? {} : { resumedFrom: record.resumedFrom }),
251
225
  windowId: record.windowId,
252
226
  windowName: record.windowName,
253
227
  ...(record.cwd === undefined ? {} : { cwd: record.cwd }),
@@ -384,7 +358,10 @@ function statusTool(results: Results, tmux: TmuxClient, readingErrors: () => Rea
384
358
  ): Promise<ReturnType<typeof toolResult>> {
385
359
  const records = selectRecords(results, params.id);
386
360
  const windows = await listWindows(tmux);
387
- const views = records.map((record) => (isStarted(record) ? view(record, windows, ctx) : record));
361
+ const resumedAs = (id: string) => results.list().find((item) => isStarted(item) && item.resumedFrom === id)?.id;
362
+ const views = records.map((record) =>
363
+ isStarted(record) ? view(record, windows, ctx, resumedAs(record.id)) : record,
364
+ );
388
365
  const marks = activeMarks();
389
366
  const errors = readingErrors();
390
367
  const details = {