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.
- package/CHANGELOG.md +42 -1
- package/README.md +94 -4
- package/package.json +3 -1
- package/src/index.ts +129 -35
- package/src/navigator.ts +382 -0
- package/src/new-delegate.ts +438 -0
- package/src/poll.ts +111 -0
- package/src/popups.ts +49 -0
- package/src/render.ts +2 -2
- package/src/resume-checks.ts +159 -0
- package/src/resume-picker.ts +50 -0
- package/src/resume.ts +69 -104
- package/src/tmux.ts +278 -38
- package/src/tracking.ts +76 -131
- package/src/window-placement.ts +31 -0
- package/src/windows.ts +24 -0
- package/src/workspace.ts +1 -1
|
@@ -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 {
|
|
13
|
+
import { sessionIdOf } from "./result-sessions.ts";
|
|
14
|
+
import { isStarted, type Results, recordedName, type StartedRecorded } from "./results.ts";
|
|
16
15
|
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
type
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
} from "./
|
|
25
|
-
import type { TmuxClient } from "./tmux.ts";
|
|
26
|
-
import { listWindows,
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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: {
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
};
|