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.
Files changed (62) hide show
  1. package/CHANGELOG.md +438 -1
  2. package/README.md +1062 -152
  3. package/package.json +4 -3
  4. package/src/agents.ts +395 -124
  5. package/src/child-tasks.ts +221 -0
  6. package/src/child.ts +107 -62
  7. package/src/completion-holds.ts +104 -0
  8. package/src/dashboard.ts +144 -0
  9. package/src/git.ts +6 -1
  10. package/src/identity.ts +152 -0
  11. package/src/index.ts +585 -107
  12. package/src/local-state.ts +21 -0
  13. package/src/navigator-view.ts +186 -0
  14. package/src/navigator.ts +152 -107
  15. package/src/new-delegate.ts +17 -8
  16. package/src/poll.ts +1 -0
  17. package/src/render.ts +4 -1
  18. package/src/result-close.ts +37 -0
  19. package/src/result-restore.ts +95 -0
  20. package/src/result-sessions.ts +39 -0
  21. package/src/result-types.ts +29 -1
  22. package/src/results-presentation.ts +47 -0
  23. package/src/results.ts +176 -108
  24. package/src/resume-checks.ts +59 -14
  25. package/src/resume-picker.ts +9 -2
  26. package/src/resume.ts +69 -15
  27. package/src/session-owner.ts +279 -0
  28. package/src/task-replies.ts +247 -0
  29. package/src/task-settlement.ts +81 -0
  30. package/src/task-store.ts +26 -0
  31. package/src/tmux.ts +134 -54
  32. package/src/tool-ceiling.ts +125 -0
  33. package/src/tracking.ts +72 -65
  34. package/src/tree-close.ts +236 -0
  35. package/src/tree-fence.ts +51 -0
  36. package/src/tree-lock.ts +77 -0
  37. package/src/tree-notice.ts +124 -0
  38. package/src/tree-registry.ts +365 -0
  39. package/src/tree-settings.ts +31 -0
  40. package/src/tree-types.ts +32 -0
  41. package/src/tree-view.ts +246 -0
  42. package/src/trust-control.ts +214 -0
  43. package/src/trust-prompt.ts +163 -0
  44. package/src/trust-wire.ts +203 -0
  45. package/src/widget-activity-join.ts +24 -0
  46. package/src/widget-activity-runtime.ts +104 -0
  47. package/src/widget-activity.ts +104 -0
  48. package/src/widget-ancestors.ts +142 -0
  49. package/src/widget-current.ts +41 -0
  50. package/src/widget-data.ts +147 -0
  51. package/src/widget-focus.ts +133 -0
  52. package/src/widget-live-process.ts +31 -0
  53. package/src/widget-live.ts +356 -0
  54. package/src/widget-render.ts +181 -0
  55. package/src/widget-selection.ts +58 -0
  56. package/src/widget-settings.ts +29 -0
  57. package/src/widget-summary.ts +51 -0
  58. package/src/widget-title-file.ts +127 -0
  59. package/src/widget-title.ts +118 -0
  60. package/src/widget.ts +272 -0
  61. package/src/window-placement.ts +30 -5
  62. 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 { type Static, Type } from "typebox";
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 { listWindows, ownWindow, windowState };
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 one delegation as `delegation_close` does; throws as the tool does. */
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 { record, state, name: recordedName(record), sessionPath, lastActive, snapshot, resumedAs, windowSession };
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
- "The delegations this session started, each 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.",
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
- /** Close one delegation: kill its window when it is open and record the close; throws when it cannot. */
331
- export async function closeDelegation(results: Results, tmux: TmuxClient, id: string): Promise<CloseResult> {
332
- const [record] = selectRecords(results, id);
333
- if (!isStarted(record)) {
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 ${record.id} is a workspace attempt (${record.attempt.phase}): no verified window id was recorded; a window may still exist, so close is unavailable`,
362
+ `delegation ${local.id} is a workspace attempt (${local.attempt.phase}); close requires a verified create outcome`,
336
363
  );
337
- }
338
- const closed = (alreadyClosed: boolean, killed: boolean): CloseResult["details"] => ({
339
- id: record.id,
340
- windowId: record.windowId,
341
- windowName: record.windowName,
342
- closed: true as const,
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
- "Close one delegation: kill its tmux window through tmux and record the close, so delegation_status reports it closed. The delegate's session file and result envelope stay on disk. An unknown or already closed id returns a message with nothing killed.",
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 a window; a second close finds it recorded and does nothing.
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
- _ctx: ExtensionContext,
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(results, tmux, readingErrors) as never);
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> => closeDelegation(results, tmux, id),
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
+ }