pi-squire 0.14.0 → 0.16.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 +438 -1
- package/README.md +1062 -152
- package/package.json +4 -3
- package/src/agents.ts +395 -124
- package/src/child-tasks.ts +221 -0
- package/src/child.ts +107 -62
- package/src/completion-holds.ts +104 -0
- package/src/dashboard.ts +144 -0
- package/src/git.ts +6 -1
- package/src/identity.ts +152 -0
- package/src/index.ts +585 -107
- package/src/local-state.ts +21 -0
- package/src/navigator-view.ts +186 -0
- package/src/navigator.ts +152 -107
- package/src/new-delegate.ts +17 -8
- package/src/poll.ts +1 -0
- package/src/render.ts +4 -1
- package/src/result-close.ts +37 -0
- package/src/result-restore.ts +95 -0
- package/src/result-sessions.ts +39 -0
- package/src/result-types.ts +29 -1
- package/src/results-presentation.ts +47 -0
- package/src/results.ts +176 -108
- package/src/resume-checks.ts +59 -14
- package/src/resume-picker.ts +9 -2
- package/src/resume.ts +69 -15
- package/src/session-owner.ts +279 -0
- package/src/task-replies.ts +247 -0
- package/src/task-settlement.ts +81 -0
- package/src/task-store.ts +26 -0
- package/src/tmux.ts +134 -54
- package/src/tool-ceiling.ts +125 -0
- package/src/tracking.ts +72 -65
- package/src/tree-close.ts +236 -0
- package/src/tree-fence.ts +51 -0
- package/src/tree-lock.ts +77 -0
- package/src/tree-notice.ts +124 -0
- package/src/tree-registry.ts +365 -0
- package/src/tree-settings.ts +31 -0
- package/src/tree-types.ts +32 -0
- package/src/tree-view.ts +246 -0
- package/src/trust-control.ts +214 -0
- package/src/trust-prompt.ts +163 -0
- package/src/trust-wire.ts +203 -0
- package/src/widget-activity-join.ts +24 -0
- package/src/widget-activity-runtime.ts +104 -0
- package/src/widget-activity.ts +104 -0
- package/src/widget-ancestors.ts +142 -0
- package/src/widget-current.ts +41 -0
- package/src/widget-data.ts +147 -0
- package/src/widget-focus.ts +133 -0
- package/src/widget-live-process.ts +31 -0
- package/src/widget-live.ts +356 -0
- package/src/widget-render.ts +181 -0
- package/src/widget-selection.ts +58 -0
- package/src/widget-settings.ts +29 -0
- package/src/widget-summary.ts +51 -0
- package/src/widget-title-file.ts +127 -0
- package/src/widget-title.ts +118 -0
- package/src/widget.ts +272 -0
- package/src/window-placement.ts +30 -5
- package/src/workspace.ts +38 -90
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// A nested launch is bounded by operations its direct parent can reach, not
|
|
2
|
+
// by the schemas currently displayed. CLI registration filtering also covers
|
|
3
|
+
// deferred tools and tools registered later by launch-time extensions.
|
|
4
|
+
import { type Args, type ExtensionAPI, parseArgs, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
/** Match Pi's tool-list glob: only * has special meaning. */
|
|
7
|
+
export function toolMatches(pattern: string, name: string): boolean {
|
|
8
|
+
const source = pattern
|
|
9
|
+
.split("*")
|
|
10
|
+
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
|
11
|
+
.join(".*");
|
|
12
|
+
return new RegExp(`^${source}$`).test(name);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Includes active model-only operations, though ctx.executeTool cannot call those. */
|
|
16
|
+
export function availableTools(pi: Pick<ExtensionAPI, "getActiveTools" | "getAllTools">): string[] {
|
|
17
|
+
const active = new Set(pi.getActiveTools());
|
|
18
|
+
return pi
|
|
19
|
+
.getAllTools()
|
|
20
|
+
.filter(
|
|
21
|
+
(tool) =>
|
|
22
|
+
tool.exposure !== "hidden" &&
|
|
23
|
+
(active.has(tool.name) || tool.exposure === "codemode" || tool.exposure === "deferred"),
|
|
24
|
+
)
|
|
25
|
+
.map((tool) => tool.name);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type CeilingInput = {
|
|
29
|
+
args: string[];
|
|
30
|
+
available: readonly string[];
|
|
31
|
+
registered?: readonly string[];
|
|
32
|
+
cwd: string;
|
|
33
|
+
agentDir: string;
|
|
34
|
+
trusted: boolean;
|
|
35
|
+
notices: string[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function requestedTools(parsed: Args, input: CeilingInput): string[] {
|
|
39
|
+
const requested = parsed.tools ?? [];
|
|
40
|
+
if (!requested.some((name) => name.startsWith("+") || name.startsWith("-"))) return requested;
|
|
41
|
+
// Pi modifiers replace the earlier --tools selection and modify the
|
|
42
|
+
// child's defaults. Use Pi's trusted settings reader, not the parent's defaults.
|
|
43
|
+
const defaults =
|
|
44
|
+
parsed.noTools || parsed.noBuiltinTools
|
|
45
|
+
? []
|
|
46
|
+
: (SettingsManager.create(input.cwd, input.agentDir, { projectTrusted: input.trusted }).getDefaultTools() ?? [
|
|
47
|
+
"read",
|
|
48
|
+
"bash",
|
|
49
|
+
"edit",
|
|
50
|
+
"write",
|
|
51
|
+
]);
|
|
52
|
+
const selected = new Set(defaults);
|
|
53
|
+
for (const entry of requested) {
|
|
54
|
+
if (entry.startsWith("+")) selected.add(entry.slice(1));
|
|
55
|
+
else selected.delete(entry.slice(1));
|
|
56
|
+
}
|
|
57
|
+
return [...selected];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function ceilingNotice(input: CeilingInput, requested: string[], exclusions: string[]): void {
|
|
61
|
+
const { available } = input;
|
|
62
|
+
const removed = [
|
|
63
|
+
...new Set([
|
|
64
|
+
...requested.filter((pattern) => pattern !== "mcp__" && !pattern.includes("*") && !available.includes(pattern)),
|
|
65
|
+
...(input.registered ?? []).filter(
|
|
66
|
+
(name) => !available.includes(name) && requested.some((pattern) => toolMatches(pattern, name)),
|
|
67
|
+
),
|
|
68
|
+
]),
|
|
69
|
+
].filter((name) => !exclusions.some((pattern) => toolMatches(pattern, name)));
|
|
70
|
+
// Child-only and future registrations cannot be enumerated in the parent.
|
|
71
|
+
const bounded = [...new Set(requested.filter((pattern) => pattern.includes("*")))];
|
|
72
|
+
if (!removed.length && !bounded.length) return;
|
|
73
|
+
const parts = ["Nested tool ceiling:"];
|
|
74
|
+
if (removed.length) parts.push(`removed unavailable tools ${removed.join(", ")}.`);
|
|
75
|
+
if (bounded.length)
|
|
76
|
+
parts.push(`Restricted patterns ${bounded.join(", ")} to exact tools available to the direct parent.`);
|
|
77
|
+
input.notices.push(parts.join(" "));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Find an option terminator, not a -- token consumed as another flag’s value. */
|
|
81
|
+
function optionBoundary(args: string[]): number {
|
|
82
|
+
let marker = "--squire-ceiling-boundary";
|
|
83
|
+
while (args.includes(marker)) marker += "-";
|
|
84
|
+
for (const [index, arg] of args.entries()) {
|
|
85
|
+
if (arg === "--" && !parseArgs([...args.slice(0, index + 1), marker]).unknownFlags.has(marker.slice(2)))
|
|
86
|
+
return index;
|
|
87
|
+
}
|
|
88
|
+
return args.length;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function exactName(name: string): boolean {
|
|
92
|
+
return !!name && name.trim() === name && !name.includes("*") && !name.includes(",") && !/^[+-]/.test(name);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolve the final customized CLI selection, then pin an exact registration
|
|
97
|
+
* allowlist. Run after all synchronous session:launch listeners, before spawn.
|
|
98
|
+
* args excludes the executable/launcher prefix. Unrelated args stay unchanged.
|
|
99
|
+
*/
|
|
100
|
+
export function enforceToolCeiling(input: CeilingInput): void {
|
|
101
|
+
const { args, available } = input;
|
|
102
|
+
const parsed = parseArgs(args);
|
|
103
|
+
const errors = parsed.diagnostics.filter((item) => item.type === "error");
|
|
104
|
+
if (errors.length)
|
|
105
|
+
throw new Error(`invalid nested launch arguments: ${errors.map((item) => item.message).join("; ")}`);
|
|
106
|
+
const requested = requestedTools(parsed, input);
|
|
107
|
+
const exclusions = parsed.excludeTools ?? [];
|
|
108
|
+
const allowed = available.filter(
|
|
109
|
+
(name) =>
|
|
110
|
+
requested.some((pattern) => toolMatches(pattern, name)) &&
|
|
111
|
+
!exclusions.some((pattern) => toolMatches(pattern, name)),
|
|
112
|
+
);
|
|
113
|
+
// Never turn a wildcard in a registered name into a grant pattern.
|
|
114
|
+
if (!allowed.every(exactName))
|
|
115
|
+
throw new Error("cannot encode the parent's tool names as an exact nested tool ceiling");
|
|
116
|
+
ceilingNotice(input, requested, exclusions);
|
|
117
|
+
const entries = allowed.some((name) => name.startsWith("mcp__")) ? allowed : [...allowed, "mcp__"];
|
|
118
|
+
// --no-tools alone would leave a previous --tools value authoritative.
|
|
119
|
+
const final = allowed.length ? ["--tools", entries.join(",")] : ["--tools", "", "--no-tools"];
|
|
120
|
+
args.splice(optionBoundary(args), 0, ...final);
|
|
121
|
+
const effective = parseArgs(args).tools;
|
|
122
|
+
const expected = allowed.length ? entries : [];
|
|
123
|
+
if (!effective || effective.length !== expected.length || effective.some((name, index) => name !== expected[index]))
|
|
124
|
+
throw new Error("could not apply the nested tool ceiling to customized launch arguments");
|
|
125
|
+
}
|
package/src/tracking.ts
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
// and the `delegation_status` and `delegation_close` tools. A delegation's
|
|
3
3
|
// state comes from `results.ts` (`deriveState`); this module only asks tmux
|
|
4
4
|
// about the window and words what it is told.
|
|
5
|
-
import { statSync } from "node:fs";
|
|
6
5
|
|
|
6
|
+
import { statSync } from "node:fs";
|
|
7
7
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import {
|
|
9
|
-
|
|
8
|
+
import { Type } from "typebox";
|
|
9
|
+
import { readIdentity } from "./identity.ts";
|
|
10
10
|
import { type Mark, readMarks } from "./limits.ts";
|
|
11
11
|
import { createPoll } from "./poll.ts";
|
|
12
12
|
import { attemptLines, cwdLine, modelLabel, snapshotLines, workspaceLine } from "./render.ts";
|
|
@@ -26,11 +26,14 @@ import { inspectWorkspace, type WorkspaceSnapshot, WorkspaceSnapshotSchema } fro
|
|
|
26
26
|
import type { TmuxClient, TmuxWindow } from "./tmux.ts";
|
|
27
27
|
import { AttemptSchema } from "./tracking-attempt.ts";
|
|
28
28
|
import { ago, autoExitLine, stateLine } from "./tracking-text.ts";
|
|
29
|
+
import { type CloseResult, CloseSchema, closeTree } from "./tree-close.ts";
|
|
30
|
+
import { accountingFor } from "./tree-view.ts";
|
|
29
31
|
import type { ReadingError } from "./usage.ts";
|
|
30
32
|
import { listWindows, ownWindow, windowState } from "./windows.ts";
|
|
31
33
|
import { WorkspaceSchema } from "./workspace.ts";
|
|
32
34
|
|
|
33
|
-
export {
|
|
35
|
+
export type { CloseResult };
|
|
36
|
+
export { CloseSchema, listWindows, ownWindow, windowState };
|
|
34
37
|
|
|
35
38
|
/** What `src/index.ts` needs from tracking. */
|
|
36
39
|
export type Tracking = {
|
|
@@ -38,17 +41,16 @@ export type Tracking = {
|
|
|
38
41
|
registerTools(): void;
|
|
39
42
|
/** Start the poll when a delegation is running. */
|
|
40
43
|
startPolling(): void;
|
|
41
|
-
/** Close
|
|
42
|
-
close(id: string): Promise<CloseResult>;
|
|
44
|
+
/** Close a recorded descendant subtree, with honest complete/partial details. */
|
|
45
|
+
close(id: string, ctx: ExtensionContext): Promise<CloseResult>;
|
|
46
|
+
/** Root-only explicit whole-tree action. */
|
|
47
|
+
closeAll(ctx: ExtensionContext): Promise<CloseResult>;
|
|
43
48
|
/** On `session_start`: rebuild the records and restart the poll. */
|
|
44
49
|
restore(ctx: ExtensionContext): void;
|
|
45
50
|
/** On `session_shutdown`: stop the poll, and declare nothing more. */
|
|
46
51
|
stop(): void;
|
|
47
52
|
};
|
|
48
53
|
|
|
49
|
-
/** What closing one delegation did. */
|
|
50
|
-
export type CloseResult = { text: string; details: Static<typeof CloseSchema> };
|
|
51
|
-
|
|
52
54
|
/** The one result every tool returns. `details` doubles as `structuredContent`, so it must match the tool's `outputSchema`; Pi does not check it. */
|
|
53
55
|
export function toolResult(
|
|
54
56
|
payload: string,
|
|
@@ -64,6 +66,14 @@ const STARTED_STATES = ["closed", "done", "gone", "running", "window-closed", "u
|
|
|
64
66
|
|
|
65
67
|
/** One started delegation in `delegation_status`'s structured result; `viewDetails` builds it. */
|
|
66
68
|
const StartedSchema = Type.Object({
|
|
69
|
+
sessionId: Type.String(),
|
|
70
|
+
parent: Type.Optional(Type.String()),
|
|
71
|
+
root: Type.Optional(Type.String()),
|
|
72
|
+
depth: Type.Optional(Type.Number()),
|
|
73
|
+
accounting: Type.Optional(Type.String()),
|
|
74
|
+
canResume: Type.Boolean({
|
|
75
|
+
description: "Direct-parent authority only; current admission and exited-session checks still apply.",
|
|
76
|
+
}),
|
|
67
77
|
kind: Type.Literal("started"),
|
|
68
78
|
id: Type.String(),
|
|
69
79
|
name: Type.String(),
|
|
@@ -118,15 +128,6 @@ export const StatusSchema = Type.Object({
|
|
|
118
128
|
readingErrors: Type.Array(Type.Object({ provider: Type.String(), reason: Type.String(), at: Type.String() })),
|
|
119
129
|
});
|
|
120
130
|
|
|
121
|
-
export const CloseSchema = Type.Object({
|
|
122
|
-
id: Type.String(),
|
|
123
|
-
windowId: Type.String(),
|
|
124
|
-
windowName: Type.String(),
|
|
125
|
-
closed: Type.Literal(true),
|
|
126
|
-
alreadyClosed: Type.Boolean({ description: "An earlier delegation_close had recorded it; nothing was done." }),
|
|
127
|
-
killed: Type.Boolean({ description: "This call killed the delegate's window." }),
|
|
128
|
-
});
|
|
129
|
-
|
|
130
131
|
/** A tool as registered; `execute` takes the tool's own parameters, which the registration call does not check. */
|
|
131
132
|
export type ToolDef = {
|
|
132
133
|
name: string;
|
|
@@ -152,6 +153,7 @@ type View = {
|
|
|
152
153
|
resumedAs: string | undefined;
|
|
153
154
|
/** The session its window is in now, looked up; undefined when the window is gone or unknown. */
|
|
154
155
|
windowSession: string | undefined;
|
|
156
|
+
canResume: boolean;
|
|
155
157
|
};
|
|
156
158
|
|
|
157
159
|
/** The view of a delegation: its state and the fields the text and details share. */
|
|
@@ -173,7 +175,17 @@ function view(
|
|
|
173
175
|
}
|
|
174
176
|
const snapshot = inspectWorkspace(record.workspace?.cwd, record.workspace?.git);
|
|
175
177
|
const windowSession = windows === undefined ? undefined : ownWindow(windows, record)?.session;
|
|
176
|
-
return {
|
|
178
|
+
return {
|
|
179
|
+
record,
|
|
180
|
+
state,
|
|
181
|
+
name: recordedName(record),
|
|
182
|
+
sessionPath,
|
|
183
|
+
lastActive,
|
|
184
|
+
snapshot,
|
|
185
|
+
resumedAs,
|
|
186
|
+
windowSession,
|
|
187
|
+
canResume: (record.identity?.parent ?? ctx.sessionManager.getSessionId()) === ctx.sessionManager.getSessionId(),
|
|
188
|
+
};
|
|
177
189
|
}
|
|
178
190
|
|
|
179
191
|
/** The text block for one delegation. */
|
|
@@ -183,6 +195,8 @@ function viewText({ record, state, name, sessionPath, lastActive, snapshot, resu
|
|
|
183
195
|
const lines = [
|
|
184
196
|
`${record.id} ${record.agent} (${modelLabel(record.model, record.result?.finishedModel)}, thinking ${record.thinking})`,
|
|
185
197
|
` name: ${name}`,
|
|
198
|
+
` identity: session ${sessionIdOf(record)} · parent ${record.identity?.parent ?? "this session"} · root ${record.identity?.root ?? "this session"} · depth ${record.identity?.depth ?? 1}`,
|
|
199
|
+
...(accountingFor(record) ? [` shared accounting: ${accountingFor(record)} (not result processing)`] : []),
|
|
186
200
|
...(record.resumedFrom === undefined ? [] : [` resumed from ${record.resumedFrom}`]),
|
|
187
201
|
...(resumedAs === undefined ? [] : [` resumed as ${resumedAs}`]),
|
|
188
202
|
` state: ${stateLine(state, record)}${seen}`,
|
|
@@ -207,9 +221,16 @@ function viewDetails({
|
|
|
207
221
|
lastActive,
|
|
208
222
|
snapshot,
|
|
209
223
|
windowSession,
|
|
224
|
+
canResume,
|
|
210
225
|
}: View): Record<string, unknown> {
|
|
211
226
|
return {
|
|
212
227
|
kind: "started",
|
|
228
|
+
sessionId: sessionIdOf(record),
|
|
229
|
+
parent: record.identity?.parent,
|
|
230
|
+
root: record.identity?.root,
|
|
231
|
+
depth: record.identity?.depth,
|
|
232
|
+
accounting: accountingFor(record),
|
|
233
|
+
canResume,
|
|
213
234
|
id: record.id,
|
|
214
235
|
name,
|
|
215
236
|
agent: record.agent,
|
|
@@ -276,7 +297,7 @@ function statusTool(results: Results, tmux: TmuxClient, readingErrors: () => Rea
|
|
|
276
297
|
name: "delegation_status",
|
|
277
298
|
label: "Delegation status",
|
|
278
299
|
description:
|
|
279
|
-
"
|
|
300
|
+
"This session’s descendant delegations, scoped to its own subtree. Parent/root/depth and stable session ids identify ownership; only a direct parent may resume a child. Shared accounting does not acknowledge result processing. Each delegation is reported as running (the window is open and no result has arrived yet), done (with the result envelope's status) or closed (closed with delegation_close, or its window is gone). A delegation whose window went with no reply is reported closed without a result until a late reply makes it done. Call it to check on a delegation that seems stuck or to find its files; a result arrives as a message on its own, so there is no need to call it to wait for one. Names the delegation id, agent, session name, model, thinking, window, working directory, delegate session path and result envelope path. A running delegation also shows when its session file was last written; a long gap may mean it is stuck but does not prove it. A delegate's window being open means it is connected, not that its task is unfinished or finished; only a result means done.",
|
|
280
301
|
parameters: Type.Object({
|
|
281
302
|
id: Type.Optional(Type.String({ description: "One delegation id; omit to report every delegation." })),
|
|
282
303
|
}),
|
|
@@ -327,68 +348,49 @@ function statusTool(results: Results, tmux: TmuxClient, readingErrors: () => Rea
|
|
|
327
348
|
};
|
|
328
349
|
}
|
|
329
350
|
|
|
330
|
-
/**
|
|
331
|
-
export async function closeDelegation(
|
|
332
|
-
|
|
333
|
-
|
|
351
|
+
/** Shared explicit-close entry point, also used by the navigator. */
|
|
352
|
+
export async function closeDelegation(
|
|
353
|
+
results: Results,
|
|
354
|
+
tmux: TmuxClient,
|
|
355
|
+
id: string | undefined,
|
|
356
|
+
ctx: ExtensionContext,
|
|
357
|
+
assertActive: () => void,
|
|
358
|
+
): Promise<CloseResult> {
|
|
359
|
+
const local = id ? results.find(id) : undefined;
|
|
360
|
+
if (local && !isStarted(local))
|
|
334
361
|
throw new Error(
|
|
335
|
-
`delegation ${
|
|
362
|
+
`delegation ${local.id} is a workspace attempt (${local.attempt.phase}); close requires a verified create outcome`,
|
|
336
363
|
);
|
|
337
|
-
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
alreadyClosed,
|
|
344
|
-
killed,
|
|
345
|
-
});
|
|
346
|
-
if (record.closed === true)
|
|
347
|
-
return { text: `Delegation ${record.id} is already closed.`, details: closed(true, false) };
|
|
348
|
-
let windows: TmuxWindow[];
|
|
349
|
-
try {
|
|
350
|
-
windows = await tmux.listWindows();
|
|
351
|
-
} catch (err) {
|
|
352
|
-
throw new Error(`could not ask tmux about window ${record.windowName}: ${(err as Error).message}`);
|
|
353
|
-
}
|
|
354
|
-
// Only a window with the recorded id and name is ours, exited program or not.
|
|
355
|
-
const alive = ownWindow(windows, record) !== undefined;
|
|
356
|
-
if (alive) {
|
|
357
|
-
try {
|
|
358
|
-
await tmux.kill(record.windowId);
|
|
359
|
-
} catch (err) {
|
|
360
|
-
throw new Error(`could not kill window ${record.windowName} (${record.windowId}): ${(err as Error).message}`);
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
results.recordClose(record.id);
|
|
364
|
-
const how = alive
|
|
365
|
-
? `killed window ${record.windowName} (${record.windowId})`
|
|
366
|
-
: `its window ${record.windowName} was already gone`;
|
|
367
|
-
return { text: `Closed delegation ${record.id}: ${how}.`, details: closed(false, alive) };
|
|
364
|
+
const actor = ctx.sessionManager.getSessionId();
|
|
365
|
+
const root = readIdentity(actor)?.root ?? actor;
|
|
366
|
+
const result = await closeTree({ root, actor, id, tmux, assertActive });
|
|
367
|
+
assertActive();
|
|
368
|
+
results.syncCloses();
|
|
369
|
+
return result;
|
|
368
370
|
}
|
|
369
371
|
|
|
370
372
|
/** The `delegation_close` tool. */
|
|
371
|
-
function closeTool(results: Results, tmux: TmuxClient): ToolDef {
|
|
373
|
+
function closeTool(results: Results, tmux: TmuxClient, assertActive: () => void): ToolDef {
|
|
372
374
|
return {
|
|
373
375
|
name: "delegation_close",
|
|
374
376
|
label: "Close delegation",
|
|
375
377
|
description:
|
|
376
|
-
"
|
|
378
|
+
"Explicitly close a descendant and its recorded subtree. Fences new child launches, kills only verified current tmux windows and retains worktrees, branches and session/result files. Any ancestor may close; only the direct parent may resume. Partial errors or unknown in-flight creates stay fenced and are reported for an explicit retry, never as successful closure.",
|
|
377
379
|
parameters: Type.Object({
|
|
378
380
|
id: Type.String({ description: "The delegation id to close." }),
|
|
379
381
|
}),
|
|
380
382
|
outputSchema: CloseSchema,
|
|
381
|
-
// Kills
|
|
383
|
+
// Kills only verified windows; repeats retry unresolved members and preserve confirmed facts.
|
|
382
384
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, ...CLOSED_WORLD },
|
|
383
385
|
async execute(
|
|
384
386
|
_toolCallId: string,
|
|
385
387
|
params: { id: string },
|
|
386
388
|
_signal: unknown,
|
|
387
389
|
_onUpdate: unknown,
|
|
388
|
-
|
|
390
|
+
ctx: ExtensionContext,
|
|
389
391
|
): Promise<ReturnType<typeof toolResult>> {
|
|
390
|
-
const { text, details } = await closeDelegation(results, tmux, params.id);
|
|
391
|
-
return toolResult(text, details);
|
|
392
|
+
const { text, details } = await closeDelegation(results, tmux, params.id, ctx, assertActive);
|
|
393
|
+
return { ...toolResult(text, details), ...(!details.complete ? { isError: true } : {}) };
|
|
392
394
|
},
|
|
393
395
|
};
|
|
394
396
|
}
|
|
@@ -399,15 +401,20 @@ export function createTracking(
|
|
|
399
401
|
results: Results,
|
|
400
402
|
tmux: TmuxClient,
|
|
401
403
|
readingErrors: () => ReadingError[] = () => [],
|
|
404
|
+
views: Results = results,
|
|
405
|
+
assertActive: () => void = () => {},
|
|
402
406
|
): Tracking {
|
|
403
407
|
const poll = createPoll(pi, results, tmux);
|
|
404
408
|
return {
|
|
405
409
|
registerTools(): void {
|
|
406
|
-
pi.registerTool(statusTool(
|
|
407
|
-
pi.registerTool(closeTool(results, tmux) as never);
|
|
410
|
+
pi.registerTool(statusTool(views, tmux, readingErrors) as never);
|
|
411
|
+
pi.registerTool(closeTool(results, tmux, assertActive) as never);
|
|
408
412
|
},
|
|
409
413
|
startPolling: poll.start,
|
|
410
|
-
close: (id: string): Promise<CloseResult> =>
|
|
414
|
+
close: (id: string, ctx: ExtensionContext): Promise<CloseResult> =>
|
|
415
|
+
closeDelegation(results, tmux, id, ctx, assertActive),
|
|
416
|
+
closeAll: (ctx: ExtensionContext): Promise<CloseResult> =>
|
|
417
|
+
closeDelegation(results, tmux, undefined, ctx, assertActive),
|
|
411
418
|
restore(ctx: ExtensionContext): void {
|
|
412
419
|
poll.arm();
|
|
413
420
|
results.restore(ctx);
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Explicit close only. Shared structural facts fence admission; each owner
|
|
2
|
+
// separately imports confirmed closes into its own semantic processing inbox.
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import type { TmuxClient, TmuxWindow } from "./tmux.ts";
|
|
6
|
+
import { below } from "./tree-fence.ts";
|
|
7
|
+
import { descendants, readTree, treeTransaction } from "./tree-registry.ts";
|
|
8
|
+
import type { TreeClose, TreeEntry, TreeState } from "./tree-types.ts";
|
|
9
|
+
|
|
10
|
+
const MemberSchema = Type.Object({
|
|
11
|
+
id: Type.String(),
|
|
12
|
+
sessionId: Type.String(),
|
|
13
|
+
windowId: Type.Optional(Type.String()),
|
|
14
|
+
windowName: Type.Optional(Type.String()),
|
|
15
|
+
outcome: Type.Union(
|
|
16
|
+
["killed", "absent", "already-closed", "not-started", "pending", "error"].map((s) => Type.Literal(s)),
|
|
17
|
+
),
|
|
18
|
+
closed: Type.Boolean(),
|
|
19
|
+
killed: Type.Boolean(),
|
|
20
|
+
error: Type.Optional(Type.String()),
|
|
21
|
+
});
|
|
22
|
+
export const CloseSchema = Type.Object({
|
|
23
|
+
id: Type.String(),
|
|
24
|
+
windowId: Type.Optional(Type.String()),
|
|
25
|
+
windowName: Type.Optional(Type.String()),
|
|
26
|
+
closed: Type.Boolean({ description: "True only when every member is confirmed closed or never dispatched." }),
|
|
27
|
+
complete: Type.Boolean(),
|
|
28
|
+
alreadyClosed: Type.Boolean(),
|
|
29
|
+
killed: Type.Boolean(),
|
|
30
|
+
members: Type.Array(MemberSchema),
|
|
31
|
+
error: Type.Optional(Type.String()),
|
|
32
|
+
});
|
|
33
|
+
export type CloseMember = {
|
|
34
|
+
id: string;
|
|
35
|
+
sessionId: string;
|
|
36
|
+
windowId?: string;
|
|
37
|
+
windowName?: string;
|
|
38
|
+
outcome: "killed" | "absent" | "already-closed" | "not-started" | "pending" | "error";
|
|
39
|
+
closed: boolean;
|
|
40
|
+
killed: boolean;
|
|
41
|
+
error?: string;
|
|
42
|
+
};
|
|
43
|
+
export type CloseDetails = {
|
|
44
|
+
id: string;
|
|
45
|
+
windowId?: string;
|
|
46
|
+
windowName?: string;
|
|
47
|
+
closed: boolean;
|
|
48
|
+
complete: boolean;
|
|
49
|
+
alreadyClosed: boolean;
|
|
50
|
+
killed: boolean;
|
|
51
|
+
members: CloseMember[];
|
|
52
|
+
error?: string;
|
|
53
|
+
};
|
|
54
|
+
export type CloseResult = { text: string; details: CloseDetails };
|
|
55
|
+
|
|
56
|
+
/** Atomic capture/fence; rootAll has no entry, so also fence root admission. */
|
|
57
|
+
export function beginTreeClose(root: string, actor: string, id?: string): TreeClose {
|
|
58
|
+
return treeTransaction(root, (state) => {
|
|
59
|
+
const target: string = closeTarget(state, actor, id);
|
|
60
|
+
const existing: TreeClose | undefined = state.closes?.find((close) => close.target === target);
|
|
61
|
+
if (existing) return existing;
|
|
62
|
+
const ids: Set<string> = new Set(descendants(state, target).map((entry) => entry.id));
|
|
63
|
+
const entries: TreeEntry[] = state.entries.filter((entry) => entry.sessionId === target || ids.has(entry.id));
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
entry.closing = true;
|
|
66
|
+
if (entry.phase === "reserved") entry.phase = "not-started";
|
|
67
|
+
}
|
|
68
|
+
const close: TreeClose = { id: randomUUID(), target, entries: entries.map((entry) => entry.id) };
|
|
69
|
+
state.closes = [...(state.closes ?? []), close];
|
|
70
|
+
return close;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
function closeTarget(state: TreeState, actor: string, id: string | undefined): string {
|
|
74
|
+
if (id === undefined) {
|
|
75
|
+
if (actor !== state.root) throw new Error("only the root may close all delegations");
|
|
76
|
+
return state.root;
|
|
77
|
+
}
|
|
78
|
+
const entry: TreeEntry | undefined = state.entries.find((item) => item.id === id);
|
|
79
|
+
if (!entry || entry.sessionId === actor || !below(state, entry.identity.parent, actor))
|
|
80
|
+
throw new Error(`no delegation ${JSON.stringify(id)} in this session's descendant tree; close refused`);
|
|
81
|
+
const successor: TreeEntry | undefined = state.entries.find(
|
|
82
|
+
(item) => item.resumedFrom === id && item.phase !== "not-started",
|
|
83
|
+
);
|
|
84
|
+
if (successor)
|
|
85
|
+
throw new Error(`delegation ${id} was resumed as ${successor.id}; close the newest delegation instead`);
|
|
86
|
+
return entry.sessionId;
|
|
87
|
+
}
|
|
88
|
+
function member(entry: TreeEntry, outcome: CloseMember["outcome"], error?: string): CloseMember {
|
|
89
|
+
return {
|
|
90
|
+
id: entry.id,
|
|
91
|
+
sessionId: entry.sessionId,
|
|
92
|
+
...(entry.windowId ? { windowId: entry.windowId } : {}),
|
|
93
|
+
...(entry.windowName ? { windowName: entry.windowName } : {}),
|
|
94
|
+
outcome,
|
|
95
|
+
closed: !["pending", "error"].includes(outcome),
|
|
96
|
+
killed: outcome === "killed",
|
|
97
|
+
...(error ? { error } : {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function sameLocator(a: TreeEntry, b: TreeEntry): boolean {
|
|
101
|
+
return a.windowId === b.windowId && a.windowName === b.windowName && !b.createPending;
|
|
102
|
+
}
|
|
103
|
+
/** Publish only positive window/no-start facts; never invent a task result. */
|
|
104
|
+
function confirm(root: string, close: TreeClose, previous: TreeEntry): boolean {
|
|
105
|
+
return treeTransaction(root, (state) => {
|
|
106
|
+
const entry: TreeEntry | undefined = state.entries.find((item) => item.id === previous.id);
|
|
107
|
+
if (!entry || !sameLocator(previous, entry)) return false;
|
|
108
|
+
if (!entry.closed && !state.closes?.some((item) => item.id === close.id)) return false;
|
|
109
|
+
entry.closed = true;
|
|
110
|
+
return true;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
function finishClose(root: string, token: string): boolean {
|
|
114
|
+
return treeTransaction(root, (state) => {
|
|
115
|
+
state.closes = (state.closes ?? []).filter((close) =>
|
|
116
|
+
close.entries.some((id) => {
|
|
117
|
+
const entry: TreeEntry | undefined = state.entries.find((item) => item.id === id);
|
|
118
|
+
return !entry || (!entry.closed && entry.phase !== "not-started");
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
return !state.closes.some((close) => close.id === token);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
async function closeMember(
|
|
125
|
+
root: string,
|
|
126
|
+
close: TreeClose,
|
|
127
|
+
entry: TreeEntry,
|
|
128
|
+
tmux: TmuxClient,
|
|
129
|
+
assertActive: () => void,
|
|
130
|
+
): Promise<CloseMember> {
|
|
131
|
+
if (entry.phase === "not-started") return member(entry, "not-started");
|
|
132
|
+
if (entry.closed) return member(entry, "already-closed");
|
|
133
|
+
if (entry.createPending || entry.phase === "dispatched" || !entry.windowId || !entry.windowName)
|
|
134
|
+
return member(
|
|
135
|
+
entry,
|
|
136
|
+
"pending",
|
|
137
|
+
"create outcome/current window locator is unknown; reservation remains charged and fenced",
|
|
138
|
+
);
|
|
139
|
+
const windows: TmuxWindow[] = await tmux.listWindows();
|
|
140
|
+
assertActive();
|
|
141
|
+
const current: TreeEntry | undefined = readTree(root).entries.find((item) => item.id === entry.id);
|
|
142
|
+
if (!current || !sameLocator(entry, current))
|
|
143
|
+
return member(entry, "pending", "current window locator changed; retry close");
|
|
144
|
+
if (current.closed) return member(current, "already-closed");
|
|
145
|
+
const window: TmuxWindow | undefined = windows.find(
|
|
146
|
+
(item) => item.id === entry.windowId && item.name === entry.windowName,
|
|
147
|
+
);
|
|
148
|
+
if (window) await tmux.kill(window.id);
|
|
149
|
+
assertActive();
|
|
150
|
+
try {
|
|
151
|
+
if (!confirm(root, close, entry))
|
|
152
|
+
return { ...member(entry, "pending", "window changed before close publication; retry close"), killed: !!window };
|
|
153
|
+
} catch (error) {
|
|
154
|
+
return {
|
|
155
|
+
...member(
|
|
156
|
+
entry,
|
|
157
|
+
"error",
|
|
158
|
+
`window was ${window ? "killed" : "already absent"}, but close publication failed: ${String(error)}`,
|
|
159
|
+
),
|
|
160
|
+
killed: !!window,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return member(entry, window ? "killed" : "absent");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export type CloseOptions = {
|
|
167
|
+
root: string;
|
|
168
|
+
actor: string;
|
|
169
|
+
id?: string;
|
|
170
|
+
tmux: TmuxClient;
|
|
171
|
+
assertActive(): void;
|
|
172
|
+
};
|
|
173
|
+
/** Narrow shared control entry point; no UI/mail/foreign semantic writes. */
|
|
174
|
+
export async function closeTree(options: CloseOptions): Promise<CloseResult> {
|
|
175
|
+
const { root, actor, id, tmux, assertActive } = options;
|
|
176
|
+
assertActive();
|
|
177
|
+
const close: TreeClose = beginTreeClose(root, actor, id);
|
|
178
|
+
const outcomes: Map<string, CloseMember> = new Map();
|
|
179
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
180
|
+
const entries: TreeEntry[] = readTree(root)
|
|
181
|
+
.entries.filter((entry) => close.entries.includes(entry.id))
|
|
182
|
+
.sort((a, b) => b.identity.depth - a.identity.depth);
|
|
183
|
+
for (const entry of entries) {
|
|
184
|
+
if (outcomes.get(entry.id)?.closed) continue;
|
|
185
|
+
assertActive();
|
|
186
|
+
try {
|
|
187
|
+
outcomes.set(entry.id, await closeMember(root, close, entry, tmux, assertActive));
|
|
188
|
+
} catch (error) {
|
|
189
|
+
outcomes.set(entry.id, member(entry, "error", `could not close window/publication: ${String(error)}`));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (![...outcomes.values()].some((item) => item.outcome === "pending")) break;
|
|
193
|
+
if (pass < 2) await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
|
194
|
+
assertActive();
|
|
195
|
+
}
|
|
196
|
+
return closeResult(id ?? root, [...outcomes.values()], finishOperation(root, close.id, assertActive));
|
|
197
|
+
}
|
|
198
|
+
function finishOperation(root: string, token: string, assertActive: () => void): string | undefined {
|
|
199
|
+
try {
|
|
200
|
+
assertActive();
|
|
201
|
+
if (!finishClose(root, token)) return "shared close still has unresolved members; scope remains fenced";
|
|
202
|
+
} catch (error) {
|
|
203
|
+
return String(error);
|
|
204
|
+
}
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
function closeResult(id: string, members: CloseMember[], error: string | undefined): CloseResult {
|
|
208
|
+
const complete: boolean = !error && members.every((item) => item.closed);
|
|
209
|
+
const target: CloseMember | undefined = members.find((item) => item.id === id);
|
|
210
|
+
const details: CloseDetails = {
|
|
211
|
+
id,
|
|
212
|
+
...(target?.windowId ? { windowId: target.windowId } : {}),
|
|
213
|
+
...(target?.windowName ? { windowName: target.windowName } : {}),
|
|
214
|
+
closed: complete,
|
|
215
|
+
complete,
|
|
216
|
+
alreadyClosed: members.length > 0 && members.every((item) => item.outcome === "already-closed"),
|
|
217
|
+
killed: members.some((item) => item.killed),
|
|
218
|
+
members,
|
|
219
|
+
...(error ? { error } : {}),
|
|
220
|
+
};
|
|
221
|
+
const lines: string[] = members.map(
|
|
222
|
+
(item) =>
|
|
223
|
+
`- ${item.id}: ${item.outcome}${item.windowId ? ` ${item.windowName} (${item.windowId})` : ""}${item.error ? ` — ${item.error}` : ""}`,
|
|
224
|
+
);
|
|
225
|
+
return {
|
|
226
|
+
text: [
|
|
227
|
+
`${complete ? "Closed" : "Partial close of"} delegation subtree ${id} (${members.length} members).`,
|
|
228
|
+
...lines,
|
|
229
|
+
...(error ? [`Close operation: ${error}`] : []),
|
|
230
|
+
...(complete
|
|
231
|
+
? []
|
|
232
|
+
: ["Unresolved members remain fenced; retry this explicit close after positive window/create evidence."]),
|
|
233
|
+
].join("\n"),
|
|
234
|
+
details,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Admission and explicit close share session ancestry, never tmux session names.
|
|
2
|
+
import type { TreeEntry, TreeIntent, TreeState } from "./tree-types.ts";
|
|
3
|
+
|
|
4
|
+
export function below(state: TreeState, session: string, ancestor: string): boolean {
|
|
5
|
+
const seen: Set<string> = new Set();
|
|
6
|
+
let current: string = session;
|
|
7
|
+
while (!seen.has(current)) {
|
|
8
|
+
if (current === ancestor) return true;
|
|
9
|
+
seen.add(current);
|
|
10
|
+
const entry: TreeEntry | undefined = state.entries.find((item) => item.sessionId === current);
|
|
11
|
+
if (!entry) return false;
|
|
12
|
+
current = entry.identity.parent;
|
|
13
|
+
}
|
|
14
|
+
throw new Error("invalid delegation ancestry; operation refused");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function activeFence(state: TreeState, session: string): boolean {
|
|
18
|
+
return (state.closes ?? []).some((close) => below(state, session, close.target));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function fenced(state: TreeState, parent: string): boolean {
|
|
22
|
+
if (activeFence(state, parent)) return true;
|
|
23
|
+
return state.entries.some(
|
|
24
|
+
(entry) =>
|
|
25
|
+
entry.closing &&
|
|
26
|
+
entry.phase !== "not-started" &&
|
|
27
|
+
!state.entries.some((next) => next.resumedFrom === entry.id && next.phase !== "not-started") &&
|
|
28
|
+
below(state, parent, entry.sessionId),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The caller already passed 06 authority/owner checks; enforce the shared chain atomically too. */
|
|
33
|
+
export function checkResumeFence(state: TreeState, intent: TreeIntent): void {
|
|
34
|
+
if (activeFence(state, intent.sessionId)) throw new Error("delegation subtree is closing; resume refused");
|
|
35
|
+
if (!intent.resumedFrom) return;
|
|
36
|
+
const previous: TreeEntry | undefined = state.entries.find((entry) => entry.id === intent.resumedFrom);
|
|
37
|
+
// 06 has already authenticated a parent-local record. With no shared session
|
|
38
|
+
//history there is no historical fence/chain to supersede; do not invent one.
|
|
39
|
+
if (!previous && !state.entries.some((entry) => entry.sessionId === intent.sessionId)) return;
|
|
40
|
+
const successor: TreeEntry | undefined = state.entries.find(
|
|
41
|
+
(entry) => entry.resumedFrom === intent.resumedFrom && entry.phase !== "not-started",
|
|
42
|
+
);
|
|
43
|
+
if (successor?.phase === "reserved") throw new Error(`delegation ${intent.resumedFrom} is already being resumed`);
|
|
44
|
+
if (
|
|
45
|
+
!previous ||
|
|
46
|
+
previous.sessionId !== intent.sessionId ||
|
|
47
|
+
previous.identity.parent !== intent.identity.parent ||
|
|
48
|
+
successor
|
|
49
|
+
)
|
|
50
|
+
throw new Error("obsolete or inconsistent shared resume chain; resume refused");
|
|
51
|
+
}
|