pi-squire 0.19.1 → 0.20.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,33 @@ follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.20.1] - 2026-11-02
10
+
11
+ ### Changed
12
+
13
+ - `delegation_fork` warns when its effective model differs from the source's
14
+ last model: copied context will be written to a new cache, with an approximate
15
+ token count when source usage is available. The model parameter and README
16
+ explain the cost; same-model forks retain their cache behavior. New fork tests
17
+ cover changed/same models, effective model locks and missing or invalid usage.
18
+
19
+ ## [0.20.0] - 2026-11-02
20
+
21
+ ### Added
22
+
23
+ - `delegation_fork({ id, task })` starts a new delegation from a direct child's
24
+ session context using `pi --fork`, leaving the original session and window
25
+ untouched. It runs in the child's folder or a new managed worktree, inherits
26
+ resume-style model/thinking/auto-exit defaults, and reapplies current roster,
27
+ nesting, capacity and trust checks. New records, status and results carry
28
+ `forkedFrom`; replies use the new session's own address. The metadata budget
29
+ test increases from 10,000 to 12,000 characters to cover the fifth tool.
30
+ Managed forks report their cwd, branch, base and uncopied changes, and their
31
+ prompt directs them away from the original checkout; non-isolated forks warn
32
+ about sharing a folder with a running source. Dashboard details show the
33
+ source id, foreign callers get an explicit direct-parent refusal, and the
34
+ fork tool declares its destructive-operation hint.
35
+
9
36
  ## [0.19.1] - 2026-11-02
10
37
 
11
38
  ### Removed
@@ -1094,7 +1121,9 @@ author's dotfiles; the changes below are against that copy.
1094
1121
 
1095
1122
  - The published file list includes `child.ts`, which `index.ts` imports.
1096
1123
 
1097
- [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.19.1...HEAD
1124
+ [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.20.1...HEAD
1125
+ [0.20.1]: https://github.com/gvanderclay/pi-squire/compare/v0.20.0...v0.20.1
1126
+ [0.20.0]: https://github.com/gvanderclay/pi-squire/compare/v0.19.1...v0.20.0
1098
1127
  [0.19.1]: https://github.com/gvanderclay/pi-squire/compare/v0.19.0...v0.19.1
1099
1128
  [0.19.0]: https://github.com/gvanderclay/pi-squire/compare/v0.18.4...v0.19.0
1100
1129
  [0.18.4]: https://github.com/gvanderclay/pi-squire/compare/v0.18.3...v0.18.4
package/README.md CHANGED
@@ -175,7 +175,7 @@ scalars, quoted booleans, malformed lists and patterns) disable the agent with
175
175
  a path-specific warning. Unknown or unavailable names warn and cannot launch;
176
176
  known entries remain usable, and later roster changes apply normally.
177
177
  Below the limit the grant adds `delegate`,
178
- `delegation_status`, `delegation_close` and `delegation_resume` even with an
178
+ `delegation_status`, `delegation_close`, `delegation_resume` and `delegation_fork` even with an
179
179
  explicit `tools` list. Merely listing those
180
180
  tools (including by pattern) does not opt in and produces
181
181
  a roster warning. At the depth limit they are inactive and not passed onward.
@@ -1021,7 +1021,7 @@ agent that uses a bundle, with the reason; agents without bundles are
1021
1021
  unaffected. (`$`, unlike `@`, may start a plain YAML value.)
1022
1022
 
1023
1023
  The delegation tools (`delegate`, `delegation_status`, `delegation_close`,
1024
- `delegation_resume`) are removed from ordinary tool selections. An opted-in
1024
+ `delegation_resume`, `delegation_fork`) are removed from ordinary tool selections. An opted-in
1025
1025
  coordinator below `maxDepth` receives them automatically; a leaf has them
1026
1026
  inactive. Commands are still registered but cannot bypass admission. See the
1027
1027
  unreleased [nesting restrictions](#nested-delegation-unreleased).
@@ -1135,7 +1135,7 @@ agent's `auto_exit`.
1135
1135
 
1136
1136
  ## Tools
1137
1137
 
1138
- The model gets four tools. They share the command's launch path and the
1138
+ The model gets five tools. They share the command's launch path and the
1139
1139
  session's delegation records. In a delegate they are active only for an
1140
1140
  opted-in coordinator below `maxDepth`; admission also checks every launch/resume.
1141
1141
 
@@ -1145,6 +1145,7 @@ opted-in coordinator below `maxDepth`; admission also checks every launch/resume
1145
1145
  | `delegation_status` | optional `id` | Reports each delegation as running, done, closed or closed without a result, with the window, the delegate's session and the result envelope's path. |
1146
1146
  | `delegation_close` | `id` | Explicitly fences and closes a descendant subtree using current verified windows. Returns per-member outcomes and complete/partial status; retains worktrees and branches. |
1147
1147
  | `delegation_resume` | `id`, `task`, optional `model`, `thinking` and `auto_exit` | Reopens an exited delegate's own session in a new window with a follow-up task, and returns the new delegation's id at once (see [Resuming a delegation](#resuming-a-delegation)). |
1148
+ | `delegation_fork` | `id`, `task`, optional `model`, `thinking`, `auto_exit`, `worktree` and `worktree_base` | Starts a new delegation from a copy of a direct child's context, leaving the original untouched (see [Forking a delegation](#forking-a-delegation)). |
1148
1149
 
1149
1150
  The `delegate` tool's description lists the current roster, one line per agent:
1150
1151
  its name and any display name, description, default model (or this session's)
@@ -1638,6 +1639,43 @@ and it resumes as the command does.
1638
1639
  - Not detected: a Pi process you opened yourself on the same session outside
1639
1640
  pi-squire's window. Close it before resuming.
1640
1641
 
1642
+ ## Forking a delegation
1643
+
1644
+ Call `delegation_fork({ id, task })` when new work needs a child's built context
1645
+ without continuing that child's session. It runs
1646
+ `pi --fork <child session file> --session-id <new id>` in a new tmux window.
1647
+ The source may still be running; its window, task and session file are untouched.
1648
+ Without `worktree`, both sessions share the working folder; the tool warns when
1649
+ the source is running and recommends `worktree` to isolate their file changes.
1650
+ Pi copies the source history available when the new process starts, not future
1651
+ messages. An older delegation in a resumed chain names that chain's shared
1652
+ session file, so it copies the session's current history rather than an old snapshot.
1653
+
1654
+ The new session runs in the working folder from the source session's header.
1655
+ Pass `worktree: "<new branch>"` to create a managed worktree from that folder's
1656
+ checkout instead; `worktree_base` optionally selects a local commit-ish rather
1657
+ than its HEAD. Normal worktree trust, placement and rollback checks apply.
1658
+ This copies conversation context, not uncommitted checkout changes.
1659
+ The fork's launch prompt names the source folder and tells it to work only in
1660
+ the new worktree, where the source's uncommitted changes are absent.
1661
+
1662
+ The tool returns the `delegate` result plus `forkedFrom`. The new delegation
1663
+ has its own session/mail address and window name, and receives the new task
1664
+ like a fresh delegate. Its result arrives through the normal `[delegate]`
1665
+ message; results and status name `forkedFrom`. Forking does not supersede the
1666
+ source or prevent further forks/resumes.
1667
+
1668
+ Only the source's direct parent may fork it. The source session and working
1669
+ folder must exist, its agent must still be enabled, and current nesting grants,
1670
+ depth, capacity and trust checks apply. Copied history does not transfer
1671
+ ownership of the original child's descendants. Model, thinking and auto-exit
1672
+ default to the source's last settings with the same locks and fallbacks as a
1673
+ resume; the current roster supplies the prompt and tools. A different effective
1674
+ model re-reads the copied context without the source's cache; the result warns
1675
+ about the new cache write, estimating copied tokens when source usage is
1676
+ available. Keep the source's model unless the task needs another role.
1677
+ `session:launch` is emitted with the `delegation_fork` tool call id.
1678
+
1641
1679
  ## Hooks
1642
1680
 
1643
1681
  The `js` block below is the contract's worked example, and
@@ -1660,7 +1698,7 @@ the window is open (see [Resuming a delegation](#resuming-a-delegation)).
1660
1698
  | `env` | the child's environment, as `KEY`/`value` pairs |
1661
1699
  | `agent` | the agent's name, as in `<agent dir>/agents/<name>.md` or `<cwd>/.pi/agents/<name>.md` (never its `display_name`); read it, do not change it |
1662
1700
  | `cwd` | the directory the window opens in, the parent's or the resolved `--cwd`; read it, do not change it |
1663
- | `toolCallId` | the ID of the `delegate` or `delegation_resume` tool call that started the launch; absent when started from the `/delegate` or `/delegate-resume` command; read it, do not change it |
1701
+ | `toolCallId` | the ID of the `delegate`, `delegation_resume` or `delegation_fork` tool call that started the launch; absent when started from the `/delegate` or `/delegate-resume` command; read it, do not change it |
1664
1702
  | `parent` | Read-only direct parent session id. |
1665
1703
  | `root` | Read-only root session id; retained on resume. |
1666
1704
  | `depth` | Read-only numeric child depth, root = 0; retained on resume. |
@@ -1688,9 +1726,9 @@ environment into these arguments.
1688
1726
 
1689
1727
  `cwd`, the trust flag (`--approve` or `--no-approve`), `--session-dir` and the
1690
1728
  session choice (`--session-id` for a new delegate, `--session` for a [resumed](#resuming-a-delegation)
1691
- one) are launch invariants that `pi-squire` chooses. The window opens in its own `cwd`,
1729
+ one, `--fork` with `--session-id` for a [fork](#forking-a-delegation)) are launch invariants that `pi-squire` chooses. The window opens in its own `cwd`,
1692
1730
  whatever a listener assigns to the payload. A listener must not add another
1693
- trust flag, `--session-dir`, `--session` or `--session-id`, or a working-directory change to `args`, since
1731
+ trust flag, `--session-dir`, `--session`, `--fork` or `--session-id`, or a working-directory change to `args`, since
1694
1732
  that would launch a child whose trust or session storage the parent did not
1695
1733
  choose and cannot find; append only unrelated settings.
1696
1734
 
@@ -1965,7 +2003,7 @@ Other extensions may rely on:
1965
2003
  [Hooks](#hooks));
1966
2004
  - the `message:send`, `message:reply`, `message:inbound` and `message:scan` hooks, consumed as
1967
2005
  their sections document;
1968
- - the `delegate`, `delegation_status`, `delegation_close` and `delegation_resume` tool names,
2006
+ - the `delegate`, `delegation_status`, `delegation_close`, `delegation_resume` and `delegation_fork` tool names,
1969
2007
  parameters, the recorded `workspace` shape and the `workspaceSnapshot`
1970
2008
  shape in results and status;
1971
2009
  - the `/delegate` and `/delegate-resume` syntax and flags, and their
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squire",
3
- "version": "0.19.1",
3
+ "version": "0.20.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/agents.ts CHANGED
@@ -174,7 +174,7 @@ function warnDelegationTools(path: string, nested: unknown, tools: string[] | un
174
174
  if (nested === true || (Array.isArray(nested) && nested.length > 0)) return;
175
175
  if (
176
176
  tools?.some((name) =>
177
- ["delegate", "delegation_status", "delegation_close", "delegation_resume"].some((tool) =>
177
+ ["delegate", "delegation_status", "delegation_close", "delegation_resume", "delegation_fork"].some((tool) =>
178
178
  toolMatches(name, tool),
179
179
  ),
180
180
  )
package/src/fork.ts ADDED
@@ -0,0 +1,183 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+
4
+ import { disabledProblem, THINKING_LEVELS } from "./agents.ts";
5
+ import { dirtyNotice } from "./render.ts";
6
+ import { finishedModel, sessionInputTokens } from "./result-sessions.ts";
7
+ import { deriveState, isStarted, type StartedRecorded } from "./results.ts";
8
+ import type { ResumeDeps, Resumed } from "./resume.ts";
9
+ import { sessionFor, settleModel } from "./resume-checks.ts";
10
+ import { listWindows, type ToolDef, toolResult, windowState } from "./tracking.ts";
11
+ import { assertChildAuthority, recordedIdentity } from "./tree-view.ts";
12
+
13
+ export type ForkSource = {
14
+ id: string;
15
+ sessionPath: string;
16
+ sessionDir: string;
17
+ rootPlacement: string;
18
+ sourceCwd: string;
19
+ };
20
+ type Forked = Omit<Resumed, "resumedFrom" | "fallbackNote">;
21
+ type ForkRequest = {
22
+ id: string;
23
+ task: string;
24
+ model?: string | null;
25
+ thinking?: string | null;
26
+ auto_exit?: boolean | null;
27
+ worktree?: string | null;
28
+ worktree_base?: string | null;
29
+ };
30
+ type ForkStart = Awaited<ReturnType<ResumeDeps["chooseModel"]>> & {
31
+ agent: ReturnType<ResumeDeps["roster"]>["agents"][number];
32
+ thinking: string;
33
+ autoExit: boolean;
34
+ task: string;
35
+ cwd: string;
36
+ worktree?: string;
37
+ worktree_base?: string;
38
+ };
39
+ type ForkDeps = Pick<
40
+ ResumeDeps,
41
+ "results" | "tmux" | "roster" | "parentDefaults" | "chooseModel" | "thinkingProblem" | "delegateSchema"
42
+ > & {
43
+ authorize(id: string, ctx: ExtensionContext): void;
44
+ admit(agent: string, ctx: ExtensionContext): void;
45
+ launch(
46
+ start: ForkStart,
47
+ source: ForkSource,
48
+ ctx: ExtensionContext,
49
+ signal: AbortSignal | undefined,
50
+ toolCallId: string,
51
+ ): Promise<Forked>;
52
+ };
53
+
54
+ function given(value: string | null | undefined): string | undefined {
55
+ return value == null || value === "null" ? undefined : value;
56
+ }
57
+
58
+ function sourceRecord(deps: ForkDeps, id: string, ctx: ExtensionContext): StartedRecorded {
59
+ deps.authorize(id, ctx);
60
+ const record = deps.results.find(id);
61
+ if (!record) throw new Error(`no delegation ${JSON.stringify(id)}`);
62
+ if (!isStarted(record)) throw new Error(`delegation ${id} is a workspace attempt, not a session to fork`);
63
+ assertChildAuthority(record, ctx, "fork");
64
+ deps.admit(record.agent, ctx);
65
+ deps.results.checkStorage(true);
66
+ return record;
67
+ }
68
+
69
+ function forkSource(record: StartedRecorded, session: ReturnType<typeof sessionFor>): ForkSource {
70
+ const own = recordedIdentity(record);
71
+ if (!own) throw new Error(`delegation ${record.id} has no original root placement identity; fork refused`);
72
+ return {
73
+ id: record.id,
74
+ sessionPath: session.path,
75
+ sessionDir: session.dir,
76
+ rootPlacement: own.rootPlacement,
77
+ sourceCwd: session.cwd,
78
+ };
79
+ }
80
+
81
+ function fallbackNote(chosen: Awaited<ReturnType<ResumeDeps["chooseModel"]>>): string {
82
+ return chosen.skipped.length
83
+ ? ` Used fallback ${chosen.model} because ${chosen.skipped.map((item) => `${item.model} is ${item.why}`).join("; ")}.`
84
+ : "";
85
+ }
86
+
87
+ async function sharedFolderNotice(deps: ForkDeps, record: StartedRecorded, worktree?: string): Promise<string[]> {
88
+ if (worktree || deriveState(record, windowState(await listWindows(deps.tmux), record)) !== "running") return [];
89
+ return [`shares a working folder with running delegation ${record.id}; pass worktree to isolate`];
90
+ }
91
+
92
+ function modelNotice(record: StartedRecorded, sessionPath: string, model: string): string[] {
93
+ const sourceModel = finishedModel(sessionPath) ?? record.result?.finishedModel ?? record.model;
94
+ if (model === sourceModel) return [];
95
+ const tokens = sessionInputTokens(sessionPath);
96
+ const estimate = tokens === undefined ? "copied tokens" : `about ${Math.round(tokens / 1000)}k copied tokens`;
97
+ return [`${model} differs from the source's ${sourceModel}: ${estimate} will be written to a new cache.`];
98
+ }
99
+
100
+ function launchText(id: string, details: Forked, chosen: Awaited<ReturnType<ResumeDeps["chooseModel"]>>): string {
101
+ const workspace = details.workspace;
102
+ const managed =
103
+ workspace.mode === "managed"
104
+ ? ` Branch ${workspace.createdBranch} starts at ${workspace.baseCommit}. ${dirtyNotice(workspace.sourceDirty)}`
105
+ : "";
106
+ return `Forked delegation ${id} as ${details.id} in window ${details.windowName} in ${details.cwd}.${managed} The original session is untouched. Its result arrives as a message that starts your next turn on its own; delegation_close ends it.${fallbackNote(chosen)}${details.notices.map((notice) => ` ${notice}`).join("")}`;
107
+ }
108
+
109
+ const modelParameter = Type.Optional(
110
+ Type.String({
111
+ description:
112
+ "provider/id; defaults to the source's last model. A different model re-reads the copied context without its cache; keep the source's model unless the task needs another role.",
113
+ }),
114
+ );
115
+
116
+ export function forkTool(deps: ForkDeps): ToolDef {
117
+ return {
118
+ name: "delegation_fork",
119
+ label: "Fork delegation",
120
+ description:
121
+ "Start a new direct child from a copy of an existing child's session context, with a new task, session id and window. The original child is untouched and may still be running. Runs in its working folder, or a new managed worktree from that folder's checkout. Results arrive as messages like other delegations.",
122
+ parameters: Type.Object({
123
+ id: Type.String({ description: "The direct child's delegation id whose context to copy." }),
124
+ task: Type.String(),
125
+ model: modelParameter,
126
+ thinking: Type.Optional(
127
+ Type.String({ description: `${THINKING_LEVELS.join(", ")}; defaults to the source's last level.` }),
128
+ ),
129
+ auto_exit: Type.Optional(Type.Boolean({ description: "Defaults to the source delegation's setting." })),
130
+ worktree: Type.Optional(
131
+ Type.String({ description: "New branch for a managed worktree from the child's checkout." }),
132
+ ),
133
+ worktree_base: Type.Optional(
134
+ Type.String({ description: "Local commit-ish; requires worktree. Defaults to the child's checkout HEAD." }),
135
+ ),
136
+ }),
137
+ outputSchema: Type.Object({ ...deps.delegateSchema.properties, forkedFrom: Type.String() }),
138
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
139
+ async execute(
140
+ toolCallId: string,
141
+ params: ForkRequest,
142
+ signal: AbortSignal | undefined,
143
+ _onUpdate: unknown,
144
+ ctx: ExtensionContext,
145
+ ): Promise<ReturnType<typeof toolResult>> {
146
+ if (!params.task?.trim()) throw new Error("a task is required");
147
+ const record = sourceRecord(deps, params.id, ctx);
148
+ const session = sessionFor(record, ctx, "forkable");
149
+ const roster = deps.roster(ctx);
150
+ const disabled = disabledProblem(roster, record.agent);
151
+ if (disabled) throw new Error(disabled);
152
+ const agent = roster.agents.find((item) => item.name === record.agent);
153
+ if (!agent) throw new Error(`agent ${JSON.stringify(record.agent)} is no longer in the roster`);
154
+ const { thinking, chosen } = await settleModel(
155
+ deps,
156
+ { record, agent, session, request: { model: given(params.model), thinking: given(params.thinking) } },
157
+ ctx,
158
+ );
159
+ const source = forkSource(record, session);
160
+ chosen.notices.push(...modelNotice(record, session.path, chosen.model));
161
+ chosen.notices.push(...(await sharedFolderNotice(deps, record, given(params.worktree))));
162
+ const details = await deps.launch(
163
+ {
164
+ ...chosen,
165
+ agent,
166
+ thinking,
167
+ autoExit: typeof params.auto_exit === "boolean" ? params.auto_exit : record.autoExit === true,
168
+ task: params.task,
169
+ cwd: session.cwd,
170
+ worktree: given(params.worktree),
171
+ worktree_base: given(params.worktree_base),
172
+ },
173
+ source,
174
+ ctx,
175
+ signal,
176
+ toolCallId,
177
+ );
178
+ const line = launchText(record.id, details, chosen);
179
+ ctx.ui.notify(`delegate: ${line}`, "info");
180
+ return toolResult(line, { outcome: "started", ...details, forkedFrom: record.id });
181
+ },
182
+ };
183
+ }
package/src/identity.ts CHANGED
@@ -8,7 +8,13 @@ import type { Agent, Roster } from "./agents.ts";
8
8
  import { readState, writeState } from "./local-state.ts";
9
9
  import { assertRootPlacement } from "./window-placement.ts";
10
10
 
11
- export const DELEGATION_TOOLS = ["delegate", "delegation_status", "delegation_close", "delegation_resume"] as const;
11
+ export const DELEGATION_TOOLS = [
12
+ "delegate",
13
+ "delegation_status",
14
+ "delegation_close",
15
+ "delegation_resume",
16
+ "delegation_fork",
17
+ ] as const;
12
18
  export type Identity = {
13
19
  parent: string;
14
20
  root: string;
package/src/index.ts CHANGED
@@ -24,7 +24,7 @@
24
24
  // An agent with no `model` or `thinking` runs
25
25
  // on the parent's.
26
26
  //
27
- // The model gets four tools. `delegate` takes agent, task and optional model
27
+ // The model gets five tools. `delegate` takes agent, task and optional model
28
28
  // and thinking, validates them the way the command does, and starts the
29
29
  // delegation at once, opening no dialog, so parallel calls cannot block one
30
30
  // another; the one exception is a `cwd` whose project trust is unresolved,
@@ -34,7 +34,9 @@
34
34
  // envelope paths.
35
35
  // `delegation_close` kills the window and records the close. `delegation_resume`
36
36
  // (`resume.ts`) reopens an exited delegate's session in a new window with a
37
- // follow-up task, recorded as a new delegation. A delegate can launch children
37
+ // follow-up task, recorded as a new delegation. `delegation_fork` copies a
38
+ // child's context into a new session, leaving the original untouched.
39
+ // A delegate can launch children
38
40
  // only below maxDepth and with its current agent allow_nested_delegation grant.
39
41
  // Durable identity restores that boundary even on a manual session reopening.
40
42
  //
@@ -67,6 +69,7 @@ import {
67
69
  } from "./agents.ts";
68
70
  import { AGENT_ENV, AUTO_EXIT_ENV, FALLBACK_ENV, registerChild } from "./child.ts";
69
71
  import { registerCompletionHost } from "./completion-holds.ts";
72
+ import { type ForkSource, forkTool } from "./fork.ts";
70
73
  import { createIdentity, grantsNesting, type Identity, readIdentity, writeIdentity } from "./identity.ts";
71
74
  import { activeMark, clearMarks, isFree, readMarks } from "./limits.ts";
72
75
  import { createNavigator, OPEN_USAGE, openCompletions, parseOpen } from "./navigator.ts";
@@ -94,7 +97,7 @@ import {
94
97
  reconcileTree,
95
98
  reserveTree,
96
99
  } from "./tree-registry.ts";
97
- import { assertResumeAuthority, historicalRecord, visibleRecords } from "./tree-view.ts";
100
+ import { assertChildAuthority, assertResumeAuthority, historicalRecord, visibleRecords } from "./tree-view.ts";
98
101
  import { registerTrustControl } from "./trust-control.ts";
99
102
  import { createProactive, createUsageClient, type Proactive, type UsageClient } from "./usage.ts";
100
103
  import { registerDelegateWidget } from "./widget.ts";
@@ -135,6 +138,7 @@ const DELEGATION_TOOLS: ReadonlySet<string> = new Set([
135
138
  "delegation_status",
136
139
  "delegation_close",
137
140
  "delegation_resume",
141
+ "delegation_fork",
138
142
  ]);
139
143
  /** Asked of every delegate, so the parent gets an answer it can use alone. */
140
144
 
@@ -912,7 +916,8 @@ export default function delegate(
912
916
  * Build the delegate's command line and environment, emit `session:launch`
913
917
  * and open its window; returns the window id and session. A launch and a resume share
914
918
  * it and differ only in `session`, the arguments that choose the session:
915
- * `--session-id <id> --name <name>` for a new one, `--session <path>` to reopen.
919
+ * `--session-id <id> --name <name>` for a new one (plus `--fork <path>`
920
+ * to copy context), `--session <path>` to reopen.
916
921
  */
917
922
  async function openChild(input: {
918
923
  id: string;
@@ -933,6 +938,7 @@ export default function delegate(
933
938
  onWindowOpened?: () => void;
934
939
  identity: Identity;
935
940
  sessionId?: string;
941
+ forkSourceCwd?: string;
936
942
  }): Promise<OpenedWindow> {
937
943
  const {
938
944
  id,
@@ -964,7 +970,11 @@ export default function delegate(
964
970
  : workspace.mode === "existing"
965
971
  ? `\n\nThe session that started you chose your working directory: ${workspace.cwd}`
966
972
  : "";
967
- const prompt = writePromptFile(delegatePrompt(start.agent, managedContext));
973
+ const forkContext =
974
+ workspace.mode === "managed" && input.forkSourceCwd
975
+ ? `\n\nThis session was forked from one that worked in ${input.forkSourceCwd}. Work only in ${workspace.cwd}; its uncommitted changes are not here.`
976
+ : "";
977
+ const prompt = writePromptFile(delegatePrompt(start.agent, managedContext + forkContext));
968
978
  const argv = [
969
979
  ...parentCommand(),
970
980
  ...session,
@@ -1060,6 +1070,7 @@ export default function delegate(
1060
1070
  placement: WindowPlacement;
1061
1071
  tools: readonly string[];
1062
1072
  onWindowOpened: () => void;
1073
+ fork?: ForkSource;
1063
1074
  }): Promise<Launched> {
1064
1075
  const { id, requestId, workspace, trusted, cwd, sessionDir, start, toolCallId, ctx, placement, onWindowOpened } =
1065
1076
  input;
@@ -1067,7 +1078,7 @@ export default function delegate(
1067
1078
  const { id: windowId, session: windowSession } = await openChild({
1068
1079
  id,
1069
1080
  name,
1070
- session: ["--session-id", id, "--name", name],
1081
+ session: [...(input.fork ? ["--fork", input.fork.sessionPath] : []), "--session-id", id, "--name", name],
1071
1082
  identity: input.identity,
1072
1083
  workspace,
1073
1084
  trusted,
@@ -1079,6 +1090,7 @@ export default function delegate(
1079
1090
  placement,
1080
1091
  tools: input.tools,
1081
1092
  onWindowOpened,
1093
+ forkSourceCwd: input.fork?.sourceCwd,
1082
1094
  });
1083
1095
  // No task text in the record: the request copy in `sent/` has it.
1084
1096
  results.recordStart(
@@ -1097,6 +1109,7 @@ export default function delegate(
1097
1109
  cwd,
1098
1110
  sessionDir,
1099
1111
  workspace,
1112
+ ...(input.fork ? { forkedFrom: input.fork.id } : {}),
1100
1113
  },
1101
1114
  ctx,
1102
1115
  );
@@ -1109,6 +1122,7 @@ export default function delegate(
1109
1122
  ctx: ExtensionContext,
1110
1123
  signal?: AbortSignal,
1111
1124
  toolCallId?: string,
1125
+ fork?: ForkSource,
1112
1126
  ): Promise<Launched> {
1113
1127
  admit(start.agent.name, currentRoster(ctx));
1114
1128
  results.checkStorage(true);
@@ -1119,13 +1133,15 @@ export default function delegate(
1119
1133
  const parentIdentity = identity.current();
1120
1134
  const project = parentIdentity
1121
1135
  ? { companion: parentIdentity.rootPlacement, notices: [] }
1122
- : placementProject(ctx.cwd, companionSuffix);
1136
+ : fork
1137
+ ? { companion: fork.rootPlacement, notices: [] }
1138
+ : placementProject(ctx.cwd, companionSuffix);
1123
1139
  assertRootPlacement(project.companion);
1124
1140
  const own = identity.child(start.agent, ctx, project.companion);
1125
1141
  if (parentIdentity) assertParentPlacement(readTree(own.root), own);
1126
1142
  if (placement === "companion") start.notices.push(...project.notices);
1127
1143
  const tools = childTools(start.agent);
1128
- const sessionDir = ctx.sessionManager.getSessionDir();
1144
+ const sessionDir = fork?.sessionDir ?? ctx.sessionManager.getSessionDir();
1129
1145
  if (!isAbsolute(sessionDir))
1130
1146
  throw new Error(
1131
1147
  `the parent's session directory ${JSON.stringify(sessionDir)} is not an absolute path; start Pi with an absolute --session-dir`,
@@ -1188,7 +1204,13 @@ export default function delegate(
1188
1204
  await reconcileTree(own.root, tmux);
1189
1205
  admit(start.agent.name, currentRoster(ctx));
1190
1206
  if (combined?.aborted) throw new Error("the launch was aborted");
1191
- reservation = reserveTree({ id, sessionId: id, identity: own, sessionDir });
1207
+ reservation = reserveTree({
1208
+ id,
1209
+ sessionId: id,
1210
+ identity: own,
1211
+ sessionDir,
1212
+ ...(fork ? { forkedFrom: fork.id } : {}),
1213
+ });
1192
1214
  if (plan !== undefined) {
1193
1215
  revalidate(plan);
1194
1216
  revalidateTrust?.();
@@ -1259,6 +1281,7 @@ export default function delegate(
1259
1281
  onWindowOpened: () => {
1260
1282
  windowOpened = true;
1261
1283
  },
1284
+ fork,
1262
1285
  });
1263
1286
  };
1264
1287
  const runWithRollback = async (): Promise<Launched> => {
@@ -1743,4 +1766,51 @@ export default function delegate(
1743
1766
  registerDelegate(toolDescription(currentRoster().agents));
1744
1767
  tracking.registerTools();
1745
1768
  pi.registerTool(resumer.tool() as never);
1769
+ pi.registerTool(
1770
+ forkTool({
1771
+ results,
1772
+ tmux,
1773
+ delegateSchema: DelegateSchema,
1774
+ roster: rosterFor,
1775
+ parentDefaults,
1776
+ chooseModel: (explicit, agent, models, preferred) =>
1777
+ chooseModel(explicit, agent, models, proactive, agentModels(agent, undefined), preferred),
1778
+ thinkingProblem,
1779
+ authorize: (id, ctx) => {
1780
+ owner.assert();
1781
+ results.checkStorage();
1782
+ const record = results.find(id) ?? viewResults.find(id) ?? historicalRecord(id, ctx);
1783
+ if (record && isStarted(record)) assertChildAuthority(record, ctx, "fork");
1784
+ },
1785
+ admit: (agent, ctx) => admit(agent, currentRoster(ctx)),
1786
+ launch: async (start, source, ctx, signal, toolCallId) => {
1787
+ const launched = await launch(
1788
+ {
1789
+ ...start,
1790
+ cwd: start.worktree ? undefined : start.cwd,
1791
+ worktree_source: start.worktree ? start.cwd : undefined,
1792
+ },
1793
+ ctx,
1794
+ signal,
1795
+ toolCallId,
1796
+ source,
1797
+ );
1798
+ return {
1799
+ id: launched.id,
1800
+ agent: start.agent.name,
1801
+ model: start.model,
1802
+ skipped: start.skipped.map(({ model, reason }) => ({ model, reason })),
1803
+ notices: start.notices,
1804
+ thinking: start.thinking,
1805
+ autoExit: start.autoExit,
1806
+ windowId: launched.windowId,
1807
+ windowName: launched.windowName,
1808
+ ...(launched.windowSession === undefined ? {} : { windowSession: launched.windowSession }),
1809
+ cwd: launched.cwd,
1810
+ sessionDir: launched.sessionDir,
1811
+ workspace: launched.workspace,
1812
+ };
1813
+ },
1814
+ }) as never,
1815
+ );
1746
1816
  }
@@ -165,6 +165,7 @@ function detailLines(item: OpenDelegate): string[] {
165
165
  `session file: ${item.live?.sessionFile ?? locateSession({ ...record }, undefined) ?? "not found"}`,
166
166
  `session directory: ${record.sessionDir ?? "not recorded"}`,
167
167
  `result: ${record.result?.status ?? "none received"} · envelope: ${record.result?.envelopePath ?? "none"}`,
168
+ ...(record.forkedFrom === undefined ? [] : [`forked from: ${record.forkedFrom}`]),
168
169
  `resumed from: ${record.resumedFrom ?? "none"} · resumed as: ${item.resumedAs ?? "none"} · auto-exit: ${record.autoExit === true ? "on" : "off"}`,
169
170
  `model: ${record.model} · thinking: ${record.thinking}`,
170
171
  ];
package/src/render.ts CHANGED
@@ -155,6 +155,7 @@ function plainLines(text: string): string[] {
155
155
  function idLines(details: MessageDetails): string[] {
156
156
  const lines = [`delegation ${details.id}`, `request ${details.requestId}`];
157
157
  if (details.resumedFrom !== undefined) lines.splice(1, 0, `resumed from ${details.resumedFrom}`);
158
+ if (details.forkedFrom !== undefined) lines.splice(1, 0, `forked from ${details.forkedFrom}`);
158
159
  if (details.kind === "result") lines.push(`envelope ${tilde(details.envelopePath)}`);
159
160
  else lines.push(`window ${details.windowName}`);
160
161
  lines.push(`cwd ${details.cwd === undefined ? cwdLine(undefined) : tilde(details.cwd)}`);
@@ -35,9 +35,14 @@ function limitLine(sessionId: string, status: string): string | undefined {
35
35
  }
36
36
 
37
37
  /** The details that say a delegation reopened an earlier one's session. */
38
- export function resumeDetails(record: StartedRecorded): { resumedFrom?: string; sessionId?: string } {
38
+ export function resumeDetails(record: StartedRecorded): {
39
+ resumedFrom?: string;
40
+ forkedFrom?: string;
41
+ sessionId?: string;
42
+ } {
39
43
  return {
40
44
  ...(record.resumedFrom === undefined ? {} : { resumedFrom: record.resumedFrom }),
45
+ ...(record.forkedFrom === undefined ? {} : { forkedFrom: record.forkedFrom }),
41
46
  ...(record.sessionId === undefined ? {} : { sessionId: record.sessionId }),
42
47
  };
43
48
  }
@@ -98,7 +103,7 @@ export function resultText(details: ResultDetails): string {
98
103
  const { task } = details;
99
104
  const taskLine = "note" in task ? task.note : `Task: ${task.path}`;
100
105
  return [
101
- `[delegate] Result from ${details.agent} (${modelLabel(details.model, details.finishedModel)}), delegation ${details.id}${details.resumedFrom === undefined ? "" : ` (resumed from ${details.resumedFrom})`}, request ${details.requestId}.`,
106
+ `[delegate] Result from ${details.agent} (${modelLabel(details.model, details.finishedModel)}), delegation ${details.id}${details.resumedFrom === undefined ? "" : ` (resumed from ${details.resumedFrom})`}${details.forkedFrom === undefined ? "" : ` (forked from ${details.forkedFrom})`}, request ${details.requestId}.`,
102
107
  `Status: ${details.status}`,
103
108
  ...(details.limit === undefined ? [] : [details.limit]),
104
109
  taskLine,
@@ -78,6 +78,32 @@ export function finishedModel(sessionPath: string | undefined): string | undefin
78
78
  return undefined;
79
79
  }
80
80
 
81
+ /** Approximate copied context from the last assistant request, excluding its output. */
82
+ export function sessionInputTokens(sessionPath: string): number | undefined {
83
+ let lines: string[];
84
+ try {
85
+ lines = readFileSync(sessionPath, "utf8").split("\n");
86
+ } catch {
87
+ return undefined;
88
+ }
89
+ for (let i = lines.length - 1; i >= 0; i--) {
90
+ let entry: { type?: unknown; message?: { role?: unknown; usage?: Record<string, unknown> } };
91
+ try {
92
+ entry = JSON.parse(lines[i]);
93
+ } catch {
94
+ continue;
95
+ }
96
+ if (entry?.type !== "message" || entry.message?.role !== "assistant") continue;
97
+ const usage = entry.message.usage;
98
+ const counts = [usage?.input, usage?.cacheRead, usage?.cacheWrite];
99
+ if (!counts.every((count): count is number => typeof count === "number" && Number.isFinite(count) && count >= 0))
100
+ return undefined;
101
+ const total = counts.reduce((sum, count) => sum + count, 0);
102
+ return Number.isFinite(total) && total > 0 ? total : undefined;
103
+ }
104
+ return undefined;
105
+ }
106
+
81
107
  /** One session-file line, as much of it as the branch walk reads. */
82
108
  type BranchEntry = { type?: unknown; id?: unknown; parentId?: unknown; thinkingLevel?: unknown };
83
109
 
package/src/results.ts CHANGED
@@ -66,6 +66,8 @@ export type Delegation = {
66
66
  workspace?: Workspace;
67
67
  /** The delegation this one resumed, when it reopened that delegation's session. */
68
68
  resumedFrom?: string;
69
+ /** The delegation whose context this new session copied. */
70
+ forkedFrom?: string;
69
71
  /** The Pi session id; absent means `id`. A resumed delegation holds the session id of the one it resumed. */
70
72
  sessionId?: string;
71
73
  };
@@ -113,6 +115,7 @@ type CommonDetails = {
113
115
  id: string;
114
116
  /** The delegation this one resumed. */
115
117
  resumedFrom?: string;
118
+ forkedFrom?: string;
116
119
  /** The Pi session id when it is not `id`: a resumed delegation's. */
117
120
  sessionId?: string;
118
121
  requestId: string;
@@ -375,7 +378,7 @@ export function createResults(
375
378
  windowName: record.windowName,
376
379
  };
377
380
  const content = [
378
- `[delegate] Delegation ${record.id}${record.resumedFrom === undefined ? "" : ` (resumed from ${record.resumedFrom})`} (${record.agent}, ${modelLabel(record.model, finished)}), request ${record.requestId}, closed without a result: its window ${record.windowName} is gone and no reply arrived.`,
381
+ `[delegate] Delegation ${record.id}${record.resumedFrom === undefined ? "" : ` (resumed from ${record.resumedFrom})`}${record.forkedFrom === undefined ? "" : ` (forked from ${record.forkedFrom})`} (${record.agent}, ${modelLabel(record.model, finished)}), request ${record.requestId}, closed without a result: its window ${record.windowName} is gone and no reply arrived.`,
379
382
  `Working directory: ${cwdLine(record.cwd)}`,
380
383
  ...(record.workspace === undefined ? [] : [`Workspace: ${workspaceLine(record.workspace)}`]),
381
384
  `Delegate session: ${session ?? sessionIdOf(record)}`,
@@ -53,28 +53,32 @@ export function isDirectory(path: string): boolean {
53
53
  export type SessionFile = { path: string; cwd: string; dir: string };
54
54
 
55
55
  /** Step 5: the session file, and the directory its header says Pi will run it in. */
56
- export function sessionFor(record: StartedRecorded, ctx: ExtensionContext): SessionFile {
56
+ export function sessionFor(
57
+ record: StartedRecorded,
58
+ ctx: ExtensionContext,
59
+ purpose: "resumable" | "forkable" = "resumable",
60
+ ): SessionFile {
57
61
  const dir = record.sessionDir ?? ctx.sessionManager.getSessionDir();
58
62
  const path = locateSession(record, ctx.sessionManager.getSessionDir());
59
63
  if (path === undefined) {
60
64
  throw new Error(
61
- `no session file for session ${sessionIdOf(record)} was found in ${dir}, so delegation ${record.id} is not resumable`,
65
+ `no session file for session ${sessionIdOf(record)} was found in ${dir}, so delegation ${record.id} is not ${purpose}`,
62
66
  );
63
67
  }
64
68
  if (!isAbsolute(path) || !isAbsolute(dir)) {
65
69
  throw new Error(
66
- `the session file ${path} or its directory ${dir} is not an absolute path, so delegation ${record.id} is not resumable`,
70
+ `the session file ${path} or its directory ${dir} is not an absolute path, so delegation ${record.id} is not ${purpose}`,
67
71
  );
68
72
  }
69
73
  const cwd = sessionHeaderCwd(path);
70
74
  if (cwd === undefined) {
71
75
  throw new Error(
72
- `could not read a working directory from the header of ${path}, so delegation ${record.id} is not resumable`,
76
+ `could not read a working directory from the header of ${path}, so delegation ${record.id} is not ${purpose}`,
73
77
  );
74
78
  }
75
79
  if (!isDirectory(cwd)) {
76
80
  throw new Error(
77
- `delegation ${record.id}'s session runs in ${cwd}, which no longer exists, so it is not resumable (a removed worktree cannot be resumed)`,
81
+ `delegation ${record.id}'s session runs in ${cwd}, which no longer exists, so it is not ${purpose} (a removed worktree cannot be ${purpose === "resumable" ? "resumed" : "forked"})`,
78
82
  );
79
83
  }
80
84
  return { path, cwd, dir };
@@ -144,7 +148,7 @@ export function recordCheck(
144
148
 
145
149
  /** The thinking level and model a resume runs with; throws when the level is invalid or no model can be used. */
146
150
  export async function settleModel(
147
- deps: ResumeDeps,
151
+ deps: Pick<ResumeDeps, "parentDefaults" | "thinkingProblem" | "chooseModel">,
148
152
  input: {
149
153
  record: StartedRecorded;
150
154
  agent: Agent;
package/src/tracking.ts CHANGED
@@ -81,6 +81,7 @@ const StartedSchema = Type.Object({
81
81
  thinking: Type.String(),
82
82
  autoExit: Type.Boolean(),
83
83
  resumedFrom: Type.Optional(Type.String()),
84
+ forkedFrom: Type.Optional(Type.String()),
84
85
  windowId: Type.String(),
85
86
  windowName: Type.String(),
86
87
  windowSession: Type.Optional(Type.String()),
@@ -182,6 +183,7 @@ function viewText({ record, state, name, sessionPath, lastActive, snapshot, resu
182
183
  ` identity: session ${sessionIdOf(record)} · parent ${record.identity?.parent ?? "this session"} · root ${record.identity?.root ?? "this session"} · depth ${record.identity?.depth ?? 1}`,
183
184
  ...(accountingFor(record) ? [` shared accounting: ${accountingFor(record)} (not result processing)`] : []),
184
185
  ...(record.resumedFrom === undefined ? [] : [` resumed from ${record.resumedFrom}`]),
186
+ ...(record.forkedFrom === undefined ? [] : [` forked from ${record.forkedFrom}`]),
185
187
  ...(resumedAs === undefined ? [] : [` resumed as ${resumedAs}`]),
186
188
  ` state: ${stateLine(state, record)}${seen}`,
187
189
  ` window: ${record.windowName} (${record.windowId})${windowSession === undefined || windowSession === "" ? "" : ` in session ${windowSession}`}`,
@@ -223,6 +225,7 @@ function viewDetails({
223
225
  thinking: record.thinking,
224
226
  autoExit: record.autoExit === true,
225
227
  ...(record.resumedFrom === undefined ? {} : { resumedFrom: record.resumedFrom }),
228
+ ...(record.forkedFrom === undefined ? {} : { forkedFrom: record.forkedFrom }),
226
229
  windowId: record.windowId,
227
230
  windowName: record.windowName,
228
231
  ...(windowSession === undefined || windowSession === "" ? {} : { windowSession }),
@@ -82,7 +82,7 @@ function validateFields(entry: TreeEntry): void {
82
82
  for (const key of ["finished", "gone", "closed", "closing", "createPending"] as const)
83
83
  if (entry[key] !== undefined && typeof entry[key] !== "boolean")
84
84
  throw new Error(`invalid ${key} in tree entry ${entry.id}`);
85
- for (const key of ["requestId", "windowId", "windowName", "cwd", "sessionDir", "resumedFrom"] as const)
85
+ for (const key of ["requestId", "windowId", "windowName", "cwd", "sessionDir", "resumedFrom", "forkedFrom"] as const)
86
86
  if (entry[key] !== undefined && typeof entry[key] !== "string")
87
87
  throw new Error(`invalid ${key} in tree entry ${entry.id}`);
88
88
  }
@@ -264,6 +264,7 @@ export function publishTreeRecords(root: string, parent: string, records: Record
264
264
  ownerPid: process.pid,
265
265
  phase: "started",
266
266
  resumedFrom: record.resumedFrom,
267
+ forkedFrom: record.forkedFrom,
267
268
  };
268
269
  state.entries.push(entry);
269
270
  }
package/src/tree-types.ts CHANGED
@@ -16,6 +16,7 @@ export type TreeEntry = {
16
16
  sessionDir?: string;
17
17
  workspace?: Workspace;
18
18
  resumedFrom?: string;
19
+ forkedFrom?: string;
19
20
  launch?: Delegation;
20
21
  finished?: boolean;
21
22
  gone?: boolean;
@@ -28,5 +29,5 @@ export type TreeEntry = {
28
29
  export type TreeClose = { id: string; target: string; entries: string[] };
29
30
  export type TreeState = { version: 1; root: string; entries: TreeEntry[]; closes?: TreeClose[] };
30
31
  export type Reservation = { root: string; id: string };
31
- export type TreeIntent = Pick<TreeEntry, "id" | "sessionId" | "identity" | "resumedFrom" | "sessionDir">;
32
+ export type TreeIntent = Pick<TreeEntry, "id" | "sessionId" | "identity" | "resumedFrom" | "forkedFrom" | "sessionDir">;
32
33
  export type TreeCounts = { concurrent: number; lifetime: number };
package/src/tree-view.ts CHANGED
@@ -33,24 +33,35 @@ export function belongsTo(record: Recorded, ctx: ExtensionContext): boolean {
33
33
  // never by a branch copied into a fresh session file.
34
34
  return !ctx.sessionManager.getHeader?.()?.parentSession;
35
35
  }
36
- export function assertResumeAuthority(record: StartedRecorded, ctx: ExtensionContext): void {
36
+ export function assertChildAuthority(
37
+ record: StartedRecorded,
38
+ ctx: ExtensionContext,
39
+ operation: "resume" | "fork",
40
+ ): void {
37
41
  const child: Identity | undefined = recordedIdentity(record);
38
42
  if (!belongsTo(record, ctx))
39
43
  throw new Error(
40
- `only direct parent ${child?.parent ?? "(original session)"} may resume delegation ${record.id}; reopen that parent session`,
44
+ `only direct parent ${child?.parent ?? "(original session)"} may ${operation} delegation ${record.id}; reopen that parent session`,
41
45
  );
42
- if (!child) throw new Error(`delegation ${record.id} has no original root placement identity; resume refused`);
46
+ if (!child) throw new Error(`delegation ${record.id} has no original root placement identity; ${operation} refused`);
43
47
  const parent: Identity | undefined = readIdentity(ctx.sessionManager.getSessionId());
44
48
  if (
45
49
  child.root !== (parent?.root ?? ctx.sessionManager.getSessionId()) ||
46
50
  child.depth !== (parent?.depth ?? 0) + 1 ||
47
51
  (parent && child.rootPlacement !== parent.rootPlacement)
48
52
  )
49
- throw new Error(`delegation ${record.id} has inconsistent root/depth or rootPlacement ownership; resume refused`);
53
+ throw new Error(
54
+ `delegation ${record.id} has inconsistent root/depth or rootPlacement ownership; ${operation} refused`,
55
+ );
50
56
  if (child.depth > maxDepth())
51
57
  throw new Error(
52
58
  `delegation ${record.id} depth ${child.depth} exceeds maxDepth ${maxDepth()} in squire/settings.json`,
53
59
  );
60
+ }
61
+ export function assertResumeAuthority(record: StartedRecorded, ctx: ExtensionContext): void {
62
+ assertChildAuthority(record, ctx, "resume");
63
+ const child = recordedIdentity(record);
64
+ if (!child) throw new Error(`delegation ${record.id} has no original root placement identity; resume refused`);
54
65
  const state = readTree(child.root);
55
66
  const newer: TreeEntry | undefined = newestSuccessor(state.entries, record.id);
56
67
  if (newer)
@@ -197,6 +208,7 @@ function registryView(entry: TreeEntry): StartedRecorded {
197
208
  sessionDir: entry.sessionDir,
198
209
  workspace: entry.workspace,
199
210
  resumedFrom: entry.resumedFrom,
211
+ forkedFrom: entry.forkedFrom,
200
212
  };
201
213
  }
202
214
  function hierarchy(records: Recorded[], viewer: string): Recorded[] {