pi-squire 0.9.4 → 0.10.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 +97 -1
- package/README.md +319 -27
- package/package.json +1 -1
- package/src/git.ts +177 -0
- package/src/index.ts +415 -65
- package/src/render.ts +232 -123
- package/src/result-sessions.ts +47 -0
- package/src/result-types.ts +36 -0
- package/src/results.ts +122 -77
- package/src/snapshot.ts +207 -0
- package/src/tmux.ts +30 -3
- package/src/tracking-attempt.ts +65 -0
- package/src/tracking.ts +78 -20
- 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
|
|
@@ -23,7 +24,8 @@
|
|
|
23
24
|
// The model gets three 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.
|
|
@@ -36,7 +38,7 @@
|
|
|
36
38
|
import { randomUUID } from "node:crypto";
|
|
37
39
|
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
38
40
|
import { tmpdir } from "node:os";
|
|
39
|
-
import { basename, join } from "node:path";
|
|
41
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
40
42
|
|
|
41
43
|
import {
|
|
42
44
|
type ExtensionAPI,
|
|
@@ -49,11 +51,27 @@ import { Type } from "typebox";
|
|
|
49
51
|
import { type Agent, isThinking, type Roster, readRoster, THINKING_LEVELS } from "./agents.ts";
|
|
50
52
|
import { AGENT_ENV, AUTO_EXIT_ENV, FALLBACK_ENV, registerChild } from "./child.ts";
|
|
51
53
|
import { activeMark, clearMarks, isFree, readMarks } from "./limits.ts";
|
|
52
|
-
import { renderDelegateCall, renderDelegateResult } from "./render.ts";
|
|
53
|
-
import { createResults, delegateName } from "./results.ts";
|
|
54
|
-
import { createTmuxClient, type TmuxClient } from "./tmux.ts";
|
|
54
|
+
import { dirtyNotice, renderDelegateCall, renderDelegateResult, resourceLine } from "./render.ts";
|
|
55
|
+
import { createResults, delegateName, type WorkspaceAttempt } from "./results.ts";
|
|
56
|
+
import { createTmuxClient, type TmuxClient, WindowOpenError } from "./tmux.ts";
|
|
55
57
|
import { createTracking, toolResult } from "./tracking.ts";
|
|
56
58
|
import { createProactive, createUsageClient, type Proactive, type UsageClient } from "./usage.ts";
|
|
59
|
+
import {
|
|
60
|
+
normalizeWorkspaceValue,
|
|
61
|
+
prepareDirectory,
|
|
62
|
+
trustPlannedWorktree,
|
|
63
|
+
type Workspace,
|
|
64
|
+
WorkspaceSchema,
|
|
65
|
+
} from "./workspace.ts";
|
|
66
|
+
import {
|
|
67
|
+
createWorktree,
|
|
68
|
+
inQueue,
|
|
69
|
+
prepareWorktree,
|
|
70
|
+
recoveryCommands,
|
|
71
|
+
revalidate,
|
|
72
|
+
rollbackWorktree,
|
|
73
|
+
type WorktreePlan,
|
|
74
|
+
} from "./worktree.ts";
|
|
57
75
|
|
|
58
76
|
/** The optional parent guidance file under `<agent dir>/squire/`. */
|
|
59
77
|
const PARENT_PROMPT = "delegation_prompt.md";
|
|
@@ -102,7 +120,17 @@ function parentTrust(parent: string): string {
|
|
|
102
120
|
].join(" ");
|
|
103
121
|
}
|
|
104
122
|
/** The flags `/delegate` takes before the task. */
|
|
105
|
-
const FLAGS = [
|
|
123
|
+
const FLAGS = [
|
|
124
|
+
"--model",
|
|
125
|
+
"--thinking",
|
|
126
|
+
"--label",
|
|
127
|
+
"--cwd",
|
|
128
|
+
"--worktree",
|
|
129
|
+
"--worktree-base",
|
|
130
|
+
"--worktree-source",
|
|
131
|
+
"--auto-exit",
|
|
132
|
+
"--no-auto-exit",
|
|
133
|
+
] as const;
|
|
106
134
|
|
|
107
135
|
/** The `delegate` tool's structured result, which is also its details. */
|
|
108
136
|
export const DelegateSchema = Type.Object({
|
|
@@ -118,16 +146,31 @@ export const DelegateSchema = Type.Object({
|
|
|
118
146
|
autoExit: Type.Boolean(),
|
|
119
147
|
windowId: Type.String(),
|
|
120
148
|
windowName: Type.String(),
|
|
149
|
+
cwd: Type.String({ description: "The directory the delegate's window opened in." }),
|
|
150
|
+
sessionDir: Type.String({ description: "The session-storage directory the delegate was told to use." }),
|
|
151
|
+
workspace: WorkspaceSchema,
|
|
121
152
|
});
|
|
122
153
|
|
|
123
154
|
const NOT_IN_TMUX = "this session is not inside tmux; start it in a tmux pane to open a delegate window";
|
|
124
155
|
|
|
125
156
|
const USAGE =
|
|
126
|
-
"usage: /delegate <agent> [--model <provider/id>] [--thinking <level>] [--label <name>] [--auto-exit | --no-auto-exit] <task>";
|
|
157
|
+
"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>";
|
|
127
158
|
|
|
128
159
|
type SendPayload = { to: unknown; body: unknown; envelope?: { id?: unknown }; error?: unknown };
|
|
129
|
-
|
|
130
|
-
type
|
|
160
|
+
/** `cwd` is informational: the window opens in the launch's own value, whatever a listener assigns. */
|
|
161
|
+
type LaunchPayload = { args: string[]; env: Record<string, string>; agent: string; readonly cwd: string };
|
|
162
|
+
type Parsed = {
|
|
163
|
+
agent: string;
|
|
164
|
+
model?: string;
|
|
165
|
+
thinking?: string;
|
|
166
|
+
label?: string;
|
|
167
|
+
cwd?: string;
|
|
168
|
+
worktree?: string;
|
|
169
|
+
worktree_base?: string;
|
|
170
|
+
worktree_source?: string;
|
|
171
|
+
autoExit?: boolean;
|
|
172
|
+
task: string;
|
|
173
|
+
};
|
|
131
174
|
/** A request that passed the roster, model, thinking and label checks. */
|
|
132
175
|
type Start = {
|
|
133
176
|
agent: Agent;
|
|
@@ -140,13 +183,28 @@ type Start = {
|
|
|
140
183
|
rest: string[];
|
|
141
184
|
thinking: string;
|
|
142
185
|
label?: string;
|
|
186
|
+
/** The existing directory to run in, as given; undefined inherits the parent's. */
|
|
187
|
+
cwd?: string;
|
|
188
|
+
/** The new local branch to create a worktree for; undefined creates none. */
|
|
189
|
+
worktree?: string;
|
|
190
|
+
worktree_base?: string;
|
|
191
|
+
worktree_source?: string;
|
|
143
192
|
autoExit: boolean;
|
|
144
193
|
task: string;
|
|
145
194
|
};
|
|
146
195
|
/** A candidate model passed over: `why` finishes "<model> is …", `reason` is the long form for a refusal. */
|
|
147
196
|
type Skipped = { model: string; why: string; reason: string };
|
|
148
197
|
/** A delegation that is open in a tmux window. */
|
|
149
|
-
type Launched = {
|
|
198
|
+
type Launched = {
|
|
199
|
+
id: string;
|
|
200
|
+
name: string;
|
|
201
|
+
windowId: string;
|
|
202
|
+
windowName: string;
|
|
203
|
+
requestId: string;
|
|
204
|
+
cwd: string;
|
|
205
|
+
sessionDir: string;
|
|
206
|
+
workspace: Workspace;
|
|
207
|
+
};
|
|
150
208
|
|
|
151
209
|
/**
|
|
152
210
|
* Write the appended system prompt to a fresh owner-only directory under the
|
|
@@ -206,31 +264,74 @@ function closest(value: string, candidates: readonly string[]): string[] {
|
|
|
206
264
|
.map(({ candidate }) => candidate);
|
|
207
265
|
}
|
|
208
266
|
|
|
267
|
+
/** One whitespace-separated word of `text` at or after `from`, verbatim; undefined at the end. */
|
|
268
|
+
function word(text: string, from: number): { text: string; start: number; end: number } | undefined {
|
|
269
|
+
const match = /\S+/g;
|
|
270
|
+
match.lastIndex = from;
|
|
271
|
+
const found = match.exec(text);
|
|
272
|
+
return found === null ? undefined : { text: found[0], start: found.index, end: found.index + found[0].length };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* A flag's value at or after `from`: a plain word, or a double-quoted string
|
|
277
|
+
* in which `\"` and `\\` stand for `"` and `\`. No shell expansion of any kind.
|
|
278
|
+
*/
|
|
279
|
+
function flagValue(text: string, from: number, flag: string): { value: string; end: number } | { error: string } {
|
|
280
|
+
const next = word(text, from);
|
|
281
|
+
if (next === undefined || next.text.startsWith("--")) return { error: `${flag} needs a value; ${USAGE}` };
|
|
282
|
+
if (!next.text.startsWith('"')) return { value: next.text, end: next.end };
|
|
283
|
+
let value = "";
|
|
284
|
+
let i = next.start + 1;
|
|
285
|
+
while (i < text.length && text[i] !== '"') {
|
|
286
|
+
const escaped = text[i] === "\\" && (text[i + 1] === '"' || text[i + 1] === "\\");
|
|
287
|
+
value += escaped ? text[i + 1] : text[i];
|
|
288
|
+
i += escaped ? 2 : 1;
|
|
289
|
+
}
|
|
290
|
+
if (i >= text.length) return { error: `${flag}'s value has no closing quote; ${USAGE}` };
|
|
291
|
+
if (i + 1 < text.length && !/\s/.test(text[i + 1])) {
|
|
292
|
+
return { error: `${flag}'s quoted value must be followed by a space; ${USAGE}` };
|
|
293
|
+
}
|
|
294
|
+
return { value, end: i + 1 };
|
|
295
|
+
}
|
|
296
|
+
|
|
209
297
|
/** `/delegate`'s arguments: an agent, optional flags, then a verbatim task. */
|
|
210
298
|
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 =
|
|
299
|
+
const agent = word(text, 0);
|
|
300
|
+
if (agent === undefined) return { error: USAGE };
|
|
301
|
+
if (agent.text.startsWith("-")) return { error: `the first argument is the agent name; ${USAGE}` };
|
|
302
|
+
const parsed: Parsed = { agent: agent.text, task: "" };
|
|
303
|
+
let at = agent.end;
|
|
304
|
+
for (let next = word(text, at); next?.text.startsWith("-"); next = word(text, at)) {
|
|
305
|
+
const flag = next.text;
|
|
218
306
|
if (flag === "--auto-exit" || flag === "--no-auto-exit") {
|
|
219
307
|
parsed.autoExit = flag === "--auto-exit";
|
|
220
|
-
|
|
308
|
+
at = next.end;
|
|
221
309
|
continue;
|
|
222
310
|
}
|
|
223
|
-
if (
|
|
311
|
+
if (
|
|
312
|
+
flag !== "--model" &&
|
|
313
|
+
flag !== "--thinking" &&
|
|
314
|
+
flag !== "--label" &&
|
|
315
|
+
flag !== "--cwd" &&
|
|
316
|
+
flag !== "--worktree" &&
|
|
317
|
+
flag !== "--worktree-base" &&
|
|
318
|
+
flag !== "--worktree-source"
|
|
319
|
+
) {
|
|
224
320
|
return { error: `unknown flag ${flag}; ${USAGE}` };
|
|
225
321
|
}
|
|
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
|
-
|
|
322
|
+
const read = flagValue(text, next.end, flag);
|
|
323
|
+
if ("error" in read) return read;
|
|
324
|
+
if (flag === "--model") parsed.model = read.value;
|
|
325
|
+
else if (flag === "--thinking") parsed.thinking = read.value;
|
|
326
|
+
else if (flag === "--label") parsed.label = read.value;
|
|
327
|
+
else if (flag === "--cwd") parsed.cwd = read.value;
|
|
328
|
+
else if (flag === "--worktree") parsed.worktree = read.value;
|
|
329
|
+
else if (flag === "--worktree-base") parsed.worktree_base = read.value;
|
|
330
|
+
else parsed.worktree_source = read.value;
|
|
331
|
+
at = read.end;
|
|
232
332
|
}
|
|
233
|
-
|
|
333
|
+
// The task is the rest of the text from its first word, untouched by flag parsing.
|
|
334
|
+
parsed.task = text.slice(word(text, at)?.start ?? text.length).trim();
|
|
234
335
|
if (parsed.task === "") return { error: `a task is required after the agent and flags; ${USAGE}` };
|
|
235
336
|
return { parsed };
|
|
236
337
|
}
|
|
@@ -352,7 +453,7 @@ function delegateVisible(options: { selectedTools: string[]; hiddenTools: string
|
|
|
352
453
|
/** The `delegate` tool's description: what delegation does, and the roster as it stands. */
|
|
353
454
|
function toolDescription(agents: readonly Agent[]): string {
|
|
354
455
|
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
|
|
456
|
+
"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
457
|
if (agents.length === 0) {
|
|
357
458
|
return `${intro}\n\nNo agents are defined yet. Add ${join(
|
|
358
459
|
getAgentDir(),
|
|
@@ -373,6 +474,7 @@ function toolDescription(agents: readonly Agent[]): string {
|
|
|
373
474
|
}${agent.tools.length > 0 ? `, tools: ${agent.tools.join("/")}` : ""})`,
|
|
374
475
|
),
|
|
375
476
|
"",
|
|
477
|
+
"`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.",
|
|
376
478
|
"`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.",
|
|
377
479
|
"`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
480
|
"",
|
|
@@ -464,39 +566,46 @@ export default function delegate(
|
|
|
464
566
|
rest,
|
|
465
567
|
thinking,
|
|
466
568
|
label: request.label,
|
|
569
|
+
cwd: request.cwd,
|
|
570
|
+
worktree: request.worktree,
|
|
571
|
+
worktree_base: request.worktree_base,
|
|
572
|
+
worktree_source: request.worktree_source,
|
|
467
573
|
autoExit: request.autoExit ?? agent.autoExit,
|
|
468
574
|
task: request.task,
|
|
469
575
|
};
|
|
470
576
|
}
|
|
471
577
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
throw new Error(
|
|
485
|
-
`needs a provider of message:* to send the task; install a mailbox extension (for example pi-session-mail)`,
|
|
486
|
-
);
|
|
487
|
-
}
|
|
488
|
-
const requestId = sent.envelope.id;
|
|
489
|
-
if (typeof requestId !== "string") throw new Error(`the message:send provider wrote no request id for ${id}`);
|
|
490
|
-
|
|
578
|
+
async function launchWindow(input: {
|
|
579
|
+
id: string;
|
|
580
|
+
requestId: string;
|
|
581
|
+
workspace: Workspace;
|
|
582
|
+
trusted: boolean;
|
|
583
|
+
cwd: string;
|
|
584
|
+
sessionDir: string;
|
|
585
|
+
start: Start;
|
|
586
|
+
ctx: ExtensionContext;
|
|
587
|
+
onWindowOpened: () => void;
|
|
588
|
+
}): Promise<Launched> {
|
|
589
|
+
const { id, requestId, workspace, trusted, cwd, sessionDir, start, ctx, onWindowOpened } = input;
|
|
491
590
|
const parent = ctx.sessionManager.getSessionId();
|
|
492
591
|
const name = delegateName(start.agent.name, id, start.label);
|
|
493
|
-
const
|
|
592
|
+
const managedContext =
|
|
593
|
+
workspace.mode === "managed"
|
|
594
|
+
? `\n\nYour managed worktree is ${workspace.cwd}, branch ${workspace.createdBranch}, based on commit ${workspace.baseCommit}.`
|
|
595
|
+
: workspace.mode === "existing"
|
|
596
|
+
? `\n\nThe session that started you chose your working directory: ${workspace.cwd}`
|
|
597
|
+
: "";
|
|
598
|
+
const prompt = writePromptFile(`${start.agent.prompt}\n\n${FINAL_LINE}\n\n${parentTrust(parent)}${managedContext}`);
|
|
494
599
|
const argv = [
|
|
495
600
|
...parentCommand(),
|
|
496
601
|
"--session-id",
|
|
497
602
|
id,
|
|
498
603
|
"--name",
|
|
499
604
|
name,
|
|
605
|
+
// Always explicit, so a permissive global default cannot trust what the parent does not.
|
|
606
|
+
trusted ? "--approve" : "--no-approve",
|
|
607
|
+
"--session-dir",
|
|
608
|
+
sessionDir,
|
|
500
609
|
"--model",
|
|
501
610
|
start.model,
|
|
502
611
|
"--thinking",
|
|
@@ -516,20 +625,22 @@ export default function delegate(
|
|
|
516
625
|
PI_UNATTENDED: "1",
|
|
517
626
|
};
|
|
518
627
|
if (start.rest.length > 0) env[FALLBACK_ENV] = start.rest.join(",");
|
|
519
|
-
|
|
520
|
-
|
|
628
|
+
// Pi ranks `--session-dir` above this variable, so it cannot move the child's storage.
|
|
629
|
+
const envSessionDir = process.env[SESSION_DIR_ENV];
|
|
630
|
+
if (envSessionDir !== undefined && envSessionDir !== "") env[SESSION_DIR_ENV] = envSessionDir;
|
|
521
631
|
// 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 };
|
|
632
|
+
const payload: LaunchPayload = { args: argv, env, agent: start.agent.name, cwd };
|
|
523
633
|
pi.events.emit(LAUNCH, payload);
|
|
524
|
-
|
|
525
|
-
const windowName = name;
|
|
526
634
|
let windowId: string;
|
|
527
635
|
try {
|
|
528
|
-
windowId = await tmux.openWindow({ name
|
|
636
|
+
windowId = await tmux.openWindow({ name, cwd, argv: payload.args, env: payload.env });
|
|
637
|
+
onWindowOpened();
|
|
529
638
|
} catch (err) {
|
|
530
|
-
|
|
639
|
+
if (err instanceof WindowOpenError) {
|
|
640
|
+
throw new WindowOpenError(`could not open window ${name} for ${id}: ${err.message}`, err.certainty);
|
|
641
|
+
}
|
|
642
|
+
throw new WindowOpenError(`could not open window ${name} for ${id}: ${(err as Error).message}`, "unknown");
|
|
531
643
|
}
|
|
532
|
-
|
|
533
644
|
// No task text in the record: the request copy in `sent/` has it.
|
|
534
645
|
results.recordStart(
|
|
535
646
|
{
|
|
@@ -539,18 +650,209 @@ export default function delegate(
|
|
|
539
650
|
thinking: start.thinking,
|
|
540
651
|
name,
|
|
541
652
|
windowId,
|
|
542
|
-
windowName,
|
|
653
|
+
windowName: name,
|
|
543
654
|
requestId,
|
|
544
655
|
autoExit: start.autoExit,
|
|
656
|
+
cwd,
|
|
657
|
+
sessionDir,
|
|
658
|
+
workspace,
|
|
545
659
|
},
|
|
546
660
|
ctx,
|
|
547
661
|
);
|
|
548
662
|
tracking.startPolling();
|
|
549
|
-
return { id, name, windowId, windowName, requestId };
|
|
663
|
+
return { id, name, windowId, windowName: name, requestId, cwd, sessionDir, workspace };
|
|
550
664
|
}
|
|
551
665
|
|
|
666
|
+
async function launch(start: Start, ctx: ExtensionContext, signal?: AbortSignal): Promise<Launched> {
|
|
667
|
+
if (!tmux.insideTmux()) throw new Error(NOT_IN_TMUX);
|
|
668
|
+
const sessionDir = ctx.sessionManager.getSessionDir();
|
|
669
|
+
if (!isAbsolute(sessionDir))
|
|
670
|
+
throw new Error(
|
|
671
|
+
`the parent's session directory ${JSON.stringify(sessionDir)} is not an absolute path; start Pi with an absolute --session-dir`,
|
|
672
|
+
);
|
|
673
|
+
const cwdOption = normalizeWorkspaceValue(start.cwd, "cwd");
|
|
674
|
+
const worktreeOption = normalizeWorkspaceValue(start.worktree, "worktree");
|
|
675
|
+
const baseOption = normalizeWorkspaceValue(start.worktree_base, "worktree_base");
|
|
676
|
+
const sourceOption = normalizeWorkspaceValue(start.worktree_source, "worktree_source");
|
|
677
|
+
if (sourceOption !== undefined && worktreeOption === undefined)
|
|
678
|
+
throw new Error("worktree_source requires worktree");
|
|
679
|
+
if (baseOption !== undefined && worktreeOption === undefined) throw new Error("worktree_base requires worktree");
|
|
680
|
+
if (cwdOption !== undefined && worktreeOption !== undefined)
|
|
681
|
+
throw new Error("cwd and worktree are mutually exclusive; name only one");
|
|
682
|
+
const signals = [signal, ctx.signal].filter((item): item is AbortSignal => item !== undefined);
|
|
683
|
+
const combined = signals.length ? AbortSignal.any(signals) : undefined;
|
|
684
|
+
let plan: WorktreePlan | undefined;
|
|
685
|
+
let directory: Workspace | undefined;
|
|
686
|
+
let trusted: boolean;
|
|
687
|
+
let revalidateTrust: (() => void) | undefined;
|
|
688
|
+
if (worktreeOption !== undefined) {
|
|
689
|
+
plan = await prepareWorktree(
|
|
690
|
+
{
|
|
691
|
+
worktree: worktreeOption,
|
|
692
|
+
...(baseOption === undefined ? {} : { worktree_base: baseOption }),
|
|
693
|
+
...(sourceOption === undefined ? {} : { worktree_source: sourceOption }),
|
|
694
|
+
},
|
|
695
|
+
ctx,
|
|
696
|
+
getAgentDir(),
|
|
697
|
+
);
|
|
698
|
+
if (combined?.aborted) throw new Error("the launch was aborted");
|
|
699
|
+
const trust = await trustPlannedWorktree(plan, ctx, getAgentDir(), combined);
|
|
700
|
+
if (combined?.aborted) throw new Error("the launch was aborted");
|
|
701
|
+
trusted = trust.trusted;
|
|
702
|
+
revalidateTrust = trust.revalidate;
|
|
703
|
+
plan.trusted = trusted;
|
|
704
|
+
} else {
|
|
705
|
+
const prepared = await prepareDirectory({ cwd: cwdOption, signal }, ctx, getAgentDir());
|
|
706
|
+
directory = prepared.workspace;
|
|
707
|
+
trusted = prepared.trusted;
|
|
708
|
+
}
|
|
709
|
+
const id = randomUUID();
|
|
710
|
+
const parentId = ctx.sessionManager.getSessionId();
|
|
711
|
+
let attempt: WorkspaceAttempt | undefined;
|
|
712
|
+
let managed: Extract<Workspace, { mode: "managed" }> | undefined;
|
|
713
|
+
let windowOpened = false;
|
|
714
|
+
const run = async (): Promise<Launched> => {
|
|
715
|
+
if (plan !== undefined) {
|
|
716
|
+
revalidate(plan);
|
|
717
|
+
revalidateTrust?.();
|
|
718
|
+
attempt = {
|
|
719
|
+
kind: "workspace-attempt",
|
|
720
|
+
id,
|
|
721
|
+
parentId,
|
|
722
|
+
agent: start.agent.name,
|
|
723
|
+
model: start.model,
|
|
724
|
+
thinking: start.thinking,
|
|
725
|
+
...(start.label === undefined ? {} : { name: start.label }),
|
|
726
|
+
sessionDir,
|
|
727
|
+
plan,
|
|
728
|
+
attempt: { phase: "planned", resources: { kind: "none" } },
|
|
729
|
+
};
|
|
730
|
+
results.recordAttempt(attempt, ctx);
|
|
731
|
+
const created = createWorktree(plan);
|
|
732
|
+
if (!("workspace" in created)) {
|
|
733
|
+
const recovery = recoveryCommands(plan, created.resources);
|
|
734
|
+
attempt = {
|
|
735
|
+
...attempt,
|
|
736
|
+
attempt:
|
|
737
|
+
created.resources.kind === "none"
|
|
738
|
+
? { phase: "rolled-back", resources: { kind: "none" }, cause: created.cause }
|
|
739
|
+
: { phase: "retained", resources: created.resources, cause: created.cause, recovery },
|
|
740
|
+
};
|
|
741
|
+
results.recordAttempt(attempt, ctx);
|
|
742
|
+
throw new Error(
|
|
743
|
+
`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" : ""}`,
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
managed = created.workspace;
|
|
747
|
+
attempt = {
|
|
748
|
+
...attempt,
|
|
749
|
+
attempt: {
|
|
750
|
+
phase: "created",
|
|
751
|
+
resources: { kind: "worktree", cwd: managed.cwd, branch: managed.createdBranch, head: managed.baseCommit },
|
|
752
|
+
},
|
|
753
|
+
};
|
|
754
|
+
results.recordAttempt(attempt, ctx);
|
|
755
|
+
}
|
|
756
|
+
let workspace: Workspace;
|
|
757
|
+
if (managed !== undefined) workspace = managed;
|
|
758
|
+
else if (directory !== undefined) workspace = directory;
|
|
759
|
+
else throw new Error("workspace preparation did not produce a launch directory");
|
|
760
|
+
// The task goes on disk before the window exists: the delegate finds it
|
|
761
|
+
// at session start without a handshake, and a window that fails leaves
|
|
762
|
+
// mail that is reported rather than cleaned up.
|
|
763
|
+
const sent: SendPayload = { to: id, body: start.task };
|
|
764
|
+
pi.events.emit(SEND, sent);
|
|
765
|
+
if (typeof sent.error === "string") throw new Error(`could not send the task to ${id}: ${sent.error}`);
|
|
766
|
+
if (sent.envelope === undefined)
|
|
767
|
+
throw new Error(
|
|
768
|
+
"needs a provider of message:* to send the task; install a mailbox extension (for example pi-session-mail)",
|
|
769
|
+
);
|
|
770
|
+
const requestId = sent.envelope.id;
|
|
771
|
+
if (typeof requestId !== "string") throw new Error(`the message:send provider wrote no request id for ${id}`);
|
|
772
|
+
if (attempt !== undefined) {
|
|
773
|
+
attempt = { ...attempt, requestId };
|
|
774
|
+
results.recordAttempt(attempt, ctx);
|
|
775
|
+
}
|
|
776
|
+
return launchWindow({
|
|
777
|
+
id,
|
|
778
|
+
requestId,
|
|
779
|
+
workspace,
|
|
780
|
+
trusted,
|
|
781
|
+
cwd: workspace.cwd,
|
|
782
|
+
sessionDir,
|
|
783
|
+
start,
|
|
784
|
+
ctx,
|
|
785
|
+
onWindowOpened: () => {
|
|
786
|
+
windowOpened = true;
|
|
787
|
+
},
|
|
788
|
+
});
|
|
789
|
+
};
|
|
790
|
+
const runWithRollback = async (): Promise<Launched> => {
|
|
791
|
+
try {
|
|
792
|
+
return await run();
|
|
793
|
+
} catch (error) {
|
|
794
|
+
if (attempt !== undefined && managed !== undefined) {
|
|
795
|
+
const cause = (error as Error).message;
|
|
796
|
+
if (windowOpened || (error instanceof WindowOpenError && error.certainty === "unknown")) {
|
|
797
|
+
attempt = {
|
|
798
|
+
...attempt,
|
|
799
|
+
attempt: {
|
|
800
|
+
phase: "retained",
|
|
801
|
+
resources: {
|
|
802
|
+
kind: "worktree",
|
|
803
|
+
cwd: managed.cwd,
|
|
804
|
+
branch: managed.createdBranch,
|
|
805
|
+
head: managed.baseCommit,
|
|
806
|
+
},
|
|
807
|
+
cause,
|
|
808
|
+
recovery: [],
|
|
809
|
+
},
|
|
810
|
+
};
|
|
811
|
+
} else {
|
|
812
|
+
const rollbackPlan = plan;
|
|
813
|
+
if (rollbackPlan === undefined) throw error;
|
|
814
|
+
const rolled = rollbackWorktree(rollbackPlan, managed);
|
|
815
|
+
attempt =
|
|
816
|
+
rolled.kind === "rolled-back"
|
|
817
|
+
? { ...attempt, attempt: { phase: "rolled-back", resources: { kind: "none" }, cause } }
|
|
818
|
+
: {
|
|
819
|
+
...attempt,
|
|
820
|
+
attempt: {
|
|
821
|
+
phase: "retained",
|
|
822
|
+
resources: rolled.resources,
|
|
823
|
+
cause: `${cause}; ${rolled.reason}`,
|
|
824
|
+
recovery: recoveryCommands(rollbackPlan, rolled.resources),
|
|
825
|
+
},
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
results.recordAttempt(attempt, ctx);
|
|
829
|
+
const state = attempt.attempt;
|
|
830
|
+
const recovery =
|
|
831
|
+
state.phase !== "retained"
|
|
832
|
+
? ""
|
|
833
|
+
: state.recovery.length > 0
|
|
834
|
+
? `; recovery: ${state.recovery.join("; ")}`
|
|
835
|
+
: state.resources.kind === "worktree"
|
|
836
|
+
? "; inspect and preserve its contents before any cleanup"
|
|
837
|
+
: "";
|
|
838
|
+
throw new Error(
|
|
839
|
+
`worktree launch ${id} from ${managed.sourceCwd} at ${managed.cwd} (${managed.createdBranch}) failed: ${cause}; outcome ${state.phase}${recovery}`,
|
|
840
|
+
{ cause: error },
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
throw error;
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
if (plan === undefined) return runWithRollback();
|
|
847
|
+
return inQueue(plan.source.commonDir, combined, runWithRollback);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** Where a selected directory put the delegate, for the start text; inherited starts stay compact. */
|
|
851
|
+
const placedIn = (launched: Launched): string =>
|
|
852
|
+
launched.workspace.mode !== "inherited" ? ` in ${launched.cwd}` : "";
|
|
853
|
+
|
|
552
854
|
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("")}`;
|
|
855
|
+
`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
856
|
|
|
555
857
|
async function command(input: string, ctx: ExtensionContext): Promise<void> {
|
|
556
858
|
const fail = (message: string) => ctx.ui.notify(`delegate: ${message}`, "error");
|
|
@@ -560,7 +862,17 @@ export default function delegate(
|
|
|
560
862
|
return;
|
|
561
863
|
}
|
|
562
864
|
try {
|
|
563
|
-
const
|
|
865
|
+
const cwd = normalizeWorkspaceValue(outcome.parsed.cwd, "--cwd");
|
|
866
|
+
const worktree_source = normalizeWorkspaceValue(outcome.parsed.worktree_source, "--worktree-source");
|
|
867
|
+
const worktree_base = normalizeWorkspaceValue(outcome.parsed.worktree_base, "--worktree-base");
|
|
868
|
+
const worktree = normalizeWorkspaceValue(outcome.parsed.worktree, "--worktree");
|
|
869
|
+
if (worktree_source !== undefined && worktree === undefined)
|
|
870
|
+
throw new Error("--worktree-source requires --worktree");
|
|
871
|
+
const start = await validate(
|
|
872
|
+
{ ...outcome.parsed, cwd, worktree, worktree_base, worktree_source },
|
|
873
|
+
rosterFor(ctx),
|
|
874
|
+
ctx.modelRegistry,
|
|
875
|
+
);
|
|
564
876
|
const launched = await launch(start, ctx);
|
|
565
877
|
ctx.ui.notify(started(launched, start), "info");
|
|
566
878
|
} catch (err) {
|
|
@@ -589,6 +901,29 @@ export default function delegate(
|
|
|
589
901
|
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
902
|
}),
|
|
591
903
|
),
|
|
904
|
+
cwd: Type.Optional(
|
|
905
|
+
Type.Union([Type.String(), Type.Null()], {
|
|
906
|
+
description:
|
|
907
|
+
"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.",
|
|
908
|
+
}),
|
|
909
|
+
),
|
|
910
|
+
worktree: Type.Optional(
|
|
911
|
+
Type.Union([Type.String(), Type.Null()], {
|
|
912
|
+
description:
|
|
913
|
+
"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.",
|
|
914
|
+
}),
|
|
915
|
+
),
|
|
916
|
+
worktree_source: Type.Optional(
|
|
917
|
+
Type.Union([Type.String(), Type.Null()], {
|
|
918
|
+
description:
|
|
919
|
+
"Existing Git checkout to create from; relative to this session or starting with ~/. Requires worktree.",
|
|
920
|
+
}),
|
|
921
|
+
),
|
|
922
|
+
worktree_base: Type.Optional(
|
|
923
|
+
Type.Union([Type.String(), Type.Null()], {
|
|
924
|
+
description: "Local commit-ish to create the branch from; requires worktree. No fetch is performed.",
|
|
925
|
+
}),
|
|
926
|
+
),
|
|
592
927
|
auto_exit: Type.Optional(
|
|
593
928
|
Type.Boolean({
|
|
594
929
|
description:
|
|
@@ -600,7 +935,7 @@ export default function delegate(
|
|
|
600
935
|
renderCall: renderDelegateCall,
|
|
601
936
|
renderResult: renderDelegateResult,
|
|
602
937
|
// 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:
|
|
938
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
604
939
|
async execute(
|
|
605
940
|
_toolCallId: string,
|
|
606
941
|
params: {
|
|
@@ -610,30 +945,42 @@ export default function delegate(
|
|
|
610
945
|
thinking?: string | null;
|
|
611
946
|
label?: string | null;
|
|
612
947
|
auto_exit?: boolean | null;
|
|
948
|
+
cwd?: unknown;
|
|
949
|
+
worktree?: unknown;
|
|
950
|
+
worktree_base?: unknown;
|
|
951
|
+
worktree_source?: unknown;
|
|
613
952
|
},
|
|
614
|
-
|
|
953
|
+
signal: AbortSignal | undefined,
|
|
615
954
|
_onUpdate: unknown,
|
|
616
955
|
ctx: ExtensionContext,
|
|
617
956
|
) {
|
|
618
957
|
if (!tmux.insideTmux()) throw new Error(NOT_IN_TMUX);
|
|
619
958
|
// Some models fill every optional field with null or the string "null"; both mean unset.
|
|
620
959
|
const given = (value: string | null | undefined) => (value == null || value === "null" ? undefined : value);
|
|
960
|
+
const cwd = normalizeWorkspaceValue(params.cwd, "cwd");
|
|
961
|
+
const worktree = normalizeWorkspaceValue(params.worktree, "worktree");
|
|
962
|
+
const worktree_base = normalizeWorkspaceValue(params.worktree_base, "worktree_base");
|
|
963
|
+
const worktree_source = normalizeWorkspaceValue(params.worktree_source, "worktree_source");
|
|
621
964
|
const start = await validate(
|
|
622
965
|
{
|
|
623
966
|
agent: params.agent,
|
|
624
967
|
model: given(params.model),
|
|
625
968
|
thinking: given(params.thinking),
|
|
626
969
|
label: given(params.label),
|
|
970
|
+
cwd,
|
|
971
|
+
worktree,
|
|
972
|
+
worktree_base,
|
|
973
|
+
worktree_source,
|
|
627
974
|
autoExit: typeof params.auto_exit === "boolean" ? params.auto_exit : undefined,
|
|
628
975
|
task: params.task ?? "",
|
|
629
976
|
},
|
|
630
977
|
rosterFor(ctx),
|
|
631
978
|
ctx.modelRegistry,
|
|
632
979
|
);
|
|
633
|
-
const launched = await launch(start, ctx);
|
|
980
|
+
const launched = await launch(start, ctx, signal);
|
|
634
981
|
ctx.ui.notify(started(launched, start), "info");
|
|
635
982
|
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("")}`,
|
|
983
|
+
`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
984
|
{
|
|
638
985
|
outcome: "started",
|
|
639
986
|
id: launched.id,
|
|
@@ -645,6 +992,9 @@ export default function delegate(
|
|
|
645
992
|
autoExit: start.autoExit,
|
|
646
993
|
windowId: launched.windowId,
|
|
647
994
|
windowName: launched.windowName,
|
|
995
|
+
cwd: launched.cwd,
|
|
996
|
+
sessionDir: launched.sessionDir,
|
|
997
|
+
workspace: launched.workspace,
|
|
648
998
|
},
|
|
649
999
|
);
|
|
650
1000
|
},
|