pi-squire 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,34 @@ follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.20.0] - 2026-11-02
10
+
11
+ ### Added
12
+
13
+ - `delegation_fork({ id, task })` starts a new delegation from a direct child's
14
+ session context using `pi --fork`, leaving the original session and window
15
+ untouched. It runs in the child's folder or a new managed worktree, inherits
16
+ resume-style model/thinking/auto-exit defaults, and reapplies current roster,
17
+ nesting, capacity and trust checks. New records, status and results carry
18
+ `forkedFrom`; replies use the new session's own address. The metadata budget
19
+ test increases from 10,000 to 12,000 characters to cover the fifth tool.
20
+ Managed forks report their cwd, branch, base and uncopied changes, and their
21
+ prompt directs them away from the original checkout; non-isolated forks warn
22
+ about sharing a folder with a running source. Dashboard details show the
23
+ source id, foreign callers get an explicit direct-parent refusal, and the
24
+ fork tool declares its destructive-operation hint.
25
+
26
+ ## [0.19.1] - 2026-11-02
27
+
28
+ ### Removed
29
+
30
+ - Fresh sessions no longer report other sessions' running delegates at
31
+ startup, and the "owner discovery unavailable" and "discovery is limited"
32
+ warnings are gone with that check. A session only ever reports its own tree;
33
+ to collect the delegates of a session you closed, reopen that session. The
34
+ tests that pinned the startup notices now pin that a fresh session reports
35
+ nothing, and the notice-budget tests are removed.
36
+
9
37
  ## [0.19.0] - 2026-11-02
10
38
 
11
39
  ### Changed
@@ -1083,7 +1111,9 @@ author's dotfiles; the changes below are against that copy.
1083
1111
 
1084
1112
  - The published file list includes `child.ts`, which `index.ts` imports.
1085
1113
 
1086
- [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.19.0...HEAD
1114
+ [Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.20.0...HEAD
1115
+ [0.20.0]: https://github.com/gvanderclay/pi-squire/compare/v0.19.1...v0.20.0
1116
+ [0.19.1]: https://github.com/gvanderclay/pi-squire/compare/v0.19.0...v0.19.1
1087
1117
  [0.19.0]: https://github.com/gvanderclay/pi-squire/compare/v0.18.4...v0.19.0
1088
1118
  [0.18.4]: https://github.com/gvanderclay/pi-squire/compare/v0.18.3...v0.18.4
1089
1119
  [0.18.3]: https://github.com/gvanderclay/pi-squire/compare/v0.18.2...v0.18.3
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.
@@ -323,21 +323,9 @@ one Pi process per session. Ownership refusals name the PID and owner file; if
323
323
  stale/corrupt, verify the original owner has ended, stop affected Squire runtimes
324
324
  and repair only that record. Never remove live lock tickets or force adoption.
325
325
 
326
- Fresh sessions do not adopt earlier trees. At startup a fresh root inspects only
327
- this agent directory's newest **64** registry files, and only entries using its
328
- current session-storage directory. It reports at most **10** direct owners with
329
- positively matched live windows and explains reopening the owning session (or
330
- returning to its existing owner). Registry-content reads are limited to 1 MiB
331
- per tree and one **200 ms total cooperative notice budget**, which starts once
332
- tmux has listed its windows, with registry lock waits capped at the remaining
333
- budget or 20 ms. Discovery runs after `session_start` returns, so it never
334
- delays startup or a session switch; the tmux listing is bounded by the tmux
335
- client's own timeout, and a session that ends first reports nothing. The scan
336
- yields between trees and reports, once, a tmux failure with its message,
337
- unreadable owner records or truncated scope. Individual filesystem operations cannot be preempted.
338
- Directory names/mtimes are enumerated, but no recursive
339
- session search, mailbox search or other agent-directory search occurs. Exact-id
340
- restoration is not restricted by this notice budget.
326
+ Fresh sessions neither adopt nor report earlier trees. Each session sees only
327
+ its own tree; to collect the delegates of a session you closed, reopen that
328
+ exact session, which restores its full tree.
341
329
 
342
330
  Both `delegation_status` and `/delegates` show descendants: a root sees its tree;
343
331
  a coordinator sees only its own subtree, not siblings or unrelated roots.
@@ -1033,7 +1021,7 @@ agent that uses a bundle, with the reason; agents without bundles are
1033
1021
  unaffected. (`$`, unlike `@`, may start a plain YAML value.)
1034
1022
 
1035
1023
  The delegation tools (`delegate`, `delegation_status`, `delegation_close`,
1036
- `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
1037
1025
  coordinator below `maxDepth` receives them automatically; a leaf has them
1038
1026
  inactive. Commands are still registered but cannot bypass admission. See the
1039
1027
  unreleased [nesting restrictions](#nested-delegation-unreleased).
@@ -1147,7 +1135,7 @@ agent's `auto_exit`.
1147
1135
 
1148
1136
  ## Tools
1149
1137
 
1150
- 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
1151
1139
  session's delegation records. In a delegate they are active only for an
1152
1140
  opted-in coordinator below `maxDepth`; admission also checks every launch/resume.
1153
1141
 
@@ -1157,6 +1145,7 @@ opted-in coordinator below `maxDepth`; admission also checks every launch/resume
1157
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. |
1158
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. |
1159
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)). |
1160
1149
 
1161
1150
  The `delegate` tool's description lists the current roster, one line per agent:
1162
1151
  its name and any display name, description, default model (or this session's)
@@ -1650,6 +1639,40 @@ and it resumes as the command does.
1650
1639
  - Not detected: a Pi process you opened yourself on the same session outside
1651
1640
  pi-squire's window. Close it before resuming.
1652
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.
1674
+ `session:launch` is emitted with the `delegation_fork` tool call id.
1675
+
1653
1676
  ## Hooks
1654
1677
 
1655
1678
  The `js` block below is the contract's worked example, and
@@ -1672,7 +1695,7 @@ the window is open (see [Resuming a delegation](#resuming-a-delegation)).
1672
1695
  | `env` | the child's environment, as `KEY`/`value` pairs |
1673
1696
  | `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 |
1674
1697
  | `cwd` | the directory the window opens in, the parent's or the resolved `--cwd`; read it, do not change it |
1675
- | `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 |
1698
+ | `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 |
1676
1699
  | `parent` | Read-only direct parent session id. |
1677
1700
  | `root` | Read-only root session id; retained on resume. |
1678
1701
  | `depth` | Read-only numeric child depth, root = 0; retained on resume. |
@@ -1700,9 +1723,9 @@ environment into these arguments.
1700
1723
 
1701
1724
  `cwd`, the trust flag (`--approve` or `--no-approve`), `--session-dir` and the
1702
1725
  session choice (`--session-id` for a new delegate, `--session` for a [resumed](#resuming-a-delegation)
1703
- one) are launch invariants that `pi-squire` chooses. The window opens in its own `cwd`,
1726
+ 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`,
1704
1727
  whatever a listener assigns to the payload. A listener must not add another
1705
- trust flag, `--session-dir`, `--session` or `--session-id`, or a working-directory change to `args`, since
1728
+ trust flag, `--session-dir`, `--session`, `--fork` or `--session-id`, or a working-directory change to `args`, since
1706
1729
  that would launch a child whose trust or session storage the parent did not
1707
1730
  choose and cannot find; append only unrelated settings.
1708
1731
 
@@ -1977,7 +2000,7 @@ Other extensions may rely on:
1977
2000
  [Hooks](#hooks));
1978
2001
  - the `message:send`, `message:reply`, `message:inbound` and `message:scan` hooks, consumed as
1979
2002
  their sections document;
1980
- - the `delegate`, `delegation_status`, `delegation_close` and `delegation_resume` tool names,
2003
+ - the `delegate`, `delegation_status`, `delegation_close`, `delegation_resume` and `delegation_fork` tool names,
1981
2004
  parameters, the recorded `workspace` shape and the `workspaceSnapshot`
1982
2005
  shape in results and status;
1983
2006
  - 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.0",
3
+ "version": "0.20.0",
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,166 @@
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 { deriveState, isStarted, type StartedRecorded } from "./results.ts";
7
+ import type { ResumeDeps, Resumed } from "./resume.ts";
8
+ import { sessionFor, settleModel } from "./resume-checks.ts";
9
+ import { listWindows, type ToolDef, toolResult, windowState } from "./tracking.ts";
10
+ import { assertChildAuthority, recordedIdentity } from "./tree-view.ts";
11
+
12
+ export type ForkSource = {
13
+ id: string;
14
+ sessionPath: string;
15
+ sessionDir: string;
16
+ rootPlacement: string;
17
+ sourceCwd: string;
18
+ };
19
+ type Forked = Omit<Resumed, "resumedFrom" | "fallbackNote">;
20
+ type ForkRequest = {
21
+ id: string;
22
+ task: string;
23
+ model?: string | null;
24
+ thinking?: string | null;
25
+ auto_exit?: boolean | null;
26
+ worktree?: string | null;
27
+ worktree_base?: string | null;
28
+ };
29
+ type ForkStart = Awaited<ReturnType<ResumeDeps["chooseModel"]>> & {
30
+ agent: ReturnType<ResumeDeps["roster"]>["agents"][number];
31
+ thinking: string;
32
+ autoExit: boolean;
33
+ task: string;
34
+ cwd: string;
35
+ worktree?: string;
36
+ worktree_base?: string;
37
+ };
38
+ type ForkDeps = Pick<
39
+ ResumeDeps,
40
+ "results" | "tmux" | "roster" | "parentDefaults" | "chooseModel" | "thinkingProblem" | "delegateSchema"
41
+ > & {
42
+ authorize(id: string, ctx: ExtensionContext): void;
43
+ admit(agent: string, ctx: ExtensionContext): void;
44
+ launch(
45
+ start: ForkStart,
46
+ source: ForkSource,
47
+ ctx: ExtensionContext,
48
+ signal: AbortSignal | undefined,
49
+ toolCallId: string,
50
+ ): Promise<Forked>;
51
+ };
52
+
53
+ function given(value: string | null | undefined): string | undefined {
54
+ return value == null || value === "null" ? undefined : value;
55
+ }
56
+
57
+ function sourceRecord(deps: ForkDeps, id: string, ctx: ExtensionContext): StartedRecorded {
58
+ deps.authorize(id, ctx);
59
+ const record = deps.results.find(id);
60
+ if (!record) throw new Error(`no delegation ${JSON.stringify(id)}`);
61
+ if (!isStarted(record)) throw new Error(`delegation ${id} is a workspace attempt, not a session to fork`);
62
+ assertChildAuthority(record, ctx, "fork");
63
+ deps.admit(record.agent, ctx);
64
+ deps.results.checkStorage(true);
65
+ return record;
66
+ }
67
+
68
+ function forkSource(record: StartedRecorded, session: ReturnType<typeof sessionFor>): ForkSource {
69
+ const own = recordedIdentity(record);
70
+ if (!own) throw new Error(`delegation ${record.id} has no original root placement identity; fork refused`);
71
+ return {
72
+ id: record.id,
73
+ sessionPath: session.path,
74
+ sessionDir: session.dir,
75
+ rootPlacement: own.rootPlacement,
76
+ sourceCwd: session.cwd,
77
+ };
78
+ }
79
+
80
+ function fallbackNote(chosen: Awaited<ReturnType<ResumeDeps["chooseModel"]>>): string {
81
+ return chosen.skipped.length
82
+ ? ` Used fallback ${chosen.model} because ${chosen.skipped.map((item) => `${item.model} is ${item.why}`).join("; ")}.`
83
+ : "";
84
+ }
85
+
86
+ async function sharedFolderNotice(deps: ForkDeps, record: StartedRecorded, worktree?: string): Promise<string[]> {
87
+ if (worktree || deriveState(record, windowState(await listWindows(deps.tmux), record)) !== "running") return [];
88
+ return [`shares a working folder with running delegation ${record.id}; pass worktree to isolate`];
89
+ }
90
+
91
+ function launchText(id: string, details: Forked, chosen: Awaited<ReturnType<ResumeDeps["chooseModel"]>>): string {
92
+ const workspace = details.workspace;
93
+ const managed =
94
+ workspace.mode === "managed"
95
+ ? ` Branch ${workspace.createdBranch} starts at ${workspace.baseCommit}. ${dirtyNotice(workspace.sourceDirty)}`
96
+ : "";
97
+ 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("")}`;
98
+ }
99
+
100
+ export function forkTool(deps: ForkDeps): ToolDef {
101
+ return {
102
+ name: "delegation_fork",
103
+ label: "Fork delegation",
104
+ description:
105
+ "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.",
106
+ parameters: Type.Object({
107
+ id: Type.String({ description: "The direct child's delegation id whose context to copy." }),
108
+ task: Type.String(),
109
+ model: Type.Optional(Type.String({ description: "provider/id; defaults to the source's last model." })),
110
+ thinking: Type.Optional(
111
+ Type.String({ description: `${THINKING_LEVELS.join(", ")}; defaults to the source's last level.` }),
112
+ ),
113
+ auto_exit: Type.Optional(Type.Boolean({ description: "Defaults to the source delegation's setting." })),
114
+ worktree: Type.Optional(
115
+ Type.String({ description: "New branch for a managed worktree from the child's checkout." }),
116
+ ),
117
+ worktree_base: Type.Optional(
118
+ Type.String({ description: "Local commit-ish; requires worktree. Defaults to the child's checkout HEAD." }),
119
+ ),
120
+ }),
121
+ outputSchema: Type.Object({ ...deps.delegateSchema.properties, forkedFrom: Type.String() }),
122
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
123
+ async execute(
124
+ toolCallId: string,
125
+ params: ForkRequest,
126
+ signal: AbortSignal | undefined,
127
+ _onUpdate: unknown,
128
+ ctx: ExtensionContext,
129
+ ): Promise<ReturnType<typeof toolResult>> {
130
+ if (!params.task?.trim()) throw new Error("a task is required");
131
+ const record = sourceRecord(deps, params.id, ctx);
132
+ const session = sessionFor(record, ctx, "forkable");
133
+ const roster = deps.roster(ctx);
134
+ const disabled = disabledProblem(roster, record.agent);
135
+ if (disabled) throw new Error(disabled);
136
+ const agent = roster.agents.find((item) => item.name === record.agent);
137
+ if (!agent) throw new Error(`agent ${JSON.stringify(record.agent)} is no longer in the roster`);
138
+ const { thinking, chosen } = await settleModel(
139
+ deps,
140
+ { record, agent, session, request: { model: given(params.model), thinking: given(params.thinking) } },
141
+ ctx,
142
+ );
143
+ const source = forkSource(record, session);
144
+ chosen.notices.push(...(await sharedFolderNotice(deps, record, given(params.worktree))));
145
+ const details = await deps.launch(
146
+ {
147
+ ...chosen,
148
+ agent,
149
+ thinking,
150
+ autoExit: typeof params.auto_exit === "boolean" ? params.auto_exit : record.autoExit === true,
151
+ task: params.task,
152
+ cwd: session.cwd,
153
+ worktree: given(params.worktree),
154
+ worktree_base: given(params.worktree_base),
155
+ },
156
+ source,
157
+ ctx,
158
+ signal,
159
+ toolCallId,
160
+ );
161
+ const line = launchText(record.id, details, chosen);
162
+ ctx.ui.notify(`delegate: ${line}`, "info");
163
+ return toolResult(line, { outcome: "started", ...details, forkedFrom: record.id });
164
+ },
165
+ };
166
+ }
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";
@@ -80,7 +83,6 @@ import { registerTaskReplies } from "./task-replies.ts";
80
83
  import { createTmuxClient, type OpenedWindow, requireTmuxVersion, type TmuxClient, WindowOpenError } from "./tmux.ts";
81
84
  import { availableTools } from "./tool-names.ts";
82
85
  import { createTracking, toolResult } from "./tracking.ts";
83
- import { treeNotice } from "./tree-notice.ts";
84
86
  import {
85
87
  assertParentPlacement,
86
88
  bindTree,
@@ -95,7 +97,7 @@ import {
95
97
  reconcileTree,
96
98
  reserveTree,
97
99
  } from "./tree-registry.ts";
98
- import { assertResumeAuthority, historicalRecord, visibleRecords } from "./tree-view.ts";
100
+ import { assertChildAuthority, assertResumeAuthority, historicalRecord, visibleRecords } from "./tree-view.ts";
99
101
  import { registerTrustControl } from "./trust-control.ts";
100
102
  import { createProactive, createUsageClient, type Proactive, type UsageClient } from "./usage.ts";
101
103
  import { registerDelegateWidget } from "./widget.ts";
@@ -136,6 +138,7 @@ const DELEGATION_TOOLS: ReadonlySet<string> = new Set([
136
138
  "delegation_status",
137
139
  "delegation_close",
138
140
  "delegation_resume",
141
+ "delegation_fork",
139
142
  ]);
140
143
  /** Asked of every delegate, so the parent gets an answer it can use alone. */
141
144
 
@@ -741,15 +744,6 @@ export default function delegate(
741
744
  pi.on("session_start", (_event, ctx) => {
742
745
  viewContext = ctx;
743
746
  });
744
- // Discovery is advisory: it runs after session_start returns, so tmux never delays startup or a session switch.
745
- pi.on("session_start", (_event, ctx) => {
746
- if (!identity.current() && results.list().length === 0) {
747
- const active = () => owner.active();
748
- treeNotice(ctx, tmux, active).catch((error: unknown) => {
749
- if (active()) ctx.ui.notify(`delegate: owner discovery unavailable: ${String(error)}`, "warning");
750
- });
751
- }
752
- });
753
747
  const viewResults = {
754
748
  ...results,
755
749
  list: () => (viewContext ? visibleRecords(results, viewContext) : results.list()),
@@ -922,7 +916,8 @@ export default function delegate(
922
916
  * Build the delegate's command line and environment, emit `session:launch`
923
917
  * and open its window; returns the window id and session. A launch and a resume share
924
918
  * it and differ only in `session`, the arguments that choose the session:
925
- * `--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.
926
921
  */
927
922
  async function openChild(input: {
928
923
  id: string;
@@ -943,6 +938,7 @@ export default function delegate(
943
938
  onWindowOpened?: () => void;
944
939
  identity: Identity;
945
940
  sessionId?: string;
941
+ forkSourceCwd?: string;
946
942
  }): Promise<OpenedWindow> {
947
943
  const {
948
944
  id,
@@ -974,7 +970,11 @@ export default function delegate(
974
970
  : workspace.mode === "existing"
975
971
  ? `\n\nThe session that started you chose your working directory: ${workspace.cwd}`
976
972
  : "";
977
- 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));
978
978
  const argv = [
979
979
  ...parentCommand(),
980
980
  ...session,
@@ -1070,6 +1070,7 @@ export default function delegate(
1070
1070
  placement: WindowPlacement;
1071
1071
  tools: readonly string[];
1072
1072
  onWindowOpened: () => void;
1073
+ fork?: ForkSource;
1073
1074
  }): Promise<Launched> {
1074
1075
  const { id, requestId, workspace, trusted, cwd, sessionDir, start, toolCallId, ctx, placement, onWindowOpened } =
1075
1076
  input;
@@ -1077,7 +1078,7 @@ export default function delegate(
1077
1078
  const { id: windowId, session: windowSession } = await openChild({
1078
1079
  id,
1079
1080
  name,
1080
- session: ["--session-id", id, "--name", name],
1081
+ session: [...(input.fork ? ["--fork", input.fork.sessionPath] : []), "--session-id", id, "--name", name],
1081
1082
  identity: input.identity,
1082
1083
  workspace,
1083
1084
  trusted,
@@ -1089,6 +1090,7 @@ export default function delegate(
1089
1090
  placement,
1090
1091
  tools: input.tools,
1091
1092
  onWindowOpened,
1093
+ forkSourceCwd: input.fork?.sourceCwd,
1092
1094
  });
1093
1095
  // No task text in the record: the request copy in `sent/` has it.
1094
1096
  results.recordStart(
@@ -1107,6 +1109,7 @@ export default function delegate(
1107
1109
  cwd,
1108
1110
  sessionDir,
1109
1111
  workspace,
1112
+ ...(input.fork ? { forkedFrom: input.fork.id } : {}),
1110
1113
  },
1111
1114
  ctx,
1112
1115
  );
@@ -1119,6 +1122,7 @@ export default function delegate(
1119
1122
  ctx: ExtensionContext,
1120
1123
  signal?: AbortSignal,
1121
1124
  toolCallId?: string,
1125
+ fork?: ForkSource,
1122
1126
  ): Promise<Launched> {
1123
1127
  admit(start.agent.name, currentRoster(ctx));
1124
1128
  results.checkStorage(true);
@@ -1129,13 +1133,15 @@ export default function delegate(
1129
1133
  const parentIdentity = identity.current();
1130
1134
  const project = parentIdentity
1131
1135
  ? { companion: parentIdentity.rootPlacement, notices: [] }
1132
- : placementProject(ctx.cwd, companionSuffix);
1136
+ : fork
1137
+ ? { companion: fork.rootPlacement, notices: [] }
1138
+ : placementProject(ctx.cwd, companionSuffix);
1133
1139
  assertRootPlacement(project.companion);
1134
1140
  const own = identity.child(start.agent, ctx, project.companion);
1135
1141
  if (parentIdentity) assertParentPlacement(readTree(own.root), own);
1136
1142
  if (placement === "companion") start.notices.push(...project.notices);
1137
1143
  const tools = childTools(start.agent);
1138
- const sessionDir = ctx.sessionManager.getSessionDir();
1144
+ const sessionDir = fork?.sessionDir ?? ctx.sessionManager.getSessionDir();
1139
1145
  if (!isAbsolute(sessionDir))
1140
1146
  throw new Error(
1141
1147
  `the parent's session directory ${JSON.stringify(sessionDir)} is not an absolute path; start Pi with an absolute --session-dir`,
@@ -1198,7 +1204,13 @@ export default function delegate(
1198
1204
  await reconcileTree(own.root, tmux);
1199
1205
  admit(start.agent.name, currentRoster(ctx));
1200
1206
  if (combined?.aborted) throw new Error("the launch was aborted");
1201
- 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
+ });
1202
1214
  if (plan !== undefined) {
1203
1215
  revalidate(plan);
1204
1216
  revalidateTrust?.();
@@ -1269,6 +1281,7 @@ export default function delegate(
1269
1281
  onWindowOpened: () => {
1270
1282
  windowOpened = true;
1271
1283
  },
1284
+ fork,
1272
1285
  });
1273
1286
  };
1274
1287
  const runWithRollback = async (): Promise<Launched> => {
@@ -1753,4 +1766,51 @@ export default function delegate(
1753
1766
  registerDelegate(toolDescription(currentRoster().agents));
1754
1767
  tracking.registerTools();
1755
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
+ );
1756
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,
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 }),
@@ -1,7 +1,3 @@
1
- // One registry per root owns structural/admission accounting. Parent-local
2
- // child facts and frozen worker replies remain semantic authorities. No bodies,
3
- // mailboxes, processing inboxes, or ancestor-liveness policy live here.
4
- import { readdirSync } from "node:fs";
5
1
  import { join } from "node:path";
6
2
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
7
3
  import { readChildRecords } from "./child-tasks.ts";
@@ -24,17 +20,6 @@ export type { Reservation, TreeCounts, TreeEntry, TreeIntent, TreeState } from "
24
20
  export function treePath(root: string): string {
25
21
  return join(getAgentDir(), "squire", "trees", `${encodeURIComponent(root)}.json`);
26
22
  }
27
- /** Discovery only; callers choose notice/view scope, never adopt another root. */
28
- export function listTreeRoots(): string[] {
29
- try {
30
- return readdirSync(join(getAgentDir(), "squire", "trees"))
31
- .filter((name) => name.endsWith(".json"))
32
- .map((name) => decodeURIComponent(name.slice(0, -5)));
33
- } catch (error) {
34
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
35
- throw error;
36
- }
37
- }
38
23
  function load(root: string): TreeState {
39
24
  const saved: TreeState | undefined = readState<TreeState>(treePath(root));
40
25
  const state: TreeState = saved === undefined ? { version: 1, root, entries: [] } : saved;
@@ -97,7 +82,7 @@ function validateFields(entry: TreeEntry): void {
97
82
  for (const key of ["finished", "gone", "closed", "closing", "createPending"] as const)
98
83
  if (entry[key] !== undefined && typeof entry[key] !== "boolean")
99
84
  throw new Error(`invalid ${key} in tree entry ${entry.id}`);
100
- 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)
101
86
  if (entry[key] !== undefined && typeof entry[key] !== "string")
102
87
  throw new Error(`invalid ${key} in tree entry ${entry.id}`);
103
88
  }
@@ -279,6 +264,7 @@ export function publishTreeRecords(root: string, parent: string, records: Record
279
264
  ownerPid: process.pid,
280
265
  phase: "started",
281
266
  resumedFrom: record.resumedFrom,
267
+ forkedFrom: record.forkedFrom,
282
268
  };
283
269
  state.entries.push(entry);
284
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[] {
@@ -1,132 +0,0 @@
1
- // Bounded discovery is a notice only. Exact-session restore never uses this budget.
2
- // Callers start it without awaiting, so a slow tmux never holds session_start; the
3
- // tmux client's own timeout bounds the listing, and the budget bounds only the scan.
4
- import { statSync } from "node:fs";
5
- import { performance } from "node:perf_hooks";
6
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
7
- import { findSession } from "./result-sessions.ts";
8
- import { ownerDetails, sessionIsOwned } from "./session-owner.ts";
9
- import type { TmuxClient, TmuxWindow } from "./tmux.ts";
10
- import { listTreeRoots, readTree, type TreeEntry, treePath } from "./tree-registry.ts";
11
-
12
- export const NOTICE_TREES = 64;
13
- export const NOTICE_OWNERS = 10;
14
- export const NOTICE_MS = 200;
15
- export async function treeNotice(
16
- ctx: ExtensionContext,
17
- tmux: TmuxClient,
18
- active: () => boolean = () => true,
19
- ): Promise<void> {
20
- const session: string = ctx.sessionManager.getSessionId();
21
- const dir: string = ctx.sessionManager.getSessionDir();
22
- const roots: string[] = listTreeRoots().filter((root) => root !== session);
23
- const newest: string[] = newestRoots(roots);
24
- if (!newest.length) return;
25
- const windows: TmuxWindow[] | undefined = await noticeWindows(ctx, tmux, active);
26
- if (!windows || !active()) return;
27
- // The scan budget starts once tmux has answered.
28
- const deadline: number = performance.now() + NOTICE_MS;
29
- const owners: Map<string, TreeEntry> = new Map();
30
- let unavailable = 0,
31
- scanned = 0,
32
- ownerErrors = 0;
33
- for (const root of newest) {
34
- if (!active() || remaining(deadline) <= 0 || owners.size >= NOTICE_OWNERS) break;
35
- try {
36
- collectOwners(root, session, dir, windows, owners, deadline);
37
- } catch {
38
- unavailable++;
39
- }
40
- scanned++;
41
- await new Promise<void>((resolve) => setImmediate(resolve));
42
- }
43
- if (!active()) return;
44
- ownerErrors = reportOwners(ctx, dir, owners, deadline);
45
- if (roots.length > NOTICE_TREES || scanned < newest.length || unavailable || ownerErrors)
46
- ctx.ui.notify(
47
- `delegate: discovery is limited to newest ${NOTICE_TREES} trees and ${NOTICE_OWNERS} owners in this agent/session directory, with a ${NOTICE_MS} ms total budget and 1 MiB per tree; ${unavailable} registries unavailable and ${ownerErrors} owner records unreadable. Reopen an exact owner to restore its full tree.`,
48
- "warning",
49
- );
50
- }
51
- function reportOwners(ctx: ExtensionContext, dir: string, owners: Map<string, TreeEntry>, deadline: number): number {
52
- let ownerErrors = 0;
53
- for (const [parent, entry] of [...owners].slice(0, NOTICE_OWNERS)) {
54
- const path: string | undefined = remaining(deadline) > 0 ? findSession(dir, parent) : undefined;
55
- let action: string;
56
- try {
57
- action = sessionIsOwned(parent)
58
- ? "Its Squire owner is already open; return to that process."
59
- : `Reopen the owning Pi session with pi --session ${shellArg(path ?? parent)} --session-dir ${shellArg(dir)}.`;
60
- } catch {
61
- ownerErrors++;
62
- action = `Owner status is unavailable: ${ownerDetails(parent)}. Inspect it before reopening.`;
63
- }
64
- ctx.ui.notify(
65
- `delegate: earlier session ${parent} has running delegates (tree ${entry.identity.root}). ${action} This session does not adopt them.`,
66
- "info",
67
- );
68
- }
69
- return ownerErrors;
70
- }
71
- /** The newest roots first; each tree is stat'ed once, not on every sort comparison. */
72
- function newestRoots(roots: string[]): string[] {
73
- return roots
74
- .map((root) => ({ root, mtime: modified(root) }))
75
- .sort((a, b) => b.mtime - a.mtime)
76
- .slice(0, NOTICE_TREES)
77
- .map(({ root }) => root);
78
- }
79
- /** tmux's windows, or undefined after warning (while still active) that tmux failed. */
80
- async function noticeWindows(
81
- ctx: ExtensionContext,
82
- tmux: TmuxClient,
83
- active: () => boolean,
84
- ): Promise<TmuxWindow[] | undefined> {
85
- try {
86
- return await tmux.listWindows();
87
- } catch (error) {
88
- if (!active()) return undefined;
89
- const reason: string = error instanceof Error ? error.message : String(error);
90
- ctx.ui.notify(
91
- `delegate: owner discovery unavailable: tmux could not be asked: ${reason}; no absence or ownership was inferred.`,
92
- "warning",
93
- );
94
- return undefined;
95
- }
96
- }
97
- function remaining(deadline: number): number {
98
- return Math.max(0, deadline - performance.now());
99
- }
100
- function collectOwners(
101
- root: string,
102
- session: string,
103
- dir: string,
104
- windows: TmuxWindow[],
105
- owners: Map<string, TreeEntry>,
106
- deadline: number,
107
- ): void {
108
- if (statSync(treePath(root)).size > 1024 * 1024) throw new Error("tree exceeds notice byte budget");
109
- for (const entry of readTree(root, Math.min(20, remaining(deadline))).entries) {
110
- if (owners.size >= NOTICE_OWNERS || remaining(deadline) <= 0) return;
111
- if (entry.identity.parent !== session && entry.sessionDir === dir && live(entry, windows))
112
- owners.set(entry.identity.parent, entry);
113
- }
114
- }
115
- function shellArg(value: string): string {
116
- return `'${value.replaceAll("'", "'\\''")}'`;
117
- }
118
- function modified(root: string): number {
119
- try {
120
- return statSync(treePath(root)).mtimeMs;
121
- } catch {
122
- return 0;
123
- }
124
- }
125
- function live(entry: TreeEntry, windows: TmuxWindow[]): boolean {
126
- return (
127
- entry.phase === "started" &&
128
- !entry.finished &&
129
- !entry.closed &&
130
- windows.some((window) => window.id === entry.windowId && window.name === entry.windowName && !window.exited)
131
- );
132
- }