pi-squire 0.14.0 → 0.15.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
@@ -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
@@ -17,9 +18,13 @@
17
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), and
24
+ // each of its `extensions` as `-e`, both for the child alone. A tool only the
25
+ // agent's own extensions register is passed only when `tools` or
26
+ // `delegateTools` names it. An agent with no `model` or `thinking` runs
27
+ // on the parent's.
23
28
  //
24
29
  // The model gets four tools. `delegate` takes agent, task and optional model
25
30
  // and thinking, validates them the way the command does, and starts the
@@ -50,7 +55,18 @@ import {
50
55
  } from "@earendil-works/pi-coding-agent";
51
56
  import { Type } from "typebox";
52
57
 
53
- import { type Agent, isThinking, type Roster, readRoster, THINKING_LEVELS } from "./agents.ts";
58
+ import {
59
+ type Agent,
60
+ delegateTools,
61
+ disabledProblem,
62
+ globalAgentsDir,
63
+ isThinking,
64
+ projectAgentsDir,
65
+ type Roster,
66
+ readRoster,
67
+ THINKING_LEVELS,
68
+ withoutLocked,
69
+ } from "./agents.ts";
54
70
  import { AGENT_ENV, AUTO_EXIT_ENV, FALLBACK_ENV, registerChild } from "./child.ts";
55
71
  import { activeMark, clearMarks, isFree, readMarks } from "./limits.ts";
56
72
  import { createNavigator, OPEN_USAGE, openCompletions, parseOpen } from "./navigator.ts";
@@ -92,6 +108,13 @@ const LAUNCH = "session:launch";
92
108
  const PARENT_ENV = "PI_DELEGATE_PARENT";
93
109
  /** Forwarded to the child when the parent has it set. */
94
110
  const SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
111
+ /** The tools this extension registers in a parent; a delegate never gets them. */
112
+ const DELEGATION_TOOLS: ReadonlySet<string> = new Set([
113
+ "delegate",
114
+ "delegation_status",
115
+ "delegation_close",
116
+ "delegation_resume",
117
+ ]);
95
118
  /** Asked of every delegate, so the parent gets an answer it can use alone. */
96
119
  const FINAL_LINE =
97
120
  "End with one self-contained final message: the parent session sees only that message, never this conversation.";
@@ -406,26 +429,49 @@ function modelProblem(value: string, registry: ModelRegistry): string | undefine
406
429
  return `unknown model ${JSON.stringify(value)}${close.length > 0 ? `; close matches: ${close.join(", ")}` : ""}`;
407
430
  }
408
431
 
432
+ /** The registry's model for an exact `provider/id`; undefined for anything else, such as a pattern. */
433
+ function registered(value: string, registry: ModelRegistry): { provider: string; id: string } | undefined {
434
+ const slash = value.indexOf("/");
435
+ if (slash <= 0 || slash === value.length - 1) return undefined;
436
+ return registry.find(value.slice(0, slash), value.slice(slash + 1));
437
+ }
438
+
439
+ /** An agent's own models in order: its `model`, or the parent's when it has none, then its fallbacks. */
440
+ function agentModels(agent: Agent, parentModel: string | undefined): string[] {
441
+ const first = agent.model ?? parentModel;
442
+ return [...(first === undefined ? [] : [first]), ...agent.fallback];
443
+ }
444
+
409
445
  /**
410
446
  * 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.
447
+ * `preferred`, by default `own`: the agent's model then its fallbacks. The
448
+ * first candidate that passes `modelProblem` and has no active mark wins; the
449
+ * rest are returned as skipped. A paid candidate on a provider with a quota
450
+ * check is read first, and skipped when its quota is used up. A candidate from
451
+ * the agent file that is not an exact `provider/id` the registry knows, such
452
+ * as a pattern, is passed to the child unchanged for Pi to resolve. Refuses,
453
+ * listing every candidate, when none is left. A resume passes its own
454
+ * `preferred`.
417
455
  */
418
456
  async function chooseModel(
419
457
  explicit: string | undefined,
420
458
  agent: Agent,
421
459
  models: ModelRegistry,
422
460
  proactive: Proactive,
423
- preferred: readonly string[] = [agent.model, ...agent.fallback],
461
+ own: readonly string[],
462
+ preferred: readonly string[] = own,
424
463
  ): Promise<{ model: string; skipped: Skipped[]; notices: string[]; rest: string[] }> {
425
464
  const candidates = explicit !== undefined ? [explicit] : [...preferred];
465
+ if (candidates.length === 0)
466
+ throw new Error(
467
+ `agent ${agent.name} names no model and this session has none; add model to ${agent.path}, or pass one`,
468
+ );
426
469
  const skipped: Skipped[] = [];
427
470
  const notices: string[] = [];
428
471
  for (const model of candidates) {
472
+ const rest = () => candidates.slice(candidates.indexOf(model) + 1);
473
+ if (explicit === undefined && registered(model, models) === undefined)
474
+ return { model, skipped, notices, rest: rest() };
429
475
  const problem = modelProblem(model, models);
430
476
  if (problem !== undefined) {
431
477
  skipped.push({ model, why: `unavailable (${problem})`, reason: problem });
@@ -445,7 +491,7 @@ async function chooseModel(
445
491
  if (checked.notice !== undefined && !notices.includes(checked.notice)) notices.push(checked.notice);
446
492
  mark = activeMark(models, provider, id) ?? checked.mark;
447
493
  }
448
- if (mark === undefined) return { model, skipped, notices, rest: candidates.slice(candidates.indexOf(model) + 1) };
494
+ if (mark === undefined) return { model, skipped, notices, rest: rest() };
449
495
  const what = mark.scope.includes("/") ? `model ${mark.scope}` : `provider ${mark.scope}`;
450
496
  const until = new Date(mark.clearsAt).toISOString();
451
497
  skipped.push({
@@ -458,8 +504,10 @@ async function chooseModel(
458
504
  const [only] = skipped;
459
505
  if (!only.why.startsWith("usage-limited")) throw new Error(only.reason);
460
506
  // 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;
507
+ const open = own.filter((model) => {
508
+ if (model === only.model) return false;
509
+ if (registered(model, models) === undefined) return true;
510
+ if (modelProblem(model, models) !== undefined) return false;
463
511
  const slash = model.indexOf("/");
464
512
  return activeMark(models, model.slice(0, slash), model.slice(slash + 1)) === undefined;
465
513
  });
@@ -508,32 +556,97 @@ function delegateVisible(options: { selectedTools: string[]; hiddenTools: string
508
556
  return options.selectedTools.includes("delegate") && !options.hiddenTools.includes("delegate");
509
557
  }
510
558
 
559
+ /** Where agent files go, for the messages that say how to add one. */
560
+ function whereAgentsGo(): string {
561
+ 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, extensions, fallback, auto-exit, prompt_mode, display_name, locked and enabled`;
562
+ }
563
+
564
+ /** One roster line for the `delegate` tool's description. */
565
+ function rosterLine(agent: Agent): string {
566
+ const models = [agent.model ?? "this session's model", ...agent.fallback.map((model) => `fallback ${model}`)];
567
+ const details = [
568
+ `default ${models.join(", ")}`,
569
+ `thinking ${agent.thinking ?? "this session's"}`,
570
+ ...(agent.autoExit ? [] : ["auto-exit off"]),
571
+ ...toolsDetail(agent),
572
+ ...(agent.locked.length > 0 ? [`locked: ${agent.locked.join("/")}`] : []),
573
+ ];
574
+ const shown = agent.displayName === undefined ? "" : ` "${agent.displayName}"`;
575
+ return `- ${agent.name}${shown} — ${agent.description} (${details.join(", ")})`;
576
+ }
577
+
578
+ /**
579
+ * The entry that makes Pi filter MCP tools by `--tools`. Pi keeps every MCP
580
+ * tool registered (callable from codemode, loadable by tool_search) unless an
581
+ * entry starts with `mcp__`; this one does and names no tool, since an MCP
582
+ * tool is `mcp__<server>__<tool>`.
583
+ */
584
+ const MCP_FILTER = "mcp__";
585
+
586
+ /**
587
+ * The child's tool arguments for a selection: `--tools` with exactly those
588
+ * tools, MCP tools included, or `--no-tools` (which Pi applies to MCP tools
589
+ * too) when it is empty. Always explicit: no flag would give the child the
590
+ * parent's tools rather than Pi's defaults.
591
+ */
592
+ function toolArgs(tools: readonly string[]): string[] {
593
+ if (tools.length === 0) return ["--no-tools"];
594
+ const filtered = tools.some((name) => name.startsWith(MCP_FILTER)) ? tools : [...tools, MCP_FILTER];
595
+ return ["--tools", filtered.join(",")];
596
+ }
597
+
598
+ /** The roster line's word on an agent's tools; nothing for the parent's tools unchanged. */
599
+ function toolsDetail(agent: Agent): string[] {
600
+ if (agent.tools !== undefined) return [agent.tools.length > 0 ? `tools: ${agent.tools.join("/")}` : "no tools"];
601
+ return agent.toolsExcept.length > 0 ? [`tools: this session's except ${agent.toolsExcept.join("/")}`] : [];
602
+ }
603
+
604
+ /** Whether tool `name` is `pattern`, where `*` matches any characters, as in Pi's tool lists. */
605
+ function toolMatches(pattern: string, name: string): boolean {
606
+ const source = pattern
607
+ .split("*")
608
+ .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
609
+ .join(".*");
610
+ return new RegExp(`^${source}$`).test(name);
611
+ }
612
+
613
+ /**
614
+ * The delegate's tool selection, always a plain list: the agent's `tools`
615
+ * names, or `parentTools` less its `-name` entries when it has none, then
616
+ * `extra` (the `delegateTools` setting), with the delegation tools left out.
617
+ * Empty means the delegate gets no tools.
618
+ */
619
+ function toolSelection(agent: Agent, parentTools: readonly string[], extra: readonly string[]): string[] {
620
+ const base =
621
+ agent.tools ?? parentTools.filter((name) => !agent.toolsExcept.some((pattern) => toolMatches(pattern, name)));
622
+ return [...new Set([...base, ...extra])].filter((name) => !DELEGATION_TOOLS.has(name));
623
+ }
624
+
625
+ /** The delegate's appended prompt: the agent's body, then the lines every delegate gets. */
626
+ function delegatePrompt(agent: Agent, parent: string, managedContext: string): string {
627
+ const tail = `${FINAL_LINE}\n\n${parentTrust(parent)}${managedContext}`;
628
+ if (agent.prompt === "") return tail;
629
+ // `replace` is how every delegate was built before `prompt_mode`, byte for byte.
630
+ const body =
631
+ agent.promptMode === "replace" ? agent.prompt : `<agent_instructions>\n${agent.prompt}\n</agent_instructions>`;
632
+ return `${body}\n\n${tail}`;
633
+ }
634
+
511
635
  /** The `delegate` tool's description: what delegation does, and the roster as it stands. */
512
636
  function toolDescription(agents: readonly Agent[]): string {
513
637
  const intro =
514
638
  "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
639
  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.`;
640
+ return `${intro}\n\nNo agents are defined yet. Add ${whereAgentsGo()}.`;
523
641
  }
524
642
  return [
525
643
  intro,
526
644
  "",
527
645
  "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
- ),
646
+ ...agents.map(rosterLine),
534
647
  "",
535
648
  "`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.",
649
+ "`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
650
  "`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
651
  "",
539
652
  "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.",
@@ -564,44 +677,75 @@ export default function delegate(
564
677
 
565
678
  /** Captured because `getArgumentCompletions` is called without a context. */
566
679
  let registry: ModelRegistry | undefined;
680
+ /** The latest context, for the roster a completion reads; undefined before the session starts. */
681
+ let latest: ExtensionContext | undefined;
567
682
 
568
683
  const modelIds = (source: ModelRegistry | undefined): string[] =>
569
684
  (source?.getAll() ?? []).map((model) => `${model.provider}/${model.id}`);
570
685
 
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 {
686
+ /** The project's agents folder when `ctx`'s project is trusted; undefined otherwise or without a context. */
687
+ function trustedProject(ctx: ExtensionContext | undefined): string | undefined {
688
+ if (ctx === undefined) return undefined;
689
+ try {
690
+ return ctx.isProjectTrusted() ? projectAgentsDir(ctx.cwd) : undefined;
691
+ } catch {
692
+ return undefined; // a stale context after a session replacement
693
+ }
694
+ }
695
+
696
+ /** The roster as it stands: the global agents, and the project's when it is trusted. */
697
+ const currentRoster = (ctx: ExtensionContext | undefined = latest): Roster =>
698
+ readRoster(getAgentDir(), trustedProject(ctx));
699
+
700
+ /** What an agent with no `model` or `thinking` runs on: the parent's model and level now. */
701
+ function parentDefaults(ctx: ExtensionContext): { model?: string; thinking: string } {
702
+ let thinking = "medium";
576
703
  try {
577
- return pi.getAllTools().map((tool) => tool.name);
704
+ thinking = pi.getThinkingLevel();
578
705
  } catch {
579
- return undefined;
706
+ // Pi binds this before any call can reach here; the default only covers a host without it.
580
707
  }
708
+ return { ...(ctx.model === undefined ? {} : { model: `${ctx.model.provider}/${ctx.model.id}` }), thinking };
581
709
  }
582
710
 
583
- /** The roster as it stands, checked against the session's tools. */
584
- const currentRoster = (): Roster => readRoster(getAgentDir(), sessionTools());
711
+ /**
712
+ * The delegate's tools for a launch or resume starting now: the agent's
713
+ * named tools, or the parent's active tools at this moment (less any
714
+ * `-name` entries), then the `delegateTools` setting. The agent's own
715
+ * extensions are never loaded here: a tool only they register is passed
716
+ * only when `tools` or `delegateTools` names it. Throws, naming the file,
717
+ * on an invalid setting.
718
+ */
719
+ function childTools(agent: Agent): string[] {
720
+ return toolSelection(agent, agent.tools === undefined ? pi.getActiveTools() : [], delegateTools(getAgentDir()));
721
+ }
585
722
 
586
723
  /** Read the roster now, warning about every file left out. */
587
724
  function rosterFor(ctx: ExtensionContext): Roster {
588
- const roster = currentRoster();
725
+ const roster = currentRoster(ctx);
589
726
  for (const warning of roster.warnings) ctx.ui.notify(`delegate: ${warning}`, "warning");
590
727
  return roster;
591
728
  }
592
729
 
730
+ /** The roster warnings already shown this session, so a run shows only new ones. */
731
+ const announced = new Set<string>();
732
+
733
+ /** Show each roster warning not yet shown this session, such as a file that is disabled. */
734
+ function announce(roster: Roster, ctx: ExtensionContext): void {
735
+ for (const warning of roster.warnings) {
736
+ if (announced.has(warning)) continue;
737
+ announced.add(warning);
738
+ ctx.ui.notify(`delegate: ${warning}`, "warning");
739
+ }
740
+ }
741
+
593
742
  /** 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`;
743
+ const noAgents = (): string => `no agents are defined; add ${whereAgentsGo()}`;
602
744
 
603
745
  /** The request as a start, or an error naming what to fix. */
604
- async function validate(request: Parsed, roster: Roster, models: ModelRegistry): Promise<Start> {
746
+ async function validate(request: Parsed, roster: Roster, ctx: ExtensionContext): Promise<Start> {
747
+ const disabled = disabledProblem(roster, request.agent);
748
+ if (disabled !== undefined) throw new Error(disabled);
605
749
  if (roster.agents.length === 0) throw new Error(noAgents());
606
750
  const agent = roster.agents.find((candidate) => candidate.name === request.agent);
607
751
  if (agent === undefined) {
@@ -610,8 +754,14 @@ export default function delegate(
610
754
  );
611
755
  }
612
756
  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);
757
+ const parent = parentDefaults(ctx);
758
+ const own = agentModels(agent, parent.model);
759
+ const defaultThinking = agent.thinking ?? parent.thinking;
760
+ const unlocked = withoutLocked(agent, request, { model: own[0], thinking: defaultThinking });
761
+ const thinking = unlocked.request.thinking ?? defaultThinking;
762
+ const chosen = await chooseModel(unlocked.request.model, agent, ctx.modelRegistry, proactive, own);
763
+ const { model, skipped, rest } = chosen;
764
+ const notices = unlocked.notice === undefined ? chosen.notices : [unlocked.notice, ...chosen.notices];
615
765
  const badThinking = thinkingProblem(thinking);
616
766
  if (badThinking !== undefined) throw new Error(badThinking);
617
767
  const badLabel = request.label === undefined ? undefined : labelProblem(request.label);
@@ -667,6 +817,8 @@ export default function delegate(
667
817
  ctx: ExtensionContext;
668
818
  /** Chosen once, when the launch or resume began, so no later settings change can refuse it after mail. */
669
819
  placement: WindowPlacement;
820
+ /** The delegate's tools, from `toolSelection`, read when the launch began; empty means none. */
821
+ tools: readonly string[];
670
822
  onWindowOpened?: () => void;
671
823
  }): Promise<OpenedWindow> {
672
824
  const {
@@ -681,6 +833,7 @@ export default function delegate(
681
833
  toolCallId,
682
834
  ctx,
683
835
  placement,
836
+ tools,
684
837
  onWindowOpened,
685
838
  } = input;
686
839
  const parent = ctx.sessionManager.getSessionId();
@@ -690,7 +843,7 @@ export default function delegate(
690
843
  : workspace.mode === "existing"
691
844
  ? `\n\nThe session that started you chose your working directory: ${workspace.cwd}`
692
845
  : "";
693
- const prompt = writePromptFile(`${start.agent.prompt}\n\n${FINAL_LINE}\n\n${parentTrust(parent)}${managedContext}`);
846
+ const prompt = writePromptFile(delegatePrompt(start.agent, parent, managedContext));
694
847
  const argv = [
695
848
  ...parentCommand(),
696
849
  ...session,
@@ -703,8 +856,7 @@ export default function delegate(
703
856
  "--thinking",
704
857
  start.thinking,
705
858
  ...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(",")] : []),
859
+ ...toolArgs(tools),
708
860
  "--append-system-prompt",
709
861
  prompt,
710
862
  ];
@@ -747,11 +899,12 @@ export default function delegate(
747
899
  toolCallId: string | undefined;
748
900
  ctx: ExtensionContext;
749
901
  placement: WindowPlacement;
902
+ tools: readonly string[];
750
903
  onWindowOpened: () => void;
751
904
  }): Promise<Launched> {
752
905
  const { id, requestId, workspace, trusted, cwd, sessionDir, start, toolCallId, ctx, placement, onWindowOpened } =
753
906
  input;
754
- const name = delegateName(start.agent.name, id, start.label);
907
+ const name = delegateName(start.agent.displayName ?? start.agent.name, id, start.label);
755
908
  const { id: windowId, session: windowSession } = await openChild({
756
909
  id,
757
910
  name,
@@ -764,6 +917,7 @@ export default function delegate(
764
917
  toolCallId,
765
918
  ctx,
766
919
  placement,
920
+ tools: input.tools,
767
921
  onWindowOpened,
768
922
  });
769
923
  // No task text in the record: the request copy in `sent/` has it.
@@ -771,6 +925,7 @@ export default function delegate(
771
925
  {
772
926
  id,
773
927
  agent: start.agent.name,
928
+ ...(start.agent.displayName === undefined ? {} : { displayName: start.agent.displayName }),
774
929
  model: start.model,
775
930
  thinking: start.thinking,
776
931
  name,
@@ -796,8 +951,9 @@ export default function delegate(
796
951
  ): Promise<Launched> {
797
952
  if (!tmux.insideTmux()) throw new Error(NOT_IN_TMUX);
798
953
  await requireTmuxVersion(tmux);
799
- // Read once, before anything is created or mailed; the launch keeps this value.
954
+ // Read once, before anything is created or mailed; the launch keeps these values.
800
955
  const placement = windowPlacement(getAgentDir());
956
+ const tools = childTools(start.agent);
801
957
  const sessionDir = ctx.sessionManager.getSessionDir();
802
958
  if (!isAbsolute(sessionDir))
803
959
  throw new Error(
@@ -909,6 +1065,7 @@ export default function delegate(
909
1065
  toolCallId,
910
1066
  ctx,
911
1067
  placement,
1068
+ tools,
912
1069
  onWindowOpened: () => {
913
1070
  windowOpened = true;
914
1071
  },
@@ -982,7 +1139,9 @@ export default function delegate(
982
1139
  startPolling: tracking.startPolling,
983
1140
  placement: () => windowPlacement(getAgentDir()),
984
1141
  roster: rosterFor,
985
- chooseModel: (explicit, agent, models, preferred) => chooseModel(explicit, agent, models, proactive, preferred),
1142
+ parentDefaults,
1143
+ chooseModel: (explicit, agent, models, preferred) =>
1144
+ chooseModel(explicit, agent, models, proactive, agentModels(agent, undefined), preferred),
986
1145
  thinkingProblem,
987
1146
  prepareDirectory: (cwd, ctx, signal) => prepareDirectory({ cwd, signal }, ctx, getAgentDir()),
988
1147
  sendTask,
@@ -999,6 +1158,8 @@ export default function delegate(
999
1158
  toolCallId,
1000
1159
  ctx,
1001
1160
  placement,
1161
+ // Read before the window opens, and a resume mails only after it opens.
1162
+ tools: childTools(start.agent),
1002
1163
  }),
1003
1164
  });
1004
1165
 
@@ -1022,11 +1183,7 @@ export default function delegate(
1022
1183
  const worktree = normalizeWorkspaceValue(parsed.worktree, "--worktree");
1023
1184
  if (worktree_source !== undefined && worktree === undefined)
1024
1185
  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
- );
1186
+ const start = await validate({ ...parsed, cwd, worktree, worktree_base, worktree_source }, rosterFor(ctx), ctx);
1030
1187
  const launched = await launch(start, ctx);
1031
1188
  ctx.ui.notify(started(launched, start), "info");
1032
1189
  } catch (err) {
@@ -1039,6 +1196,11 @@ export default function delegate(
1039
1196
  unavailable: () => (tmux.insideTmux() ? undefined : NOT_IN_TMUX),
1040
1197
  roster: rosterFor,
1041
1198
  noAgents,
1199
+ defaults: (agent, ctx) => {
1200
+ const parent = parentDefaults(ctx);
1201
+ const model = agentModels(agent, parent.model)[0];
1202
+ return { ...(model === undefined ? {} : { model }), thinking: agent.thinking ?? parent.thinking };
1203
+ },
1042
1204
  labelProblem,
1043
1205
  start: (request: NewRequest, context) => runDelegate(request, context),
1044
1206
  };
@@ -1096,9 +1258,16 @@ export default function delegate(
1096
1258
  task: Type.String({
1097
1259
  description: "The task for the delegate. It sees this and its agent prompt, nothing else of this session.",
1098
1260
  }),
1099
- model: Type.Optional(Type.String({ description: "provider/id model for this call; defaults to the agent's." })),
1261
+ model: Type.Optional(
1262
+ Type.String({
1263
+ description:
1264
+ "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.",
1265
+ }),
1266
+ ),
1100
1267
  thinking: Type.Optional(
1101
- Type.String({ description: `Thinking level (${THINKING_LEVELS.join(", ")}); defaults to the agent's.` }),
1268
+ Type.String({
1269
+ 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.`,
1270
+ }),
1102
1271
  ),
1103
1272
  label: Type.Optional(
1104
1273
  Type.String({
@@ -1179,7 +1348,7 @@ export default function delegate(
1179
1348
  task: params.task ?? "",
1180
1349
  },
1181
1350
  rosterFor(ctx),
1182
- ctx.modelRegistry,
1351
+ ctx,
1183
1352
  );
1184
1353
  const launched = await launch(start, ctx, signal, toolCallId);
1185
1354
  ctx.ui.notify(started(launched, start), "info");
@@ -1212,15 +1381,22 @@ export default function delegate(
1212
1381
 
1213
1382
  pi.on("session_start", (_event, ctx) => {
1214
1383
  registry = ctx.modelRegistry;
1384
+ latest = ctx;
1215
1385
  tracking.restore(ctx);
1216
1386
  // The roster is read at call time; the description is rebuilt here and before each run.
1217
- registerDelegate(toolDescription(currentRoster().agents));
1387
+ const roster = currentRoster(ctx);
1388
+ announced.clear();
1389
+ announce(roster, ctx);
1390
+ registerDelegate(toolDescription(roster.agents));
1218
1391
  });
1219
1392
 
1220
1393
  // One roster/prompt snapshot prepares this run: refresh the description only when
1221
1394
  // it changed, and replace only this extension's prompt section from the same snapshot.
1222
1395
  pi.on("before_agent_start", (event, ctx) => {
1223
- const description = toolDescription(currentRoster().agents);
1396
+ latest = ctx;
1397
+ const roster = currentRoster(ctx);
1398
+ announce(roster, ctx);
1399
+ const description = toolDescription(roster.agents);
1224
1400
  const prompt = readParentPrompt();
1225
1401
  const visible = delegateVisible(event.systemPromptOptions);
1226
1402
  // Report a read failure even when guidance cannot be used, so it does not go unseen.
package/src/navigator.ts CHANGED
@@ -205,7 +205,7 @@ async function openDelegates({ results, tmux }: Env): Promise<OpenDelegate[] | u
205
205
  if (window === undefined || window.exited) continue;
206
206
  const state = deriveState(record, "open");
207
207
  if (state !== "running" && state !== "done") continue;
208
- found.push({ id: record.id, name: recordedName(record), agent: record.agent, state, window });
208
+ found.push({ id: record.id, name: recordedName(record), agent: record.displayName ?? record.agent, state, window });
209
209
  }
210
210
  return found.reverse();
211
211
  }
@@ -47,6 +47,8 @@ export type NewDelegateDeps = {
47
47
  roster(ctx: ExtensionContext): Roster;
48
48
  /** The refusal text for an empty roster. */
49
49
  noAgents(): string;
50
+ /** The model and thinking `agent` runs on when the request names none: its own, else the parent's. */
51
+ defaults(agent: Agent, ctx: ExtensionContext): { model?: string; thinking: string };
50
52
  /** Why `value` is not a usable label, or undefined; the check `--label` gets. */
51
53
  labelProblem(value: string): string | undefined;
52
54
  /** Start the delegate as `/delegate` would, notifying the start or the refusal. */
@@ -207,12 +209,12 @@ export class SettingsForm implements Component {
207
209
  this.options = options;
208
210
  const { agent, models, style } = options;
209
211
  for (const model of models) this.byId.set(`${model.provider}/${model.id}`, model);
210
- this.model = agent.model;
211
- this.thinking = agent.thinking;
212
+ this.model = agent.model ?? "";
213
+ this.thinking = agent.thinking ?? "";
212
214
  this.autoExit = agent.autoExit;
213
215
  this.cwd = options.cwd;
214
216
  this.clampInitialThinking();
215
- const modelItems: SelectItem[] = [...new Set([agent.model, ...this.byId.keys()])].map((value) => ({
217
+ const modelItems: SelectItem[] = [...new Set([this.model, ...this.byId.keys()])].map((value) => ({
216
218
  value,
217
219
  label: value,
218
220
  }));
@@ -360,8 +362,8 @@ export class SettingsForm implements Component {
360
362
  return;
361
363
  }
362
364
  this.options.done({
363
- ...(this.model === this.options.agent.model ? {} : { model: this.model }),
364
- ...(this.thinking === this.options.agent.thinking ? {} : { thinking: this.thinking }),
365
+ ...(this.model === (this.options.agent.model ?? "") ? {} : { model: this.model }),
366
+ ...(this.thinking === (this.options.agent.thinking ?? "") ? {} : { thinking: this.thinking }),
365
367
  ...(this.autoExit === this.options.agent.autoExit ? {} : { autoExit: this.autoExit }),
366
368
  ...(this.cwd === this.options.cwd ? {} : { cwd: this.cwd }),
367
369
  ...(this.worktree === "" ? {} : { worktree: this.worktree }),
@@ -410,14 +412,20 @@ export async function startNewDelegate(ctx: ExtensionContext, deps: NewDelegateD
410
412
  if (unavailable !== undefined) return fail(unavailable);
411
413
  const roster = deps.roster(ctx);
412
414
  if (roster.agents.length === 0) return fail(deps.noAgents());
413
- const options = roster.agents.map((agent) => `${agent.name} — ${agent.description}`);
415
+ const options = roster.agents.map(
416
+ (agent) =>
417
+ `${agent.name}${agent.displayName === undefined ? "" : ` (${agent.displayName})`} — ${agent.description}`,
418
+ );
414
419
  const picked = await ctx.ui.select("New delegate: agent", options);
415
420
  const agent = roster.agents[options.indexOf(picked ?? "")];
416
421
  if (agent === undefined) return;
417
422
  const task = await ctx.ui.editor(`Task for ${agent.name}`);
418
423
  if (task === undefined) return;
419
424
  if (task.trim() === "") return fail("a task is required");
420
- const typed = await ctx.ui.input(`Label (optional; empty for ${agent.name}-<id>)`, "letters, digits, _ or -");
425
+ const typed = await ctx.ui.input(
426
+ `Label (optional; empty for ${agent.displayName ?? agent.name}-<id>)`,
427
+ "letters, digits, _ or -",
428
+ );
421
429
  if (typed === undefined) return;
422
430
  const label = typed.trim() === "" ? undefined : typed.trim();
423
431
  const badLabel = label === undefined ? undefined : deps.labelProblem(label);
@@ -425,7 +433,8 @@ export async function startNewDelegate(ctx: ExtensionContext, deps: NewDelegateD
425
433
  const chosen = await ctx.ui.custom<FormResult | undefined>(
426
434
  (_tui, theme, _keys, done): Component =>
427
435
  new SettingsForm({
428
- agent,
436
+ // The rows start at what a request naming nothing runs on.
437
+ agent: { ...agent, ...deps.defaults(agent, ctx) } as Agent,
429
438
  models: ctx.modelRegistry.getAvailable() as Model<never>[],
430
439
  cwd: ctx.cwd,
431
440
  style: theme,
@@ -77,3 +77,42 @@ export function finishedModel(sessionPath: string | undefined): string | undefin
77
77
  }
78
78
  return undefined;
79
79
  }
80
+
81
+ /** One session-file line, as much of it as the branch walk reads. */
82
+ type BranchEntry = { type?: unknown; id?: unknown; parentId?: unknown; thinkingLevel?: unknown };
83
+
84
+ /**
85
+ * The thinking level a delegate's session last changed to on its active
86
+ * branch: Pi records one `thinking_level_change` when a session starts and
87
+ * one per later change, the clamp to a new model's levels included. The
88
+ * branch is the leaf, Pi's last entry, back through each `parentId`.
89
+ * Undefined when the file cannot be read or holds no change on that branch.
90
+ */
91
+ export function sessionThinking(sessionPath: string): string | undefined {
92
+ let lines: string[];
93
+ try {
94
+ lines = readFileSync(sessionPath, "utf8").split("\n");
95
+ } catch {
96
+ return undefined;
97
+ }
98
+ const byId = new Map<string, BranchEntry>();
99
+ let leaf: BranchEntry | undefined;
100
+ for (const line of lines) {
101
+ let entry: BranchEntry;
102
+ try {
103
+ entry = JSON.parse(line);
104
+ } catch {
105
+ continue;
106
+ }
107
+ if (entry?.type === "session" || typeof entry?.id !== "string") continue;
108
+ byId.set(entry.id, entry);
109
+ leaf = entry;
110
+ }
111
+ const seen = new Set<BranchEntry>();
112
+ for (let entry = leaf; entry !== undefined && !seen.has(entry); ) {
113
+ seen.add(entry);
114
+ if (entry.type === "thinking_level_change" && typeof entry.thinkingLevel === "string") return entry.thinkingLevel;
115
+ entry = typeof entry.parentId === "string" ? byId.get(entry.parentId) : undefined;
116
+ }
117
+ return undefined;
118
+ }