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/CHANGELOG.md +18 -1
- package/README.md +69 -10
- package/package.json +1 -1
- package/src/index.ts +191 -35
- package/src/render.ts +2 -1
- package/src/result-message.ts +120 -0
- package/src/result-sessions.ts +35 -3
- package/src/results.ts +37 -116
- package/src/resume.ts +448 -0
- package/src/tracking-text.ts +41 -0
- package/src/tracking.ts +26 -49
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(
|
|
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
|
|
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
|
|
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 = {
|