pi-squire 0.15.0 → 0.17.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 (61) hide show
  1. package/CHANGELOG.md +343 -1
  2. package/README.md +931 -118
  3. package/package.json +2 -1
  4. package/src/agents.ts +73 -43
  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 +342 -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-bundles.ts +84 -0
  31. package/src/tool-names.ts +29 -0
  32. package/src/tracking.ts +72 -65
  33. package/src/tree-close.ts +236 -0
  34. package/src/tree-fence.ts +51 -0
  35. package/src/tree-lock.ts +77 -0
  36. package/src/tree-notice.ts +124 -0
  37. package/src/tree-registry.ts +365 -0
  38. package/src/tree-settings.ts +31 -0
  39. package/src/tree-types.ts +32 -0
  40. package/src/tree-view.ts +246 -0
  41. package/src/trust-control.ts +214 -0
  42. package/src/trust-prompt.ts +163 -0
  43. package/src/trust-wire.ts +203 -0
  44. package/src/widget-activity-join.ts +24 -0
  45. package/src/widget-activity-runtime.ts +104 -0
  46. package/src/widget-activity.ts +104 -0
  47. package/src/widget-ancestors.ts +142 -0
  48. package/src/widget-current.ts +41 -0
  49. package/src/widget-data.ts +147 -0
  50. package/src/widget-focus.ts +133 -0
  51. package/src/widget-live-process.ts +31 -0
  52. package/src/widget-live.ts +356 -0
  53. package/src/widget-render.ts +181 -0
  54. package/src/widget-selection.ts +58 -0
  55. package/src/widget-settings.ts +29 -0
  56. package/src/widget-summary.ts +51 -0
  57. package/src/widget-title-file.ts +127 -0
  58. package/src/widget-title.ts +118 -0
  59. package/src/widget.ts +272 -0
  60. package/src/window-placement.ts +30 -5
  61. 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 } from "./tool-names.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,56 @@ 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
+ const token: Reservation = { root: own.root, id };
1048
+ bindTree(token, { windowName: name, cwd, workspace });
1049
+ dispatchTree(token);
878
1050
  let opened: OpenedWindow;
879
1051
  try {
880
- opened = await tmux.openWindow({ name, cwd, argv: payload.args, env: payload.env }, placement);
881
- onWindowOpened?.();
1052
+ opened = await tmux.openWindow({ name, cwd, argv: payload.args, env: payload.env }, placement, own.rootPlacement);
882
1053
  } catch (err) {
1054
+ if (err instanceof WindowOpenError && err.certainty === "not-opened") {
1055
+ try {
1056
+ failedTree(token, true);
1057
+ } catch (accounting) {
1058
+ ctx.ui.notify(
1059
+ `delegate: could not refund the pre-start reservation: ${String(accounting)}; it remains charged.`,
1060
+ "warning",
1061
+ );
1062
+ }
1063
+ }
883
1064
  if (err instanceof WindowOpenError) {
884
1065
  throw new WindowOpenError(`could not open window ${name} for ${id}: ${err.message}`, err.certainty);
885
1066
  }
886
1067
  throw new WindowOpenError(`could not open window ${name} for ${id}: ${(err as Error).message}`, "unknown");
887
1068
  }
1069
+ onWindowOpened?.();
1070
+ try {
1071
+ openedTree(token, opened.id);
1072
+ } catch (error) {
1073
+ ctx.ui.notify(
1074
+ `delegate: window ${opened.id} opened, but shared accounting publication failed: ${String(error)}. Local tracking continues; the reservation remains charged until accounting reconciles.`,
1075
+ "warning",
1076
+ );
1077
+ }
888
1078
  return opened;
889
1079
  }
890
1080
 
891
1081
  async function launchWindow(input: {
1082
+ identity: Identity;
892
1083
  id: string;
893
1084
  requestId: string;
894
1085
  workspace: Workspace;
@@ -909,6 +1100,7 @@ export default function delegate(
909
1100
  id,
910
1101
  name,
911
1102
  session: ["--session-id", id, "--name", name],
1103
+ identity: input.identity,
912
1104
  workspace,
913
1105
  trusted,
914
1106
  cwd,
@@ -924,6 +1116,7 @@ export default function delegate(
924
1116
  results.recordStart(
925
1117
  {
926
1118
  id,
1119
+ identity: readIdentity(id),
927
1120
  agent: start.agent.name,
928
1121
  ...(start.agent.displayName === undefined ? {} : { displayName: start.agent.displayName }),
929
1122
  model: start.model,
@@ -949,10 +1142,20 @@ export default function delegate(
949
1142
  signal?: AbortSignal,
950
1143
  toolCallId?: string,
951
1144
  ): Promise<Launched> {
1145
+ admit(start.agent.name, currentRoster(ctx));
1146
+ results.checkStorage(true);
952
1147
  if (!tmux.insideTmux()) throw new Error(NOT_IN_TMUX);
953
1148
  await requireTmuxVersion(tmux);
954
1149
  // Read once, before anything is created or mailed; the launch keeps these values.
955
1150
  const placement = windowPlacement(getAgentDir());
1151
+ const parentIdentity = identity.current();
1152
+ const project = parentIdentity
1153
+ ? { companion: parentIdentity.rootPlacement, notices: [] }
1154
+ : placementProject(ctx.cwd);
1155
+ assertRootPlacement(project.companion);
1156
+ const own = identity.child(start.agent, ctx, project.companion);
1157
+ if (parentIdentity) assertParentPlacement(readTree(own.root), own);
1158
+ if (placement === "companion") start.notices.push(...project.notices);
956
1159
  const tools = childTools(start.agent);
957
1160
  const sessionDir = ctx.sessionManager.getSessionDir();
958
1161
  if (!isAbsolute(sessionDir))
@@ -985,13 +1188,23 @@ export default function delegate(
985
1188
  getAgentDir(),
986
1189
  );
987
1190
  if (combined?.aborted) throw new Error("the launch was aborted");
988
- const trust = await trustPlannedWorktree(plan, ctx, getAgentDir(), combined);
1191
+ const trust = await trustPlannedWorktree(
1192
+ plan,
1193
+ ctx,
1194
+ getAgentDir(),
1195
+ combined,
1196
+ trustControl.forAgent(start.agent.name, ctx),
1197
+ );
989
1198
  if (combined?.aborted) throw new Error("the launch was aborted");
990
1199
  trusted = trust.trusted;
991
1200
  revalidateTrust = trust.revalidate;
992
1201
  plan.trusted = trusted;
993
1202
  } else {
994
- const prepared = await prepareDirectory({ cwd: cwdOption, signal }, ctx, getAgentDir());
1203
+ const prepared = await prepareDirectory(
1204
+ { cwd: cwdOption, signal, trust: trustControl.forAgent(start.agent.name, ctx) },
1205
+ ctx,
1206
+ getAgentDir(),
1207
+ );
995
1208
  directory = prepared.workspace;
996
1209
  trusted = prepared.trusted;
997
1210
  }
@@ -1000,7 +1213,14 @@ export default function delegate(
1000
1213
  let attempt: WorkspaceAttempt | undefined;
1001
1214
  let managed: Extract<Workspace, { mode: "managed" }> | undefined;
1002
1215
  let windowOpened = false;
1216
+ let reservation: Reservation | undefined;
1003
1217
  const run = async (): Promise<Launched> => {
1218
+ admit(start.agent.name, currentRoster(ctx));
1219
+ results.checkStorage(true);
1220
+ await reconcileTree(own.root, tmux);
1221
+ admit(start.agent.name, currentRoster(ctx));
1222
+ if (combined?.aborted) throw new Error("the launch was aborted");
1223
+ reservation = reserveTree({ id, sessionId: id, identity: own, sessionDir });
1004
1224
  if (plan !== undefined) {
1005
1225
  revalidate(plan);
1006
1226
  revalidateTrust?.();
@@ -1050,11 +1270,13 @@ export default function delegate(
1050
1270
  // at session start without a handshake, and a window that fails leaves
1051
1271
  // mail that is reported rather than cleaned up.
1052
1272
  const requestId = sendTask(id, start.task);
1273
+ bindTree(reservation, { requestId });
1053
1274
  if (attempt !== undefined) {
1054
1275
  attempt = { ...attempt, requestId };
1055
1276
  results.recordAttempt(attempt, ctx);
1056
1277
  }
1057
1278
  return launchWindow({
1279
+ identity: own,
1058
1280
  id,
1059
1281
  requestId,
1060
1282
  workspace,
@@ -1075,6 +1297,13 @@ export default function delegate(
1075
1297
  try {
1076
1298
  return await run();
1077
1299
  } catch (error) {
1300
+ if (reservation) {
1301
+ try {
1302
+ failedTree(reservation);
1303
+ } catch (accounting) {
1304
+ ctx.ui.notify(`delegate: reservation remains charged: ${String(accounting)}`, "error");
1305
+ }
1306
+ }
1078
1307
  if (attempt !== undefined && managed !== undefined) {
1079
1308
  const cause = (error as Error).message;
1080
1309
  if (windowOpened || (error instanceof WindowOpenError && error.certainty === "unknown")) {
@@ -1139,17 +1368,55 @@ export default function delegate(
1139
1368
  startPolling: tracking.startPolling,
1140
1369
  placement: () => windowPlacement(getAgentDir()),
1141
1370
  roster: rosterFor,
1371
+ admit: (agent, ctx) => admit(agent, currentRoster(ctx)),
1372
+ authorize: (id, ctx) => {
1373
+ owner.assert();
1374
+ results.checkStorage();
1375
+ const record = results.find(id) ?? viewResults.find(id) ?? historicalRecord(id, ctx);
1376
+ if (record && isStarted(record)) assertResumeAuthority(record, ctx);
1377
+ },
1378
+ reserve: async (record, id, ctx) => {
1379
+ const own = record.identity;
1380
+ if (!own) throw new Error(`delegation ${record.id} has no original root placement identity; resume refused`);
1381
+ assertRootPlacement(own.rootPlacement);
1382
+ await reconcileTree(own.root, tmux);
1383
+ admit(record.agent, currentRoster(ctx));
1384
+ return reserveTree({
1385
+ id,
1386
+ sessionId: sessionIdOf(record),
1387
+ identity: own,
1388
+ resumedFrom: record.id,
1389
+ sessionDir: record.sessionDir,
1390
+ });
1391
+ },
1142
1392
  parentDefaults,
1143
1393
  chooseModel: (explicit, agent, models, preferred) =>
1144
1394
  chooseModel(explicit, agent, models, proactive, agentModels(agent, undefined), preferred),
1145
1395
  thinkingProblem,
1146
- prepareDirectory: (cwd, ctx, signal) => prepareDirectory({ cwd, signal }, ctx, getAgentDir()),
1396
+ prepareDirectory: (cwd, ctx, signal, agent) =>
1397
+ prepareDirectory({ cwd, signal, trust: trustControl.forAgent(agent, ctx) }, ctx, getAgentDir()),
1147
1398
  sendTask,
1148
- open: ({ id, name, sessionPath, workspace, trusted, cwd, sessionDir, start, toolCallId, ctx, placement }) =>
1399
+ open: ({
1400
+ id,
1401
+ name,
1402
+ sessionPath,
1403
+ workspace,
1404
+ trusted,
1405
+ cwd,
1406
+ sessionDir,
1407
+ start,
1408
+ toolCallId,
1409
+ ctx,
1410
+ placement,
1411
+ identity: own,
1412
+ sessionId,
1413
+ }) =>
1149
1414
  openChild({
1150
1415
  id,
1151
1416
  name,
1152
1417
  session: ["--session", sessionPath],
1418
+ identity: own,
1419
+ sessionId,
1153
1420
  workspace,
1154
1421
  trusted,
1155
1422
  cwd,
@@ -1219,9 +1486,10 @@ export default function delegate(
1219
1486
  await runDelegate(outcome.parsed, ctx);
1220
1487
  }
1221
1488
 
1222
- async function resumeCommand(input: string, ctx: ExtensionContext): Promise<void> {
1223
- if (input.trim() === "") {
1224
- await pickResume(ctx, {
1489
+ async function resumePicker(ctx: ExtensionContext, id?: string): Promise<void> {
1490
+ await pickResume(
1491
+ ctx,
1492
+ {
1225
1493
  unavailable: () => (tmux.insideTmux() ? undefined : NOT_IN_TMUX),
1226
1494
  eligible: resumer.eligible,
1227
1495
  resume: async (request, context) => {
@@ -1231,9 +1499,13 @@ export default function delegate(
1231
1499
  context.ui.notify(`delegate: ${(err as Error).message}`, "error");
1232
1500
  }
1233
1501
  },
1234
- });
1235
- return;
1236
- }
1502
+ },
1503
+ id,
1504
+ );
1505
+ }
1506
+
1507
+ async function resumeCommand(input: string, ctx: ExtensionContext): Promise<void> {
1508
+ if (input.trim() === "") return resumePicker(ctx);
1237
1509
  const outcome = parseResume(input);
1238
1510
  if ("error" in outcome) {
1239
1511
  ctx.ui.notify(`delegate: ${outcome.error}`, "error");
@@ -1382,23 +1654,28 @@ export default function delegate(
1382
1654
  pi.on("session_start", (_event, ctx) => {
1383
1655
  registry = ctx.modelRegistry;
1384
1656
  latest = ctx;
1385
- tracking.restore(ctx);
1386
1657
  // The roster is read at call time; the description is rebuilt here and before each run.
1387
1658
  const roster = currentRoster(ctx);
1388
1659
  announced.clear();
1389
1660
  announce(roster, ctx);
1390
1661
  registerDelegate(toolDescription(roster.agents));
1662
+ exposeDelegation(roster);
1391
1663
  });
1392
1664
 
1393
1665
  // One roster/prompt snapshot prepares this run: refresh the description only when
1394
1666
  // it changed, and replace only this extension's prompt section from the same snapshot.
1395
1667
  pi.on("before_agent_start", (event, ctx) => {
1396
1668
  latest = ctx;
1669
+ const own = identity.current();
1397
1670
  const roster = currentRoster(ctx);
1671
+ if (own)
1672
+ event.systemPromptOptions.sections.squire_delegate_task = `You are a delegate. ${parentTrust(own.parent, coordinates(roster))}`;
1673
+ else delete event.systemPromptOptions.sections.squire_delegate_task;
1674
+ exposeDelegation(roster);
1398
1675
  announce(roster, ctx);
1399
1676
  const description = toolDescription(roster.agents);
1400
1677
  const prompt = readParentPrompt();
1401
- const visible = delegateVisible(event.systemPromptOptions);
1678
+ const visible = delegateVisible(event.systemPromptOptions) && pi.getActiveTools().includes("delegate");
1402
1679
  // Report a read failure even when guidance cannot be used, so it does not go unseen.
1403
1680
  if (prompt.error !== undefined) ctx.ui.notify(`delegate: ${prompt.error}`, "warning");
1404
1681
  if (visible && description !== registeredDescription) registerDelegate(description);
@@ -1412,6 +1689,7 @@ export default function delegate(
1412
1689
  pi.on("session_shutdown", () => {
1413
1690
  tracking.stop();
1414
1691
  });
1692
+ host.on("session_shutdown", () => owner.stop());
1415
1693
 
1416
1694
  /** Agent names, flags, model ids and thinking levels, at the cursor's token. */
1417
1695
  function completions(prefix: string): { value: string; label: string }[] | null {
@@ -1455,12 +1733,20 @@ export default function delegate(
1455
1733
  handler: resumeCommand,
1456
1734
  });
1457
1735
 
1458
- const navigator = createNavigator(results, tmux, tracking, (ctx: ExtensionContext) =>
1459
- startNewDelegate(ctx, newDelegateDeps),
1736
+ const navigator = createNavigator(
1737
+ viewResults,
1738
+ tmux,
1739
+ tracking,
1740
+ (ctx: ExtensionContext) => startNewDelegate(ctx, newDelegateDeps),
1741
+ {
1742
+ assertActive: () => owner.assert(),
1743
+ close: (id, ctx) => tracking.close(id, ctx),
1744
+ resume: (id, ctx) => resumePicker(ctx, id),
1745
+ },
1460
1746
  );
1461
1747
 
1462
1748
  pi.registerCommand("delegates", {
1463
- description: "Open the panel of delegates whose window is open: switch, peek, close",
1749
+ description: "Open the shared delegate dashboard: details, switch, peek, close, resume",
1464
1750
  handler: async (_args: string, ctx: ExtensionContext) => navigator.panel(ctx),
1465
1751
  });
1466
1752