pi-squire 0.15.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 (60) hide show
  1. package/CHANGELOG.md +323 -1
  2. package/README.md +918 -117
  3. package/package.json +2 -1
  4. package/src/agents.ts +57 -36
  5. package/src/child-tasks.ts +221 -0
  6. package/src/child.ts +35 -41
  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 +358 -56
  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/poll.ts +1 -0
  16. package/src/render.ts +4 -1
  17. package/src/result-close.ts +37 -0
  18. package/src/result-restore.ts +95 -0
  19. package/src/result-types.ts +29 -1
  20. package/src/results-presentation.ts +47 -0
  21. package/src/results.ts +174 -108
  22. package/src/resume-checks.ts +14 -2
  23. package/src/resume-picker.ts +9 -2
  24. package/src/resume.ts +62 -13
  25. package/src/session-owner.ts +279 -0
  26. package/src/task-replies.ts +247 -0
  27. package/src/task-settlement.ts +81 -0
  28. package/src/task-store.ts +26 -0
  29. package/src/tmux.ts +134 -54
  30. package/src/tool-ceiling.ts +125 -0
  31. package/src/tracking.ts +72 -65
  32. package/src/tree-close.ts +236 -0
  33. package/src/tree-fence.ts +51 -0
  34. package/src/tree-lock.ts +77 -0
  35. package/src/tree-notice.ts +124 -0
  36. package/src/tree-registry.ts +365 -0
  37. package/src/tree-settings.ts +31 -0
  38. package/src/tree-types.ts +32 -0
  39. package/src/tree-view.ts +246 -0
  40. package/src/trust-control.ts +214 -0
  41. package/src/trust-prompt.ts +163 -0
  42. package/src/trust-wire.ts +203 -0
  43. package/src/widget-activity-join.ts +24 -0
  44. package/src/widget-activity-runtime.ts +104 -0
  45. package/src/widget-activity.ts +104 -0
  46. package/src/widget-ancestors.ts +142 -0
  47. package/src/widget-current.ts +41 -0
  48. package/src/widget-data.ts +147 -0
  49. package/src/widget-focus.ts +133 -0
  50. package/src/widget-live-process.ts +31 -0
  51. package/src/widget-live.ts +356 -0
  52. package/src/widget-render.ts +181 -0
  53. package/src/widget-selection.ts +58 -0
  54. package/src/widget-settings.ts +29 -0
  55. package/src/widget-summary.ts +51 -0
  56. package/src/widget-title-file.ts +127 -0
  57. package/src/widget-title.ts +118 -0
  58. package/src/widget.ts +272 -0
  59. package/src/window-placement.ts +30 -5
  60. package/src/workspace.ts +38 -90
package/src/index.ts CHANGED
@@ -15,15 +15,13 @@
15
15
  // in the parent session; its answer is taken over on `message:inbound` and
16
16
  // shown as one result message, so the parent hears back on its own.
17
17
  //
18
- // 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
19
19
  // call) the delegate closes its own window after a normal completion; the
20
20
  // delegate's side lives in `child.ts`. With it off the window stays open.
21
21
  // An agent's `tools`, or the parent's active tools when it has no `tools`
22
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
23
+ // `delegateTools` setting added (`--no-tools` when that leaves nothing).
24
+ // An agent with no `model` or `thinking` runs
27
25
  // on the parent's.
28
26
  //
29
27
  // The model gets four tools. `delegate` takes agent, task and optional model
@@ -36,9 +34,9 @@
36
34
  // envelope paths.
37
35
  // `delegation_close` kills the window and records the close. `delegation_resume`
38
36
  // (`resume.ts`) reopens an exited delegate's session in a new window with a
39
- // follow-up task, recorded as a new delegation. Inside a delegate
40
- // (`PI_DELEGATE_PARENT` set) the extension registers only `child.ts`'s
41
- // `/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.
42
40
  //
43
41
  // The `session:launch` contract this package provides, and the `message:*`
44
42
  // hooks it consumes, live in this package's README.
@@ -68,17 +66,41 @@ import {
68
66
  withoutLocked,
69
67
  } from "./agents.ts";
70
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";
71
71
  import { activeMark, clearMarks, isFree, readMarks } from "./limits.ts";
72
72
  import { createNavigator, OPEN_USAGE, openCompletions, parseOpen } from "./navigator.ts";
73
73
  import { type NewDelegateDeps, type NewRequest, startNewDelegate } from "./new-delegate.ts";
74
74
  import { dirtyNotice, renderDelegateCall, renderDelegateResult, resourceLine } from "./render.ts";
75
- import { createResults, delegateName, type WorkspaceAttempt } from "./results.ts";
75
+ import { createResults, delegateName, isStarted, sessionIdOf, type WorkspaceAttempt } from "./results.ts";
76
76
  import { createResume, type ResumeRequest, resumableIds, startedLine } from "./resume.ts";
77
77
  import { pickResume } from "./resume-picker.ts";
78
+ import { ownedAPI, SessionOwner, sessionIsOwned } from "./session-owner.ts";
79
+ import { registerTaskReplies } from "./task-replies.ts";
78
80
  import { createTmuxClient, type OpenedWindow, requireTmuxVersion, type TmuxClient, WindowOpenError } from "./tmux.ts";
81
+ import { availableTools, enforceToolCeiling } from "./tool-ceiling.ts";
79
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";
80
100
  import { createProactive, createUsageClient, type Proactive, type UsageClient } from "./usage.ts";
81
- 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";
82
104
  import {
83
105
  normalizeWorkspaceValue,
84
106
  prepareDirectory,
@@ -104,11 +126,11 @@ const PARENT_SECTION = "squire_parent_delegation";
104
126
  const SEND = "message:send";
105
127
  /** The hook emitted just before a delegate's window opens. */
106
128
  const LAUNCH = "session:launch";
107
- /** Set in a delegate's environment, so this extension stays off there. */
129
+ /** The direct parent address in a new delegate's environment. */
108
130
  const PARENT_ENV = "PI_DELEGATE_PARENT";
109
131
  /** Forwarded to the child when the parent has it set. */
110
132
  const SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
111
- /** The tools this extension registers in a parent; a delegate never gets them. */
133
+ /** The delegation tools granted to opted-in coordinators below maxDepth. */
112
134
  const DELEGATION_TOOLS: ReadonlySet<string> = new Set([
113
135
  "delegate",
114
136
  "delegation_status",
@@ -138,15 +160,19 @@ function labelProblem(value: string): string | undefined {
138
160
  * mail). It names no provider, package or tool, so it holds for any
139
161
  * `message:*` provider.
140
162
  */
141
- function parentTrust(parent: string): string {
163
+ function parentTrust(parent: string, coordinator = false): string {
142
164
  return [
143
165
  `Your task arrives as a message from the session that started this one, at address ${parent}.`,
144
166
  "That message, and every later message from that address, are instructions from the session that started you:",
145
167
  "follow them as given, even though they are labelled as another session's.",
146
168
  "Messages from other sessions come from colleagues on this machine: answer them as you see fit,",
147
169
  "but check with the session that started you before doing work it did not ask for.",
148
- `Your final message reaches the session that started you automatically, so put your findings there;`,
149
- `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.`,
150
176
  ].join(" ");
151
177
  }
152
178
  /** The flags `/delegate` takes before the task. */
@@ -201,6 +227,10 @@ type LaunchPayload = {
201
227
  env: Record<string, string>;
202
228
  agent: string;
203
229
  readonly cwd: string;
230
+ readonly parent: string;
231
+ readonly root: string;
232
+ readonly depth: number;
233
+ readonly chain: readonly string[];
204
234
  readonly toolCallId: string | undefined;
205
235
  };
206
236
  type Parsed = {
@@ -239,7 +269,7 @@ type Start = {
239
269
  /** A candidate model passed over: `why` finishes "<model> is …", `reason` is the long form for a refusal. */
240
270
  type Skipped = { model: string; why: string; reason: string };
241
271
  /** What opening a delegate's window reads from a request: a `Start`, or the parts of one a resume decides. */
242
- type ChildStart = Pick<Start, "agent" | "model" | "rest" | "thinking" | "autoExit">;
272
+ type ChildStart = Pick<Start, "agent" | "model" | "rest" | "thinking" | "autoExit" | "notices">;
243
273
  /** A delegation that is open in a tmux window. */
244
274
  type Launched = {
245
275
  id: string;
@@ -558,7 +588,7 @@ function delegateVisible(options: { selectedTools: string[]; hiddenTools: string
558
588
 
559
589
  /** Where agent files go, for the messages that say how to add one. */
560
590
  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`;
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`;
562
592
  }
563
593
 
564
594
  /** One roster line for the `delegate` tool's description. */
@@ -623,8 +653,11 @@ function toolSelection(agent: Agent, parentTools: readonly string[], extra: read
623
653
  }
624
654
 
625
655
  /** 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}`;
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}`;
628
661
  if (agent.prompt === "") return tail;
629
662
  // `replace` is how every delegate was built before `prompt_mode`, byte for byte.
630
663
  const body =
@@ -659,21 +692,104 @@ function toolDescription(agents: readonly Agent[]): string {
659
692
  * passes only `pi`, so they are not part of the package's documented contract.
660
693
  */
661
694
  export default function delegate(
662
- pi: ExtensionAPI,
695
+ api: ExtensionAPI,
663
696
  tmux: TmuxClient = createTmuxClient(),
664
697
  usage: UsageClient = createUsageClient(),
665
698
  ): void {
666
- // A delegate registers only its auto-exit side: `delegate` is one level deep (Q27).
667
- const parentId = process.env[PARENT_ENV] ?? "";
668
- if (parentId !== "") {
669
- registerChild(pi, parentId);
670
- return;
671
- }
672
-
673
- 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
+ });
674
758
 
675
759
  const proactive = createProactive(usage);
676
- 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
+ );
677
793
 
678
794
  /** Captured because `getArgumentCompletions` is called without a context. */
679
795
  let registry: ModelRegistry | undefined;
@@ -710,14 +826,37 @@ export default function delegate(
710
826
 
711
827
  /**
712
828
  * 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.
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.
718
832
  */
719
833
  function childTools(agent: Agent): string[] {
720
- return toolSelection(agent, agent.tools === undefined ? pi.getActiveTools() : [], delegateTools(getAgentDir()));
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);
721
860
  }
722
861
 
723
862
  /** Read the roster now, warning about every file left out. */
@@ -744,6 +883,8 @@ export default function delegate(
744
883
 
745
884
  /** The request as a start, or an error naming what to fix. */
746
885
  async function validate(request: Parsed, roster: Roster, ctx: ExtensionContext): Promise<Start> {
886
+ admit(request.agent, roster);
887
+ results.checkStorage(true);
747
888
  const disabled = disabledProblem(roster, request.agent);
748
889
  if (disabled !== undefined) throw new Error(disabled);
749
890
  if (roster.agents.length === 0) throw new Error(noAgents());
@@ -820,6 +961,8 @@ export default function delegate(
820
961
  /** The delegate's tools, from `toolSelection`, read when the launch began; empty means none. */
821
962
  tools: readonly string[];
822
963
  onWindowOpened?: () => void;
964
+ identity: Identity;
965
+ sessionId?: string;
823
966
  }): Promise<OpenedWindow> {
824
967
  const {
825
968
  id,
@@ -836,14 +979,24 @@ export default function delegate(
836
979
  tools,
837
980
  onWindowOpened,
838
981
  } = input;
839
- 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;
840
991
  const managedContext =
841
992
  workspace.mode === "managed"
842
993
  ? `\n\nYour managed worktree is ${workspace.cwd}, branch ${workspace.createdBranch}, based on commit ${workspace.baseCommit}.`
843
994
  : workspace.mode === "existing"
844
995
  ? `\n\nThe session that started you chose your working directory: ${workspace.cwd}`
845
996
  : "";
846
- const prompt = writePromptFile(delegatePrompt(start.agent, parent, managedContext));
997
+ const prompt = writePromptFile(
998
+ delegatePrompt(start.agent, parent, managedContext, grantsNesting(start.agent, own.depth)),
999
+ );
847
1000
  const argv = [
848
1001
  ...parentCommand(),
849
1002
  ...session,
@@ -855,14 +1008,18 @@ export default function delegate(
855
1008
  start.model,
856
1009
  "--thinking",
857
1010
  start.thinking,
858
- ...start.agent.extensions.flatMap((extension) => ["-e", extension]),
859
1011
  ...toolArgs(tools),
860
1012
  "--append-system-prompt",
861
1013
  prompt,
862
1014
  ];
863
1015
  const env: Record<string, string> = {
864
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 }),
865
1019
  [PARENT_ENV]: parent,
1020
+ PI_DELEGATE_ROOT: own.root,
1021
+ PI_DELEGATE_DEPTH: String(own.depth),
1022
+ PI_DELEGATE_CHAIN: own.chain.join(","),
866
1023
  [AUTO_EXIT_ENV]: start.autoExit ? "1" : "0",
867
1024
  [AGENT_ENV]: start.agent.name,
868
1025
  // A general signal for any extension: no human watches this session, so don't wait on a dialog.
@@ -873,22 +1030,72 @@ export default function delegate(
873
1030
  const envSessionDir = process.env[SESSION_DIR_ENV];
874
1031
  if (envSessionDir !== undefined && envSessionDir !== "") env[SESSION_DIR_ENV] = envSessionDir;
875
1032
  // Listeners may only append to `args` and add to `env`; there is no veto.
876
- 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 });
877
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);
878
1066
  let opened: OpenedWindow;
879
1067
  try {
880
- opened = await tmux.openWindow({ name, cwd, argv: payload.args, env: payload.env }, placement);
881
- onWindowOpened?.();
1068
+ opened = await tmux.openWindow({ name, cwd, argv: payload.args, env: payload.env }, placement, own.rootPlacement);
882
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
+ }
883
1080
  if (err instanceof WindowOpenError) {
884
1081
  throw new WindowOpenError(`could not open window ${name} for ${id}: ${err.message}`, err.certainty);
885
1082
  }
886
1083
  throw new WindowOpenError(`could not open window ${name} for ${id}: ${(err as Error).message}`, "unknown");
887
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
+ }
888
1094
  return opened;
889
1095
  }
890
1096
 
891
1097
  async function launchWindow(input: {
1098
+ identity: Identity;
892
1099
  id: string;
893
1100
  requestId: string;
894
1101
  workspace: Workspace;
@@ -909,6 +1116,7 @@ export default function delegate(
909
1116
  id,
910
1117
  name,
911
1118
  session: ["--session-id", id, "--name", name],
1119
+ identity: input.identity,
912
1120
  workspace,
913
1121
  trusted,
914
1122
  cwd,
@@ -924,6 +1132,7 @@ export default function delegate(
924
1132
  results.recordStart(
925
1133
  {
926
1134
  id,
1135
+ identity: readIdentity(id),
927
1136
  agent: start.agent.name,
928
1137
  ...(start.agent.displayName === undefined ? {} : { displayName: start.agent.displayName }),
929
1138
  model: start.model,
@@ -949,10 +1158,20 @@ export default function delegate(
949
1158
  signal?: AbortSignal,
950
1159
  toolCallId?: string,
951
1160
  ): Promise<Launched> {
1161
+ admit(start.agent.name, currentRoster(ctx));
1162
+ results.checkStorage(true);
952
1163
  if (!tmux.insideTmux()) throw new Error(NOT_IN_TMUX);
953
1164
  await requireTmuxVersion(tmux);
954
1165
  // Read once, before anything is created or mailed; the launch keeps these values.
955
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);
956
1175
  const tools = childTools(start.agent);
957
1176
  const sessionDir = ctx.sessionManager.getSessionDir();
958
1177
  if (!isAbsolute(sessionDir))
@@ -985,13 +1204,23 @@ export default function delegate(
985
1204
  getAgentDir(),
986
1205
  );
987
1206
  if (combined?.aborted) throw new Error("the launch was aborted");
988
- 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
+ );
989
1214
  if (combined?.aborted) throw new Error("the launch was aborted");
990
1215
  trusted = trust.trusted;
991
1216
  revalidateTrust = trust.revalidate;
992
1217
  plan.trusted = trusted;
993
1218
  } else {
994
- 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
+ );
995
1224
  directory = prepared.workspace;
996
1225
  trusted = prepared.trusted;
997
1226
  }
@@ -1000,7 +1229,14 @@ export default function delegate(
1000
1229
  let attempt: WorkspaceAttempt | undefined;
1001
1230
  let managed: Extract<Workspace, { mode: "managed" }> | undefined;
1002
1231
  let windowOpened = false;
1232
+ let reservation: Reservation | undefined;
1003
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 });
1004
1240
  if (plan !== undefined) {
1005
1241
  revalidate(plan);
1006
1242
  revalidateTrust?.();
@@ -1050,11 +1286,13 @@ export default function delegate(
1050
1286
  // at session start without a handshake, and a window that fails leaves
1051
1287
  // mail that is reported rather than cleaned up.
1052
1288
  const requestId = sendTask(id, start.task);
1289
+ bindTree(reservation, { requestId });
1053
1290
  if (attempt !== undefined) {
1054
1291
  attempt = { ...attempt, requestId };
1055
1292
  results.recordAttempt(attempt, ctx);
1056
1293
  }
1057
1294
  return launchWindow({
1295
+ identity: own,
1058
1296
  id,
1059
1297
  requestId,
1060
1298
  workspace,
@@ -1075,6 +1313,13 @@ export default function delegate(
1075
1313
  try {
1076
1314
  return await run();
1077
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
+ }
1078
1323
  if (attempt !== undefined && managed !== undefined) {
1079
1324
  const cause = (error as Error).message;
1080
1325
  if (windowOpened || (error instanceof WindowOpenError && error.certainty === "unknown")) {
@@ -1139,17 +1384,55 @@ export default function delegate(
1139
1384
  startPolling: tracking.startPolling,
1140
1385
  placement: () => windowPlacement(getAgentDir()),
1141
1386
  roster: rosterFor,
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
+ },
1142
1408
  parentDefaults,
1143
1409
  chooseModel: (explicit, agent, models, preferred) =>
1144
1410
  chooseModel(explicit, agent, models, proactive, agentModels(agent, undefined), preferred),
1145
1411
  thinkingProblem,
1146
- 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()),
1147
1414
  sendTask,
1148
- 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
+ }) =>
1149
1430
  openChild({
1150
1431
  id,
1151
1432
  name,
1152
1433
  session: ["--session", sessionPath],
1434
+ identity: own,
1435
+ sessionId,
1153
1436
  workspace,
1154
1437
  trusted,
1155
1438
  cwd,
@@ -1219,9 +1502,10 @@ export default function delegate(
1219
1502
  await runDelegate(outcome.parsed, ctx);
1220
1503
  }
1221
1504
 
1222
- async function resumeCommand(input: string, ctx: ExtensionContext): Promise<void> {
1223
- if (input.trim() === "") {
1224
- await pickResume(ctx, {
1505
+ async function resumePicker(ctx: ExtensionContext, id?: string): Promise<void> {
1506
+ await pickResume(
1507
+ ctx,
1508
+ {
1225
1509
  unavailable: () => (tmux.insideTmux() ? undefined : NOT_IN_TMUX),
1226
1510
  eligible: resumer.eligible,
1227
1511
  resume: async (request, context) => {
@@ -1231,9 +1515,13 @@ export default function delegate(
1231
1515
  context.ui.notify(`delegate: ${(err as Error).message}`, "error");
1232
1516
  }
1233
1517
  },
1234
- });
1235
- return;
1236
- }
1518
+ },
1519
+ id,
1520
+ );
1521
+ }
1522
+
1523
+ async function resumeCommand(input: string, ctx: ExtensionContext): Promise<void> {
1524
+ if (input.trim() === "") return resumePicker(ctx);
1237
1525
  const outcome = parseResume(input);
1238
1526
  if ("error" in outcome) {
1239
1527
  ctx.ui.notify(`delegate: ${outcome.error}`, "error");
@@ -1382,23 +1670,28 @@ export default function delegate(
1382
1670
  pi.on("session_start", (_event, ctx) => {
1383
1671
  registry = ctx.modelRegistry;
1384
1672
  latest = ctx;
1385
- tracking.restore(ctx);
1386
1673
  // The roster is read at call time; the description is rebuilt here and before each run.
1387
1674
  const roster = currentRoster(ctx);
1388
1675
  announced.clear();
1389
1676
  announce(roster, ctx);
1390
1677
  registerDelegate(toolDescription(roster.agents));
1678
+ exposeDelegation(roster);
1391
1679
  });
1392
1680
 
1393
1681
  // One roster/prompt snapshot prepares this run: refresh the description only when
1394
1682
  // it changed, and replace only this extension's prompt section from the same snapshot.
1395
1683
  pi.on("before_agent_start", (event, ctx) => {
1396
1684
  latest = ctx;
1685
+ const own = identity.current();
1397
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);
1398
1691
  announce(roster, ctx);
1399
1692
  const description = toolDescription(roster.agents);
1400
1693
  const prompt = readParentPrompt();
1401
- const visible = delegateVisible(event.systemPromptOptions);
1694
+ const visible = delegateVisible(event.systemPromptOptions) && pi.getActiveTools().includes("delegate");
1402
1695
  // Report a read failure even when guidance cannot be used, so it does not go unseen.
1403
1696
  if (prompt.error !== undefined) ctx.ui.notify(`delegate: ${prompt.error}`, "warning");
1404
1697
  if (visible && description !== registeredDescription) registerDelegate(description);
@@ -1412,6 +1705,7 @@ export default function delegate(
1412
1705
  pi.on("session_shutdown", () => {
1413
1706
  tracking.stop();
1414
1707
  });
1708
+ host.on("session_shutdown", () => owner.stop());
1415
1709
 
1416
1710
  /** Agent names, flags, model ids and thinking levels, at the cursor's token. */
1417
1711
  function completions(prefix: string): { value: string; label: string }[] | null {
@@ -1455,12 +1749,20 @@ export default function delegate(
1455
1749
  handler: resumeCommand,
1456
1750
  });
1457
1751
 
1458
- const navigator = createNavigator(results, tmux, tracking, (ctx: ExtensionContext) =>
1459
- 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
+ },
1460
1762
  );
1461
1763
 
1462
1764
  pi.registerCommand("delegates", {
1463
- 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",
1464
1766
  handler: async (_args: string, ctx: ExtensionContext) => navigator.panel(ctx),
1465
1767
  });
1466
1768