pi-squire 0.18.0 → 0.18.2

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/CHANGELOG.md CHANGED
@@ -6,6 +6,47 @@ follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.18.2] - 2026-11-01
10
+
11
+ ### Fixed
12
+
13
+ - Agents without a `tools:` field inherit available deferred and codemode tools
14
+ from root sessions, just as they already did from nested delegates. Inactive
15
+ direct tools and hidden tools remain excluded. Pi initially declares the
16
+ selected tools in the child; extensions that enable optional tools on demand
17
+ must deselect them during child startup. This can increase initial declarations
18
+ for root-launched delegates whose extensions do not deselect those tools.
19
+ - Creating a companion session no longer saves the first delegate's environment,
20
+ including `PI_UNATTENDED` and API keys, into the session for later windows to
21
+ inherit. Each delegate still receives its own launch environment. Existing
22
+ polluted sessions are not changed. The fresh-companion argument test now pins
23
+ window-local environment delivery instead of session-level `-e` options.
24
+ Launches still pass only Squire's environment and `session:launch` additions,
25
+ never a copy of the parent's full process environment.
26
+ - tmux failures report a bounded subcommand error instead of raw process errors
27
+ or stderr that could expose launch arguments or environment values. The two
28
+ failed ownership-recheck tests now expect the bounded error wording.
29
+ - The discovery-count regression uses a controlled clock so additional parallel
30
+ test load does not turn its structural count assertion into a timing failure.
31
+ The separate real-time discovery deadline test and 200 ms product bound are
32
+ unchanged.
33
+
34
+ ## [0.18.1] - 2026-11-01
35
+
36
+ ### Changed
37
+
38
+ - The delegation tools put about 70% less text in a model's context: in a
39
+ codemode root their declarations drop from about 5,100 estimated tokens to
40
+ about 1,400. The descriptions and parameter notes are shorter, and the output
41
+ schemas carry no field descriptions. `workspace`, `workspaceSnapshot`, and a
42
+ workspace attempt's `plan` and `attempt` are declared by their discriminant
43
+ and directory only, with their other fields left open; the structured data
44
+ itself is unchanged. Tests that pinned the old wording are updated.
45
+ - A delegate's appended prompt is its agent body and working-directory line
46
+ only. The paragraph naming the parent's address and the final-message rule
47
+ are in the per-run `squire_delegate_task` section alone, where they were
48
+ already repeated.
49
+
9
50
  ## [0.18.0] - 2026-11-01
10
51
 
11
52
  ### Changed
@@ -1008,7 +1049,9 @@ author's dotfiles; the changes below are against that copy.
1008
1049
 
1009
1050
  - The published file list includes `child.ts`, which `index.ts` imports.
1010
1051
 
1011
- [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.18.0...HEAD
1052
+ [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.18.2...HEAD
1053
+ [0.18.2]: https://github.com/gvanderclay/pi-squire/compare/v0.18.1...v0.18.2
1054
+ [0.18.1]: https://github.com/gvanderclay/pi-squire/compare/v0.18.0...v0.18.1
1012
1055
  [0.18.0]: https://github.com/gvanderclay/pi-squire/compare/v0.17.0...v0.18.0
1013
1056
  [0.17.0]: https://github.com/gvanderclay/pi-squire/compare/v0.16.0...v0.17.0
1014
1057
  [0.16.0]: https://github.com/gvanderclay/pi-squire/compare/v0.15.0...v0.16.0
package/README.md CHANGED
@@ -992,11 +992,14 @@ are not checked against the parent's tools, at top level or nested: normal Pi
992
992
  extension discovery or `session:launch` customization can provide tools in the
993
993
  child.
994
994
 
995
- - No `tools` field: the delegate gets the parent session's tools as they are
996
- when the launch (or resume) starts: active tools for a top-level launch, or
997
- available tools including codemode/deferred operations for a nested one, passed as
998
- `--tools`, plus the `delegateTools` entries. A parent limited to `read`
999
- gives such a delegate only `read`.
995
+ - No `tools` field: the delegate gets the parent session's available tools
996
+ when the launch (or resume) starts, at any depth: active tools plus registered
997
+ codemode/deferred operations, excluding hidden tools and inactive direct tools,
998
+ passed as `--tools`, plus the `delegateTools` entries. Pi initially declares
999
+ these selected tools in the child, even when their exposure is codemode or
1000
+ deferred. An extension can deselect optional tools during child startup and
1001
+ enable them later. A parent with only `read` available gives such a delegate
1002
+ only `read`.
1000
1003
  - An empty `tools` field, `tools:` or `tools: []`: the delegate gets no tools
1001
1004
  except the `delegateTools` entries. With none of those either, it starts
1002
1005
  with `--no-tools`.
@@ -1063,14 +1066,14 @@ limit, it resolves each entry the way Pi resolves `--model`: an exact
1063
1066
  order the entries are listed. No entry may be a `*` pattern: a `model` or
1064
1067
  `fallback` entry with `*` makes the file invalid, and the agent is disabled.
1065
1068
 
1066
- The body is followed by one fixed final-message line and a paragraph naming the
1067
- parent session's address. The delegate's task arrives as a message from that
1069
+ Each run of the delegate also gets a system-prompt section naming the parent
1070
+ session's address. The delegate's task arrives as a message from that
1068
1071
  session, and that message and every later one from the same address are
1069
1072
  instructions to follow as given. Other sessions are colleagues: the delegate
1070
1073
  may answer them, but checks with the parent before doing work the parent did
1071
- not ask for. The paragraph names no provider, package or tool, so it holds
1072
- whatever `message:*` provider delivers the task. `prompt_mode: replace` builds
1073
- the prompt exactly as every delegate was built before `prompt_mode` existed.
1074
+ not ask for. Its final message reaches the parent on its own. The section names
1075
+ no provider, package or tool, so it holds whatever `message:*` provider
1076
+ delivers the task. `prompt_mode: replace` appends the body as written.
1074
1077
 
1075
1078
  An agent file that cannot be used is invalid, and its agent is disabled:
1076
1079
  frontmatter that is not readable YAML or not a set of fields, an `enabled` or
@@ -1692,6 +1695,11 @@ An empty final set also receives `--no-tools` and an empty allowlist so earlier
1692
1695
  selection flags cannot restore tools. Do all work synchronously: the emitter
1693
1696
  reads the payload the moment `emit` returns.
1694
1697
 
1698
+ Environment additions are not a secret transport. They can be visible in tmux
1699
+ command arguments and a fresh companion's first pane start command to anyone
1700
+ with access to its socket. pi-squire never copies the parent's full process
1701
+ environment into these arguments.
1702
+
1695
1703
  `cwd`, the trust flag (`--approve` or `--no-approve`), `--session-dir` and the
1696
1704
  session choice (`--session-id` for a new delegate, `--session` for a [resumed](#resuming-a-delegation)
1697
1705
  one) are launch invariants that `pi-squire` chooses. The window opens in its own `cwd`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squire",
3
- "version": "0.18.0",
3
+ "version": "0.18.2",
4
4
  "description": "Starts a delegate Pi session in a background tmux window, sends it the task over message:*, and shows the parent its result",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/git.ts CHANGED
@@ -6,8 +6,6 @@ import { spawnSync } from "node:child_process";
6
6
  import { realpathSync } from "node:fs";
7
7
  import { basename, dirname, resolve } from "node:path";
8
8
 
9
- import { Type } from "typebox";
10
-
11
9
  /** A bounded list of file names, as data; never shell text. */
12
10
  export type PathSummary = { paths: string[]; total: number; truncated: boolean };
13
11
  /** A checkout's repository, as canonical absolute paths. */
@@ -19,13 +17,6 @@ export type GitLaunch =
19
17
  | { kind: "non-git" }
20
18
  | { kind: "unknown"; reason: string };
21
19
 
22
- /** `PathSummary` as a schema. */
23
- export const PathSummarySchema = Type.Object({
24
- paths: Type.Array(Type.String(), { description: "The first paths, whole; never more than 100 or 16 KiB." }),
25
- total: Type.Number({ description: "Every path observed in this category." }),
26
- truncated: Type.Boolean(),
27
- });
28
-
29
20
  /** How many paths, and how many bytes of them, a summary lists. */
30
21
  const LIST_PATHS = 100;
31
22
  const LIST_BYTES = 16 * 1024;
package/src/index.ts CHANGED
@@ -138,8 +138,6 @@ const DELEGATION_TOOLS: ReadonlySet<string> = new Set([
138
138
  "delegation_resume",
139
139
  ]);
140
140
  /** Asked of every delegate, so the parent gets an answer it can use alone. */
141
- const FINAL_LINE =
142
- "End with one self-contained final message: the parent session sees only that message, never this conversation.";
143
141
 
144
142
  /** The longest label a delegation can carry. */
145
143
  const LABEL_MAX = 32;
@@ -162,17 +160,11 @@ function labelProblem(value: string): string | undefined {
162
160
  */
163
161
  function parentTrust(parent: string, coordinator = false): string {
164
162
  return [
165
- `Your task arrives as a message from the session that started this one, at address ${parent}.`,
166
- "That message, and every later message from that address, are instructions from the session that started you:",
167
- "follow them as given, even though they are labelled as another session's.",
168
- "Messages from other sessions come from colleagues on this machine: answer them as you see fit,",
169
- "but check with the session that started you before doing work it did not ask for.",
163
+ `Your task arrives as a message from the session that started you, at address ${parent}; follow it and every later message from that address as instructions, though they are labelled as another session's.`,
164
+ "Messages from other sessions come from colleagues: answer as you see fit, but check with the session that started you before doing work it did not ask for.",
170
165
  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.`,
166
+ ? `Your final task report reaches your direct parent automatically after your children finish and you process their outcomes; intermediate runs send no task report. Message ${parent} before your final task report only with a question you cannot continue without.`
167
+ : `Your final message reaches the session that started you automatically and is all it sees, so make it self-contained. Message ${parent} before then only with a question you cannot continue without.`,
176
168
  ].join(" ");
177
169
  }
178
170
  /** The flags `/delegate` takes before the task. */
@@ -191,22 +183,18 @@ const FLAGS = [
191
183
  /** The `delegate` tool's structured result, which is also its details. */
192
184
  export const DelegateSchema = Type.Object({
193
185
  outcome: Type.Literal("started"),
194
- id: Type.String({ description: "The delegation id that delegation_status and delegation_close take." }),
186
+ id: Type.String(),
195
187
  agent: Type.String(),
196
- model: Type.String({ description: "The provider/id it started on, after any fallback." }),
197
- skipped: Type.Array(Type.Object({ model: Type.String(), reason: Type.String() }), {
198
- description: "Earlier models in the fallback list that were passed over, and why.",
199
- }),
188
+ model: Type.String(),
189
+ skipped: Type.Array(Type.Object({ model: Type.String(), reason: Type.String() })),
200
190
  notices: Type.Array(Type.String()),
201
191
  thinking: Type.String(),
202
192
  autoExit: Type.Boolean(),
203
193
  windowId: Type.String(),
204
194
  windowName: Type.String(),
205
- windowSession: Type.Optional(
206
- Type.String({ description: "The tmux session the window opened in, as tmux named it then." }),
207
- ),
208
- cwd: Type.String({ description: "The directory the delegate's window opened in." }),
209
- sessionDir: Type.String({ description: "The session-storage directory the delegate was told to use." }),
195
+ windowSession: Type.Optional(Type.String()),
196
+ cwd: Type.String(),
197
+ sessionDir: Type.String(),
210
198
  workspace: WorkspaceSchema,
211
199
  });
212
200
 
@@ -652,23 +640,19 @@ function toolSelection(agent: Agent, parentTools: readonly string[], extra: read
652
640
  return [...new Set([...base, ...extra])].filter((name) => !DELEGATION_TOOLS.has(name));
653
641
  }
654
642
 
655
- /** The delegate's appended prompt: the agent's body, then the lines every delegate gets. */
656
- function delegatePrompt(agent: Agent, parent: string, managedContext: string, coordinator: boolean): string {
657
- const final = coordinator
658
- ? "End the task with one self-contained final report to your direct parent after your child tasks finish and you process their outcomes."
659
- : FINAL_LINE;
660
- const tail = `${final}\n\n${parentTrust(parent, coordinator)}${managedContext}`;
661
- if (agent.prompt === "") return tail;
643
+ /** The delegate's appended prompt: the agent's body and where it works; the run-time section carries the parent's address. */
644
+ function delegatePrompt(agent: Agent, managedContext: string): string {
645
+ if (agent.prompt === "") return managedContext.trimStart();
662
646
  // `replace` is how every delegate was built before `prompt_mode`, byte for byte.
663
647
  const body =
664
648
  agent.promptMode === "replace" ? agent.prompt : `<agent_instructions>\n${agent.prompt}\n</agent_instructions>`;
665
- return `${body}\n\n${tail}`;
649
+ return `${body}${managedContext}`;
666
650
  }
667
651
 
668
652
  /** The `delegate` tool's description: what delegation does, and the roster as it stands. */
669
653
  function toolDescription(agents: readonly Agent[]): string {
670
654
  const intro =
671
- "Hand a self-contained task to a delegate Pi session in a background tmux window, so it works while you do not. Each call starts the delegation at once; the delegate runs in this session's working directory, or the cwd given, with this session's agent root, and reports back later as one message. While it runs, keep working or end your turn: its result starts your next turn on its own. Do not poll delegation_status or send a blocking mail request merely to wait for the result; send a substantive follow-up only when you have something to add or ask. Use it when the work is self-contained and you would rather keep your own context for it.";
655
+ "Start a delegate Pi session on a self-contained task in a background tmux window. Its result arrives later as a message that starts your next turn, so keep working or end your turn; do not poll delegation_status or mail it just to wait.";
672
656
  if (agents.length === 0) {
673
657
  return `${intro}\n\nNo agents are defined yet. Add ${whereAgentsGo()}.`;
674
658
  }
@@ -678,11 +662,7 @@ function toolDescription(agents: readonly Agent[]): string {
678
662
  "Agents:",
679
663
  ...agents.map(rosterLine),
680
664
  "",
681
- "`cwd` runs the delegate in another existing directory. Alternatively, `worktree` creates a new local branch and checkout from this repository's committed HEAD, or `worktree_source` selects another existing Git checkout; `worktree_base` selects a commit in that source. Source dirt is reported but not copied. `cwd` and `worktree` are mutually exclusive, and source/base require worktree.",
682
- "`model` and `thinking` override the agent's defaults for this call, except a field the agent locks; an unknown value is refused with close matches. `label` names the delegate's window and session; give one that says what the task is. `delegation_status` reports a delegation, and `delegation_close` ends one. `delegation_resume` continues an exited delegate in its own session with a follow-up task, for work that needs the context it already built.",
683
- "`auto_exit` (default the agent's, normally true) closes the delegate's window once it finishes. Set it false when you mean to keep talking to the delegate by mail after its result; the user can also keep a window open by typing in it.",
684
- "",
685
- "Writing the task: the delegate sees nothing of this session, so the task must stand alone. State the question, the decision its answer feeds, what is already known or ruled out, and the files or paths to start from. When the work produces a report, name the path to write it to and ask for the conclusion back, not the contents.",
665
+ "The delegate sees only the task and its agent prompt. State the question, the decision it feeds, what is known or ruled out, and where to start. For a long report, name a file to write and ask for the conclusion back.",
686
666
  ].join("\n");
687
667
  }
688
668
 
@@ -827,13 +807,13 @@ export default function delegate(
827
807
  /**
828
808
  * The delegate's tools for a launch or resume starting now: the agent's
829
809
  * named tools, or the parent's current tools (including indirect callable
830
- * tools when nested, less any `-name` entries), then `delegateTools`.
810
+ * tools, less any `-name` entries), then `delegateTools`.
831
811
  * Throws, naming the file, on an invalid setting.
832
812
  */
833
813
  function childTools(agent: Agent): string[] {
834
814
  const selected = toolSelection(
835
815
  agent,
836
- agent.tools === undefined ? (identity.current() ? availableTools(pi) : pi.getActiveTools()) : [],
816
+ agent.tools === undefined ? availableTools(pi) : [],
837
817
  delegateTools(getAgentDir()),
838
818
  );
839
819
  if (grantsNesting(agent, (identity.current()?.depth ?? 0) + 1)) selected.push(...DELEGATION_TOOLS);
@@ -994,9 +974,7 @@ export default function delegate(
994
974
  : workspace.mode === "existing"
995
975
  ? `\n\nThe session that started you chose your working directory: ${workspace.cwd}`
996
976
  : "";
997
- const prompt = writePromptFile(
998
- delegatePrompt(start.agent, parent, managedContext, grantsNesting(start.agent, own.depth)),
999
- );
977
+ const prompt = writePromptFile(delegatePrompt(start.agent, managedContext));
1000
978
  const argv = [
1001
979
  ...parentCommand(),
1002
980
  ...session,
@@ -1526,53 +1504,32 @@ export default function delegate(
1526
1504
  label: "Delegate",
1527
1505
  description,
1528
1506
  parameters: Type.Object({
1529
- agent: Type.String({ description: "Roster agent name to run." }),
1530
- task: Type.String({
1531
- description: "The task for the delegate. It sees this and its agent prompt, nothing else of this session.",
1532
- }),
1533
- model: Type.Optional(
1534
- Type.String({
1535
- description:
1536
- "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.",
1537
- }),
1538
- ),
1539
- thinking: Type.Optional(
1540
- Type.String({
1541
- 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.`,
1542
- }),
1543
- ),
1507
+ agent: Type.String({ description: "Roster agent name." }),
1508
+ task: Type.String(),
1509
+ model: Type.Optional(Type.String({ description: "provider/id; defaults to the agent's." })),
1510
+ thinking: Type.Optional(Type.String({ description: `${THINKING_LEVELS.join(", ")}; defaults to the agent's.` })),
1544
1511
  label: Type.Optional(
1545
1512
  Type.String({
1546
- description: `A short name for this delegation (letters, digits, _ or -, at most ${LABEL_MAX}), such as roster-research. Its session and tmux window are named <agent>-<label>; without it, <agent>-<first 8 id characters>.`,
1513
+ description: `Window and session name suffix: letters, digits, _ or -, at most ${LABEL_MAX}.`,
1547
1514
  }),
1548
1515
  ),
1549
1516
  cwd: Type.Optional(
1550
- Type.Union([Type.String(), Type.Null()], {
1551
- description:
1552
- "An existing directory to run the delegate in: absolute, relative to this session's working directory, or starting with ~/. Omit to run in this session's working directory. The directory is used as it is; nothing is created or cleaned up. Mutually exclusive with worktree.",
1553
- }),
1517
+ Type.Union([Type.String(), Type.Null()], { description: "Existing directory to run in. Not with worktree." }),
1554
1518
  ),
1555
1519
  worktree: Type.Optional(
1556
1520
  Type.Union([Type.String(), Type.Null()], {
1557
- description:
1558
- "Create a new managed worktree and local branch with this literal branch name, from this repository's committed HEAD or explicit worktree_base. Source dirt is not copied. Mutually exclusive with cwd.",
1521
+ description: "New branch name: creates a managed worktree from HEAD or worktree_base. Not with cwd.",
1559
1522
  }),
1560
1523
  ),
1561
1524
  worktree_source: Type.Optional(
1562
- Type.Union([Type.String(), Type.Null()], {
1563
- description:
1564
- "Existing Git checkout to create from; relative to this session or starting with ~/. Requires worktree.",
1565
- }),
1525
+ Type.Union([Type.String(), Type.Null()], { description: "Git checkout to create the worktree from." }),
1566
1526
  ),
1567
1527
  worktree_base: Type.Optional(
1568
- Type.Union([Type.String(), Type.Null()], {
1569
- description: "Local commit-ish to create the branch from; requires worktree. No fetch is performed.",
1570
- }),
1528
+ Type.Union([Type.String(), Type.Null()], { description: "Local commit-ish for the worktree branch." }),
1571
1529
  ),
1572
1530
  auto_exit: Type.Optional(
1573
1531
  Type.Boolean({
1574
- description:
1575
- "Close the delegate's window once it finishes; defaults to the agent's setting. False keeps it open to talk to it by mail after its result.",
1532
+ description: "Close the window when it finishes; defaults to the agent's. False keeps it open for mail.",
1576
1533
  }),
1577
1534
  ),
1578
1535
  }),
package/src/render.ts CHANGED
@@ -281,7 +281,7 @@ export function renderDelegateResult(
281
281
  const text = result.content.map((part) => part.text ?? "").join("\n");
282
282
  return new Text(context.isError ? theme.fg("error", text) : text, 0, 0);
283
283
  }
284
- const started = details as Static<typeof DelegateSchema>;
284
+ const started = details as Static<typeof DelegateSchema> & { workspace: Workspace };
285
285
  const lines = [
286
286
  `${theme.fg("accent", "●")} ${theme.fg("toolTitle", theme.bold(started.windowName))}${theme.fg(
287
287
  "dim",
package/src/resume.ts CHANGED
@@ -171,7 +171,7 @@ async function ensureExited(record: StartedRecorded, tmux: TmuxClient): Promise<
171
171
  function resultSchema(delegateSchema: TObject): TObject {
172
172
  return Type.Object({
173
173
  ...delegateSchema.properties,
174
- resumedFrom: Type.String({ description: "The delegation whose session this one reopened." }),
174
+ resumedFrom: Type.String(),
175
175
  });
176
176
  }
177
177
 
@@ -391,26 +391,15 @@ function resumeTool(deps: ResumeDeps): ToolDef {
391
391
  name: "delegation_resume",
392
392
  label: "Resume delegation",
393
393
  description:
394
- "Only this session’s direct children may be resumed; viewing a descendant does not grant authority, and fork/clone copies must return to the original parent. Current depth, grants, caps, tools and trust are checked again. Continue an exited delegate with a follow-up task: reopen its own session, so it keeps the context it built, in a new window. The follow-up is recorded as a new delegation, and its result arrives as a message on its own, like a delegate's. Use it when the next task needs what the delegate already read or decided; for unrelated work, use delegate. It refuses a delegation that is still running (send it a message instead), one whose window is still open, an older delegation of a chain that was already resumed (it names the newest), and one whose working directory is gone, a removed worktree included. A delegate whose window was killed mid-turn can be resumed.",
394
+ "Continue an exited direct child in its own session, so it keeps the context it built, with a follow-up task in a new window. It is recorded as a new delegation whose result arrives as a message. For unrelated work, use delegate. Refuses a running delegation (mail it instead), an open window, an older link of a resumed chain (it names the newest) and a missing working directory.",
395
395
  parameters: Type.Object({
396
- id: Type.String({ description: "The delegation to resume, from a delegate result or delegation_status." }),
397
- task: Type.String({
398
- description: "The follow-up task. The delegate sees its earlier conversation plus this.",
399
- }),
400
- model: Type.Optional(
401
- Type.String({
402
- description:
403
- "provider/id to resume on; used as given. Omit to continue on the model the delegate finished on, or its agent's next fallback when that one is unavailable. Not used when the agent locks model: the agent's current model applies.",
404
- }),
405
- ),
396
+ id: Type.String({ description: "The newest delegation id of the child." }),
397
+ task: Type.String(),
398
+ model: Type.Optional(Type.String({ description: "provider/id; defaults to the model it finished on." })),
406
399
  thinking: Type.Optional(
407
- Type.String({
408
- description: `Thinking level (${THINKING_LEVELS.join(", ")}); defaults to the level the delegate last ran on, from its session. Not used when the agent locks thinking: the agent's current level applies.`,
409
- }),
410
- ),
411
- auto_exit: Type.Optional(
412
- Type.Boolean({ description: "Close the window once it finishes; defaults to the delegation's setting." }),
400
+ Type.String({ description: `${THINKING_LEVELS.join(", ")}; defaults to the level it last ran on.` }),
413
401
  ),
402
+ auto_exit: Type.Optional(Type.Boolean({ description: "Defaults to the delegation's setting." })),
414
403
  }),
415
404
  outputSchema: resultSchema(deps.delegateSchema),
416
405
  // Opens a window and removes nothing but an exited window of the old run; each call starts another run.
package/src/snapshot.ts CHANGED
@@ -16,11 +16,9 @@ import {
16
16
  type GitPosition,
17
17
  onePath,
18
18
  type PathSummary,
19
- PathSummarySchema,
20
19
  pendingPaths,
21
20
  summarize,
22
21
  } from "./git.ts";
23
- import { PositionSchema } from "./workspace.ts";
24
22
 
25
23
  /**
26
24
  * A checkout as observed at one moment: its position and head against the
@@ -41,31 +39,10 @@ export type WorkspaceSnapshot =
41
39
  pending: { staged: PathSummary; unstaged: PathSummary; untracked: PathSummary; conflicted: PathSummary };
42
40
  };
43
41
 
44
- /** `WorkspaceSnapshot` as a schema, for `delegation_status`. */
45
- export const WorkspaceSnapshotSchema = Type.Union(
46
- [
47
- Type.Object({ kind: Type.Literal("non-git") }),
48
- Type.Object({ kind: Type.Literal("unknown"), reason: Type.String() }),
49
- Type.Object({
50
- kind: Type.Literal("git"),
51
- position: PositionSchema,
52
- head: Type.String(),
53
- baseline: Type.String({ description: "The launch commit the changes are counted from." }),
54
- ahead: Type.Number({ description: "Commits reachable from head but not from the baseline." }),
55
- workingState: Type.Union([Type.Literal("clean"), Type.Literal("dirty")]),
56
- committed: PathSummarySchema,
57
- pending: Type.Object({
58
- staged: PathSummarySchema,
59
- unstaged: PathSummarySchema,
60
- untracked: PathSummarySchema,
61
- conflicted: PathSummarySchema,
62
- }),
63
- }),
64
- ],
65
- {
66
- description:
67
- "The checkout observed now: git (changes since the launch commit, committed and pending), non-git, or unknown with a reason.",
68
- },
42
+ /** `WorkspaceSnapshot` as a schema for `delegation_status`: the discriminant, the rest open. */
43
+ export const WorkspaceSnapshotSchema = Type.Object(
44
+ { kind: Type.Union([Type.Literal("git"), Type.Literal("non-git"), Type.Literal("unknown")]) },
45
+ { additionalProperties: true },
69
46
  );
70
47
 
71
48
  /** Reporting limits for one snapshot; never evidence for deleting anything. */
package/src/tmux.ts CHANGED
@@ -18,7 +18,6 @@ export type TmuxWindow = { id: string; session: string; name: string; exited: bo
18
18
  /** A window just opened: its id and the name of the session it is in, when known. */
19
19
  export type OpenedWindow = { id: string; session: string | undefined };
20
20
 
21
- /** The oldest tmux delegate windows need: `new-session -e` arrived in 3.2. */
22
21
  export const MIN_TMUX = { major: 3, minor: 2 } as const;
23
22
 
24
23
  export type TmuxVersion = { text: string; major: number; minor: number };
@@ -124,8 +123,8 @@ function runTmux(args: string[], timeout: number): Promise<string> {
124
123
  reject(new Error(`tmux ${args[0]} timed out after ${timeout / 1000} s`));
125
124
  return;
126
125
  }
127
- const detail = typeof stderr === "string" && stderr.trim() !== "" ? stderr.trim() : error.message;
128
- reject(new Error(detail));
126
+ const reason = ["duplicate session", "can't find session"].find((text) => stderr.includes(text));
127
+ reject(new Error(`tmux ${args[0]} failed${reason === undefined ? "" : `: ${reason}`}`));
129
128
  return;
130
129
  }
131
130
  resolve(stdout);
@@ -215,8 +214,17 @@ async function openCompanion(run: Run, spec: WindowSpec, base: string): Promise<
215
214
  continue;
216
215
  }
217
216
  }
218
- const args = ["new-session", "-d", "-s", session, "-n", spec.name, "-c", spec.cwd, ...envArgs(spec)];
219
- args.push("-P", "-F", "#{window_id}", "--", ...spec.argv);
217
+ const args = ["new-session", "-d", "-s", session, "-n", spec.name, "-c", spec.cwd];
218
+ // new-session -e persists in the session; env confines these values to its first window.
219
+ args.push(
220
+ "-P",
221
+ "-F",
222
+ "#{window_id}",
223
+ "--",
224
+ "env",
225
+ ...Object.entries(spec.env).map(([key, value]) => `${key}=${value}`),
226
+ ...spec.argv,
227
+ );
220
228
  args.push(";", "set-option", "-t", `=${session}:`, COMPANION_OPTION, "1");
221
229
  args.push(";", "set-option", "-t", `=${session}:`, "detach-on-destroy", "off");
222
230
  certainty = "unknown";
package/src/tool-names.ts CHANGED
@@ -12,7 +12,7 @@ export function toolMatches(pattern: string, name: string): boolean {
12
12
 
13
13
  /**
14
14
  * The operations this session can reach: active tools plus codemode and
15
- * deferred ones, never hidden ones. A nested delegate with no `tools` field
15
+ * deferred ones, never hidden ones. A delegate with no `tools` field
16
16
  * inherits these. Includes active model-only operations, though
17
17
  * ctx.executeTool cannot call those.
18
18
  */
@@ -1,46 +1,7 @@
1
1
  import { Type } from "typebox";
2
- import { WorktreePlanSchema } from "./workspace.ts";
3
2
 
4
3
  const ATTEMPT_PHASES = ["planned", "created", "rolled-back", "retained", "interrupted"] as const;
5
-
6
- const ResourceSchema = Type.Union(
7
- [
8
- Type.Object({ kind: Type.Literal("none") }),
9
- Type.Object({ kind: Type.Literal("branch"), branch: Type.String(), head: Type.String() }),
10
- Type.Object({ kind: Type.Literal("worktree"), cwd: Type.String(), branch: Type.String(), head: Type.String() }),
11
- Type.Object({
12
- kind: Type.Literal("unknown"),
13
- reason: Type.String(),
14
- observedPaths: Type.Array(Type.String()),
15
- observedRefs: Type.Array(Type.String()),
16
- }),
17
- ],
18
- { description: "What is known to exist for a launch that has no window, as facts." },
19
- );
20
-
21
- const AttemptStateSchema = Type.Union([
22
- Type.Object({ phase: Type.Literal("planned"), resources: ResourceSchema }),
23
- Type.Object({ phase: Type.Literal("created"), resources: ResourceSchema }),
24
- Type.Object({
25
- phase: Type.Literal("rolled-back"),
26
- resources: Type.Object({ kind: Type.Literal("none") }),
27
- cause: Type.String(),
28
- }),
29
- Type.Object({
30
- phase: Type.Literal("retained"),
31
- resources: ResourceSchema,
32
- cause: Type.String(),
33
- recovery: Type.Array(Type.String(), {
34
- description: "Non-forced commands that remove exactly the resources known to remain.",
35
- }),
36
- }),
37
- Type.Object({
38
- phase: Type.Literal("interrupted"),
39
- resources: ResourceSchema,
40
- lastPhase: Type.Union([Type.Literal("planned"), Type.Literal("created")]),
41
- reason: Type.String(),
42
- }),
43
- ]);
4
+ const PHASE = Type.Union(ATTEMPT_PHASES.map((phase) => Type.Literal(phase)));
44
5
 
45
6
  /** A managed launch that has no window: never running, never closable, with no window id. */
46
7
  export const AttemptSchema = Type.Object({
@@ -52,14 +13,8 @@ export const AttemptSchema = Type.Object({
52
13
  thinking: Type.String(),
53
14
  name: Type.Optional(Type.String()),
54
15
  sessionDir: Type.String(),
55
- plan: WorktreePlanSchema,
56
- attempt: AttemptStateSchema,
57
- requestId: Type.Optional(Type.String({ description: "The task's request id, only when mail really returned one." })),
58
- state: Type.Union(
59
- ATTEMPT_PHASES.map((phase) => Type.Literal(phase)),
60
- {
61
- description:
62
- "planned, created: a launch in progress. rolled-back: failed before a window and everything was removed. retained: failed before a window and something remains (see attempt.recovery). interrupted: the parent ended mid-launch; what exists is unknown. No window was opened.",
63
- },
64
- ),
16
+ plan: Type.Object({ branch: Type.String(), destination: Type.String() }, { additionalProperties: true }),
17
+ attempt: Type.Object({ phase: PHASE }, { additionalProperties: true }),
18
+ requestId: Type.Optional(Type.String()),
19
+ state: PHASE,
65
20
  });
package/src/tracking.ts CHANGED
@@ -71,46 +71,30 @@ const StartedSchema = Type.Object({
71
71
  root: Type.Optional(Type.String()),
72
72
  depth: Type.Optional(Type.Number()),
73
73
  accounting: Type.Optional(Type.String()),
74
- canResume: Type.Boolean({
75
- description: "Direct-parent authority only; current admission and exited-session checks still apply.",
76
- }),
74
+ canResume: Type.Boolean(),
77
75
  kind: Type.Literal("started"),
78
76
  id: Type.String(),
79
77
  name: Type.String(),
80
78
  agent: Type.String(),
81
- model: Type.String({ description: "The model it was launched on." }),
82
- finishedModel: Type.Optional(
83
- Type.String({ description: "The model its result came from, read from its session file; done only." }),
84
- ),
79
+ model: Type.String(),
80
+ finishedModel: Type.Optional(Type.String()),
85
81
  thinking: Type.String(),
86
82
  autoExit: Type.Boolean(),
87
- resumedFrom: Type.Optional(Type.String({ description: "The delegation whose session this one reopened." })),
83
+ resumedFrom: Type.Optional(Type.String()),
88
84
  windowId: Type.String(),
89
85
  windowName: Type.String(),
90
- windowSession: Type.Optional(
91
- Type.String({ description: "The tmux session its window is in now; absent when the window is gone." }),
92
- ),
93
- cwd: Type.Optional(
94
- Type.String({ description: "The directory its window opened in; absent for records from before it was kept." }),
95
- ),
96
- sessionDir: Type.Optional(Type.String({ description: "The session-storage directory it was told to use." })),
86
+ windowSession: Type.Optional(Type.String()),
87
+ cwd: Type.Optional(Type.String()),
88
+ sessionDir: Type.Optional(Type.String()),
97
89
  workspace: Type.Optional(WorkspaceSchema),
98
90
  workspaceSnapshot: WorkspaceSnapshotSchema,
99
- state: Type.Union(
100
- STARTED_STATES.map((state) => Type.Literal(state)),
101
- {
102
- description:
103
- "running: window open, no result. done: a result arrived (see status, envelopePath). gone: the window went with no result; a late reply makes it done. closed: delegation_close recorded it. window-closed: the window is gone. unknown: tmux could not be asked. done and closed are final, though a later delegation_resume continues one as a new delegation; gone and window-closed turn done if a late reply arrives.",
104
- },
105
- ),
91
+ state: Type.Union(STARTED_STATES.map((state) => Type.Literal(state))),
106
92
  closed: Type.Boolean(),
107
93
  closedWithoutResult: Type.Boolean(),
108
- status: Type.Optional(Type.String({ description: "The result envelope's status: done, failed or stopped." })),
94
+ status: Type.Optional(Type.String()),
109
95
  envelopePath: Type.Optional(Type.String()),
110
96
  sessionPath: Type.Optional(Type.String()),
111
- lastActiveAt: Type.Optional(
112
- Type.String({ description: "ISO time the session file was last written; running only." }),
113
- ),
97
+ lastActiveAt: Type.Optional(Type.String()),
114
98
  });
115
99
 
116
100
  const DelegationSchema = Type.Union([StartedSchema, AttemptSchema]);
@@ -297,10 +281,8 @@ function statusTool(results: Results, tmux: TmuxClient, readingErrors: () => Rea
297
281
  name: "delegation_status",
298
282
  label: "Delegation status",
299
283
  description:
300
- "This session’s descendant delegations, scoped to its own subtree. Parent/root/depth and stable session ids identify ownership; only a direct parent may resume a child. Shared accounting does not acknowledge result processing. Each delegation is reported as running (the window is open and no result has arrived yet), done (with the result envelope's status) or closed (closed with delegation_close, or its window is gone). A delegation whose window went with no reply is reported closed without a result until a late reply makes it done. Call it to check on a delegation that seems stuck or to find its files; a result arrives as a message on its own, so there is no need to call it to wait for one. Names the delegation id, agent, session name, model, thinking, window, working directory, delegate session path and result envelope path. A running delegation also shows when its session file was last written; a long gap may mean it is stuck but does not prove it. A delegate's window being open means it is connected, not that its task is unfinished or finished; only a result means done.",
301
- parameters: Type.Object({
302
- id: Type.Optional(Type.String({ description: "One delegation id; omit to report every delegation." })),
303
- }),
284
+ "Report this session's descendant delegations, or one by id: state, window, model, paths and workspace. Results arrive as messages on their own, so call it only for a delegation that seems stuck or to find its files. An open window does not mean the task is unfinished; only a result means done. gone and window-closed turn done if a late reply arrives.",
285
+ parameters: Type.Object({ id: Type.Optional(Type.String()) }),
304
286
  outputSchema: StatusSchema,
305
287
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, ...CLOSED_WORLD },
306
288
  async execute(
@@ -375,10 +357,8 @@ function closeTool(results: Results, tmux: TmuxClient, assertActive: () => void)
375
357
  name: "delegation_close",
376
358
  label: "Close delegation",
377
359
  description:
378
- "Explicitly close a descendant and its recorded subtree. Fences new child launches, kills only verified current tmux windows and retains worktrees, branches and session/result files. Any ancestor may close; only the direct parent may resume. Partial errors or unknown in-flight creates stay fenced and are reported for an explicit retry, never as successful closure.",
379
- parameters: Type.Object({
380
- id: Type.String({ description: "The delegation id to close." }),
381
- }),
360
+ "Close a descendant and its recorded subtree: fence new launches and kill its verified windows, keeping worktrees, branches and session files. Check closed and complete; partial failures stay fenced for a retry.",
361
+ parameters: Type.Object({ id: Type.String() }),
382
362
  outputSchema: CloseSchema,
383
363
  // Kills only verified windows; repeats retry unresolved members and preserve confirmed facts.
384
364
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, ...CLOSED_WORLD },
package/src/tree-close.ts CHANGED
@@ -23,7 +23,7 @@ export const CloseSchema = Type.Object({
23
23
  id: Type.String(),
24
24
  windowId: Type.Optional(Type.String()),
25
25
  windowName: Type.Optional(Type.String()),
26
- closed: Type.Boolean({ description: "True only when every member is confirmed closed or never dispatched." }),
26
+ closed: Type.Boolean(),
27
27
  complete: Type.Boolean(),
28
28
  alreadyClosed: Type.Boolean(),
29
29
  killed: Type.Boolean(),
package/src/workspace.ts CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  ProjectTrustStore,
19
19
  } from "@earendil-works/pi-coding-agent";
20
20
  import { Type } from "typebox";
21
- import { askGit, type GitLaunch, inspectGit, PathSummarySchema } from "./git.ts";
21
+ import { askGit, type GitLaunch, inspectGit } from "./git.ts";
22
22
  import { localTrust, type TrustDecision, type TrustResolver, trustChoices } from "./trust-prompt.ts";
23
23
  import type { ManagedWorkspace } from "./worktree.ts";
24
24
 
@@ -41,69 +41,14 @@ export type DirectoryWorkspace = {
41
41
  git: GitLaunch;
42
42
  };
43
43
  export type Workspace = DirectoryWorkspace | ManagedWorkspace;
44
- /** `GitPosition` as a schema. */
45
- export const PositionSchema = Type.Union([
46
- Type.Object({ branch: Type.String(), detached: Type.Literal(false) }),
47
- Type.Object({ branch: Type.Null(), detached: Type.Literal(true) }),
48
- ]);
49
-
50
- /** `GitIdentity` as a schema. */
51
- export const IdentitySchema = Type.Object({
52
- mainRoot: Type.String(),
53
- commonDir: Type.String(),
54
- checkoutRoot: Type.String(),
55
- });
56
-
57
- const CheckoutSchema = Type.Object({
58
- kind: Type.Literal("git"),
59
- identity: IdentitySchema,
60
- position: PositionSchema,
61
- head: Type.Union([Type.String(), Type.Null()]),
62
- baseline: Type.Union([Type.String(), Type.Null()], {
63
- description: "The commit checked out at launch; null when the checkout had no commit yet.",
64
- }),
65
- });
66
-
67
- const CWD_DESCRIPTION = "The directory the delegate's window opened in.";
68
-
69
- /** `Workspace` as a schema, for the tools' structured outputs. */
70
- export const WorkspaceSchema = Type.Union([
71
- Type.Object({
72
- mode: Type.Union([Type.Literal("inherited"), Type.Literal("existing")]),
73
- cwd: Type.String({ description: CWD_DESCRIPTION }),
74
- git: Type.Union(
75
- [
76
- CheckoutSchema,
77
- Type.Object({ kind: Type.Literal("non-git") }),
78
- Type.Object({ kind: Type.Literal("unknown"), reason: Type.String() }),
79
- ],
80
- { description: "The checkout at launch: git, non-git, or unknown when Git could not be asked." },
81
- ),
82
- }),
83
- Type.Object({
84
- mode: Type.Literal("managed"),
85
- cwd: Type.String({ description: CWD_DESCRIPTION }),
86
- git: CheckoutSchema,
87
- sourceCwd: Type.String({ description: "The checkout the worktree was created from." }),
88
- source: IdentitySchema,
89
- createdBranch: Type.String({ description: "The branch created for the worktree, as originally named." }),
90
- requestedBase: Type.String(),
91
- baseCommit: Type.String({ description: "The commit the branch was created at." }),
92
- sourceDirty: PathSummarySchema,
93
- }),
94
- ]);
95
-
96
- /** `WorktreePlan` as a schema: what an attempt intended. */
97
- export const WorktreePlanSchema = Type.Object({
98
- sourceCwd: Type.String(),
99
- source: IdentitySchema,
100
- destination: Type.String(),
101
- branch: Type.String(),
102
- requestedBase: Type.String(),
103
- baseCommit: Type.String(),
104
- sourceDirty: PathSummarySchema,
105
- trusted: Type.Boolean(),
106
- });
44
+ /** `Workspace` as a schema for the tools' structured outputs: the discriminant and directory, the rest open to keep tool declarations short. */
45
+ export const WorkspaceSchema = Type.Object(
46
+ {
47
+ mode: Type.Union([Type.Literal("inherited"), Type.Literal("existing"), Type.Literal("managed")]),
48
+ cwd: Type.String(),
49
+ },
50
+ { additionalProperties: true },
51
+ );
107
52
 
108
53
  /** Optional inputs some models fill with these instead of leaving them out. */
109
54
  const OMITTED = new Set(["", "null", "none", "false", "undefined"]);