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/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 running the parent's own Pi. The window and the
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. `delegation_status`
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 = ["--model", "--thinking", "--label", "--auto-exit", "--no-auto-exit"] as const;
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
- type LaunchPayload = { args: string[]; env: Record<string, string>; agent: string };
130
- type Parsed = { agent: string; model?: string; thinking?: string; label?: string; autoExit?: boolean; task: string };
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 = { id: string; name: string; windowId: string; windowName: string; requestId: string };
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 tokens = [...text.matchAll(/\S+/g)].map((match) => ({ text: match[0], start: match.index }));
212
- if (tokens.length === 0) return { error: USAGE };
213
- if (tokens[0].text.startsWith("-")) return { error: `the first argument is the agent name; ${USAGE}` };
214
- const parsed: Parsed = { agent: tokens[0].text, task: "" };
215
- let i = 1;
216
- while (i < tokens.length && tokens[i].text.startsWith("-")) {
217
- const flag = tokens[i].text;
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
- i += 1;
308
+ at = next.end;
221
309
  continue;
222
310
  }
223
- if (flag !== "--model" && flag !== "--thinking" && flag !== "--label") {
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 value = tokens[i + 1]?.text;
227
- if (value === undefined || value.startsWith("--")) return { error: `${flag} needs a value; ${USAGE}` };
228
- if (flag === "--model") parsed.model = value;
229
- else if (flag === "--thinking") parsed.thinking = value;
230
- else parsed.label = value;
231
- i += 2;
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
- parsed.task = text.slice(tokens[i]?.start ?? text.length).trim();
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 and 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.";
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
- /** The launch order of the spec: task, argv, env, listeners, window, record. */
473
- async function launch(start: Start, ctx: ExtensionContext): Promise<Launched> {
474
- if (!tmux.insideTmux()) throw new Error(NOT_IN_TMUX);
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 };
481
- pi.events.emit(SEND, sent);
482
- if (typeof sent.error === "string") throw new Error(`could not send the task to ${id}: ${sent.error}`);
483
- if (sent.envelope === undefined) {
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 prompt = writePromptFile(`${start.agent.prompt}\n\n${FINAL_LINE}\n\n${parentTrust(parent)}`);
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
- const sessionDir = process.env[SESSION_DIR_ENV];
520
- if (sessionDir !== undefined && sessionDir !== "") env[SESSION_DIR_ENV] = sessionDir;
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: windowName, cwd: ctx.cwd, argv: payload.args, env: payload.env });
636
+ windowId = await tmux.openWindow({ name, cwd, argv: payload.args, env: payload.env });
637
+ onWindowOpened();
529
638
  } catch (err) {
530
- throw new Error(`could not open window ${windowName} for ${id}: ${(err as Error).message}`);
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 start = await validate(outcome.parsed, rosterFor(ctx), ctx.modelRegistry);
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: false, idempotentHint: false, openWorldHint: true },
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
- _signal: unknown,
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
  },