pi-squire 0.9.5 → 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 +102 -1
- package/README.md +366 -18
- package/package.json +1 -1
- package/src/git.ts +177 -0
- package/src/index.ts +576 -70
- package/src/render.ts +108 -2
- package/src/result-message.ts +120 -0
- package/src/result-sessions.ts +79 -0
- package/src/result-types.ts +36 -0
- package/src/results.ts +138 -172
- package/src/resume.ts +448 -0
- package/src/snapshot.ts +207 -0
- package/src/tmux.ts +30 -3
- package/src/tracking-attempt.ts +65 -0
- package/src/tracking-text.ts +41 -0
- package/src/tracking.ts +90 -55
- package/src/workspace.ts +420 -0
- package/src/worktree-placement.ts +138 -0
- package/src/worktree.ts +474 -0
package/src/index.ts
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
// window, over the `message:*` hooks.
|
|
3
3
|
//
|
|
4
4
|
// `/delegate <agent> [--model <provider/id>] [--thinking <level>]
|
|
5
|
-
// [--label <name>] [--auto-exit | --no-auto-exit] <task…>`
|
|
5
|
+
// [--label <name>] [--cwd <dir> | --worktree <branch> [--worktree-source <dir>] [--worktree-base <commit-ish>] ] [--auto-exit | --no-auto-exit] <task…>`
|
|
6
6
|
// reads the agent from `<agent dir>/squire/agents/<name>/AGENT.md`, writes the task
|
|
7
7
|
// to the delegate's inbox through `message:send`, emits `session:launch` so
|
|
8
8
|
// listeners can add arguments and environment, then opens a window in the
|
|
9
|
-
// parent's working directory
|
|
9
|
+
// parent's working directory, or the existing directory `--cwd` selects
|
|
10
|
+
// (`workspace.ts`), running the parent's own Pi. The window and the
|
|
10
11
|
// session share one name: `<agent>-<label>`, or `<agent>-<first 8 of the id>`
|
|
11
12
|
// without a label. Starting a delegation adds nothing to
|
|
12
13
|
// the model's context. Each delegation is recorded
|
|
@@ -20,14 +21,17 @@
|
|
|
20
21
|
// session has no tool for leaves the agent out of the roster. Its `tools` goes
|
|
21
22
|
// as `--tools` and each of its `extensions` as `-e`, both for the child alone.
|
|
22
23
|
//
|
|
23
|
-
// The model gets
|
|
24
|
+
// The model gets four tools. `delegate` takes agent, task and optional model
|
|
24
25
|
// and thinking, validates them the way the command does, and starts the
|
|
25
26
|
// delegation at once, opening no dialog, so parallel calls cannot block one
|
|
26
|
-
// another
|
|
27
|
+
// another; the one exception is a `cwd` whose project trust is unresolved,
|
|
28
|
+
// which the user answers in the parent. `delegation_status`
|
|
27
29
|
// reports each delegation as running (window open, no result yet), done (with
|
|
28
30
|
// its result status) or closed, with the session name, window, session and
|
|
29
31
|
// envelope paths.
|
|
30
|
-
// `delegation_close` kills the window and records the close.
|
|
32
|
+
// `delegation_close` kills the window and records the close. `delegation_resume`
|
|
33
|
+
// (`resume.ts`) reopens an exited delegate's session in a new window with a
|
|
34
|
+
// follow-up task, recorded as a new delegation. Inside a delegate
|
|
31
35
|
// (`PI_DELEGATE_PARENT` set) the extension registers only `child.ts`'s
|
|
32
36
|
// `/auto-exit` and handlers, so a delegate cannot delegate.
|
|
33
37
|
//
|
|
@@ -36,7 +40,7 @@
|
|
|
36
40
|
import { randomUUID } from "node:crypto";
|
|
37
41
|
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
38
42
|
import { tmpdir } from "node:os";
|
|
39
|
-
import { basename, join } from "node:path";
|
|
43
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
40
44
|
|
|
41
45
|
import {
|
|
42
46
|
type ExtensionAPI,
|
|
@@ -49,11 +53,28 @@ import { Type } from "typebox";
|
|
|
49
53
|
import { type Agent, isThinking, type Roster, readRoster, THINKING_LEVELS } from "./agents.ts";
|
|
50
54
|
import { AGENT_ENV, AUTO_EXIT_ENV, FALLBACK_ENV, registerChild } from "./child.ts";
|
|
51
55
|
import { activeMark, clearMarks, isFree, readMarks } from "./limits.ts";
|
|
52
|
-
import { renderDelegateCall, renderDelegateResult } from "./render.ts";
|
|
53
|
-
import { createResults, delegateName } from "./results.ts";
|
|
54
|
-
import {
|
|
56
|
+
import { dirtyNotice, renderDelegateCall, renderDelegateResult, resourceLine } from "./render.ts";
|
|
57
|
+
import { createResults, delegateName, type WorkspaceAttempt } from "./results.ts";
|
|
58
|
+
import { createResume, type ResumeRequest, resumableIds, startedLine } from "./resume.ts";
|
|
59
|
+
import { createTmuxClient, type TmuxClient, WindowOpenError } from "./tmux.ts";
|
|
55
60
|
import { createTracking, toolResult } from "./tracking.ts";
|
|
56
61
|
import { createProactive, createUsageClient, type Proactive, type UsageClient } from "./usage.ts";
|
|
62
|
+
import {
|
|
63
|
+
normalizeWorkspaceValue,
|
|
64
|
+
prepareDirectory,
|
|
65
|
+
trustPlannedWorktree,
|
|
66
|
+
type Workspace,
|
|
67
|
+
WorkspaceSchema,
|
|
68
|
+
} from "./workspace.ts";
|
|
69
|
+
import {
|
|
70
|
+
createWorktree,
|
|
71
|
+
inQueue,
|
|
72
|
+
prepareWorktree,
|
|
73
|
+
recoveryCommands,
|
|
74
|
+
revalidate,
|
|
75
|
+
rollbackWorktree,
|
|
76
|
+
type WorktreePlan,
|
|
77
|
+
} from "./worktree.ts";
|
|
57
78
|
|
|
58
79
|
/** The optional parent guidance file under `<agent dir>/squire/`. */
|
|
59
80
|
const PARENT_PROMPT = "delegation_prompt.md";
|
|
@@ -102,7 +123,17 @@ function parentTrust(parent: string): string {
|
|
|
102
123
|
].join(" ");
|
|
103
124
|
}
|
|
104
125
|
/** The flags `/delegate` takes before the task. */
|
|
105
|
-
const FLAGS = [
|
|
126
|
+
const FLAGS = [
|
|
127
|
+
"--model",
|
|
128
|
+
"--thinking",
|
|
129
|
+
"--label",
|
|
130
|
+
"--cwd",
|
|
131
|
+
"--worktree",
|
|
132
|
+
"--worktree-base",
|
|
133
|
+
"--worktree-source",
|
|
134
|
+
"--auto-exit",
|
|
135
|
+
"--no-auto-exit",
|
|
136
|
+
] as const;
|
|
106
137
|
|
|
107
138
|
/** The `delegate` tool's structured result, which is also its details. */
|
|
108
139
|
export const DelegateSchema = Type.Object({
|
|
@@ -118,16 +149,42 @@ export const DelegateSchema = Type.Object({
|
|
|
118
149
|
autoExit: Type.Boolean(),
|
|
119
150
|
windowId: Type.String(),
|
|
120
151
|
windowName: Type.String(),
|
|
152
|
+
cwd: Type.String({ description: "The directory the delegate's window opened in." }),
|
|
153
|
+
sessionDir: Type.String({ description: "The session-storage directory the delegate was told to use." }),
|
|
154
|
+
workspace: WorkspaceSchema,
|
|
121
155
|
});
|
|
122
156
|
|
|
123
157
|
const NOT_IN_TMUX = "this session is not inside tmux; start it in a tmux pane to open a delegate window";
|
|
124
158
|
|
|
125
159
|
const USAGE =
|
|
126
|
-
"usage: /delegate <agent> [--model <provider/id>] [--thinking <level>] [--label <name>] [--auto-exit | --no-auto-exit] <task>";
|
|
160
|
+
"usage: /delegate <agent> [--model <provider/id>] [--thinking <level>] [--label <name>] [--cwd <dir> | --worktree <branch> [--worktree-source <dir>] [--worktree-base <commit-ish>] ] [--auto-exit | --no-auto-exit] <task>";
|
|
161
|
+
|
|
162
|
+
/** The flags `/delegate-resume` takes before the task. */
|
|
163
|
+
const RESUME_FLAGS = ["--model", "--thinking", "--auto-exit", "--no-auto-exit"] as const;
|
|
164
|
+
const RESUME_USAGE =
|
|
165
|
+
"usage: /delegate-resume <id> [--model <provider/id>] [--thinking <level>] [--auto-exit | --no-auto-exit] <task>";
|
|
127
166
|
|
|
128
167
|
type SendPayload = { to: unknown; body: unknown; envelope?: { id?: unknown }; error?: unknown };
|
|
129
|
-
|
|
130
|
-
type
|
|
168
|
+
/** `cwd` is informational: the window opens in the launch's own value, whatever a listener assigns. */
|
|
169
|
+
type LaunchPayload = {
|
|
170
|
+
args: string[];
|
|
171
|
+
env: Record<string, string>;
|
|
172
|
+
agent: string;
|
|
173
|
+
readonly cwd: string;
|
|
174
|
+
readonly toolCallId: string | undefined;
|
|
175
|
+
};
|
|
176
|
+
type Parsed = {
|
|
177
|
+
agent: string;
|
|
178
|
+
model?: string;
|
|
179
|
+
thinking?: string;
|
|
180
|
+
label?: string;
|
|
181
|
+
cwd?: string;
|
|
182
|
+
worktree?: string;
|
|
183
|
+
worktree_base?: string;
|
|
184
|
+
worktree_source?: string;
|
|
185
|
+
autoExit?: boolean;
|
|
186
|
+
task: string;
|
|
187
|
+
};
|
|
131
188
|
/** A request that passed the roster, model, thinking and label checks. */
|
|
132
189
|
type Start = {
|
|
133
190
|
agent: Agent;
|
|
@@ -140,13 +197,30 @@ type Start = {
|
|
|
140
197
|
rest: string[];
|
|
141
198
|
thinking: string;
|
|
142
199
|
label?: string;
|
|
200
|
+
/** The existing directory to run in, as given; undefined inherits the parent's. */
|
|
201
|
+
cwd?: string;
|
|
202
|
+
/** The new local branch to create a worktree for; undefined creates none. */
|
|
203
|
+
worktree?: string;
|
|
204
|
+
worktree_base?: string;
|
|
205
|
+
worktree_source?: string;
|
|
143
206
|
autoExit: boolean;
|
|
144
207
|
task: string;
|
|
145
208
|
};
|
|
146
209
|
/** A candidate model passed over: `why` finishes "<model> is …", `reason` is the long form for a refusal. */
|
|
147
210
|
type Skipped = { model: string; why: string; reason: string };
|
|
211
|
+
/** What opening a delegate's window reads from a request: a `Start`, or the parts of one a resume decides. */
|
|
212
|
+
type ChildStart = Pick<Start, "agent" | "model" | "rest" | "thinking" | "autoExit">;
|
|
148
213
|
/** A delegation that is open in a tmux window. */
|
|
149
|
-
type Launched = {
|
|
214
|
+
type Launched = {
|
|
215
|
+
id: string;
|
|
216
|
+
name: string;
|
|
217
|
+
windowId: string;
|
|
218
|
+
windowName: string;
|
|
219
|
+
requestId: string;
|
|
220
|
+
cwd: string;
|
|
221
|
+
sessionDir: string;
|
|
222
|
+
workspace: Workspace;
|
|
223
|
+
};
|
|
150
224
|
|
|
151
225
|
/**
|
|
152
226
|
* Write the appended system prompt to a fresh owner-only directory under the
|
|
@@ -206,35 +280,109 @@ function closest(value: string, candidates: readonly string[]): string[] {
|
|
|
206
280
|
.map(({ candidate }) => candidate);
|
|
207
281
|
}
|
|
208
282
|
|
|
283
|
+
/** One whitespace-separated word of `text` at or after `from`, verbatim; undefined at the end. */
|
|
284
|
+
function word(text: string, from: number): { text: string; start: number; end: number } | undefined {
|
|
285
|
+
const match = /\S+/g;
|
|
286
|
+
match.lastIndex = from;
|
|
287
|
+
const found = match.exec(text);
|
|
288
|
+
return found === null ? undefined : { text: found[0], start: found.index, end: found.index + found[0].length };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* A flag's value at or after `from`: a plain word, or a double-quoted string
|
|
293
|
+
* in which `\"` and `\\` stand for `"` and `\`. No shell expansion of any kind.
|
|
294
|
+
*/
|
|
295
|
+
function flagValue(
|
|
296
|
+
text: string,
|
|
297
|
+
from: number,
|
|
298
|
+
flag: string,
|
|
299
|
+
usage: string = USAGE,
|
|
300
|
+
): { value: string; end: number } | { error: string } {
|
|
301
|
+
const next = word(text, from);
|
|
302
|
+
if (next === undefined || next.text.startsWith("--")) return { error: `${flag} needs a value; ${usage}` };
|
|
303
|
+
if (!next.text.startsWith('"')) return { value: next.text, end: next.end };
|
|
304
|
+
let value = "";
|
|
305
|
+
let i = next.start + 1;
|
|
306
|
+
while (i < text.length && text[i] !== '"') {
|
|
307
|
+
const escaped = text[i] === "\\" && (text[i + 1] === '"' || text[i + 1] === "\\");
|
|
308
|
+
value += escaped ? text[i + 1] : text[i];
|
|
309
|
+
i += escaped ? 2 : 1;
|
|
310
|
+
}
|
|
311
|
+
if (i >= text.length) return { error: `${flag}'s value has no closing quote; ${usage}` };
|
|
312
|
+
if (i + 1 < text.length && !/\s/.test(text[i + 1])) {
|
|
313
|
+
return { error: `${flag}'s quoted value must be followed by a space; ${usage}` };
|
|
314
|
+
}
|
|
315
|
+
return { value, end: i + 1 };
|
|
316
|
+
}
|
|
317
|
+
|
|
209
318
|
/** `/delegate`'s arguments: an agent, optional flags, then a verbatim task. */
|
|
210
319
|
function parse(text: string): { parsed: Parsed } | { error: string } {
|
|
211
|
-
const
|
|
212
|
-
if (
|
|
213
|
-
if (
|
|
214
|
-
const parsed: Parsed = { agent:
|
|
215
|
-
let
|
|
216
|
-
|
|
217
|
-
const flag =
|
|
320
|
+
const agent = word(text, 0);
|
|
321
|
+
if (agent === undefined) return { error: USAGE };
|
|
322
|
+
if (agent.text.startsWith("-")) return { error: `the first argument is the agent name; ${USAGE}` };
|
|
323
|
+
const parsed: Parsed = { agent: agent.text, task: "" };
|
|
324
|
+
let at = agent.end;
|
|
325
|
+
for (let next = word(text, at); next?.text.startsWith("-"); next = word(text, at)) {
|
|
326
|
+
const flag = next.text;
|
|
218
327
|
if (flag === "--auto-exit" || flag === "--no-auto-exit") {
|
|
219
328
|
parsed.autoExit = flag === "--auto-exit";
|
|
220
|
-
|
|
329
|
+
at = next.end;
|
|
221
330
|
continue;
|
|
222
331
|
}
|
|
223
|
-
if (
|
|
332
|
+
if (
|
|
333
|
+
flag !== "--model" &&
|
|
334
|
+
flag !== "--thinking" &&
|
|
335
|
+
flag !== "--label" &&
|
|
336
|
+
flag !== "--cwd" &&
|
|
337
|
+
flag !== "--worktree" &&
|
|
338
|
+
flag !== "--worktree-base" &&
|
|
339
|
+
flag !== "--worktree-source"
|
|
340
|
+
) {
|
|
224
341
|
return { error: `unknown flag ${flag}; ${USAGE}` };
|
|
225
342
|
}
|
|
226
|
-
const
|
|
227
|
-
if (
|
|
228
|
-
if (flag === "--model") parsed.model = value;
|
|
229
|
-
else if (flag === "--thinking") parsed.thinking = value;
|
|
230
|
-
else parsed.label = value;
|
|
231
|
-
|
|
343
|
+
const read = flagValue(text, next.end, flag);
|
|
344
|
+
if ("error" in read) return read;
|
|
345
|
+
if (flag === "--model") parsed.model = read.value;
|
|
346
|
+
else if (flag === "--thinking") parsed.thinking = read.value;
|
|
347
|
+
else if (flag === "--label") parsed.label = read.value;
|
|
348
|
+
else if (flag === "--cwd") parsed.cwd = read.value;
|
|
349
|
+
else if (flag === "--worktree") parsed.worktree = read.value;
|
|
350
|
+
else if (flag === "--worktree-base") parsed.worktree_base = read.value;
|
|
351
|
+
else parsed.worktree_source = read.value;
|
|
352
|
+
at = read.end;
|
|
232
353
|
}
|
|
233
|
-
|
|
354
|
+
// The task is the rest of the text from its first word, untouched by flag parsing.
|
|
355
|
+
parsed.task = text.slice(word(text, at)?.start ?? text.length).trim();
|
|
234
356
|
if (parsed.task === "") return { error: `a task is required after the agent and flags; ${USAGE}` };
|
|
235
357
|
return { parsed };
|
|
236
358
|
}
|
|
237
359
|
|
|
360
|
+
/** `/delegate-resume`'s arguments: a delegation id, optional flags, then a verbatim task. */
|
|
361
|
+
function parseResume(text: string): { request: ResumeRequest } | { error: string } {
|
|
362
|
+
const id = word(text, 0);
|
|
363
|
+
if (id === undefined) return { error: RESUME_USAGE };
|
|
364
|
+
if (id.text.startsWith("-")) return { error: `the first argument is the delegation id; ${RESUME_USAGE}` };
|
|
365
|
+
const request: ResumeRequest = { id: id.text, task: "" };
|
|
366
|
+
let at = id.end;
|
|
367
|
+
for (let next = word(text, at); next?.text.startsWith("-"); next = word(text, at)) {
|
|
368
|
+
const flag = next.text;
|
|
369
|
+
if (flag === "--auto-exit" || flag === "--no-auto-exit") {
|
|
370
|
+
request.autoExit = flag === "--auto-exit";
|
|
371
|
+
at = next.end;
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
if (flag !== "--model" && flag !== "--thinking") return { error: `unknown flag ${flag}; ${RESUME_USAGE}` };
|
|
375
|
+
const read = flagValue(text, next.end, flag, RESUME_USAGE);
|
|
376
|
+
if ("error" in read) return read;
|
|
377
|
+
if (flag === "--model") request.model = read.value;
|
|
378
|
+
else request.thinking = read.value;
|
|
379
|
+
at = read.end;
|
|
380
|
+
}
|
|
381
|
+
request.task = text.slice(word(text, at)?.start ?? text.length).trim();
|
|
382
|
+
if (request.task === "") return { error: `a task is required after the id and flags; ${RESUME_USAGE}` };
|
|
383
|
+
return { request };
|
|
384
|
+
}
|
|
385
|
+
|
|
238
386
|
/** Why `value` is not a usable model, or undefined when it is. */
|
|
239
387
|
function modelProblem(value: string, registry: ModelRegistry): string | undefined {
|
|
240
388
|
const slash = value.indexOf("/");
|
|
@@ -255,15 +403,17 @@ function modelProblem(value: string, registry: ModelRegistry): string | undefine
|
|
|
255
403
|
* `modelProblem` and has no active mark wins; the rest are returned as skipped.
|
|
256
404
|
* A paid candidate on a provider with a quota check is read first, and
|
|
257
405
|
* skipped when its quota is used up. Refuses, listing every candidate, when
|
|
258
|
-
* none is left.
|
|
406
|
+
* none is left. `preferred` replaces the agent's own list when there is no
|
|
407
|
+
* explicit model, as a resume does.
|
|
259
408
|
*/
|
|
260
409
|
async function chooseModel(
|
|
261
410
|
explicit: string | undefined,
|
|
262
411
|
agent: Agent,
|
|
263
412
|
models: ModelRegistry,
|
|
264
413
|
proactive: Proactive,
|
|
414
|
+
preferred: readonly string[] = [agent.model, ...agent.fallback],
|
|
265
415
|
): Promise<{ model: string; skipped: Skipped[]; notices: string[]; rest: string[] }> {
|
|
266
|
-
const candidates = explicit !== undefined ? [explicit] : [
|
|
416
|
+
const candidates = explicit !== undefined ? [explicit] : [...preferred];
|
|
267
417
|
const skipped: Skipped[] = [];
|
|
268
418
|
const notices: string[] = [];
|
|
269
419
|
for (const model of candidates) {
|
|
@@ -352,7 +502,7 @@ function delegateVisible(options: { selectedTools: string[]; hiddenTools: string
|
|
|
352
502
|
/** The `delegate` tool's description: what delegation does, and the roster as it stands. */
|
|
353
503
|
function toolDescription(agents: readonly Agent[]): string {
|
|
354
504
|
const intro =
|
|
355
|
-
"Hand a self-contained task to a delegate Pi session in a background tmux window, so it works while you do not. Each call starts the delegation at once; the delegate runs in this session's working directory
|
|
505
|
+
"Hand a self-contained task to a delegate Pi session in a background tmux window, so it works while you do not. Each call starts the delegation at once; the delegate runs in this session's working directory, or the cwd given, with this session's agent root, and reports back later as one message. While it runs, keep working or end your turn: its result starts your next turn on its own. Do not poll delegation_status or send a blocking mail request merely to wait for the result; send a substantive follow-up only when you have something to add or ask. Use it when the work is self-contained and you would rather keep your own context for it.";
|
|
356
506
|
if (agents.length === 0) {
|
|
357
507
|
return `${intro}\n\nNo agents are defined yet. Add ${join(
|
|
358
508
|
getAgentDir(),
|
|
@@ -373,7 +523,8 @@ function toolDescription(agents: readonly Agent[]): string {
|
|
|
373
523
|
}${agent.tools.length > 0 ? `, tools: ${agent.tools.join("/")}` : ""})`,
|
|
374
524
|
),
|
|
375
525
|
"",
|
|
376
|
-
"`
|
|
526
|
+
"`cwd` runs the delegate in another existing directory. Alternatively, `worktree` creates a new local branch and checkout from this repository's committed HEAD, or `worktree_source` selects another existing Git checkout; `worktree_base` selects a commit in that source. Source dirt is reported but not copied. `cwd` and `worktree` are mutually exclusive, and source/base require worktree.",
|
|
527
|
+
"`model` and `thinking` override the agent's defaults for this call; an unknown value is refused with close matches. `label` names the delegate's window and session; give one that says what the task is. `delegation_status` reports a delegation, and `delegation_close` ends one. `delegation_resume` continues an exited delegate in its own session with a follow-up task, for work that needs the context it already built.",
|
|
377
528
|
"`auto_exit` (default the agent's, normally true) closes the delegate's window once it finishes. Set it false when you mean to keep talking to the delegate by mail after its result; the user can also keep a window open by typing in it.",
|
|
378
529
|
"",
|
|
379
530
|
"Writing the task: the delegate sees nothing of this session, so the task must stand alone. State the question, the decision its answer feeds, what is already known or ruled out, and the files or paths to start from. When the work produces a report, name the path to write it to and ask for the conclusion back, not the contents.",
|
|
@@ -464,39 +615,65 @@ export default function delegate(
|
|
|
464
615
|
rest,
|
|
465
616
|
thinking,
|
|
466
617
|
label: request.label,
|
|
618
|
+
cwd: request.cwd,
|
|
619
|
+
worktree: request.worktree,
|
|
620
|
+
worktree_base: request.worktree_base,
|
|
621
|
+
worktree_source: request.worktree_source,
|
|
467
622
|
autoExit: request.autoExit ?? agent.autoExit,
|
|
468
623
|
task: request.task,
|
|
469
624
|
};
|
|
470
625
|
}
|
|
471
626
|
|
|
472
|
-
/**
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
// The task goes on disk before the window exists: the delegate finds it
|
|
477
|
-
// at session start without a handshake, and a window that fails leaves
|
|
478
|
-
// mail that is reported rather than cleaned up.
|
|
479
|
-
const id = randomUUID();
|
|
480
|
-
const sent: SendPayload = { to: id, body: start.task };
|
|
627
|
+
/** Write the task to the delegate's inbox and return its request id; the delegate finds it at session start. */
|
|
628
|
+
function sendTask(to: string, task: string): string {
|
|
629
|
+
const sent: SendPayload = { to, body: task };
|
|
481
630
|
pi.events.emit(SEND, sent);
|
|
482
|
-
if (typeof sent.error === "string") throw new Error(`could not send the task to ${
|
|
483
|
-
if (sent.envelope === undefined)
|
|
631
|
+
if (typeof sent.error === "string") throw new Error(`could not send the task to ${to}: ${sent.error}`);
|
|
632
|
+
if (sent.envelope === undefined)
|
|
484
633
|
throw new Error(
|
|
485
|
-
|
|
634
|
+
"needs a provider of message:* to send the task; install a mailbox extension (for example pi-session-mail)",
|
|
486
635
|
);
|
|
487
|
-
}
|
|
488
636
|
const requestId = sent.envelope.id;
|
|
489
|
-
if (typeof requestId !== "string") throw new Error(`the message:send provider wrote no request id for ${
|
|
637
|
+
if (typeof requestId !== "string") throw new Error(`the message:send provider wrote no request id for ${to}`);
|
|
638
|
+
return requestId;
|
|
639
|
+
}
|
|
490
640
|
|
|
641
|
+
/**
|
|
642
|
+
* Build the delegate's command line and environment, emit `session:launch`
|
|
643
|
+
* and open its window; returns the window id. A launch and a resume share
|
|
644
|
+
* it and differ only in `session`, the arguments that choose the session:
|
|
645
|
+
* `--session-id <id> --name <name>` for a new one, `--session <path>` to reopen.
|
|
646
|
+
*/
|
|
647
|
+
async function openChild(input: {
|
|
648
|
+
id: string;
|
|
649
|
+
name: string;
|
|
650
|
+
session: string[];
|
|
651
|
+
workspace: Workspace;
|
|
652
|
+
trusted: boolean;
|
|
653
|
+
cwd: string;
|
|
654
|
+
sessionDir: string;
|
|
655
|
+
start: ChildStart;
|
|
656
|
+
/** The tool call that started this launch or resume; undefined from a command. */
|
|
657
|
+
toolCallId: string | undefined;
|
|
658
|
+
ctx: ExtensionContext;
|
|
659
|
+
onWindowOpened?: () => void;
|
|
660
|
+
}): Promise<string> {
|
|
661
|
+
const { id, name, session, workspace, trusted, cwd, sessionDir, start, toolCallId, ctx, onWindowOpened } = input;
|
|
491
662
|
const parent = ctx.sessionManager.getSessionId();
|
|
492
|
-
const
|
|
493
|
-
|
|
663
|
+
const managedContext =
|
|
664
|
+
workspace.mode === "managed"
|
|
665
|
+
? `\n\nYour managed worktree is ${workspace.cwd}, branch ${workspace.createdBranch}, based on commit ${workspace.baseCommit}.`
|
|
666
|
+
: workspace.mode === "existing"
|
|
667
|
+
? `\n\nThe session that started you chose your working directory: ${workspace.cwd}`
|
|
668
|
+
: "";
|
|
669
|
+
const prompt = writePromptFile(`${start.agent.prompt}\n\n${FINAL_LINE}\n\n${parentTrust(parent)}${managedContext}`);
|
|
494
670
|
const argv = [
|
|
495
671
|
...parentCommand(),
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
"--
|
|
499
|
-
|
|
672
|
+
...session,
|
|
673
|
+
// Always explicit, so a permissive global default cannot trust what the parent does not.
|
|
674
|
+
trusted ? "--approve" : "--no-approve",
|
|
675
|
+
"--session-dir",
|
|
676
|
+
sessionDir,
|
|
500
677
|
"--model",
|
|
501
678
|
start.model,
|
|
502
679
|
"--thinking",
|
|
@@ -516,20 +693,52 @@ export default function delegate(
|
|
|
516
693
|
PI_UNATTENDED: "1",
|
|
517
694
|
};
|
|
518
695
|
if (start.rest.length > 0) env[FALLBACK_ENV] = start.rest.join(",");
|
|
519
|
-
|
|
520
|
-
|
|
696
|
+
// Pi ranks `--session-dir` above this variable, so it cannot move the child's storage.
|
|
697
|
+
const envSessionDir = process.env[SESSION_DIR_ENV];
|
|
698
|
+
if (envSessionDir !== undefined && envSessionDir !== "") env[SESSION_DIR_ENV] = envSessionDir;
|
|
521
699
|
// Listeners may only append to `args` and add to `env`; there is no veto.
|
|
522
|
-
const payload: LaunchPayload = { args: argv, env, agent: start.agent.name };
|
|
700
|
+
const payload: LaunchPayload = { args: argv, env, agent: start.agent.name, cwd, toolCallId };
|
|
523
701
|
pi.events.emit(LAUNCH, payload);
|
|
524
|
-
|
|
525
|
-
const windowName = name;
|
|
526
702
|
let windowId: string;
|
|
527
703
|
try {
|
|
528
|
-
windowId = await tmux.openWindow({ name
|
|
704
|
+
windowId = await tmux.openWindow({ name, cwd, argv: payload.args, env: payload.env });
|
|
705
|
+
onWindowOpened?.();
|
|
529
706
|
} catch (err) {
|
|
530
|
-
|
|
707
|
+
if (err instanceof WindowOpenError) {
|
|
708
|
+
throw new WindowOpenError(`could not open window ${name} for ${id}: ${err.message}`, err.certainty);
|
|
709
|
+
}
|
|
710
|
+
throw new WindowOpenError(`could not open window ${name} for ${id}: ${(err as Error).message}`, "unknown");
|
|
531
711
|
}
|
|
712
|
+
return windowId;
|
|
713
|
+
}
|
|
532
714
|
|
|
715
|
+
async function launchWindow(input: {
|
|
716
|
+
id: string;
|
|
717
|
+
requestId: string;
|
|
718
|
+
workspace: Workspace;
|
|
719
|
+
trusted: boolean;
|
|
720
|
+
cwd: string;
|
|
721
|
+
sessionDir: string;
|
|
722
|
+
start: Start;
|
|
723
|
+
toolCallId: string | undefined;
|
|
724
|
+
ctx: ExtensionContext;
|
|
725
|
+
onWindowOpened: () => void;
|
|
726
|
+
}): Promise<Launched> {
|
|
727
|
+
const { id, requestId, workspace, trusted, cwd, sessionDir, start, toolCallId, ctx, onWindowOpened } = input;
|
|
728
|
+
const name = delegateName(start.agent.name, id, start.label);
|
|
729
|
+
const windowId = await openChild({
|
|
730
|
+
id,
|
|
731
|
+
name,
|
|
732
|
+
session: ["--session-id", id, "--name", name],
|
|
733
|
+
workspace,
|
|
734
|
+
trusted,
|
|
735
|
+
cwd,
|
|
736
|
+
sessionDir,
|
|
737
|
+
start,
|
|
738
|
+
toolCallId,
|
|
739
|
+
ctx,
|
|
740
|
+
onWindowOpened,
|
|
741
|
+
});
|
|
533
742
|
// No task text in the record: the request copy in `sent/` has it.
|
|
534
743
|
results.recordStart(
|
|
535
744
|
{
|
|
@@ -539,18 +748,233 @@ export default function delegate(
|
|
|
539
748
|
thinking: start.thinking,
|
|
540
749
|
name,
|
|
541
750
|
windowId,
|
|
542
|
-
windowName,
|
|
751
|
+
windowName: name,
|
|
543
752
|
requestId,
|
|
544
753
|
autoExit: start.autoExit,
|
|
754
|
+
cwd,
|
|
755
|
+
sessionDir,
|
|
756
|
+
workspace,
|
|
545
757
|
},
|
|
546
758
|
ctx,
|
|
547
759
|
);
|
|
548
760
|
tracking.startPolling();
|
|
549
|
-
return { id, name, windowId, windowName, requestId };
|
|
761
|
+
return { id, name, windowId, windowName: name, requestId, cwd, sessionDir, workspace };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
async function launch(
|
|
765
|
+
start: Start,
|
|
766
|
+
ctx: ExtensionContext,
|
|
767
|
+
signal?: AbortSignal,
|
|
768
|
+
toolCallId?: string,
|
|
769
|
+
): Promise<Launched> {
|
|
770
|
+
if (!tmux.insideTmux()) throw new Error(NOT_IN_TMUX);
|
|
771
|
+
const sessionDir = ctx.sessionManager.getSessionDir();
|
|
772
|
+
if (!isAbsolute(sessionDir))
|
|
773
|
+
throw new Error(
|
|
774
|
+
`the parent's session directory ${JSON.stringify(sessionDir)} is not an absolute path; start Pi with an absolute --session-dir`,
|
|
775
|
+
);
|
|
776
|
+
const cwdOption = normalizeWorkspaceValue(start.cwd, "cwd");
|
|
777
|
+
const worktreeOption = normalizeWorkspaceValue(start.worktree, "worktree");
|
|
778
|
+
const baseOption = normalizeWorkspaceValue(start.worktree_base, "worktree_base");
|
|
779
|
+
const sourceOption = normalizeWorkspaceValue(start.worktree_source, "worktree_source");
|
|
780
|
+
if (sourceOption !== undefined && worktreeOption === undefined)
|
|
781
|
+
throw new Error("worktree_source requires worktree");
|
|
782
|
+
if (baseOption !== undefined && worktreeOption === undefined) throw new Error("worktree_base requires worktree");
|
|
783
|
+
if (cwdOption !== undefined && worktreeOption !== undefined)
|
|
784
|
+
throw new Error("cwd and worktree are mutually exclusive; name only one");
|
|
785
|
+
const signals = [signal, ctx.signal].filter((item): item is AbortSignal => item !== undefined);
|
|
786
|
+
const combined = signals.length ? AbortSignal.any(signals) : undefined;
|
|
787
|
+
let plan: WorktreePlan | undefined;
|
|
788
|
+
let directory: Workspace | undefined;
|
|
789
|
+
let trusted: boolean;
|
|
790
|
+
let revalidateTrust: (() => void) | undefined;
|
|
791
|
+
if (worktreeOption !== undefined) {
|
|
792
|
+
plan = await prepareWorktree(
|
|
793
|
+
{
|
|
794
|
+
worktree: worktreeOption,
|
|
795
|
+
...(baseOption === undefined ? {} : { worktree_base: baseOption }),
|
|
796
|
+
...(sourceOption === undefined ? {} : { worktree_source: sourceOption }),
|
|
797
|
+
},
|
|
798
|
+
ctx,
|
|
799
|
+
getAgentDir(),
|
|
800
|
+
);
|
|
801
|
+
if (combined?.aborted) throw new Error("the launch was aborted");
|
|
802
|
+
const trust = await trustPlannedWorktree(plan, ctx, getAgentDir(), combined);
|
|
803
|
+
if (combined?.aborted) throw new Error("the launch was aborted");
|
|
804
|
+
trusted = trust.trusted;
|
|
805
|
+
revalidateTrust = trust.revalidate;
|
|
806
|
+
plan.trusted = trusted;
|
|
807
|
+
} else {
|
|
808
|
+
const prepared = await prepareDirectory({ cwd: cwdOption, signal }, ctx, getAgentDir());
|
|
809
|
+
directory = prepared.workspace;
|
|
810
|
+
trusted = prepared.trusted;
|
|
811
|
+
}
|
|
812
|
+
const id = randomUUID();
|
|
813
|
+
const parentId = ctx.sessionManager.getSessionId();
|
|
814
|
+
let attempt: WorkspaceAttempt | undefined;
|
|
815
|
+
let managed: Extract<Workspace, { mode: "managed" }> | undefined;
|
|
816
|
+
let windowOpened = false;
|
|
817
|
+
const run = async (): Promise<Launched> => {
|
|
818
|
+
if (plan !== undefined) {
|
|
819
|
+
revalidate(plan);
|
|
820
|
+
revalidateTrust?.();
|
|
821
|
+
attempt = {
|
|
822
|
+
kind: "workspace-attempt",
|
|
823
|
+
id,
|
|
824
|
+
parentId,
|
|
825
|
+
agent: start.agent.name,
|
|
826
|
+
model: start.model,
|
|
827
|
+
thinking: start.thinking,
|
|
828
|
+
...(start.label === undefined ? {} : { name: start.label }),
|
|
829
|
+
sessionDir,
|
|
830
|
+
plan,
|
|
831
|
+
attempt: { phase: "planned", resources: { kind: "none" } },
|
|
832
|
+
};
|
|
833
|
+
results.recordAttempt(attempt, ctx);
|
|
834
|
+
const created = createWorktree(plan);
|
|
835
|
+
if (!("workspace" in created)) {
|
|
836
|
+
const recovery = recoveryCommands(plan, created.resources);
|
|
837
|
+
attempt = {
|
|
838
|
+
...attempt,
|
|
839
|
+
attempt:
|
|
840
|
+
created.resources.kind === "none"
|
|
841
|
+
? { phase: "rolled-back", resources: { kind: "none" }, cause: created.cause }
|
|
842
|
+
: { phase: "retained", resources: created.resources, cause: created.cause, recovery },
|
|
843
|
+
};
|
|
844
|
+
results.recordAttempt(attempt, ctx);
|
|
845
|
+
throw new Error(
|
|
846
|
+
`worktree launch ${id} from ${plan.sourceCwd} failed at ${plan.destination} (${plan.branch}): ${created.cause}; ${resourceLine(created.resources)}${recovery.length ? `; recovery: ${recovery.join("; ")}` : created.resources.kind === "worktree" ? "; inspect and preserve its contents before any cleanup" : ""}`,
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
managed = created.workspace;
|
|
850
|
+
attempt = {
|
|
851
|
+
...attempt,
|
|
852
|
+
attempt: {
|
|
853
|
+
phase: "created",
|
|
854
|
+
resources: { kind: "worktree", cwd: managed.cwd, branch: managed.createdBranch, head: managed.baseCommit },
|
|
855
|
+
},
|
|
856
|
+
};
|
|
857
|
+
results.recordAttempt(attempt, ctx);
|
|
858
|
+
}
|
|
859
|
+
let workspace: Workspace;
|
|
860
|
+
if (managed !== undefined) workspace = managed;
|
|
861
|
+
else if (directory !== undefined) workspace = directory;
|
|
862
|
+
else throw new Error("workspace preparation did not produce a launch directory");
|
|
863
|
+
// The task goes on disk before the window exists: the delegate finds it
|
|
864
|
+
// at session start without a handshake, and a window that fails leaves
|
|
865
|
+
// mail that is reported rather than cleaned up.
|
|
866
|
+
const requestId = sendTask(id, start.task);
|
|
867
|
+
if (attempt !== undefined) {
|
|
868
|
+
attempt = { ...attempt, requestId };
|
|
869
|
+
results.recordAttempt(attempt, ctx);
|
|
870
|
+
}
|
|
871
|
+
return launchWindow({
|
|
872
|
+
id,
|
|
873
|
+
requestId,
|
|
874
|
+
workspace,
|
|
875
|
+
trusted,
|
|
876
|
+
cwd: workspace.cwd,
|
|
877
|
+
sessionDir,
|
|
878
|
+
start,
|
|
879
|
+
toolCallId,
|
|
880
|
+
ctx,
|
|
881
|
+
onWindowOpened: () => {
|
|
882
|
+
windowOpened = true;
|
|
883
|
+
},
|
|
884
|
+
});
|
|
885
|
+
};
|
|
886
|
+
const runWithRollback = async (): Promise<Launched> => {
|
|
887
|
+
try {
|
|
888
|
+
return await run();
|
|
889
|
+
} catch (error) {
|
|
890
|
+
if (attempt !== undefined && managed !== undefined) {
|
|
891
|
+
const cause = (error as Error).message;
|
|
892
|
+
if (windowOpened || (error instanceof WindowOpenError && error.certainty === "unknown")) {
|
|
893
|
+
attempt = {
|
|
894
|
+
...attempt,
|
|
895
|
+
attempt: {
|
|
896
|
+
phase: "retained",
|
|
897
|
+
resources: {
|
|
898
|
+
kind: "worktree",
|
|
899
|
+
cwd: managed.cwd,
|
|
900
|
+
branch: managed.createdBranch,
|
|
901
|
+
head: managed.baseCommit,
|
|
902
|
+
},
|
|
903
|
+
cause,
|
|
904
|
+
recovery: [],
|
|
905
|
+
},
|
|
906
|
+
};
|
|
907
|
+
} else {
|
|
908
|
+
const rollbackPlan = plan;
|
|
909
|
+
if (rollbackPlan === undefined) throw error;
|
|
910
|
+
const rolled = rollbackWorktree(rollbackPlan, managed);
|
|
911
|
+
attempt =
|
|
912
|
+
rolled.kind === "rolled-back"
|
|
913
|
+
? { ...attempt, attempt: { phase: "rolled-back", resources: { kind: "none" }, cause } }
|
|
914
|
+
: {
|
|
915
|
+
...attempt,
|
|
916
|
+
attempt: {
|
|
917
|
+
phase: "retained",
|
|
918
|
+
resources: rolled.resources,
|
|
919
|
+
cause: `${cause}; ${rolled.reason}`,
|
|
920
|
+
recovery: recoveryCommands(rollbackPlan, rolled.resources),
|
|
921
|
+
},
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
results.recordAttempt(attempt, ctx);
|
|
925
|
+
const state = attempt.attempt;
|
|
926
|
+
const recovery =
|
|
927
|
+
state.phase !== "retained"
|
|
928
|
+
? ""
|
|
929
|
+
: state.recovery.length > 0
|
|
930
|
+
? `; recovery: ${state.recovery.join("; ")}`
|
|
931
|
+
: state.resources.kind === "worktree"
|
|
932
|
+
? "; inspect and preserve its contents before any cleanup"
|
|
933
|
+
: "";
|
|
934
|
+
throw new Error(
|
|
935
|
+
`worktree launch ${id} from ${managed.sourceCwd} at ${managed.cwd} (${managed.createdBranch}) failed: ${cause}; outcome ${state.phase}${recovery}`,
|
|
936
|
+
{ cause: error },
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
throw error;
|
|
940
|
+
}
|
|
941
|
+
};
|
|
942
|
+
if (plan === undefined) return runWithRollback();
|
|
943
|
+
return inQueue(plan.source.commonDir, combined, runWithRollback);
|
|
550
944
|
}
|
|
551
945
|
|
|
946
|
+
const resumer = createResume({
|
|
947
|
+
results,
|
|
948
|
+
tmux,
|
|
949
|
+
delegateSchema: DelegateSchema,
|
|
950
|
+
notInTmux: NOT_IN_TMUX,
|
|
951
|
+
startPolling: tracking.startPolling,
|
|
952
|
+
roster: rosterFor,
|
|
953
|
+
chooseModel: (explicit, agent, models, preferred) => chooseModel(explicit, agent, models, proactive, preferred),
|
|
954
|
+
thinkingProblem,
|
|
955
|
+
prepareDirectory: (cwd, ctx, signal) => prepareDirectory({ cwd, signal }, ctx, getAgentDir()),
|
|
956
|
+
sendTask,
|
|
957
|
+
open: ({ id, name, sessionPath, workspace, trusted, cwd, sessionDir, start, toolCallId, ctx }) =>
|
|
958
|
+
openChild({
|
|
959
|
+
id,
|
|
960
|
+
name,
|
|
961
|
+
session: ["--session", sessionPath],
|
|
962
|
+
workspace,
|
|
963
|
+
trusted,
|
|
964
|
+
cwd,
|
|
965
|
+
sessionDir,
|
|
966
|
+
start,
|
|
967
|
+
toolCallId,
|
|
968
|
+
ctx,
|
|
969
|
+
}),
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
/** Where a selected directory put the delegate, for the start text; inherited starts stay compact. */
|
|
973
|
+
const placedIn = (launched: Launched): string =>
|
|
974
|
+
launched.workspace.mode !== "inherited" ? ` in ${launched.cwd}` : "";
|
|
975
|
+
|
|
552
976
|
const started = (launched: Launched, start: Start): string =>
|
|
553
|
-
`delegate: ${start.agent.name} ${launched.id} started in window ${launched.windowName}${start.skipped.length > 0 ? `. ${fallbackNote(start)}` : ""}${start.notices.map((line) => `. ${line}`).join("")}`;
|
|
977
|
+
`delegate: ${start.agent.name} ${launched.id} started in window ${launched.windowName}${placedIn(launched)}${launched.workspace.mode === "managed" ? `. Branch ${launched.workspace.createdBranch} starts at ${launched.workspace.baseCommit}. ${dirtyNotice(launched.workspace.sourceDirty)}` : ""}${start.skipped.length > 0 ? `. ${fallbackNote(start)}` : ""}${start.notices.map((line) => `. ${line}`).join("")}`;
|
|
554
978
|
|
|
555
979
|
async function command(input: string, ctx: ExtensionContext): Promise<void> {
|
|
556
980
|
const fail = (message: string) => ctx.ui.notify(`delegate: ${message}`, "error");
|
|
@@ -560,7 +984,17 @@ export default function delegate(
|
|
|
560
984
|
return;
|
|
561
985
|
}
|
|
562
986
|
try {
|
|
563
|
-
const
|
|
987
|
+
const cwd = normalizeWorkspaceValue(outcome.parsed.cwd, "--cwd");
|
|
988
|
+
const worktree_source = normalizeWorkspaceValue(outcome.parsed.worktree_source, "--worktree-source");
|
|
989
|
+
const worktree_base = normalizeWorkspaceValue(outcome.parsed.worktree_base, "--worktree-base");
|
|
990
|
+
const worktree = normalizeWorkspaceValue(outcome.parsed.worktree, "--worktree");
|
|
991
|
+
if (worktree_source !== undefined && worktree === undefined)
|
|
992
|
+
throw new Error("--worktree-source requires --worktree");
|
|
993
|
+
const start = await validate(
|
|
994
|
+
{ ...outcome.parsed, cwd, worktree, worktree_base, worktree_source },
|
|
995
|
+
rosterFor(ctx),
|
|
996
|
+
ctx.modelRegistry,
|
|
997
|
+
);
|
|
564
998
|
const launched = await launch(start, ctx);
|
|
565
999
|
ctx.ui.notify(started(launched, start), "info");
|
|
566
1000
|
} catch (err) {
|
|
@@ -568,6 +1002,19 @@ export default function delegate(
|
|
|
568
1002
|
}
|
|
569
1003
|
}
|
|
570
1004
|
|
|
1005
|
+
async function resumeCommand(input: string, ctx: ExtensionContext): Promise<void> {
|
|
1006
|
+
const outcome = parseResume(input);
|
|
1007
|
+
if ("error" in outcome) {
|
|
1008
|
+
ctx.ui.notify(`delegate: ${outcome.error}`, "error");
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
try {
|
|
1012
|
+
ctx.ui.notify(startedLine(await resumer.resume(outcome.request, ctx)), "info");
|
|
1013
|
+
} catch (err) {
|
|
1014
|
+
ctx.ui.notify(`delegate: ${(err as Error).message}`, "error");
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
571
1018
|
/** The roster description last registered, so unchanged metadata is not registered again. */
|
|
572
1019
|
let registeredDescription: string | undefined;
|
|
573
1020
|
|
|
@@ -589,6 +1036,29 @@ export default function delegate(
|
|
|
589
1036
|
description: `A short name for this delegation (letters, digits, _ or -, at most ${LABEL_MAX}), such as roster-research. Its session and tmux window are named <agent>-<label>; without it, <agent>-<first 8 id characters>.`,
|
|
590
1037
|
}),
|
|
591
1038
|
),
|
|
1039
|
+
cwd: Type.Optional(
|
|
1040
|
+
Type.Union([Type.String(), Type.Null()], {
|
|
1041
|
+
description:
|
|
1042
|
+
"An existing directory to run the delegate in: absolute, relative to this session's working directory, or starting with ~/. Omit to run in this session's working directory. The directory is used as it is; nothing is created or cleaned up. Mutually exclusive with worktree.",
|
|
1043
|
+
}),
|
|
1044
|
+
),
|
|
1045
|
+
worktree: Type.Optional(
|
|
1046
|
+
Type.Union([Type.String(), Type.Null()], {
|
|
1047
|
+
description:
|
|
1048
|
+
"Create a new managed worktree and local branch with this literal branch name, from this repository's committed HEAD or explicit worktree_base. Source dirt is not copied. Mutually exclusive with cwd.",
|
|
1049
|
+
}),
|
|
1050
|
+
),
|
|
1051
|
+
worktree_source: Type.Optional(
|
|
1052
|
+
Type.Union([Type.String(), Type.Null()], {
|
|
1053
|
+
description:
|
|
1054
|
+
"Existing Git checkout to create from; relative to this session or starting with ~/. Requires worktree.",
|
|
1055
|
+
}),
|
|
1056
|
+
),
|
|
1057
|
+
worktree_base: Type.Optional(
|
|
1058
|
+
Type.Union([Type.String(), Type.Null()], {
|
|
1059
|
+
description: "Local commit-ish to create the branch from; requires worktree. No fetch is performed.",
|
|
1060
|
+
}),
|
|
1061
|
+
),
|
|
592
1062
|
auto_exit: Type.Optional(
|
|
593
1063
|
Type.Boolean({
|
|
594
1064
|
description:
|
|
@@ -600,9 +1070,9 @@ export default function delegate(
|
|
|
600
1070
|
renderCall: renderDelegateCall,
|
|
601
1071
|
renderResult: renderDelegateResult,
|
|
602
1072
|
// Opens a new window each call and removes nothing. Open world: the delegate calls a remote model and runs its agent's tools unattended.
|
|
603
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
1073
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
604
1074
|
async execute(
|
|
605
|
-
|
|
1075
|
+
toolCallId: string,
|
|
606
1076
|
params: {
|
|
607
1077
|
agent: string;
|
|
608
1078
|
task: string;
|
|
@@ -610,30 +1080,42 @@ export default function delegate(
|
|
|
610
1080
|
thinking?: string | null;
|
|
611
1081
|
label?: string | null;
|
|
612
1082
|
auto_exit?: boolean | null;
|
|
1083
|
+
cwd?: unknown;
|
|
1084
|
+
worktree?: unknown;
|
|
1085
|
+
worktree_base?: unknown;
|
|
1086
|
+
worktree_source?: unknown;
|
|
613
1087
|
},
|
|
614
|
-
|
|
1088
|
+
signal: AbortSignal | undefined,
|
|
615
1089
|
_onUpdate: unknown,
|
|
616
1090
|
ctx: ExtensionContext,
|
|
617
1091
|
) {
|
|
618
1092
|
if (!tmux.insideTmux()) throw new Error(NOT_IN_TMUX);
|
|
619
1093
|
// Some models fill every optional field with null or the string "null"; both mean unset.
|
|
620
1094
|
const given = (value: string | null | undefined) => (value == null || value === "null" ? undefined : value);
|
|
1095
|
+
const cwd = normalizeWorkspaceValue(params.cwd, "cwd");
|
|
1096
|
+
const worktree = normalizeWorkspaceValue(params.worktree, "worktree");
|
|
1097
|
+
const worktree_base = normalizeWorkspaceValue(params.worktree_base, "worktree_base");
|
|
1098
|
+
const worktree_source = normalizeWorkspaceValue(params.worktree_source, "worktree_source");
|
|
621
1099
|
const start = await validate(
|
|
622
1100
|
{
|
|
623
1101
|
agent: params.agent,
|
|
624
1102
|
model: given(params.model),
|
|
625
1103
|
thinking: given(params.thinking),
|
|
626
1104
|
label: given(params.label),
|
|
1105
|
+
cwd,
|
|
1106
|
+
worktree,
|
|
1107
|
+
worktree_base,
|
|
1108
|
+
worktree_source,
|
|
627
1109
|
autoExit: typeof params.auto_exit === "boolean" ? params.auto_exit : undefined,
|
|
628
1110
|
task: params.task ?? "",
|
|
629
1111
|
},
|
|
630
1112
|
rosterFor(ctx),
|
|
631
1113
|
ctx.modelRegistry,
|
|
632
1114
|
);
|
|
633
|
-
const launched = await launch(start, ctx);
|
|
1115
|
+
const launched = await launch(start, ctx, signal, toolCallId);
|
|
634
1116
|
ctx.ui.notify(started(launched, start), "info");
|
|
635
1117
|
return toolResult(
|
|
636
|
-
`Started delegation ${launched.id}: ${start.agent.name} (${start.model}, thinking ${start.thinking}) in window ${launched.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.${start.skipped.length > 0 ? ` ${fallbackNote(start)}` : ""}${start.notices.map((line) => ` ${line}`).join("")}`,
|
|
1118
|
+
`Started delegation ${launched.id}: ${start.agent.name} (${start.model}, thinking ${start.thinking}) in window ${launched.windowName}${placedIn(launched)}.${launched.workspace.mode === "managed" ? ` Branch ${launched.workspace.createdBranch} starts at ${launched.workspace.baseCommit}. ${dirtyNotice(launched.workspace.sourceDirty)}` : ""} It runs in the background, and its result arrives as a message that starts your next turn on its own; delegation_close ends it.${start.skipped.length > 0 ? ` ${fallbackNote(start)}` : ""}${start.notices.map((line) => ` ${line}`).join("")}`,
|
|
637
1119
|
{
|
|
638
1120
|
outcome: "started",
|
|
639
1121
|
id: launched.id,
|
|
@@ -645,6 +1127,9 @@ export default function delegate(
|
|
|
645
1127
|
autoExit: start.autoExit,
|
|
646
1128
|
windowId: launched.windowId,
|
|
647
1129
|
windowName: launched.windowName,
|
|
1130
|
+
cwd: launched.cwd,
|
|
1131
|
+
sessionDir: launched.sessionDir,
|
|
1132
|
+
workspace: launched.workspace,
|
|
648
1133
|
},
|
|
649
1134
|
);
|
|
650
1135
|
},
|
|
@@ -704,6 +1189,26 @@ export default function delegate(
|
|
|
704
1189
|
handler: command,
|
|
705
1190
|
});
|
|
706
1191
|
|
|
1192
|
+
/** Delegation ids, flags, model ids and thinking levels, at the cursor's token. */
|
|
1193
|
+
function resumeCompletions(prefix: string): { value: string; label: string }[] | null {
|
|
1194
|
+
const partial = /(?:^|\s)(\S*)$/.exec(prefix)?.[1] ?? "";
|
|
1195
|
+
const head = prefix.slice(0, prefix.length - partial.length);
|
|
1196
|
+
const tokens = head.trim() === "" ? [] : head.trim().split(/\s+/);
|
|
1197
|
+
const previous = tokens[tokens.length - 1];
|
|
1198
|
+
let values: readonly string[] = RESUME_FLAGS;
|
|
1199
|
+
if (tokens.length === 0) values = resumableIds(results);
|
|
1200
|
+
else if (previous === "--model") values = modelIds(registry);
|
|
1201
|
+
else if (previous === "--thinking") values = THINKING_LEVELS;
|
|
1202
|
+
const hits = values.filter((value) => value.startsWith(partial));
|
|
1203
|
+
return hits.length > 0 ? hits.map((value) => ({ value: head + value, label: value })) : null;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
pi.registerCommand("delegate-resume", {
|
|
1207
|
+
description: `Reopen an exited delegate's session in a new window with a follow-up task: ${RESUME_USAGE}`,
|
|
1208
|
+
getArgumentCompletions: resumeCompletions,
|
|
1209
|
+
handler: resumeCommand,
|
|
1210
|
+
});
|
|
1211
|
+
|
|
707
1212
|
pi.registerCommand("delegate-clear", {
|
|
708
1213
|
description: "Clear usage-limit marks: all, a provider, or a provider/model",
|
|
709
1214
|
getArgumentCompletions: (prefix: string) => {
|
|
@@ -734,4 +1239,5 @@ export default function delegate(
|
|
|
734
1239
|
|
|
735
1240
|
registerDelegate(toolDescription(currentRoster().agents));
|
|
736
1241
|
tracking.registerTools();
|
|
1242
|
+
pi.registerTool(resumer.tool() as never);
|
|
737
1243
|
}
|