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 +31 -1
- package/README.md +45 -22
- 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 -19
- 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 -16
- package/src/tree-types.ts +2 -1
- package/src/tree-view.ts +16 -4
- package/src/tree-notice.ts +0 -132
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.
|
|
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 `
|
|
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
|
|
327
|
-
|
|
328
|
-
|
|
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
|
|
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 `
|
|
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 `
|
|
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
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";
|
|
@@ -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
|
|
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
|
|
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
|
-
:
|
|
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({
|
|
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
|
}
|
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
|
@@ -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
|
|
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[] {
|
package/src/tree-notice.ts
DELETED
|
@@ -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
|
-
}
|