pi-squire 0.14.0 → 0.16.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +438 -1
  2. package/README.md +1062 -152
  3. package/package.json +4 -3
  4. package/src/agents.ts +395 -124
  5. package/src/child-tasks.ts +221 -0
  6. package/src/child.ts +107 -62
  7. package/src/completion-holds.ts +104 -0
  8. package/src/dashboard.ts +144 -0
  9. package/src/git.ts +6 -1
  10. package/src/identity.ts +152 -0
  11. package/src/index.ts +585 -107
  12. package/src/local-state.ts +21 -0
  13. package/src/navigator-view.ts +186 -0
  14. package/src/navigator.ts +152 -107
  15. package/src/new-delegate.ts +17 -8
  16. package/src/poll.ts +1 -0
  17. package/src/render.ts +4 -1
  18. package/src/result-close.ts +37 -0
  19. package/src/result-restore.ts +95 -0
  20. package/src/result-sessions.ts +39 -0
  21. package/src/result-types.ts +29 -1
  22. package/src/results-presentation.ts +47 -0
  23. package/src/results.ts +176 -108
  24. package/src/resume-checks.ts +59 -14
  25. package/src/resume-picker.ts +9 -2
  26. package/src/resume.ts +69 -15
  27. package/src/session-owner.ts +279 -0
  28. package/src/task-replies.ts +247 -0
  29. package/src/task-settlement.ts +81 -0
  30. package/src/task-store.ts +26 -0
  31. package/src/tmux.ts +134 -54
  32. package/src/tool-ceiling.ts +125 -0
  33. package/src/tracking.ts +72 -65
  34. package/src/tree-close.ts +236 -0
  35. package/src/tree-fence.ts +51 -0
  36. package/src/tree-lock.ts +77 -0
  37. package/src/tree-notice.ts +124 -0
  38. package/src/tree-registry.ts +365 -0
  39. package/src/tree-settings.ts +31 -0
  40. package/src/tree-types.ts +32 -0
  41. package/src/tree-view.ts +246 -0
  42. package/src/trust-control.ts +214 -0
  43. package/src/trust-prompt.ts +163 -0
  44. package/src/trust-wire.ts +203 -0
  45. package/src/widget-activity-join.ts +24 -0
  46. package/src/widget-activity-runtime.ts +104 -0
  47. package/src/widget-activity.ts +104 -0
  48. package/src/widget-ancestors.ts +142 -0
  49. package/src/widget-current.ts +41 -0
  50. package/src/widget-data.ts +147 -0
  51. package/src/widget-focus.ts +133 -0
  52. package/src/widget-live-process.ts +31 -0
  53. package/src/widget-live.ts +356 -0
  54. package/src/widget-render.ts +181 -0
  55. package/src/widget-selection.ts +58 -0
  56. package/src/widget-settings.ts +29 -0
  57. package/src/widget-summary.ts +51 -0
  58. package/src/widget-title-file.ts +127 -0
  59. package/src/widget-title.ts +118 -0
  60. package/src/widget.ts +272 -0
  61. package/src/window-placement.ts +30 -5
  62. package/src/workspace.ts +38 -90
package/src/index.ts CHANGED
@@ -3,7 +3,8 @@
3
3
  //
4
4
  // `/delegate <agent> [--model <provider/id>] [--thinking <level>]
5
5
  // [--label <name>] [--cwd <dir> | --worktree <branch> [--worktree-source <dir>] [--worktree-base <commit-ish>] ] [--auto-exit | --no-auto-exit] <task…>`
6
- // reads the agent from `<agent dir>/squire/agents/<name>/AGENT.md`, writes the task
6
+ // reads the agent from `<agent dir>/agents/<name>.md` or, in a trusted project,
7
+ // `<cwd>/.pi/agents/<name>.md` (`agents.ts`), writes the task
7
8
  // to the delegate's inbox through `message:send`, emits `session:launch` so
8
9
  // listeners can add arguments and environment, then opens a window in the
9
10
  // parent's working directory, or the existing directory `--cwd` selects
@@ -14,12 +15,14 @@
14
15
  // in the parent session; its answer is taken over on `message:inbound` and
15
16
  // shown as one result message, so the parent hears back on its own.
16
17
  //
17
- // With auto-exit on (the agent's `auto-exit`, default true, overridden per
18
+ // With auto-exit on (the agent's `auto_exit`, default true, overridden per
18
19
  // call) the delegate closes its own window after a normal completion; the
19
20
  // delegate's side lives in `child.ts`. With it off the window stays open.
20
- // An agent's `exclude-tools` goes to the child as `--exclude-tools`; a name the
21
- // session has no tool for leaves the agent out of the roster. Its `tools` goes
22
- // as `--tools` and each of its `extensions` as `-e`, both for the child alone.
21
+ // An agent's `tools`, or the parent's active tools when it has no `tools`
22
+ // field (less any `-name` entries), goes to the child as `--tools` with the
23
+ // `delegateTools` setting added (`--no-tools` when that leaves nothing).
24
+ // An agent with no `model` or `thinking` runs
25
+ // on the parent's.
23
26
  //
24
27
  // The model gets four tools. `delegate` takes agent, task and optional model
25
28
  // and thinking, validates them the way the command does, and starts the
@@ -31,9 +34,9 @@
31
34
  // envelope paths.
32
35
  // `delegation_close` kills the window and records the close. `delegation_resume`
33
36
  // (`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
35
- // (`PI_DELEGATE_PARENT` set) the extension registers only `child.ts`'s
36
- // `/auto-exit` and handlers, so a delegate cannot delegate.
37
+ // follow-up task, recorded as a new delegation. A delegate can launch children
38
+ // only below maxDepth and with its current agent allow_nested_delegation grant.
39
+ // Durable identity restores that boundary even on a manual session reopening.
37
40
  //
38
41
  // The `session:launch` contract this package provides, and the `message:*`
39
42
  // hooks it consumes, live in this package's README.
@@ -50,19 +53,54 @@ import {
50
53
  } from "@earendil-works/pi-coding-agent";
51
54
  import { Type } from "typebox";
52
55
 
53
- import { type Agent, isThinking, type Roster, readRoster, THINKING_LEVELS } from "./agents.ts";
56
+ import {
57
+ type Agent,
58
+ delegateTools,
59
+ disabledProblem,
60
+ globalAgentsDir,
61
+ isThinking,
62
+ projectAgentsDir,
63
+ type Roster,
64
+ readRoster,
65
+ THINKING_LEVELS,
66
+ withoutLocked,
67
+ } from "./agents.ts";
54
68
  import { AGENT_ENV, AUTO_EXIT_ENV, FALLBACK_ENV, registerChild } from "./child.ts";
69
+ import { registerCompletionHost } from "./completion-holds.ts";
70
+ import { createIdentity, grantsNesting, type Identity, readIdentity, writeIdentity } from "./identity.ts";
55
71
  import { activeMark, clearMarks, isFree, readMarks } from "./limits.ts";
56
72
  import { createNavigator, OPEN_USAGE, openCompletions, parseOpen } from "./navigator.ts";
57
73
  import { type NewDelegateDeps, type NewRequest, startNewDelegate } from "./new-delegate.ts";
58
74
  import { dirtyNotice, renderDelegateCall, renderDelegateResult, resourceLine } from "./render.ts";
59
- import { createResults, delegateName, type WorkspaceAttempt } from "./results.ts";
75
+ import { createResults, delegateName, isStarted, sessionIdOf, type WorkspaceAttempt } from "./results.ts";
60
76
  import { createResume, type ResumeRequest, resumableIds, startedLine } from "./resume.ts";
61
77
  import { pickResume } from "./resume-picker.ts";
78
+ import { ownedAPI, SessionOwner, sessionIsOwned } from "./session-owner.ts";
79
+ import { registerTaskReplies } from "./task-replies.ts";
62
80
  import { createTmuxClient, type OpenedWindow, requireTmuxVersion, type TmuxClient, WindowOpenError } from "./tmux.ts";
81
+ import { availableTools, enforceToolCeiling } from "./tool-ceiling.ts";
63
82
  import { createTracking, toolResult } from "./tracking.ts";
83
+ import { treeNotice } from "./tree-notice.ts";
84
+ import {
85
+ assertParentPlacement,
86
+ bindTree,
87
+ claimTree,
88
+ dispatchTree,
89
+ failedTree,
90
+ finishTree,
91
+ openedTree,
92
+ publishTreeRecords,
93
+ type Reservation,
94
+ readTree,
95
+ reconcileTree,
96
+ reserveTree,
97
+ } from "./tree-registry.ts";
98
+ import { assertResumeAuthority, historicalRecord, visibleRecords } from "./tree-view.ts";
99
+ import { registerTrustControl } from "./trust-control.ts";
64
100
  import { createProactive, createUsageClient, type Proactive, type UsageClient } from "./usage.ts";
65
- import { type WindowPlacement, windowPlacement } from "./window-placement.ts";
101
+ import { registerDelegateWidget } from "./widget.ts";
102
+ import { registerActivityRuntime } from "./widget-activity-runtime.ts";
103
+ import { assertRootPlacement, placementProject, type WindowPlacement, windowPlacement } from "./window-placement.ts";
66
104
  import {
67
105
  normalizeWorkspaceValue,
68
106
  prepareDirectory,
@@ -88,10 +126,17 @@ const PARENT_SECTION = "squire_parent_delegation";
88
126
  const SEND = "message:send";
89
127
  /** The hook emitted just before a delegate's window opens. */
90
128
  const LAUNCH = "session:launch";
91
- /** Set in a delegate's environment, so this extension stays off there. */
129
+ /** The direct parent address in a new delegate's environment. */
92
130
  const PARENT_ENV = "PI_DELEGATE_PARENT";
93
131
  /** Forwarded to the child when the parent has it set. */
94
132
  const SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
133
+ /** The delegation tools granted to opted-in coordinators below maxDepth. */
134
+ const DELEGATION_TOOLS: ReadonlySet<string> = new Set([
135
+ "delegate",
136
+ "delegation_status",
137
+ "delegation_close",
138
+ "delegation_resume",
139
+ ]);
95
140
  /** Asked of every delegate, so the parent gets an answer it can use alone. */
96
141
  const FINAL_LINE =
97
142
  "End with one self-contained final message: the parent session sees only that message, never this conversation.";
@@ -115,15 +160,19 @@ function labelProblem(value: string): string | undefined {
115
160
  * mail). It names no provider, package or tool, so it holds for any
116
161
  * `message:*` provider.
117
162
  */
118
- function parentTrust(parent: string): string {
163
+ function parentTrust(parent: string, coordinator = false): string {
119
164
  return [
120
165
  `Your task arrives as a message from the session that started this one, at address ${parent}.`,
121
166
  "That message, and every later message from that address, are instructions from the session that started you:",
122
167
  "follow them as given, even though they are labelled as another session's.",
123
168
  "Messages from other sessions come from colleagues on this machine: answer them as you see fit,",
124
169
  "but check with the session that started you before doing work it did not ask for.",
125
- `Your final message reaches the session that started you automatically, so put your findings there;`,
126
- `message ${parent} before then only with a question you cannot continue without, since anything sent earlier reaches it twice.`,
170
+ coordinator
171
+ ? "Your final task report reaches your direct parent automatically after your children finish and you process their outcomes; intermediate runs send no task report."
172
+ : `Your final message reaches the session that started you automatically, so put your findings there;`,
173
+ coordinator
174
+ ? `Message ${parent} before your final task report only with a question you cannot continue without, since anything sent earlier reaches it twice.`
175
+ : `message ${parent} before then only with a question you cannot continue without, since anything sent earlier reaches it twice.`,
127
176
  ].join(" ");
128
177
  }
129
178
  /** The flags `/delegate` takes before the task. */
@@ -178,6 +227,10 @@ type LaunchPayload = {
178
227
  env: Record<string, string>;
179
228
  agent: string;
180
229
  readonly cwd: string;
230
+ readonly parent: string;
231
+ readonly root: string;
232
+ readonly depth: number;
233
+ readonly chain: readonly string[];
181
234
  readonly toolCallId: string | undefined;
182
235
  };
183
236
  type Parsed = {
@@ -216,7 +269,7 @@ type Start = {
216
269
  /** A candidate model passed over: `why` finishes "<model> is …", `reason` is the long form for a refusal. */
217
270
  type Skipped = { model: string; why: string; reason: string };
218
271
  /** What opening a delegate's window reads from a request: a `Start`, or the parts of one a resume decides. */
219
- type ChildStart = Pick<Start, "agent" | "model" | "rest" | "thinking" | "autoExit">;
272
+ type ChildStart = Pick<Start, "agent" | "model" | "rest" | "thinking" | "autoExit" | "notices">;
220
273
  /** A delegation that is open in a tmux window. */
221
274
  type Launched = {
222
275
  id: string;
@@ -406,26 +459,49 @@ function modelProblem(value: string, registry: ModelRegistry): string | undefine
406
459
  return `unknown model ${JSON.stringify(value)}${close.length > 0 ? `; close matches: ${close.join(", ")}` : ""}`;
407
460
  }
408
461
 
462
+ /** The registry's model for an exact `provider/id`; undefined for anything else, such as a pattern. */
463
+ function registered(value: string, registry: ModelRegistry): { provider: string; id: string } | undefined {
464
+ const slash = value.indexOf("/");
465
+ if (slash <= 0 || slash === value.length - 1) return undefined;
466
+ return registry.find(value.slice(0, slash), value.slice(slash + 1));
467
+ }
468
+
469
+ /** An agent's own models in order: its `model`, or the parent's when it has none, then its fallbacks. */
470
+ function agentModels(agent: Agent, parentModel: string | undefined): string[] {
471
+ const first = agent.model ?? parentModel;
472
+ return [...(first === undefined ? [] : [first]), ...agent.fallback];
473
+ }
474
+
409
475
  /**
410
476
  * The model for a launch. An explicit model is the only candidate; otherwise
411
- * the agent's model then its fallbacks. The first candidate that passes
412
- * `modelProblem` and has no active mark wins; the rest are returned as skipped.
413
- * A paid candidate on a provider with a quota check is read first, and
414
- * skipped when its quota is used up. Refuses, listing every candidate, when
415
- * none is left. `preferred` replaces the agent's own list when there is no
416
- * explicit model, as a resume does.
477
+ * `preferred`, by default `own`: the agent's model then its fallbacks. The
478
+ * first candidate that passes `modelProblem` and has no active mark wins; the
479
+ * rest are returned as skipped. A paid candidate on a provider with a quota
480
+ * check is read first, and skipped when its quota is used up. A candidate from
481
+ * the agent file that is not an exact `provider/id` the registry knows, such
482
+ * as a pattern, is passed to the child unchanged for Pi to resolve. Refuses,
483
+ * listing every candidate, when none is left. A resume passes its own
484
+ * `preferred`.
417
485
  */
418
486
  async function chooseModel(
419
487
  explicit: string | undefined,
420
488
  agent: Agent,
421
489
  models: ModelRegistry,
422
490
  proactive: Proactive,
423
- preferred: readonly string[] = [agent.model, ...agent.fallback],
491
+ own: readonly string[],
492
+ preferred: readonly string[] = own,
424
493
  ): Promise<{ model: string; skipped: Skipped[]; notices: string[]; rest: string[] }> {
425
494
  const candidates = explicit !== undefined ? [explicit] : [...preferred];
495
+ if (candidates.length === 0)
496
+ throw new Error(
497
+ `agent ${agent.name} names no model and this session has none; add model to ${agent.path}, or pass one`,
498
+ );
426
499
  const skipped: Skipped[] = [];
427
500
  const notices: string[] = [];
428
501
  for (const model of candidates) {
502
+ const rest = () => candidates.slice(candidates.indexOf(model) + 1);
503
+ if (explicit === undefined && registered(model, models) === undefined)
504
+ return { model, skipped, notices, rest: rest() };
429
505
  const problem = modelProblem(model, models);
430
506
  if (problem !== undefined) {
431
507
  skipped.push({ model, why: `unavailable (${problem})`, reason: problem });
@@ -445,7 +521,7 @@ async function chooseModel(
445
521
  if (checked.notice !== undefined && !notices.includes(checked.notice)) notices.push(checked.notice);
446
522
  mark = activeMark(models, provider, id) ?? checked.mark;
447
523
  }
448
- if (mark === undefined) return { model, skipped, notices, rest: candidates.slice(candidates.indexOf(model) + 1) };
524
+ if (mark === undefined) return { model, skipped, notices, rest: rest() };
449
525
  const what = mark.scope.includes("/") ? `model ${mark.scope}` : `provider ${mark.scope}`;
450
526
  const until = new Date(mark.clearsAt).toISOString();
451
527
  skipped.push({
@@ -458,8 +534,10 @@ async function chooseModel(
458
534
  const [only] = skipped;
459
535
  if (!only.why.startsWith("usage-limited")) throw new Error(only.reason);
460
536
  // An explicit model is never substituted, but the refusal names the agent's models still open.
461
- const open = [agent.model, ...agent.fallback].filter((model) => {
462
- if (model === only.model || modelProblem(model, models) !== undefined) return false;
537
+ const open = own.filter((model) => {
538
+ if (model === only.model) return false;
539
+ if (registered(model, models) === undefined) return true;
540
+ if (modelProblem(model, models) !== undefined) return false;
463
541
  const slash = model.indexOf("/");
464
542
  return activeMark(models, model.slice(0, slash), model.slice(slash + 1)) === undefined;
465
543
  });
@@ -508,32 +586,100 @@ function delegateVisible(options: { selectedTools: string[]; hiddenTools: string
508
586
  return options.selectedTools.includes("delegate") && !options.hiddenTools.includes("delegate");
509
587
  }
510
588
 
589
+ /** Where agent files go, for the messages that say how to add one. */
590
+ function whereAgentsGo(): string {
591
+ return `${join(globalAgentsDir(getAgentDir()), "<name>.md")} (or .pi/agents/<name>.md in a trusted project), with the delegate's instructions as its body and optional frontmatter: description, model, thinking, tools, fallback, auto_exit, prompt_mode, display_name, locked and enabled`;
592
+ }
593
+
594
+ /** One roster line for the `delegate` tool's description. */
595
+ function rosterLine(agent: Agent): string {
596
+ const models = [agent.model ?? "this session's model", ...agent.fallback.map((model) => `fallback ${model}`)];
597
+ const details = [
598
+ `default ${models.join(", ")}`,
599
+ `thinking ${agent.thinking ?? "this session's"}`,
600
+ ...(agent.autoExit ? [] : ["auto-exit off"]),
601
+ ...toolsDetail(agent),
602
+ ...(agent.locked.length > 0 ? [`locked: ${agent.locked.join("/")}`] : []),
603
+ ];
604
+ const shown = agent.displayName === undefined ? "" : ` "${agent.displayName}"`;
605
+ return `- ${agent.name}${shown} — ${agent.description} (${details.join(", ")})`;
606
+ }
607
+
608
+ /**
609
+ * The entry that makes Pi filter MCP tools by `--tools`. Pi keeps every MCP
610
+ * tool registered (callable from codemode, loadable by tool_search) unless an
611
+ * entry starts with `mcp__`; this one does and names no tool, since an MCP
612
+ * tool is `mcp__<server>__<tool>`.
613
+ */
614
+ const MCP_FILTER = "mcp__";
615
+
616
+ /**
617
+ * The child's tool arguments for a selection: `--tools` with exactly those
618
+ * tools, MCP tools included, or `--no-tools` (which Pi applies to MCP tools
619
+ * too) when it is empty. Always explicit: no flag would give the child the
620
+ * parent's tools rather than Pi's defaults.
621
+ */
622
+ function toolArgs(tools: readonly string[]): string[] {
623
+ if (tools.length === 0) return ["--no-tools"];
624
+ const filtered = tools.some((name) => name.startsWith(MCP_FILTER)) ? tools : [...tools, MCP_FILTER];
625
+ return ["--tools", filtered.join(",")];
626
+ }
627
+
628
+ /** The roster line's word on an agent's tools; nothing for the parent's tools unchanged. */
629
+ function toolsDetail(agent: Agent): string[] {
630
+ if (agent.tools !== undefined) return [agent.tools.length > 0 ? `tools: ${agent.tools.join("/")}` : "no tools"];
631
+ return agent.toolsExcept.length > 0 ? [`tools: this session's except ${agent.toolsExcept.join("/")}`] : [];
632
+ }
633
+
634
+ /** Whether tool `name` is `pattern`, where `*` matches any characters, as in Pi's tool lists. */
635
+ function toolMatches(pattern: string, name: string): boolean {
636
+ const source = pattern
637
+ .split("*")
638
+ .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
639
+ .join(".*");
640
+ return new RegExp(`^${source}$`).test(name);
641
+ }
642
+
643
+ /**
644
+ * The delegate's tool selection, always a plain list: the agent's `tools`
645
+ * names, or `parentTools` less its `-name` entries when it has none, then
646
+ * `extra` (the `delegateTools` setting), with the delegation tools left out.
647
+ * Empty means the delegate gets no tools.
648
+ */
649
+ function toolSelection(agent: Agent, parentTools: readonly string[], extra: readonly string[]): string[] {
650
+ const base =
651
+ agent.tools ?? parentTools.filter((name) => !agent.toolsExcept.some((pattern) => toolMatches(pattern, name)));
652
+ return [...new Set([...base, ...extra])].filter((name) => !DELEGATION_TOOLS.has(name));
653
+ }
654
+
655
+ /** The delegate's appended prompt: the agent's body, then the lines every delegate gets. */
656
+ function delegatePrompt(agent: Agent, parent: string, managedContext: string, coordinator: boolean): string {
657
+ const final = coordinator
658
+ ? "End the task with one self-contained final report to your direct parent after your child tasks finish and you process their outcomes."
659
+ : FINAL_LINE;
660
+ const tail = `${final}\n\n${parentTrust(parent, coordinator)}${managedContext}`;
661
+ if (agent.prompt === "") return tail;
662
+ // `replace` is how every delegate was built before `prompt_mode`, byte for byte.
663
+ const body =
664
+ agent.promptMode === "replace" ? agent.prompt : `<agent_instructions>\n${agent.prompt}\n</agent_instructions>`;
665
+ return `${body}\n\n${tail}`;
666
+ }
667
+
511
668
  /** The `delegate` tool's description: what delegation does, and the roster as it stands. */
512
669
  function toolDescription(agents: readonly Agent[]): string {
513
670
  const intro =
514
671
  "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.";
515
672
  if (agents.length === 0) {
516
- return `${intro}\n\nNo agents are defined yet. Add ${join(
517
- getAgentDir(),
518
- "squire",
519
- "agents",
520
- "<name>",
521
- "AGENT.md",
522
- )} with frontmatter description, model and thinking (optionally auto-exit, exclude-tools, tools, extensions and fallback) and the prompt as its body; /delegate explains the format too.`;
673
+ return `${intro}\n\nNo agents are defined yet. Add ${whereAgentsGo()}.`;
523
674
  }
524
675
  return [
525
676
  intro,
526
677
  "",
527
678
  "Agents:",
528
- ...agents.map(
529
- (agent) =>
530
- `- ${agent.name} — ${agent.description} (default ${[agent.model, ...agent.fallback.map((model) => `fallback ${model}`)].join(", ")}, thinking ${agent.thinking}${agent.autoExit ? "" : ", auto-exit off"}${
531
- agent.excludeTools.length > 0 ? `, no ${agent.excludeTools.join("/")}` : ""
532
- }${agent.tools.length > 0 ? `, tools: ${agent.tools.join("/")}` : ""})`,
533
- ),
679
+ ...agents.map(rosterLine),
534
680
  "",
535
681
  "`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.",
536
- "`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.",
682
+ "`model` and `thinking` override the agent's defaults for this call, except a field the agent locks; 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.",
537
683
  "`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.",
538
684
  "",
539
685
  "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.",
@@ -546,62 +692,201 @@ function toolDescription(agents: readonly Agent[]): string {
546
692
  * passes only `pi`, so they are not part of the package's documented contract.
547
693
  */
548
694
  export default function delegate(
549
- pi: ExtensionAPI,
695
+ api: ExtensionAPI,
550
696
  tmux: TmuxClient = createTmuxClient(),
551
697
  usage: UsageClient = createUsageClient(),
552
698
  ): void {
553
- // A delegate registers only its auto-exit side: `delegate` is one level deep (Q27).
554
- const parentId = process.env[PARENT_ENV] ?? "";
555
- if (parentId !== "") {
556
- registerChild(pi, parentId);
557
- return;
558
- }
559
-
560
- const results = createResults(pi);
699
+ const owner = new SessionOwner();
700
+ const host = api;
701
+ host.on("session_start", (_event, ctx) => owner.start(ctx));
702
+ host.events.on("message:inbound", (payload) => owner.receive(payload as import("./results.ts").Inbound));
703
+ const pi = ownedAPI(host, owner);
704
+ const externalReady = registerCompletionHost(pi);
705
+ const identity = createIdentity(pi);
706
+ const results = createResults(
707
+ pi,
708
+ () => identity.current() !== undefined,
709
+ (ctx, records) =>
710
+ publishTreeRecords(
711
+ identity.current()?.root ?? ctx.sessionManager.getSessionId(),
712
+ ctx.sessionManager.getSessionId(),
713
+ records,
714
+ ),
715
+ () => owner.active(),
716
+ (ctx) =>
717
+ readTree(identity.current()?.root ?? ctx.sessionManager.getSessionId())
718
+ .entries.filter((entry) => entry.identity.parent === ctx.sessionManager.getSessionId() && entry.closed)
719
+ .map((entry) => entry.id),
720
+ );
721
+ pi.on("session_start", (_event, ctx) => tracking.restore(ctx));
722
+ const replies = registerTaskReplies(
723
+ pi,
724
+ () => identity.current()?.parent,
725
+ () => {
726
+ try {
727
+ owner.assert();
728
+ identity.current();
729
+ return results.completionReady() && externalReady();
730
+ } catch {
731
+ return false;
732
+ }
733
+ },
734
+ (ctx) => coordinates(currentRoster(ctx)),
735
+ (ctx, ids) => {
736
+ owner.assert();
737
+ const own = identity.current();
738
+ if (own) finishTree(own.root, ctx.sessionManager.getSessionId(), ids);
739
+ },
740
+ );
741
+ registerChild(pi, replies, identity.current, () => owner.active());
742
+ host.on("session_start", () => owner.arm());
743
+ // Restore answers synchronously before this potentially slow tmux read.
744
+ pi.on("session_start", async (_event, ctx) => {
745
+ try {
746
+ const own = identity.current();
747
+ if (!own) return;
748
+ const window = await tmux.currentWindow?.();
749
+ owner.assert();
750
+ claimTree(own.root, ctx.sessionManager.getSessionId(), window);
751
+ } catch (error) {
752
+ ctx.ui.notify(
753
+ `delegate: could not publish startup accounting: ${String(error)}; uncertain reservations remain charged.`,
754
+ "error",
755
+ );
756
+ }
757
+ });
561
758
 
562
759
  const proactive = createProactive(usage);
563
- const tracking = createTracking(pi, results, tmux, proactive.errors);
760
+ let viewContext: ExtensionContext | undefined;
761
+ pi.on("session_start", (_event, ctx) => {
762
+ viewContext = ctx;
763
+ });
764
+ pi.on("session_start", async (_event, ctx) => {
765
+ if (!identity.current() && results.list().length === 0) {
766
+ try {
767
+ await treeNotice(ctx, tmux, () => owner.active());
768
+ } catch (error) {
769
+ ctx.ui.notify(`delegate: owner discovery unavailable: ${String(error)}`, "warning");
770
+ }
771
+ }
772
+ });
773
+ const viewResults = {
774
+ ...results,
775
+ list: () => (viewContext ? visibleRecords(results, viewContext) : results.list()),
776
+ find: (id: string) =>
777
+ viewContext ? visibleRecords(results, viewContext).find((record) => record.id === id) : results.find(id),
778
+ };
779
+ const tracking = createTracking(pi, results, tmux, proactive.errors, viewResults, () => owner.assert());
780
+ const trustControl = registerTrustControl(pi, identity.current);
781
+ // Factual lifecycle observation survives failed action processing; only creation needs ownership.
782
+ registerActivityRuntime(host, () => owner.active(), tmux, owner.token);
783
+ registerDelegateWidget(
784
+ pi,
785
+ results,
786
+ tmux,
787
+ async (ctx, sessionId) => {
788
+ owner.assert();
789
+ await navigator.panel(ctx, sessionId);
790
+ },
791
+ () => owner.assert(),
792
+ );
564
793
 
565
794
  /** Captured because `getArgumentCompletions` is called without a context. */
566
795
  let registry: ModelRegistry | undefined;
796
+ /** The latest context, for the roster a completion reads; undefined before the session starts. */
797
+ let latest: ExtensionContext | undefined;
567
798
 
568
799
  const modelIds = (source: ModelRegistry | undefined): string[] =>
569
800
  (source?.getAll() ?? []).map((model) => `${model.provider}/${model.id}`);
570
801
 
571
- /**
572
- * The session's tool names, which `exclude-tools` must name; undefined
573
- * while Pi is still loading extensions and cannot list them yet.
574
- */
575
- function sessionTools(): string[] | undefined {
802
+ /** The project's agents folder when `ctx`'s project is trusted; undefined otherwise or without a context. */
803
+ function trustedProject(ctx: ExtensionContext | undefined): string | undefined {
804
+ if (ctx === undefined) return undefined;
805
+ try {
806
+ return ctx.isProjectTrusted() ? projectAgentsDir(ctx.cwd) : undefined;
807
+ } catch {
808
+ return undefined; // a stale context after a session replacement
809
+ }
810
+ }
811
+
812
+ /** The roster as it stands: the global agents, and the project's when it is trusted. */
813
+ const currentRoster = (ctx: ExtensionContext | undefined = latest): Roster =>
814
+ readRoster(getAgentDir(), trustedProject(ctx));
815
+
816
+ /** What an agent with no `model` or `thinking` runs on: the parent's model and level now. */
817
+ function parentDefaults(ctx: ExtensionContext): { model?: string; thinking: string } {
818
+ let thinking = "medium";
576
819
  try {
577
- return pi.getAllTools().map((tool) => tool.name);
820
+ thinking = pi.getThinkingLevel();
578
821
  } catch {
579
- return undefined;
822
+ // Pi binds this before any call can reach here; the default only covers a host without it.
580
823
  }
824
+ return { ...(ctx.model === undefined ? {} : { model: `${ctx.model.provider}/${ctx.model.id}` }), thinking };
581
825
  }
582
826
 
583
- /** The roster as it stands, checked against the session's tools. */
584
- const currentRoster = (): Roster => readRoster(getAgentDir(), sessionTools());
827
+ /**
828
+ * The delegate's tools for a launch or resume starting now: the agent's
829
+ * named tools, or the parent's current tools (including indirect callable
830
+ * tools when nested, less any `-name` entries), then `delegateTools`.
831
+ * Throws, naming the file, on an invalid setting.
832
+ */
833
+ function childTools(agent: Agent): string[] {
834
+ const selected = toolSelection(
835
+ agent,
836
+ agent.tools === undefined ? (identity.current() ? availableTools(pi) : pi.getActiveTools()) : [],
837
+ delegateTools(getAgentDir()),
838
+ );
839
+ if (grantsNesting(agent, (identity.current()?.depth ?? 0) + 1)) selected.push(...DELEGATION_TOOLS);
840
+ return [...new Set(selected)];
841
+ }
842
+
843
+ function coordinates(roster: Roster): boolean {
844
+ const own = identity.current();
845
+ const agent = roster.agents.find((item) => item.name === own?.agent);
846
+ return own !== undefined && agent !== undefined && grantsNesting(agent, own.depth);
847
+ }
848
+
849
+ function admit(agent: string, roster: Roster): void {
850
+ owner.assert();
851
+ results.checkStorage();
852
+ identity.admit(agent, roster);
853
+ }
854
+
855
+ function exposeDelegation(roster: Roster): void {
856
+ if (!identity.current()) return;
857
+ const enabled = coordinates(roster);
858
+ const base = pi.getActiveTools().filter((name) => !DELEGATION_TOOLS.has(name));
859
+ pi.setActiveTools(enabled ? [...base, ...DELEGATION_TOOLS] : base);
860
+ }
585
861
 
586
862
  /** Read the roster now, warning about every file left out. */
587
863
  function rosterFor(ctx: ExtensionContext): Roster {
588
- const roster = currentRoster();
864
+ const roster = currentRoster(ctx);
589
865
  for (const warning of roster.warnings) ctx.ui.notify(`delegate: ${warning}`, "warning");
590
866
  return roster;
591
867
  }
592
868
 
869
+ /** The roster warnings already shown this session, so a run shows only new ones. */
870
+ const announced = new Set<string>();
871
+
872
+ /** Show each roster warning not yet shown this session, such as a file that is disabled. */
873
+ function announce(roster: Roster, ctx: ExtensionContext): void {
874
+ for (const warning of roster.warnings) {
875
+ if (announced.has(warning)) continue;
876
+ announced.add(warning);
877
+ ctx.ui.notify(`delegate: ${warning}`, "warning");
878
+ }
879
+ }
880
+
593
881
  /** The refusal for an empty roster. */
594
- const noAgents = (): string =>
595
- `no agents are defined; add ${join(
596
- getAgentDir(),
597
- "squire",
598
- "agents",
599
- "<name>",
600
- "AGENT.md",
601
- )} with frontmatter description, model and thinking (optionally auto-exit, exclude-tools, tools, extensions and fallback), and the prompt as its body`;
882
+ const noAgents = (): string => `no agents are defined; add ${whereAgentsGo()}`;
602
883
 
603
884
  /** The request as a start, or an error naming what to fix. */
604
- async function validate(request: Parsed, roster: Roster, models: ModelRegistry): Promise<Start> {
885
+ async function validate(request: Parsed, roster: Roster, ctx: ExtensionContext): Promise<Start> {
886
+ admit(request.agent, roster);
887
+ results.checkStorage(true);
888
+ const disabled = disabledProblem(roster, request.agent);
889
+ if (disabled !== undefined) throw new Error(disabled);
605
890
  if (roster.agents.length === 0) throw new Error(noAgents());
606
891
  const agent = roster.agents.find((candidate) => candidate.name === request.agent);
607
892
  if (agent === undefined) {
@@ -610,8 +895,14 @@ export default function delegate(
610
895
  );
611
896
  }
612
897
  if (request.task.trim() === "") throw new Error("a task is required");
613
- const thinking = request.thinking ?? agent.thinking;
614
- const { model, skipped, notices, rest } = await chooseModel(request.model, agent, models, proactive);
898
+ const parent = parentDefaults(ctx);
899
+ const own = agentModels(agent, parent.model);
900
+ const defaultThinking = agent.thinking ?? parent.thinking;
901
+ const unlocked = withoutLocked(agent, request, { model: own[0], thinking: defaultThinking });
902
+ const thinking = unlocked.request.thinking ?? defaultThinking;
903
+ const chosen = await chooseModel(unlocked.request.model, agent, ctx.modelRegistry, proactive, own);
904
+ const { model, skipped, rest } = chosen;
905
+ const notices = unlocked.notice === undefined ? chosen.notices : [unlocked.notice, ...chosen.notices];
615
906
  const badThinking = thinkingProblem(thinking);
616
907
  if (badThinking !== undefined) throw new Error(badThinking);
617
908
  const badLabel = request.label === undefined ? undefined : labelProblem(request.label);
@@ -667,7 +958,11 @@ export default function delegate(
667
958
  ctx: ExtensionContext;
668
959
  /** Chosen once, when the launch or resume began, so no later settings change can refuse it after mail. */
669
960
  placement: WindowPlacement;
961
+ /** The delegate's tools, from `toolSelection`, read when the launch began; empty means none. */
962
+ tools: readonly string[];
670
963
  onWindowOpened?: () => void;
964
+ identity: Identity;
965
+ sessionId?: string;
671
966
  }): Promise<OpenedWindow> {
672
967
  const {
673
968
  id,
@@ -681,16 +976,27 @@ export default function delegate(
681
976
  toolCallId,
682
977
  ctx,
683
978
  placement,
979
+ tools,
684
980
  onWindowOpened,
685
981
  } = input;
686
- const parent = ctx.sessionManager.getSessionId();
982
+ admit(start.agent.name, currentRoster(ctx));
983
+ results.checkStorage(true);
984
+ if (input.sessionId && sessionIsOwned(input.sessionId))
985
+ throw new Error(`delegate session ${input.sessionId} is already open; resume refused before spawn`);
986
+ const own = input.identity;
987
+ assertRootPlacement(own.rootPlacement);
988
+ own.autoExit = start.autoExit;
989
+ writeIdentity(input.sessionId ?? id, own);
990
+ const parent = own.parent;
687
991
  const managedContext =
688
992
  workspace.mode === "managed"
689
993
  ? `\n\nYour managed worktree is ${workspace.cwd}, branch ${workspace.createdBranch}, based on commit ${workspace.baseCommit}.`
690
994
  : workspace.mode === "existing"
691
995
  ? `\n\nThe session that started you chose your working directory: ${workspace.cwd}`
692
996
  : "";
693
- const prompt = writePromptFile(`${start.agent.prompt}\n\n${FINAL_LINE}\n\n${parentTrust(parent)}${managedContext}`);
997
+ const prompt = writePromptFile(
998
+ delegatePrompt(start.agent, parent, managedContext, grantsNesting(start.agent, own.depth)),
999
+ );
694
1000
  const argv = [
695
1001
  ...parentCommand(),
696
1002
  ...session,
@@ -702,15 +1008,18 @@ export default function delegate(
702
1008
  start.model,
703
1009
  "--thinking",
704
1010
  start.thinking,
705
- ...start.agent.extensions.flatMap((extension) => ["-e", extension]),
706
- ...(start.agent.tools.length > 0 ? ["--tools", start.agent.tools.join(",")] : []),
707
- ...(start.agent.excludeTools.length > 0 ? ["--exclude-tools", start.agent.excludeTools.join(",")] : []),
1011
+ ...toolArgs(tools),
708
1012
  "--append-system-prompt",
709
1013
  prompt,
710
1014
  ];
711
1015
  const env: Record<string, string> = {
712
1016
  PI_CODING_AGENT_DIR: getAgentDir(),
1017
+ // Private live metadata correlation: resume retains session id, not launch id.
1018
+ PI_SQUIRE_LIVE_LAUNCH: JSON.stringify({ version: 1, sessionId: input.sessionId ?? id, launchId: id }),
713
1019
  [PARENT_ENV]: parent,
1020
+ PI_DELEGATE_ROOT: own.root,
1021
+ PI_DELEGATE_DEPTH: String(own.depth),
1022
+ PI_DELEGATE_CHAIN: own.chain.join(","),
714
1023
  [AUTO_EXIT_ENV]: start.autoExit ? "1" : "0",
715
1024
  [AGENT_ENV]: start.agent.name,
716
1025
  // A general signal for any extension: no human watches this session, so don't wait on a dialog.
@@ -721,22 +1030,72 @@ export default function delegate(
721
1030
  const envSessionDir = process.env[SESSION_DIR_ENV];
722
1031
  if (envSessionDir !== undefined && envSessionDir !== "") env[SESSION_DIR_ENV] = envSessionDir;
723
1032
  // Listeners may only append to `args` and add to `env`; there is no veto.
724
- const payload: LaunchPayload = { args: argv, env, agent: start.agent.name, cwd, toolCallId };
1033
+ const payload: LaunchPayload = {
1034
+ args: argv,
1035
+ env,
1036
+ agent: start.agent.name,
1037
+ cwd,
1038
+ toolCallId,
1039
+ parent: own.parent,
1040
+ root: own.root,
1041
+ depth: own.depth,
1042
+ chain: Object.freeze([...own.chain]),
1043
+ };
1044
+ for (const key of ["parent", "root", "depth", "chain"] as const)
1045
+ Object.defineProperty(payload, key, { writable: false, configurable: false });
725
1046
  pi.events.emit(LAUNCH, payload);
1047
+ if (identity.current()) {
1048
+ const prefix = parentCommand().length;
1049
+ const args = payload.args.slice(prefix);
1050
+ enforceToolCeiling({
1051
+ args,
1052
+ available: availableTools(pi).filter(
1053
+ (name) => grantsNesting(start.agent, own.depth) || !DELEGATION_TOOLS.has(name),
1054
+ ),
1055
+ registered: pi.getAllTools().map((tool) => tool.name),
1056
+ cwd,
1057
+ agentDir: payload.env.PI_CODING_AGENT_DIR,
1058
+ trusted,
1059
+ notices: start.notices,
1060
+ });
1061
+ payload.args.splice(prefix, payload.args.length - prefix, ...args);
1062
+ }
1063
+ const token: Reservation = { root: own.root, id };
1064
+ bindTree(token, { windowName: name, cwd, workspace });
1065
+ dispatchTree(token);
726
1066
  let opened: OpenedWindow;
727
1067
  try {
728
- opened = await tmux.openWindow({ name, cwd, argv: payload.args, env: payload.env }, placement);
729
- onWindowOpened?.();
1068
+ opened = await tmux.openWindow({ name, cwd, argv: payload.args, env: payload.env }, placement, own.rootPlacement);
730
1069
  } catch (err) {
1070
+ if (err instanceof WindowOpenError && err.certainty === "not-opened") {
1071
+ try {
1072
+ failedTree(token, true);
1073
+ } catch (accounting) {
1074
+ ctx.ui.notify(
1075
+ `delegate: could not refund the pre-start reservation: ${String(accounting)}; it remains charged.`,
1076
+ "warning",
1077
+ );
1078
+ }
1079
+ }
731
1080
  if (err instanceof WindowOpenError) {
732
1081
  throw new WindowOpenError(`could not open window ${name} for ${id}: ${err.message}`, err.certainty);
733
1082
  }
734
1083
  throw new WindowOpenError(`could not open window ${name} for ${id}: ${(err as Error).message}`, "unknown");
735
1084
  }
1085
+ onWindowOpened?.();
1086
+ try {
1087
+ openedTree(token, opened.id);
1088
+ } catch (error) {
1089
+ ctx.ui.notify(
1090
+ `delegate: window ${opened.id} opened, but shared accounting publication failed: ${String(error)}. Local tracking continues; the reservation remains charged until accounting reconciles.`,
1091
+ "warning",
1092
+ );
1093
+ }
736
1094
  return opened;
737
1095
  }
738
1096
 
739
1097
  async function launchWindow(input: {
1098
+ identity: Identity;
740
1099
  id: string;
741
1100
  requestId: string;
742
1101
  workspace: Workspace;
@@ -747,15 +1106,17 @@ export default function delegate(
747
1106
  toolCallId: string | undefined;
748
1107
  ctx: ExtensionContext;
749
1108
  placement: WindowPlacement;
1109
+ tools: readonly string[];
750
1110
  onWindowOpened: () => void;
751
1111
  }): Promise<Launched> {
752
1112
  const { id, requestId, workspace, trusted, cwd, sessionDir, start, toolCallId, ctx, placement, onWindowOpened } =
753
1113
  input;
754
- const name = delegateName(start.agent.name, id, start.label);
1114
+ const name = delegateName(start.agent.displayName ?? start.agent.name, id, start.label);
755
1115
  const { id: windowId, session: windowSession } = await openChild({
756
1116
  id,
757
1117
  name,
758
1118
  session: ["--session-id", id, "--name", name],
1119
+ identity: input.identity,
759
1120
  workspace,
760
1121
  trusted,
761
1122
  cwd,
@@ -764,13 +1125,16 @@ export default function delegate(
764
1125
  toolCallId,
765
1126
  ctx,
766
1127
  placement,
1128
+ tools: input.tools,
767
1129
  onWindowOpened,
768
1130
  });
769
1131
  // No task text in the record: the request copy in `sent/` has it.
770
1132
  results.recordStart(
771
1133
  {
772
1134
  id,
1135
+ identity: readIdentity(id),
773
1136
  agent: start.agent.name,
1137
+ ...(start.agent.displayName === undefined ? {} : { displayName: start.agent.displayName }),
774
1138
  model: start.model,
775
1139
  thinking: start.thinking,
776
1140
  name,
@@ -794,10 +1158,21 @@ export default function delegate(
794
1158
  signal?: AbortSignal,
795
1159
  toolCallId?: string,
796
1160
  ): Promise<Launched> {
1161
+ admit(start.agent.name, currentRoster(ctx));
1162
+ results.checkStorage(true);
797
1163
  if (!tmux.insideTmux()) throw new Error(NOT_IN_TMUX);
798
1164
  await requireTmuxVersion(tmux);
799
- // Read once, before anything is created or mailed; the launch keeps this value.
1165
+ // Read once, before anything is created or mailed; the launch keeps these values.
800
1166
  const placement = windowPlacement(getAgentDir());
1167
+ const parentIdentity = identity.current();
1168
+ const project = parentIdentity
1169
+ ? { companion: parentIdentity.rootPlacement, notices: [] }
1170
+ : placementProject(ctx.cwd);
1171
+ assertRootPlacement(project.companion);
1172
+ const own = identity.child(start.agent, ctx, project.companion);
1173
+ if (parentIdentity) assertParentPlacement(readTree(own.root), own);
1174
+ if (placement === "companion") start.notices.push(...project.notices);
1175
+ const tools = childTools(start.agent);
801
1176
  const sessionDir = ctx.sessionManager.getSessionDir();
802
1177
  if (!isAbsolute(sessionDir))
803
1178
  throw new Error(
@@ -829,13 +1204,23 @@ export default function delegate(
829
1204
  getAgentDir(),
830
1205
  );
831
1206
  if (combined?.aborted) throw new Error("the launch was aborted");
832
- const trust = await trustPlannedWorktree(plan, ctx, getAgentDir(), combined);
1207
+ const trust = await trustPlannedWorktree(
1208
+ plan,
1209
+ ctx,
1210
+ getAgentDir(),
1211
+ combined,
1212
+ trustControl.forAgent(start.agent.name, ctx),
1213
+ );
833
1214
  if (combined?.aborted) throw new Error("the launch was aborted");
834
1215
  trusted = trust.trusted;
835
1216
  revalidateTrust = trust.revalidate;
836
1217
  plan.trusted = trusted;
837
1218
  } else {
838
- const prepared = await prepareDirectory({ cwd: cwdOption, signal }, ctx, getAgentDir());
1219
+ const prepared = await prepareDirectory(
1220
+ { cwd: cwdOption, signal, trust: trustControl.forAgent(start.agent.name, ctx) },
1221
+ ctx,
1222
+ getAgentDir(),
1223
+ );
839
1224
  directory = prepared.workspace;
840
1225
  trusted = prepared.trusted;
841
1226
  }
@@ -844,7 +1229,14 @@ export default function delegate(
844
1229
  let attempt: WorkspaceAttempt | undefined;
845
1230
  let managed: Extract<Workspace, { mode: "managed" }> | undefined;
846
1231
  let windowOpened = false;
1232
+ let reservation: Reservation | undefined;
847
1233
  const run = async (): Promise<Launched> => {
1234
+ admit(start.agent.name, currentRoster(ctx));
1235
+ results.checkStorage(true);
1236
+ await reconcileTree(own.root, tmux);
1237
+ admit(start.agent.name, currentRoster(ctx));
1238
+ if (combined?.aborted) throw new Error("the launch was aborted");
1239
+ reservation = reserveTree({ id, sessionId: id, identity: own, sessionDir });
848
1240
  if (plan !== undefined) {
849
1241
  revalidate(plan);
850
1242
  revalidateTrust?.();
@@ -894,11 +1286,13 @@ export default function delegate(
894
1286
  // at session start without a handshake, and a window that fails leaves
895
1287
  // mail that is reported rather than cleaned up.
896
1288
  const requestId = sendTask(id, start.task);
1289
+ bindTree(reservation, { requestId });
897
1290
  if (attempt !== undefined) {
898
1291
  attempt = { ...attempt, requestId };
899
1292
  results.recordAttempt(attempt, ctx);
900
1293
  }
901
1294
  return launchWindow({
1295
+ identity: own,
902
1296
  id,
903
1297
  requestId,
904
1298
  workspace,
@@ -909,6 +1303,7 @@ export default function delegate(
909
1303
  toolCallId,
910
1304
  ctx,
911
1305
  placement,
1306
+ tools,
912
1307
  onWindowOpened: () => {
913
1308
  windowOpened = true;
914
1309
  },
@@ -918,6 +1313,13 @@ export default function delegate(
918
1313
  try {
919
1314
  return await run();
920
1315
  } catch (error) {
1316
+ if (reservation) {
1317
+ try {
1318
+ failedTree(reservation);
1319
+ } catch (accounting) {
1320
+ ctx.ui.notify(`delegate: reservation remains charged: ${String(accounting)}`, "error");
1321
+ }
1322
+ }
921
1323
  if (attempt !== undefined && managed !== undefined) {
922
1324
  const cause = (error as Error).message;
923
1325
  if (windowOpened || (error instanceof WindowOpenError && error.certainty === "unknown")) {
@@ -982,15 +1384,55 @@ export default function delegate(
982
1384
  startPolling: tracking.startPolling,
983
1385
  placement: () => windowPlacement(getAgentDir()),
984
1386
  roster: rosterFor,
985
- chooseModel: (explicit, agent, models, preferred) => chooseModel(explicit, agent, models, proactive, preferred),
1387
+ admit: (agent, ctx) => admit(agent, currentRoster(ctx)),
1388
+ authorize: (id, ctx) => {
1389
+ owner.assert();
1390
+ results.checkStorage();
1391
+ const record = results.find(id) ?? viewResults.find(id) ?? historicalRecord(id, ctx);
1392
+ if (record && isStarted(record)) assertResumeAuthority(record, ctx);
1393
+ },
1394
+ reserve: async (record, id, ctx) => {
1395
+ const own = record.identity;
1396
+ if (!own) throw new Error(`delegation ${record.id} has no original root placement identity; resume refused`);
1397
+ assertRootPlacement(own.rootPlacement);
1398
+ await reconcileTree(own.root, tmux);
1399
+ admit(record.agent, currentRoster(ctx));
1400
+ return reserveTree({
1401
+ id,
1402
+ sessionId: sessionIdOf(record),
1403
+ identity: own,
1404
+ resumedFrom: record.id,
1405
+ sessionDir: record.sessionDir,
1406
+ });
1407
+ },
1408
+ parentDefaults,
1409
+ chooseModel: (explicit, agent, models, preferred) =>
1410
+ chooseModel(explicit, agent, models, proactive, agentModels(agent, undefined), preferred),
986
1411
  thinkingProblem,
987
- prepareDirectory: (cwd, ctx, signal) => prepareDirectory({ cwd, signal }, ctx, getAgentDir()),
1412
+ prepareDirectory: (cwd, ctx, signal, agent) =>
1413
+ prepareDirectory({ cwd, signal, trust: trustControl.forAgent(agent, ctx) }, ctx, getAgentDir()),
988
1414
  sendTask,
989
- open: ({ id, name, sessionPath, workspace, trusted, cwd, sessionDir, start, toolCallId, ctx, placement }) =>
1415
+ open: ({
1416
+ id,
1417
+ name,
1418
+ sessionPath,
1419
+ workspace,
1420
+ trusted,
1421
+ cwd,
1422
+ sessionDir,
1423
+ start,
1424
+ toolCallId,
1425
+ ctx,
1426
+ placement,
1427
+ identity: own,
1428
+ sessionId,
1429
+ }) =>
990
1430
  openChild({
991
1431
  id,
992
1432
  name,
993
1433
  session: ["--session", sessionPath],
1434
+ identity: own,
1435
+ sessionId,
994
1436
  workspace,
995
1437
  trusted,
996
1438
  cwd,
@@ -999,6 +1441,8 @@ export default function delegate(
999
1441
  toolCallId,
1000
1442
  ctx,
1001
1443
  placement,
1444
+ // Read before the window opens, and a resume mails only after it opens.
1445
+ tools: childTools(start.agent),
1002
1446
  }),
1003
1447
  });
1004
1448
 
@@ -1022,11 +1466,7 @@ export default function delegate(
1022
1466
  const worktree = normalizeWorkspaceValue(parsed.worktree, "--worktree");
1023
1467
  if (worktree_source !== undefined && worktree === undefined)
1024
1468
  throw new Error("--worktree-source requires --worktree");
1025
- const start = await validate(
1026
- { ...parsed, cwd, worktree, worktree_base, worktree_source },
1027
- rosterFor(ctx),
1028
- ctx.modelRegistry,
1029
- );
1469
+ const start = await validate({ ...parsed, cwd, worktree, worktree_base, worktree_source }, rosterFor(ctx), ctx);
1030
1470
  const launched = await launch(start, ctx);
1031
1471
  ctx.ui.notify(started(launched, start), "info");
1032
1472
  } catch (err) {
@@ -1039,6 +1479,11 @@ export default function delegate(
1039
1479
  unavailable: () => (tmux.insideTmux() ? undefined : NOT_IN_TMUX),
1040
1480
  roster: rosterFor,
1041
1481
  noAgents,
1482
+ defaults: (agent, ctx) => {
1483
+ const parent = parentDefaults(ctx);
1484
+ const model = agentModels(agent, parent.model)[0];
1485
+ return { ...(model === undefined ? {} : { model }), thinking: agent.thinking ?? parent.thinking };
1486
+ },
1042
1487
  labelProblem,
1043
1488
  start: (request: NewRequest, context) => runDelegate(request, context),
1044
1489
  };
@@ -1057,9 +1502,10 @@ export default function delegate(
1057
1502
  await runDelegate(outcome.parsed, ctx);
1058
1503
  }
1059
1504
 
1060
- async function resumeCommand(input: string, ctx: ExtensionContext): Promise<void> {
1061
- if (input.trim() === "") {
1062
- await pickResume(ctx, {
1505
+ async function resumePicker(ctx: ExtensionContext, id?: string): Promise<void> {
1506
+ await pickResume(
1507
+ ctx,
1508
+ {
1063
1509
  unavailable: () => (tmux.insideTmux() ? undefined : NOT_IN_TMUX),
1064
1510
  eligible: resumer.eligible,
1065
1511
  resume: async (request, context) => {
@@ -1069,9 +1515,13 @@ export default function delegate(
1069
1515
  context.ui.notify(`delegate: ${(err as Error).message}`, "error");
1070
1516
  }
1071
1517
  },
1072
- });
1073
- return;
1074
- }
1518
+ },
1519
+ id,
1520
+ );
1521
+ }
1522
+
1523
+ async function resumeCommand(input: string, ctx: ExtensionContext): Promise<void> {
1524
+ if (input.trim() === "") return resumePicker(ctx);
1075
1525
  const outcome = parseResume(input);
1076
1526
  if ("error" in outcome) {
1077
1527
  ctx.ui.notify(`delegate: ${outcome.error}`, "error");
@@ -1096,9 +1546,16 @@ export default function delegate(
1096
1546
  task: Type.String({
1097
1547
  description: "The task for the delegate. It sees this and its agent prompt, nothing else of this session.",
1098
1548
  }),
1099
- model: Type.Optional(Type.String({ description: "provider/id model for this call; defaults to the agent's." })),
1549
+ model: Type.Optional(
1550
+ Type.String({
1551
+ description:
1552
+ "provider/id model for this call; defaults to the agent's, or this session's when it names none. Not used when the agent locks model.",
1553
+ }),
1554
+ ),
1100
1555
  thinking: Type.Optional(
1101
- Type.String({ description: `Thinking level (${THINKING_LEVELS.join(", ")}); defaults to the agent's.` }),
1556
+ Type.String({
1557
+ description: `Thinking level (${THINKING_LEVELS.join(", ")}); defaults to the agent's, or this session's when it names none. Not used when the agent locks thinking.`,
1558
+ }),
1102
1559
  ),
1103
1560
  label: Type.Optional(
1104
1561
  Type.String({
@@ -1179,7 +1636,7 @@ export default function delegate(
1179
1636
  task: params.task ?? "",
1180
1637
  },
1181
1638
  rosterFor(ctx),
1182
- ctx.modelRegistry,
1639
+ ctx,
1183
1640
  );
1184
1641
  const launched = await launch(start, ctx, signal, toolCallId);
1185
1642
  ctx.ui.notify(started(launched, start), "info");
@@ -1212,17 +1669,29 @@ export default function delegate(
1212
1669
 
1213
1670
  pi.on("session_start", (_event, ctx) => {
1214
1671
  registry = ctx.modelRegistry;
1215
- tracking.restore(ctx);
1672
+ latest = ctx;
1216
1673
  // The roster is read at call time; the description is rebuilt here and before each run.
1217
- registerDelegate(toolDescription(currentRoster().agents));
1674
+ const roster = currentRoster(ctx);
1675
+ announced.clear();
1676
+ announce(roster, ctx);
1677
+ registerDelegate(toolDescription(roster.agents));
1678
+ exposeDelegation(roster);
1218
1679
  });
1219
1680
 
1220
1681
  // One roster/prompt snapshot prepares this run: refresh the description only when
1221
1682
  // it changed, and replace only this extension's prompt section from the same snapshot.
1222
1683
  pi.on("before_agent_start", (event, ctx) => {
1223
- const description = toolDescription(currentRoster().agents);
1684
+ latest = ctx;
1685
+ const own = identity.current();
1686
+ const roster = currentRoster(ctx);
1687
+ if (own)
1688
+ event.systemPromptOptions.sections.squire_delegate_task = `You are a delegate. ${parentTrust(own.parent, coordinates(roster))}`;
1689
+ else delete event.systemPromptOptions.sections.squire_delegate_task;
1690
+ exposeDelegation(roster);
1691
+ announce(roster, ctx);
1692
+ const description = toolDescription(roster.agents);
1224
1693
  const prompt = readParentPrompt();
1225
- const visible = delegateVisible(event.systemPromptOptions);
1694
+ const visible = delegateVisible(event.systemPromptOptions) && pi.getActiveTools().includes("delegate");
1226
1695
  // Report a read failure even when guidance cannot be used, so it does not go unseen.
1227
1696
  if (prompt.error !== undefined) ctx.ui.notify(`delegate: ${prompt.error}`, "warning");
1228
1697
  if (visible && description !== registeredDescription) registerDelegate(description);
@@ -1236,6 +1705,7 @@ export default function delegate(
1236
1705
  pi.on("session_shutdown", () => {
1237
1706
  tracking.stop();
1238
1707
  });
1708
+ host.on("session_shutdown", () => owner.stop());
1239
1709
 
1240
1710
  /** Agent names, flags, model ids and thinking levels, at the cursor's token. */
1241
1711
  function completions(prefix: string): { value: string; label: string }[] | null {
@@ -1279,12 +1749,20 @@ export default function delegate(
1279
1749
  handler: resumeCommand,
1280
1750
  });
1281
1751
 
1282
- const navigator = createNavigator(results, tmux, tracking, (ctx: ExtensionContext) =>
1283
- startNewDelegate(ctx, newDelegateDeps),
1752
+ const navigator = createNavigator(
1753
+ viewResults,
1754
+ tmux,
1755
+ tracking,
1756
+ (ctx: ExtensionContext) => startNewDelegate(ctx, newDelegateDeps),
1757
+ {
1758
+ assertActive: () => owner.assert(),
1759
+ close: (id, ctx) => tracking.close(id, ctx),
1760
+ resume: (id, ctx) => resumePicker(ctx, id),
1761
+ },
1284
1762
  );
1285
1763
 
1286
1764
  pi.registerCommand("delegates", {
1287
- description: "Open the panel of delegates whose window is open: switch, peek, close",
1765
+ description: "Open the shared delegate dashboard: details, switch, peek, close, resume",
1288
1766
  handler: async (_args: string, ctx: ExtensionContext) => navigator.panel(ctx),
1289
1767
  });
1290
1768