pi-squire 0.17.0 → 0.18.1

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,32 @@ follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.18.1] - 2026-11-01
10
+
11
+ ### Changed
12
+
13
+ - The delegation tools put about 70% less text in a model's context: in a
14
+ codemode root their declarations drop from about 5,100 estimated tokens to
15
+ about 1,400. The descriptions and parameter notes are shorter, and the output
16
+ schemas carry no field descriptions. `workspace`, `workspaceSnapshot`, and a
17
+ workspace attempt's `plan` and `attempt` are declared by their discriminant
18
+ and directory only, with their other fields left open; the structured data
19
+ itself is unchanged. Tests that pinned the old wording are updated.
20
+ - A delegate's appended prompt is its agent body and working-directory line
21
+ only. The paragraph naming the parent's address and the final-message rule
22
+ are in the per-run `squire_delegate_task` section alone, where they were
23
+ already repeated.
24
+
25
+ ## [0.18.0] - 2026-11-01
26
+
27
+ ### Changed
28
+
29
+ - New root companion sessions deliberately default to `<project>-delegates`
30
+ instead of `<project>-delegate-session`. Set `companionSuffix` in
31
+ `<agent dir>/squire/settings.json` to use another suffix, such as `playpen`.
32
+ Existing companions and saved identities keep their names, including for
33
+ nested launches and resumes.
34
+
9
35
  ## [0.17.0] - 2026-11-01
10
36
 
11
37
  ### Breaking
@@ -998,7 +1024,9 @@ author's dotfiles; the changes below are against that copy.
998
1024
 
999
1025
  - The published file list includes `child.ts`, which `index.ts` imports.
1000
1026
 
1001
- [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.17.0...HEAD
1027
+ [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.18.1...HEAD
1028
+ [0.18.1]: https://github.com/gvanderclay/pi-squire/compare/v0.18.0...v0.18.1
1029
+ [0.18.0]: https://github.com/gvanderclay/pi-squire/compare/v0.17.0...v0.18.0
1002
1030
  [0.17.0]: https://github.com/gvanderclay/pi-squire/compare/v0.16.0...v0.17.0
1003
1031
  [0.16.0]: https://github.com/gvanderclay/pi-squire/compare/v0.15.0...v0.16.0
1004
1032
  [0.15.0]: https://github.com/gvanderclay/pi-squire/compare/v0.14.0...v0.15.0
package/README.md CHANGED
@@ -358,14 +358,14 @@ same-id Squire owner and obsolete local/shared resume chains are refused.
358
358
  ## Where delegate windows open
359
359
 
360
360
  By default a delegate's window opens in a shared **project companion session**
361
- named `<project>-delegate-session` on your existing tmux server. The owning
361
+ named `<project>-delegates` on your existing tmux server. The owning
362
362
  root Pi session's project chooses it, not the parent tmux session or the
363
363
  delegate's working directory. Launching into another repository or creating a
364
364
  worktree there still uses the root's companion; resuming a delegate does too.
365
365
 
366
366
  Nested launches inherit the original root project: A → coordinator in B →
367
367
  worker in C all select A's companion. The preferred base name (for example,
368
- `A-delegate-session`) is saved as `rootPlacement` in the existing durable
368
+ `A-delegates`) is saved as `rootPlacement` in the existing durable
369
369
  delegation identity and shared tree records, not recomputed from B/C or the
370
370
  containing tmux session. Reopening the **same** coordinator session without its
371
371
  launch environment restores that preference; it can launch or directly resume
@@ -384,7 +384,7 @@ uses the root working-directory basename instead.
384
384
  Project names preserve ASCII letters (including case), digits, hyphens and
385
385
  underscores. Runs of other characters become one hyphen, edge hyphens are
386
386
  trimmed, and an empty name becomes `project`: `Client API` uses
387
- `Client-API-delegate-session`.
387
+ `Client-API-delegates`.
388
388
 
389
389
  Multiple roots share a companion even across parent tmux sessions. Distinct
390
390
  paths with the same basename, separate clones named alike, and names that
@@ -394,12 +394,13 @@ each root.
394
394
 
395
395
  pi-squire verifies the tmux session option `@squire-companion` before reusing
396
396
  an occupied name. An unmarked session is left untouched; numbered alternatives
397
- start at `<project>-delegate-session-2`, skipping unmarked alternatives too.
397
+ start at `<project>-<suffix>-2`, skipping unmarked alternatives too.
398
398
  Later launches reuse an existing managed alternative even if a lower name
399
399
  becomes free. Unexpected duplicate managed candidates use the first in numeric
400
- order, without moving windows or repairing sessions. Existing legacy companions
401
- and live windows are neither renamed nor migrated; only new launches and
402
- resumes use project naming.
400
+ order, without moving windows or repairing sessions. Existing companions,
401
+ live windows and saved identities keep their names, including the former
402
+ `<project>-delegate-session` default. Nested launches and resumes retain the
403
+ saved preference; companions are not renamed or migrated.
403
404
 
404
405
  - The session is created on demand, by the launch that needs it, and tmux
405
406
  removes it with its last window, so it does not outlive its delegates.
@@ -416,6 +417,22 @@ resumes use project naming.
416
417
  up each time, so it follows a window you move to another session, which is
417
418
  still found, closed and resumed.
418
419
 
420
+ To use a different suffix, set `companionSuffix` in
421
+ `<agent dir>/squire/settings.json`:
422
+
423
+ ```json
424
+ { "companionSuffix": "playpen" }
425
+ ```
426
+
427
+ This names a new root's companion `<project>-playpen`. The default suffix is
428
+ `delegates`. A suffix must contain 1 to 32 ASCII letters, digits, underscores
429
+ or hyphens, with no leading or trailing hyphen. It cannot be entirely numeric
430
+ or end in a hyphen followed by digits, such as `2` or `x-10`, because those
431
+ names overlap with numbered alternatives. An invalid suffix refuses the launch
432
+ with an error naming the file, before the task is sent or a window opens.
433
+ The suffix is read with `windowPlacement` at launch. It does not replace a
434
+ saved root preference for nested launches or resumes.
435
+
419
436
  To open windows in your own session, as before, set `windowPlacement` in
420
437
  `<agent dir>/squire/settings.json`, beside `worktreeRoot`:
421
438
 
@@ -1046,14 +1063,14 @@ limit, it resolves each entry the way Pi resolves `--model`: an exact
1046
1063
  order the entries are listed. No entry may be a `*` pattern: a `model` or
1047
1064
  `fallback` entry with `*` makes the file invalid, and the agent is disabled.
1048
1065
 
1049
- The body is followed by one fixed final-message line and a paragraph naming the
1050
- parent session's address. The delegate's task arrives as a message from that
1066
+ Each run of the delegate also gets a system-prompt section naming the parent
1067
+ session's address. The delegate's task arrives as a message from that
1051
1068
  session, and that message and every later one from the same address are
1052
1069
  instructions to follow as given. Other sessions are colleagues: the delegate
1053
1070
  may answer them, but checks with the parent before doing work the parent did
1054
- not ask for. The paragraph names no provider, package or tool, so it holds
1055
- whatever `message:*` provider delivers the task. `prompt_mode: replace` builds
1056
- the prompt exactly as every delegate was built before `prompt_mode` existed.
1071
+ not ask for. Its final message reaches the parent on its own. The section names
1072
+ no provider, package or tool, so it holds whatever `message:*` provider
1073
+ delivers the task. `prompt_mode: replace` appends the body as written.
1057
1074
 
1058
1075
  An agent file that cannot be used is invalid, and its agent is disabled:
1059
1076
  frontmatter that is not readable YAML or not a set of fields, an `enabled` or
@@ -1076,7 +1093,7 @@ add one.
1076
1093
 
1077
1094
  `<agent dir>/squire/` holds the optional parent guidance file and
1078
1095
  `settings.json` (`maxDepth`, `maxConcurrentDelegates`, `maxDelegatesPerRoot`,
1079
- `windowPlacement`, `worktreeRoot`, `delegateTools`, `toolBundles`,
1096
+ `windowPlacement`, `companionSuffix`, `worktreeRoot`, `delegateTools`, `toolBundles`,
1080
1097
  `delegateWidgetPlacement` and `delegateWidgetMaxEntries`); the
1081
1098
  agents live in `<agent dir>/agents/` (see [Agents](#agents)).
1082
1099
  `delegation_prompt.md` is Markdown, inserted unchanged as the
@@ -1967,7 +1984,7 @@ Other extensions may rely on:
1967
1984
  `<agent dir>/agents/<name>.md` and, in a trusted project,
1968
1985
  `<cwd>/.pi/agents/<name>.md`;
1969
1986
  - the `maxDepth`, `maxConcurrentDelegates`, `maxDelegatesPerRoot`, `delegateTools`, `toolBundles`,
1970
- `windowPlacement`, `worktreeRoot`, `delegateWidgetPlacement` and
1987
+ `windowPlacement`, `companionSuffix`, `worktreeRoot`, `delegateWidgetPlacement` and
1971
1988
  `delegateWidgetMaxEntries` settings in
1972
1989
  `<agent dir>/squire/settings.json`;
1973
1990
  - the optional `<agent dir>/squire/delegation_prompt.md` file and the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squire",
3
- "version": "0.17.0",
3
+ "version": "0.18.1",
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
@@ -100,7 +100,7 @@ import { registerTrustControl } from "./trust-control.ts";
100
100
  import { createProactive, createUsageClient, type Proactive, type UsageClient } from "./usage.ts";
101
101
  import { registerDelegateWidget } from "./widget.ts";
102
102
  import { registerActivityRuntime } from "./widget-activity-runtime.ts";
103
- import { assertRootPlacement, placementProject, type WindowPlacement, windowPlacement } from "./window-placement.ts";
103
+ import { assertRootPlacement, placementProject, type WindowPlacement, windowSettings } from "./window-placement.ts";
104
104
  import {
105
105
  normalizeWorkspaceValue,
106
106
  prepareDirectory,
@@ -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
 
@@ -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,
@@ -1147,11 +1125,11 @@ export default function delegate(
1147
1125
  if (!tmux.insideTmux()) throw new Error(NOT_IN_TMUX);
1148
1126
  await requireTmuxVersion(tmux);
1149
1127
  // Read once, before anything is created or mailed; the launch keeps these values.
1150
- const placement = windowPlacement(getAgentDir());
1128
+ const { placement, companionSuffix } = windowSettings(getAgentDir());
1151
1129
  const parentIdentity = identity.current();
1152
1130
  const project = parentIdentity
1153
1131
  ? { companion: parentIdentity.rootPlacement, notices: [] }
1154
- : placementProject(ctx.cwd);
1132
+ : placementProject(ctx.cwd, companionSuffix);
1155
1133
  assertRootPlacement(project.companion);
1156
1134
  const own = identity.child(start.agent, ctx, project.companion);
1157
1135
  if (parentIdentity) assertParentPlacement(readTree(own.root), own);
@@ -1366,7 +1344,7 @@ export default function delegate(
1366
1344
  delegateSchema: DelegateSchema,
1367
1345
  notInTmux: NOT_IN_TMUX,
1368
1346
  startPolling: tracking.startPolling,
1369
- placement: () => windowPlacement(getAgentDir()),
1347
+ placement: () => windowSettings(getAgentDir()).placement,
1370
1348
  roster: rosterFor,
1371
1349
  admit: (agent, ctx) => admit(agent, currentRoster(ctx)),
1372
1350
  authorize: (id, ctx) => {
@@ -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. */
@@ -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(),
@@ -1,6 +1,6 @@
1
1
  // Call-time choice of where delegate windows open. `companion` (the default)
2
- // opens them in a shared project session; `current`
3
- // opens them in the parent's own session, as before. Read from
2
+ // opens them in a shared project session named `<project>-<companionSuffix>`
3
+ // (default suffix `delegates`); `current` opens in the parent's own session. Read from
4
4
  // `<agent dir>/squire/settings.json` at every launch, like `worktreeRoot`.
5
5
  import { readFileSync } from "node:fs";
6
6
  import { basename, join } from "node:path";
@@ -9,21 +9,25 @@ import { inspectGit } from "./git.ts";
9
9
 
10
10
  export type WindowPlacement = "companion" | "current";
11
11
 
12
+ const DEFAULT_SUFFIX = "delegates";
13
+ const safeName = /^[A-Za-z0-9_](?:[A-Za-z0-9_-]*[A-Za-z0-9_])?$/;
14
+ const numberedEnding = /(^|-)[0-9]+$/;
15
+
12
16
  /** A preferred base, never a selected numbered companion or a tmux locator. */
13
17
  export function assertRootPlacement(value: unknown): asserts value is string {
14
- if (typeof value !== "string" || !/^[A-Za-z0-9_](?:[A-Za-z0-9_-]*[A-Za-z0-9_])?-delegate-session$/.test(value))
18
+ if (typeof value !== "string" || !safeName.test(value) || !value.includes("-") || numberedEnding.test(value))
15
19
  throw new Error(
16
20
  "invalid or missing rootPlacement in delegation identity; original root companion preference is required",
17
21
  );
18
22
  }
19
23
 
20
24
  /** The owning root's project, never the delegate's chosen working directory. */
21
- export function placementProject(cwd: string): { companion: string; notices: string[] } {
25
+ export function placementProject(cwd: string, suffix = DEFAULT_SUFFIX): { companion: string; notices: string[] } {
22
26
  const git = inspectGit(cwd);
23
27
  const project = basename(git.kind === "git" ? git.identity.mainRoot : cwd);
24
28
  const normalized = project.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "project";
25
29
  return {
26
- companion: `${normalized}-delegate-session`,
30
+ companion: `${normalized}-${suffix}`,
27
31
  notices:
28
32
  git.kind === "unknown"
29
33
  ? [
@@ -38,19 +42,32 @@ export function placementProject(cwd: string): { companion: string; notices: str
38
42
  * This reader defaults malformed/unreadable files to companion; mandatory
39
43
  * tree-cap admission separately refuses those files for every launch/resume.
40
44
  */
41
- export function windowPlacement(agentDir: string): WindowPlacement {
45
+ export function windowSettings(agentDir: string): { placement: WindowPlacement; companionSuffix: string } {
42
46
  const file = join(agentDir, "squire", "settings.json");
43
47
  let parsed: unknown;
44
48
  try {
45
49
  parsed = JSON.parse(readFileSync(file, "utf8"));
46
50
  } catch {
47
- return "companion";
51
+ return { placement: "companion", companionSuffix: DEFAULT_SUFFIX };
48
52
  }
49
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return "companion";
50
- const value = (parsed as Record<string, unknown>).windowPlacement;
51
- if (value === undefined || value === "companion") return "companion";
52
- if (value === "current") return "current";
53
- throw new Error(
54
- `invalid window placement settings ${file}: windowPlacement must be "companion" or "current", not ${JSON.stringify(value)}`,
55
- );
53
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
54
+ return { placement: "companion", companionSuffix: DEFAULT_SUFFIX };
55
+ const { windowPlacement: placement = "companion", companionSuffix = DEFAULT_SUFFIX } = parsed as Record<
56
+ string,
57
+ unknown
58
+ >;
59
+ if (placement !== "companion" && placement !== "current")
60
+ throw new Error(
61
+ `invalid window placement settings ${file}: windowPlacement must be "companion" or "current", not ${JSON.stringify(placement)}`,
62
+ );
63
+ if (
64
+ typeof companionSuffix !== "string" ||
65
+ companionSuffix.length > 32 ||
66
+ !safeName.test(companionSuffix) ||
67
+ numberedEnding.test(companionSuffix)
68
+ )
69
+ throw new Error(
70
+ `invalid window placement settings ${file}: companionSuffix must be 1 to 32 ASCII letters, digits, underscores or hyphens, with no edge hyphen or numeric final segment, not ${JSON.stringify(companionSuffix)}`,
71
+ );
72
+ return { placement, companionSuffix };
56
73
  }
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"]);