pi-squire 0.10.0 → 0.12.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,472 @@
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 OpenedWindow, requireTmuxVersion, type TmuxClient } from "./tmux.ts";
26
+ import { listWindows, ownWindow, type ToolDef, toolResult, windowState } from "./tracking.ts";
27
+ import type { WindowPlacement } from "./window-placement.ts";
28
+ import type { Workspace } from "./workspace.ts";
29
+
30
+ /** What a resume asks for; the fields after `task` are optional overrides. */
31
+ export type ResumeRequest = {
32
+ id: string;
33
+ task: string;
34
+ model?: string;
35
+ thinking?: string;
36
+ autoExit?: boolean;
37
+ /** The `delegation_resume` tool call that asked for it; undefined from the command. */
38
+ toolCallId?: string;
39
+ };
40
+
41
+ /** A candidate model passed over, as `chooseModel` reports it. */
42
+ type Skipped = { model: string; why: string; reason: string };
43
+
44
+ /** What a resume reads from the launch path in `index.ts`. */
45
+ export type ResumeDeps = {
46
+ results: Results;
47
+ tmux: TmuxClient;
48
+ /** The `delegate` tool's result schema, which a resume's result extends. */
49
+ delegateSchema: TObject;
50
+ /** The refusal text for a session outside tmux. */
51
+ notInTmux: string;
52
+ startPolling(): void;
53
+ /** The window placement for this call; throws on an invalid setting. */
54
+ placement(): WindowPlacement;
55
+ roster(ctx: ExtensionContext): Roster;
56
+ chooseModel(
57
+ explicit: string | undefined,
58
+ agent: Agent,
59
+ models: ModelRegistry,
60
+ preferred: readonly string[],
61
+ ): Promise<{ model: string; skipped: Skipped[]; notices: string[]; rest: string[] }>;
62
+ thinkingProblem(value: string): string | undefined;
63
+ /** The workspace and trust for an existing directory, deciding trust again. */
64
+ prepareDirectory(
65
+ cwd: string,
66
+ ctx: ExtensionContext,
67
+ signal: AbortSignal | undefined,
68
+ ): Promise<{ workspace: Workspace; trusted: boolean }>;
69
+ /** Mail the task to a session id; returns the request id. */
70
+ sendTask(to: string, task: string): string;
71
+ /** Open the window with `--session <path>`; returns its id and session. */
72
+ open(input: {
73
+ id: string;
74
+ name: string;
75
+ sessionPath: string;
76
+ workspace: Workspace;
77
+ trusted: boolean;
78
+ cwd: string;
79
+ sessionDir: string;
80
+ start: { agent: Agent; model: string; rest: string[]; thinking: string; autoExit: boolean };
81
+ toolCallId: string | undefined;
82
+ ctx: ExtensionContext;
83
+ placement: WindowPlacement;
84
+ }): Promise<OpenedWindow>;
85
+ };
86
+
87
+ /** A started resume: what the tool and the command report. */
88
+ export type Resumed = {
89
+ id: string;
90
+ resumedFrom: string;
91
+ agent: string;
92
+ model: string;
93
+ skipped: { model: string; reason: string }[];
94
+ notices: string[];
95
+ thinking: string;
96
+ autoExit: boolean;
97
+ windowId: string;
98
+ windowName: string;
99
+ /** The session the window opened in, when tmux could say. */
100
+ windowSession?: string;
101
+ cwd: string;
102
+ sessionDir: string;
103
+ workspace: Workspace;
104
+ /** The sentence naming a fallback model, or "". */
105
+ fallbackNote: string;
106
+ };
107
+
108
+ /** The started records, in the order recorded. */
109
+ function startedRecords(results: Results): StartedRecorded[] {
110
+ return results.list().filter(isStarted);
111
+ }
112
+
113
+ /** The record a `/delegate-resume` id can name: started, with no later resume of it. */
114
+ export function resumableIds(results: Results): string[] {
115
+ const started = startedRecords(results);
116
+ return started.filter((record) => !started.some((item) => item.resumedFrom === record.id)).map(({ id }) => id);
117
+ }
118
+
119
+ /** Steps 1 and 2: the id names a started delegation that nothing resumed. */
120
+ function newestStarted(results: Results, id: string): StartedRecorded {
121
+ const record = results.find(id);
122
+ if (record === undefined) {
123
+ const known = results.list().map((item) => item.id);
124
+ throw new Error(`no delegation ${JSON.stringify(id)}; known: ${known.join(", ") || "none"}`);
125
+ }
126
+ if (!isStarted(record)) {
127
+ throw new Error(
128
+ `delegation ${id} is a workspace attempt (${record.attempt.phase}), not a started delegate, so there is no session to resume`,
129
+ );
130
+ }
131
+ const started = startedRecords(results);
132
+ let newest = record;
133
+ for (let next = started.find((item) => item.resumedFrom === newest.id); next !== undefined; ) {
134
+ newest = next;
135
+ next = started.find((item) => item.resumedFrom === newest.id);
136
+ }
137
+ if (newest !== record) {
138
+ throw new Error(
139
+ `delegation ${id} was already resumed; resume its newest delegation ${newest.id} instead (it continues the same session)`,
140
+ );
141
+ }
142
+ return record;
143
+ }
144
+
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
+ */
151
+ 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);
161
+ 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
+ try {
168
+ await tmux.kill(own.id);
169
+ } catch (err) {
170
+ throw new Error(
171
+ `could not kill the exited window ${record.windowName} (${record.windowId}): ${(err as Error).message}`,
172
+ );
173
+ }
174
+ }
175
+
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
+ /** The tool's result: the `delegate` tool's plus the delegation it resumed. */
223
+ function resultSchema(delegateSchema: TObject): TObject {
224
+ return Type.Object({
225
+ ...delegateSchema.properties,
226
+ resumedFrom: Type.String({ description: "The delegation whose session this one reopened." }),
227
+ });
228
+ }
229
+
230
+ /** The model, thinking and auto-exit a resume runs with, and the directory and workspace it opens in. */
231
+ type Plan = {
232
+ start: { agent: Agent; model: string; rest: string[]; thinking: string; autoExit: boolean };
233
+ chosen: Awaited<ReturnType<ResumeDeps["chooseModel"]>>;
234
+ workspace: Workspace;
235
+ trusted: boolean;
236
+ };
237
+
238
+ /** Settle the request's overrides against the record, and decide trust again for the session's directory. */
239
+ async function planResume(
240
+ deps: ResumeDeps,
241
+ input: { record: StartedRecorded; agent: Agent; session: SessionFile; request: ResumeRequest },
242
+ ctx: ExtensionContext,
243
+ signal: AbortSignal | undefined,
244
+ ): Promise<Plan> {
245
+ 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);
251
+ const autoExit = request.autoExit ?? record.autoExit === true;
252
+ const prepared = await deps.prepareDirectory(session.cwd, ctx, signal);
253
+ // A managed worktree keeps its launch facts while the session still runs in it.
254
+ const workspace = record.workspace?.cwd === prepared.workspace.cwd ? record.workspace : prepared.workspace;
255
+ return {
256
+ start: { agent, model: chosen.model, rest: chosen.rest, thinking, autoExit },
257
+ chosen,
258
+ workspace,
259
+ trusted: prepared.trusted,
260
+ };
261
+ }
262
+
263
+ /** Mail the task, open the window on the session file and record the new delegation. */
264
+ /**
265
+ * Mail the task to a window already open. Unlike a launch, a resume mails
266
+ * after the window opens: the session's inbox outlives a failed attempt, so
267
+ * mail sent for a window that never opened would reach the next resume as a
268
+ * second task. The reopened session's provider still delivers mail that
269
+ * arrives after it starts. A failed send kills the window, which has no task.
270
+ */
271
+ async function mailTask(deps: ResumeDeps, windowId: string, sessionId: string, task: string): Promise<string> {
272
+ try {
273
+ return deps.sendTask(sessionId, task);
274
+ } catch (err) {
275
+ await deps.tmux.kill(windowId).catch(() => {});
276
+ throw err;
277
+ }
278
+ }
279
+
280
+ async function startResume(
281
+ deps: ResumeDeps,
282
+ input: {
283
+ record: StartedRecorded;
284
+ session: SessionFile;
285
+ plan: Plan;
286
+ task: string;
287
+ toolCallId: string | undefined;
288
+ placement: WindowPlacement;
289
+ },
290
+ ctx: ExtensionContext,
291
+ ): Promise<Resumed> {
292
+ const { record, session, plan, task, toolCallId, placement } = input;
293
+ const { start, chosen, workspace } = plan;
294
+ const id = randomUUID();
295
+ const sessionId = sessionIdOf(record);
296
+ const name = recordedName(record);
297
+ const { id: windowId, session: windowSession } = await deps.open({
298
+ id,
299
+ name,
300
+ sessionPath: session.path,
301
+ workspace,
302
+ trusted: plan.trusted,
303
+ cwd: workspace.cwd,
304
+ sessionDir: session.dir,
305
+ start,
306
+ toolCallId,
307
+ ctx,
308
+ placement,
309
+ });
310
+ const requestId = await mailTask(deps, windowId, sessionId, task);
311
+ deps.results.recordStart(
312
+ {
313
+ id,
314
+ agent: start.agent.name,
315
+ model: start.model,
316
+ thinking: start.thinking,
317
+ name,
318
+ windowId,
319
+ windowName: name,
320
+ requestId,
321
+ autoExit: start.autoExit,
322
+ cwd: workspace.cwd,
323
+ sessionDir: session.dir,
324
+ workspace,
325
+ resumedFrom: record.id,
326
+ sessionId,
327
+ },
328
+ ctx,
329
+ );
330
+ deps.startPolling();
331
+ return {
332
+ id,
333
+ resumedFrom: record.id,
334
+ agent: start.agent.name,
335
+ model: start.model,
336
+ skipped: chosen.skipped.map(({ model, reason }) => ({ model, reason })),
337
+ notices: chosen.notices,
338
+ thinking: start.thinking,
339
+ autoExit: start.autoExit,
340
+ windowId,
341
+ windowName: name,
342
+ ...(windowSession === undefined ? {} : { windowSession }),
343
+ cwd: workspace.cwd,
344
+ sessionDir: session.dir,
345
+ workspace,
346
+ fallbackNote:
347
+ chosen.skipped.length === 0
348
+ ? ""
349
+ : `Used fallback ${chosen.model} because ${chosen.skipped.map((item) => `${item.model} is ${item.why}`).join("; ")}.`,
350
+ };
351
+ }
352
+
353
+ /** The delegation ids with a resume under way in this process. */
354
+ const inFlight = new Set<string>();
355
+
356
+ /** Resume `request.id`: every eligibility check in order, then the launch. */
357
+ async function resume(
358
+ deps: ResumeDeps,
359
+ request: ResumeRequest,
360
+ ctx: ExtensionContext,
361
+ signal: AbortSignal | undefined,
362
+ ): 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
366
+ if (request.task.trim() === "") throw new Error("a task is required");
367
+ const record = newestStarted(deps.results, request.id);
368
+ 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`);
377
+ inFlight.add(record.id);
378
+ try {
379
+ const plan = await planResume(deps, { record, agent, session, request }, ctx, signal);
380
+ return await startResume(
381
+ deps,
382
+ { record, session, plan, task: request.task, toolCallId: request.toolCallId, placement },
383
+ ctx,
384
+ );
385
+ } finally {
386
+ inFlight.delete(record.id);
387
+ }
388
+ }
389
+
390
+ /** The session a window opened in, for the start text. */
391
+ const inSession = (session: string | undefined): string => (session === undefined ? "" : ` in session ${session}`);
392
+
393
+ /** The one line the command and the tool tell the user. */
394
+ export function startedLine(resumed: Resumed): string {
395
+ 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("")}`;
396
+ }
397
+
398
+ /** The `delegation_resume` tool. */
399
+ function resumeTool(deps: ResumeDeps): ToolDef {
400
+ return {
401
+ name: "delegation_resume",
402
+ label: "Resume delegation",
403
+ description:
404
+ "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.",
405
+ parameters: Type.Object({
406
+ id: Type.String({ description: "The delegation to resume, from a delegate result or delegation_status." }),
407
+ task: Type.String({
408
+ description: "The follow-up task. The delegate sees its earlier conversation plus this.",
409
+ }),
410
+ model: Type.Optional(
411
+ Type.String({
412
+ description:
413
+ "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.",
414
+ }),
415
+ ),
416
+ thinking: Type.Optional(
417
+ Type.String({ description: `Thinking level (${THINKING_LEVELS.join(", ")}); defaults to the delegation's.` }),
418
+ ),
419
+ auto_exit: Type.Optional(
420
+ Type.Boolean({ description: "Close the window once it finishes; defaults to the delegation's setting." }),
421
+ ),
422
+ }),
423
+ outputSchema: resultSchema(deps.delegateSchema),
424
+ // Opens a window and removes nothing but an exited window of the old run; each call starts another run.
425
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
426
+ async execute(
427
+ toolCallId: string,
428
+ params: { id: string; task: string; model?: string | null; thinking?: string | null; auto_exit?: boolean | null },
429
+ signal: AbortSignal | undefined,
430
+ _onUpdate: unknown,
431
+ ctx: ExtensionContext,
432
+ ): Promise<ReturnType<typeof toolResult>> {
433
+ const resumed = await resume(deps, { ...requestFrom(params), toolCallId }, ctx, signal);
434
+ const { fallbackNote, ...details } = resumed;
435
+ ctx.ui.notify(startedLine(resumed), "info");
436
+ return toolResult(
437
+ `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("")}`,
438
+ { outcome: "started", ...details },
439
+ );
440
+ },
441
+ };
442
+ }
443
+
444
+ /** The request a tool call makes; some models fill every optional field with null or the string "null", and both mean unset. */
445
+ function requestFrom(params: {
446
+ id: string;
447
+ task: string;
448
+ model?: string | null;
449
+ thinking?: string | null;
450
+ auto_exit?: boolean | null;
451
+ }): ResumeRequest {
452
+ const given = (value: string | null | undefined): string | undefined =>
453
+ value == null || value === "null" ? undefined : value;
454
+ return {
455
+ id: params.id,
456
+ task: params.task ?? "",
457
+ model: given(params.model),
458
+ thinking: given(params.thinking),
459
+ autoExit: typeof params.auto_exit === "boolean" ? params.auto_exit : undefined,
460
+ };
461
+ }
462
+
463
+ /** Resuming as a function for the command, and the tool for the model. */
464
+ export function createResume(deps: ResumeDeps): {
465
+ resume(request: ResumeRequest, ctx: ExtensionContext, signal?: AbortSignal): Promise<Resumed>;
466
+ tool(): ToolDef;
467
+ } {
468
+ return {
469
+ resume: (request: ResumeRequest, ctx: ExtensionContext, signal?: AbortSignal) => resume(deps, request, ctx, signal),
470
+ tool: () => resumeTool(deps),
471
+ };
472
+ }