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/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
@@ -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 three tools. `delegate` takes agent, task and optional model
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. `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.
30
- // `delegation_close` kills the window and records the close. Inside a delegate
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 { createTmuxClient, type TmuxClient } from "./tmux.ts";
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 = ["--model", "--thinking", "--label", "--auto-exit", "--no-auto-exit"] as const;
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
- 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 };
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 = { id: string; name: string; windowId: string; windowName: string; requestId: string };
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 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;
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
- i += 1;
329
+ at = next.end;
221
330
  continue;
222
331
  }
223
- if (flag !== "--model" && flag !== "--thinking" && flag !== "--label") {
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 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;
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
- parsed.task = text.slice(tokens[i]?.start ?? text.length).trim();
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] : [agent.model, ...agent.fallback];
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 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.";
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
- "`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.",
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
- /** 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 };
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 ${id}: ${sent.error}`);
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
- `needs a provider of message:* to send the task; install a mailbox extension (for example pi-session-mail)`,
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 ${id}`);
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 name = delegateName(start.agent.name, id, start.label);
493
- const prompt = writePromptFile(`${start.agent.prompt}\n\n${FINAL_LINE}\n\n${parentTrust(parent)}`);
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
- "--session-id",
497
- id,
498
- "--name",
499
- name,
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
- const sessionDir = process.env[SESSION_DIR_ENV];
520
- if (sessionDir !== undefined && sessionDir !== "") env[SESSION_DIR_ENV] = sessionDir;
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: windowName, cwd: ctx.cwd, argv: payload.args, env: payload.env });
704
+ windowId = await tmux.openWindow({ name, cwd, argv: payload.args, env: payload.env });
705
+ onWindowOpened?.();
529
706
  } catch (err) {
530
- throw new Error(`could not open window ${windowName} for ${id}: ${(err as Error).message}`);
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 start = await validate(outcome.parsed, rosterFor(ctx), ctx.modelRegistry);
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: false, idempotentHint: false, openWorldHint: true },
1073
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
604
1074
  async execute(
605
- _toolCallId: string,
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
- _signal: unknown,
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
  }