pi-squire 0.19.1 → 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 +19 -1
- package/README.md +42 -7
- package/package.json +1 -1
- package/src/agents.ts +1 -1
- package/src/fork.ts +166 -0
- package/src/identity.ts +7 -1
- package/src/index.ts +79 -9
- package/src/navigator-view.ts +1 -0
- package/src/render.ts +1 -0
- package/src/result-message.ts +7 -2
- package/src/results.ts +4 -1
- package/src/resume-checks.ts +10 -6
- package/src/tracking.ts +3 -0
- package/src/tree-registry.ts +2 -1
- package/src/tree-types.ts +2 -1
- package/src/tree-view.ts +16 -4
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,23 @@ 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
|
+
|
|
9
26
|
## [0.19.1] - 2026-11-02
|
|
10
27
|
|
|
11
28
|
### Removed
|
|
@@ -1094,7 +1111,8 @@ author's dotfiles; the changes below are against that copy.
|
|
|
1094
1111
|
|
|
1095
1112
|
- The published file list includes `child.ts`, which `index.ts` imports.
|
|
1096
1113
|
|
|
1097
|
-
[Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.
|
|
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
|
|
1098
1116
|
[0.19.1]: https://github.com/gvanderclay/pi-squire/compare/v0.19.0...v0.19.1
|
|
1099
1117
|
[0.19.0]: https://github.com/gvanderclay/pi-squire/compare/v0.18.4...v0.19.0
|
|
1100
1118
|
[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 `
|
|
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
|
|
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,40 @@ 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.
|
|
1674
|
+
`session:launch` is emitted with the `delegation_fork` tool call id.
|
|
1675
|
+
|
|
1641
1676
|
## Hooks
|
|
1642
1677
|
|
|
1643
1678
|
The `js` block below is the contract's worked example, and
|
|
@@ -1660,7 +1695,7 @@ the window is open (see [Resuming a delegation](#resuming-a-delegation)).
|
|
|
1660
1695
|
| `env` | the child's environment, as `KEY`/`value` pairs |
|
|
1661
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 |
|
|
1662
1697
|
| `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 `
|
|
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 |
|
|
1664
1699
|
| `parent` | Read-only direct parent session id. |
|
|
1665
1700
|
| `root` | Read-only root session id; retained on resume. |
|
|
1666
1701
|
| `depth` | Read-only numeric child depth, root = 0; retained on resume. |
|
|
@@ -1688,9 +1723,9 @@ environment into these arguments.
|
|
|
1688
1723
|
|
|
1689
1724
|
`cwd`, the trust flag (`--approve` or `--no-approve`), `--session-dir` and the
|
|
1690
1725
|
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`,
|
|
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`,
|
|
1692
1727
|
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
|
|
1728
|
+
trust flag, `--session-dir`, `--session`, `--fork` or `--session-id`, or a working-directory change to `args`, since
|
|
1694
1729
|
that would launch a child whose trust or session storage the parent did not
|
|
1695
1730
|
choose and cannot find; append only unrelated settings.
|
|
1696
1731
|
|
|
@@ -1965,7 +2000,7 @@ Other extensions may rely on:
|
|
|
1965
2000
|
[Hooks](#hooks));
|
|
1966
2001
|
- the `message:send`, `message:reply`, `message:inbound` and `message:scan` hooks, consumed as
|
|
1967
2002
|
their sections document;
|
|
1968
|
-
- the `delegate`, `delegation_status`, `delegation_close` and `
|
|
2003
|
+
- the `delegate`, `delegation_status`, `delegation_close`, `delegation_resume` and `delegation_fork` tool names,
|
|
1969
2004
|
parameters, the recorded `workspace` shape and the `workspaceSnapshot`
|
|
1970
2005
|
shape in results and status;
|
|
1971
2006
|
- the `/delegate` and `/delegate-resume` syntax and flags, and their
|
package/package.json
CHANGED
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 = [
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
:
|
|
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({
|
|
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
|
}
|
package/src/navigator-view.ts
CHANGED
|
@@ -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)}`);
|
package/src/result-message.ts
CHANGED
|
@@ -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): {
|
|
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)}`,
|
package/src/resume-checks.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 }),
|
package/src/tree-registry.ts
CHANGED
|
@@ -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
|
|
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
|
|
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;
|
|
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(
|
|
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[] {
|