@jopqior/pi-subagents 1.0.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 (110) hide show
  1. package/CHANGELOG.md +2705 -0
  2. package/LICENSE +21 -0
  3. package/README.md +503 -0
  4. package/dist/public.d.ts +331 -0
  5. package/dist/settings.d.ts +82 -0
  6. package/docs/architecture/architecture.md +1566 -0
  7. package/docs/architecture/client-server-opportunities.md +127 -0
  8. package/docs/architecture/history/phase-1-api-boundary.md +8 -0
  9. package/docs/architecture/history/phase-10-structural-decomposition.md +141 -0
  10. package/docs/architecture/history/phase-11-closure-to-class.md +100 -0
  11. package/docs/architecture/history/phase-12-complexity-test-fixtures.md +55 -0
  12. package/docs/architecture/history/phase-13-remaining-smells.md +88 -0
  13. package/docs/architecture/history/phase-14-strip-policy.md +49 -0
  14. package/docs/architecture/history/phase-15-domain-model-evolution.md +73 -0
  15. package/docs/architecture/history/phase-16-invert-dependencies.md +144 -0
  16. package/docs/architecture/history/phase-17-core-consolidation.md +214 -0
  17. package/docs/architecture/history/phase-18-reconsider-ui.md +166 -0
  18. package/docs/architecture/history/phase-19-implement-ui-decisions.md +282 -0
  19. package/docs/architecture/history/phase-2-remove-scheduling.md +9 -0
  20. package/docs/architecture/history/phase-20-result-delivery.md +245 -0
  21. package/docs/architecture/history/phase-21-classification-model-boundary.md +107 -0
  22. package/docs/architecture/history/phase-3-remove-rpc-groupjoin.md +11 -0
  23. package/docs/architecture/history/phase-4-implement-service.md +8 -0
  24. package/docs/architecture/history/phase-5-decompose-index.md +42 -0
  25. package/docs/architecture/history/phase-7-encapsulation.md +173 -0
  26. package/docs/architecture/history/phase-8-testability.md +103 -0
  27. package/docs/architecture/history/phase-9-observation-ctx.md +122 -0
  28. package/docs/comparison-with-upstream.md +77 -0
  29. package/docs/configuration.md +364 -0
  30. package/docs/decisions/0001-deferred-patches.md +80 -0
  31. package/docs/decisions/0002-extensions-on-a-minimal-core.md +125 -0
  32. package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
  33. package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
  34. package/docs/decisions/0005-subagent-record-admission-policy.md +106 -0
  35. package/docs/decisions/0006-inherited-prompt-is-identity-only.md +104 -0
  36. package/docs/decisions/0007-transcript-viewer-is-not-an-overlay.md +228 -0
  37. package/docs/decisions/0008-inherited-region-is-shared-parts.md +81 -0
  38. package/docs/decisions/0009-portable-inheritance-is-provider-scoped.md +116 -0
  39. package/package.json +91 -0
  40. package/src/config/agent-types.ts +135 -0
  41. package/src/config/custom-agents.ts +151 -0
  42. package/src/config/default-agents.ts +121 -0
  43. package/src/config/invocation-config.ts +167 -0
  44. package/src/config/thinking-level.ts +58 -0
  45. package/src/debug.ts +14 -0
  46. package/src/handlers/index.ts +3 -0
  47. package/src/handlers/interrupt.ts +58 -0
  48. package/src/handlers/lifecycle.ts +71 -0
  49. package/src/handlers/widget-events.ts +49 -0
  50. package/src/index.ts +292 -0
  51. package/src/layered-settings.ts +105 -0
  52. package/src/lifecycle/child-lifecycle.ts +115 -0
  53. package/src/lifecycle/child-shutdown.ts +105 -0
  54. package/src/lifecycle/concurrency-limiter.ts +55 -0
  55. package/src/lifecycle/create-subagent-session.ts +335 -0
  56. package/src/lifecycle/parent-snapshot.ts +119 -0
  57. package/src/lifecycle/run-listeners.ts +37 -0
  58. package/src/lifecycle/selection-scope.ts +116 -0
  59. package/src/lifecycle/spawn-selection.ts +259 -0
  60. package/src/lifecycle/subagent-manager.ts +546 -0
  61. package/src/lifecycle/subagent-session.ts +347 -0
  62. package/src/lifecycle/subagent-state.ts +404 -0
  63. package/src/lifecycle/subagent.ts +885 -0
  64. package/src/lifecycle/turn-limits.ts +13 -0
  65. package/src/lifecycle/usage.ts +60 -0
  66. package/src/lifecycle/workspace-bracket.ts +76 -0
  67. package/src/lifecycle/workspace.ts +46 -0
  68. package/src/observation/composite-subagent-observer.ts +74 -0
  69. package/src/observation/notification.ts +430 -0
  70. package/src/observation/outcome-delivery.ts +239 -0
  71. package/src/observation/record-observer.ts +78 -0
  72. package/src/observation/renderer.ts +161 -0
  73. package/src/observation/subagent-events-observer.ts +148 -0
  74. package/src/runtime.ts +137 -0
  75. package/src/service/service-adapter.ts +201 -0
  76. package/src/service/service.ts +246 -0
  77. package/src/session/ask-parent-tool.ts +69 -0
  78. package/src/session/content-items.ts +53 -0
  79. package/src/session/context.ts +80 -0
  80. package/src/session/conversation.ts +49 -0
  81. package/src/session/env.ts +40 -0
  82. package/src/session/model-resolver.ts +126 -0
  83. package/src/session/notify-parent-tool.ts +83 -0
  84. package/src/session/package-exclusions.ts +75 -0
  85. package/src/session/prompts.ts +231 -0
  86. package/src/session/provider-inheritance.ts +56 -0
  87. package/src/session/selection-catalogue.ts +143 -0
  88. package/src/session/session-config.ts +202 -0
  89. package/src/session/session-dir.ts +38 -0
  90. package/src/settings.ts +447 -0
  91. package/src/tools/agent-tool.ts +305 -0
  92. package/src/tools/background-spawner.ts +83 -0
  93. package/src/tools/foreground-runner.ts +159 -0
  94. package/src/tools/get-result-renderer.ts +119 -0
  95. package/src/tools/get-result-report.ts +84 -0
  96. package/src/tools/get-result-tool.ts +192 -0
  97. package/src/tools/helpers.ts +118 -0
  98. package/src/tools/result-renderer.ts +153 -0
  99. package/src/tools/spawn-config.ts +192 -0
  100. package/src/tools/steer-tool.ts +109 -0
  101. package/src/types.ts +143 -0
  102. package/src/ui/agent-widget.ts +333 -0
  103. package/src/ui/bounded-lines.ts +45 -0
  104. package/src/ui/display.ts +180 -0
  105. package/src/ui/glyphs.ts +62 -0
  106. package/src/ui/session-navigation.ts +150 -0
  107. package/src/ui/session-navigator.ts +255 -0
  108. package/src/ui/subagents-settings.ts +179 -0
  109. package/src/ui/transcript-content.ts +374 -0
  110. package/src/ui/widget-renderer.ts +301 -0
@@ -0,0 +1,119 @@
1
+ /**
2
+ * parent-snapshot.ts — Capture parent session state as a plain data snapshot.
3
+ */
4
+
5
+ import type { Model } from "@earendil-works/pi-ai";
6
+ import { buildParentContext } from "#src/session/context";
7
+ import type { ModelRegistry } from "#src/session/model-resolver";
8
+ import type { SessionContext } from "#src/types";
9
+
10
+ /**
11
+ * The parent session's operator-authored prompt parts, as Pi reports them on
12
+ * `before_agent_start`.
13
+ *
14
+ * A narrow structural slice of Pi's `BuildSystemPromptOptions` holding the
15
+ * layers an operator wrote and nothing Pi or a tool contributed. `skills` is
16
+ * excluded for the reason ADR 0006 cuts the catalogue: the child loads its
17
+ * own. `promptGuidelines` is excluded because Pi derives it per session from
18
+ * the tools actually in the registry, so inheriting the parent's would assert
19
+ * guidance for tools the child may not hold — the defect ADR 0008 removed with
20
+ * the `<sub_agent_context>` block. `selectedTools` and `toolSnippets` are
21
+ * excluded because the tool surface is node-local prose.
22
+ */
23
+ export interface ParentPromptOptions {
24
+ /** Context files (AGENTS.md and kin) Pi loaded for the parent session. */
25
+ contextFiles?: Array<{ path: string; content: string }>;
26
+ /** Custom system prompt (`--system-prompt`), when the parent runs one. */
27
+ customPrompt?: string;
28
+ /** Appended system prompt text (`--append-system-prompt`). */
29
+ appendSystemPrompt?: string;
30
+ }
31
+
32
+ /**
33
+ * Plain data snapshot of the parent session state captured at spawn time.
34
+ * Replaces live `ExtensionContext` references so queued agents don't read stale state.
35
+ */
36
+ export interface ParentSnapshot {
37
+ /** Parent working directory. */
38
+ cwd: string;
39
+ /** Parent's effective system prompt (for append-mode agents). */
40
+ systemPrompt: string;
41
+ /** Parent's current model instance (fallback when agent config has no model). */
42
+ model: Model<any> | undefined;
43
+ /** Model registry for resolving config.model strings and creating sessions. */
44
+ modelRegistry: ModelRegistry;
45
+ /** Pre-built parent conversation text (when inheritContext was requested). */
46
+ parentContext?: string;
47
+ /**
48
+ * The parent's operator-authored parts, rendered as an identity a child on a
49
+ * re-homing provider may adopt in place of the assembled prompt (ADR 0009).
50
+ * Undefined when Pi has assembled no prompt yet, or when the parent has no
51
+ * such parts.
52
+ */
53
+ portablePrompt?: string;
54
+ }
55
+
56
+ /**
57
+ * Build an immutable snapshot of the parent session state.
58
+ *
59
+ * Called once at spawn time so queued agents capture state as it existed
60
+ * when the user requested the agent, not when a queue slot opens.
61
+ */
62
+ export function buildParentSnapshot(
63
+ ctx: SessionContext,
64
+ inheritContext?: boolean,
65
+ promptOptions?: ParentPromptOptions,
66
+ ): ParentSnapshot {
67
+ const parentContext = inheritContext ? buildParentContext(ctx) : undefined;
68
+ return {
69
+ cwd: ctx.cwd,
70
+ systemPrompt: ctx.getSystemPrompt(),
71
+ model: ctx.model,
72
+ modelRegistry: ctx.modelRegistry,
73
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || intentional: converts empty string to undefined as well as null/undefined
74
+ parentContext: parentContext || undefined,
75
+ portablePrompt: buildPortablePrompt(promptOptions),
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Compose the parent's operator-authored parts into an identity a child may
81
+ * adopt, in the order Pi's own `buildSystemPrompt` composes them: the custom
82
+ * prompt, then the appended prompt, then the project-context block.
83
+ *
84
+ * The result is what Pi would assemble for a session with a custom prompt and
85
+ * no tools or skills, so a host that re-homes it sees text shaped the way its
86
+ * own harness produces — not Pi's base preamble.
87
+ *
88
+ * Returns undefined when no part survives, which routes the caller to the
89
+ * generic base rather than back to the full prompt.
90
+ */
91
+ function buildPortablePrompt(options?: ParentPromptOptions): string | undefined {
92
+ if (!options) return undefined;
93
+ const sections: string[] = [];
94
+ const custom = options.customPrompt?.trim();
95
+ if (custom) sections.push(custom);
96
+ const appended = options.appendSystemPrompt?.trim();
97
+ if (appended) sections.push(appended);
98
+ const projectContext = renderProjectContext(options.contextFiles);
99
+ if (projectContext) sections.push(projectContext);
100
+ return sections.length > 0 ? sections.join("\n\n") : undefined;
101
+ }
102
+
103
+ /**
104
+ * Render context files as Pi's `<project_context>` block, byte for byte.
105
+ *
106
+ * Pi writes a lead-in sentence and separates each `<project_instructions>`
107
+ * block with a blank line; matching it exactly is what keeps a portable child's
108
+ * project instructions indistinguishable from a parent's.
109
+ */
110
+ function renderProjectContext(
111
+ contextFiles: ParentPromptOptions["contextFiles"],
112
+ ): string | undefined {
113
+ if (!contextFiles || contextFiles.length === 0) return undefined;
114
+ const blocks = contextFiles.map(
115
+ ({ path, content }) =>
116
+ `<project_instructions path="${path}">\n${content}\n</project_instructions>\n`,
117
+ );
118
+ return `<project_context>\n\nProject-specific instructions and guidelines:\n\n${blocks.join("\n")}\n</project_context>`;
119
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * run-listeners.ts — Per-run observer-unsubscribe and signal-detach handles.
3
+ *
4
+ * Owns the two teardown handles that a Subagent wires at run start (signal
5
+ * listener) and after session creation (record-observer unsub), releasing
6
+ * both atomically when the run ends or the agent is resumed.
7
+ */
8
+
9
+ /** Owns the per-run observer-unsubscribe and signal-detach handles. */
10
+ export class RunListeners {
11
+ private unsub?: () => void;
12
+ private detach?: () => void;
13
+
14
+ /**
15
+ * Wire a parent AbortSignal so it triggers onAbort when fired.
16
+ * No-op when signal is undefined.
17
+ */
18
+ wireSignal(signal: AbortSignal | undefined, onAbort: () => void): void {
19
+ if (!signal) return;
20
+ const listener = () => onAbort();
21
+ signal.addEventListener("abort", listener, { once: true });
22
+ this.detach = () => signal.removeEventListener("abort", listener);
23
+ }
24
+
25
+ /** Store the record-observer unsubscribe handle. */
26
+ attachObserver(unsub: () => void): void {
27
+ this.unsub = unsub;
28
+ }
29
+
30
+ /** Release the observer + signal handles. Idempotent. */
31
+ release(): void {
32
+ this.unsub?.();
33
+ this.unsub = undefined;
34
+ this.detach?.();
35
+ this.detach = undefined;
36
+ }
37
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * selection-scope.ts — Shared construction-context carrier for spawn selection.
3
+ *
4
+ * The carrier is the one piece of selection state that must survive separate
5
+ * module evaluations: Pi jiti-loads extension sources per session (and clears
6
+ * its module cache when consecutive children resolve different cwds), so a
7
+ * module-level carrier would hand a child core a channel its root never
8
+ * established. The carrier therefore lives on `globalThis` behind a
9
+ * `Symbol.for()` key — every instance of this module in the process shares it.
10
+ *
11
+ * Its stored value is a scope handle, never a process-wide flag or singleton:
12
+ * ownership, lease state, and registration live on the handle (see
13
+ * `spawn-selection.ts`), one per root runtime generation.
14
+ */
15
+
16
+ import { AsyncLocalStorage } from "node:async_hooks";
17
+ import type { SpawnSelectionProvider, SpawnSelectionRegistration } from "#src/service/service";
18
+
19
+ /** What a core factory captures at initialization and retains as a runtime dependency. */
20
+ export interface SelectionScopeHandle {
21
+ /** Root generation identity — shared by every handle derived from one root. */
22
+ readonly rootId: string;
23
+ /**
24
+ * The registration boundary: `owned` on a root lease, `inherited` (nothing
25
+ * installed) on a descendant handle, whatever the root's state.
26
+ */
27
+ register(provider: SpawnSelectionProvider): SpawnSelectionRegistration;
28
+ /**
29
+ * The root lease's provider while new runs must select — undefined when the
30
+ * lease is unconfigured or revoked, and on any handle whose root's is.
31
+ */
32
+ activeSelectionProvider(): SpawnSelectionProvider | undefined;
33
+ /**
34
+ * Aborts once this handle closes — its own shutdown, or the root lease's
35
+ * revocation. A gated run combines this with its own abort signal for the
36
+ * provider call and the pre-creation checks.
37
+ */
38
+ readonly closureSignal: AbortSignal;
39
+ /**
40
+ * Wrap the complete child factory call in the construction context: the
41
+ * context is active for everything the thunk does, including the resource
42
+ * loader's reload and the child extension factories it awaits.
43
+ *
44
+ * The thunk is invoked synchronously, so the no-provider timing pins hold.
45
+ * Rejects when this handle is already closed — a revoked lease prohibits
46
+ * subsequent creation.
47
+ */
48
+ constructChild<T>(thunk: () => Promise<T>): Promise<T>;
49
+ /**
50
+ * Invalidate this handle and its pending subtree. Owner-aware: on a root it
51
+ * revokes the lease (signalling descendants); on a child it frees only that
52
+ * child's subtree, never the root or a sibling.
53
+ */
54
+ close(): void;
55
+ /**
56
+ * Record that a runtime now owns this handle, so the construction wrapper
57
+ * that created it leaves it open once the factory call settles. Internal
58
+ * machinery — called by {@link captureInheritedSelectionScope}.
59
+ */
60
+ retain(): void;
61
+ }
62
+
63
+ /** The process-wide construction carrier behind the shared symbol. */
64
+ interface SelectionCarrier {
65
+ als: AsyncLocalStorage<SelectionScopeHandle>;
66
+ }
67
+
68
+ const CARRIER_KEY = Symbol.for("@gotgenes/pi-subagents:selection-construction-carrier");
69
+
70
+ function carrier(): SelectionCarrier {
71
+ const globals = globalThis as Record<symbol, unknown>;
72
+ let existing = globals[CARRIER_KEY] as SelectionCarrier | undefined;
73
+ if (existing === undefined) {
74
+ existing = { als: new AsyncLocalStorage<SelectionScopeHandle>() };
75
+ globals[CARRIER_KEY] = existing;
76
+ }
77
+ return existing;
78
+ }
79
+
80
+ /**
81
+ * The ambient scope handle, visible only inside a construction context —
82
+ * a child core factory calls this during its initialization and retains the
83
+ * result for its whole lifetime. Retains the handle: capturing is the
84
+ * retention point, so the construction wrapper leaves it open once the factory
85
+ * call settles.
86
+ */
87
+ export function captureInheritedSelectionScope(): SelectionScopeHandle | undefined {
88
+ const handle = carrier().als.getStore();
89
+ handle?.retain();
90
+ return handle;
91
+ }
92
+
93
+ /**
94
+ * Run `thunk` with `handle` as the ambient construction context, releasing the
95
+ * handle again if no runtime captured it.
96
+ *
97
+ * Failure always releases the handle; success releases it only when nobody
98
+ * retained it (a child that never loaded this core has no runtime to close the
99
+ * handle later, so the wrapper is its only cleanup).
100
+ */
101
+ export function runInConstructionContext<T>(
102
+ handle: SelectionScopeHandle & { wasRetained(): boolean },
103
+ thunk: () => Promise<T>,
104
+ ): Promise<T> {
105
+ const settled = carrier().als.run(handle, thunk);
106
+ return settled.then(
107
+ (result) => {
108
+ if (!handle.wasRetained()) handle.close();
109
+ return result;
110
+ },
111
+ (err: unknown) => {
112
+ handle.close();
113
+ throw err;
114
+ },
115
+ );
116
+ }
@@ -0,0 +1,259 @@
1
+ /**
2
+ * spawn-selection.ts — Lease ownership for per-spawn selection.
3
+ *
4
+ * `SpawnSelectionScope` is one root runtime generation's provider lease; a
5
+ * descendant runtime never gets its own lease but a `ChildSelectionScope`
6
+ * handle to the root's. The lease distinguishes never-configured, active, and
7
+ * revoked: a revoked lease is denied forever (a new root session is a new
8
+ * scope), and closing a child frees only that child's subtree — never the root
9
+ * or a sibling.
10
+ *
11
+ * The construction wrapper is deliberately unconditional: it establishes a
12
+ * child handle before any loader activity even when the root has never
13
+ * configured a provider, so a descendant's registration is `inherited` by
14
+ * construction rather than by a configured-only branch.
15
+ */
16
+
17
+ import { randomUUID } from "node:crypto";
18
+ import { runInConstructionContext, type SelectionScopeHandle } from "#src/lifecycle/selection-scope";
19
+ import type { SpawnSelectionProvider, SpawnSelectionRegistration } from "#src/service/service";
20
+
21
+ /** Marker distinguishing a cancelled selection from an infrastructure error. */
22
+ const CANCELLATION_MARKER = Symbol.for(
23
+ "@gotgenes/pi-subagents:spawn-selection-cancelled",
24
+ );
25
+
26
+ /**
27
+ * A gated run's selection phase ended without a pair: the user cancelled the
28
+ * dialog, the run aborted, or the lease closed while the chooser was open.
29
+ *
30
+ * Distinct from an infrastructure failure, which is an error: a cancellation
31
+ * stops the record, it does not fail it. The marker (rather than a plain
32
+ * `instanceof`) keeps the classification working even if the throwing and
33
+ * catching module instances differ.
34
+ */
35
+ export class SelectionCancelledError extends Error {
36
+ readonly [CANCELLATION_MARKER] = true;
37
+
38
+ constructor(message = "The spawn selection was cancelled.") {
39
+ super(message);
40
+ }
41
+ }
42
+
43
+ /** Whether an error means a cancelled selection rather than a failed one. */
44
+ export function isSelectionCancellation(err: unknown): boolean {
45
+ return (
46
+ typeof err === "object" &&
47
+ err !== null &&
48
+ (err as Record<symbol, unknown>)[CANCELLATION_MARKER] === true
49
+ );
50
+ }
51
+
52
+ /** The three lease states a root's selection scope moves through, in order. */
53
+ export type SelectionLeaseState = "unconfigured" | "active" | "revoked";
54
+
55
+ /** A handle a construction wrapper created and a runtime may or may not retain. */
56
+ interface ConstructedHandle extends SelectionScopeHandle {
57
+ /** True once a runtime captured this handle as its retained dependency. */
58
+ wasRetained(): boolean;
59
+ }
60
+
61
+ /** The closed-handle denial every new construction from a closed scope reads. */
62
+ function closedScopeError(): Error {
63
+ return new Error("Spawn selection scope is closed; no further child can be created from it.");
64
+ }
65
+
66
+ /**
67
+ * The root runtime generation's selection lease.
68
+ *
69
+ * Created by the extension factory when no ambient construction context exists
70
+ * (the root); captured — never re-created — when one does (a descendant).
71
+ */
72
+ export class SpawnSelectionScope implements SelectionScopeHandle {
73
+ readonly rootId: string;
74
+
75
+ private leaseState: SelectionLeaseState = "unconfigured";
76
+ private provider?: SpawnSelectionProvider;
77
+ private readonly children = new Set<ChildSelectionScope>();
78
+ private readonly closure = new AbortController();
79
+
80
+ constructor() {
81
+ this.rootId = `spawn-selection-${randomUUID()}`;
82
+ }
83
+
84
+ /** The lease's current state — unconfigured, active, or revoked. */
85
+ get state(): SelectionLeaseState {
86
+ return this.leaseState;
87
+ }
88
+
89
+ /** The registered provider, defined only while the lease is active. */
90
+ get activeProvider(): SpawnSelectionProvider | undefined {
91
+ return this.leaseState === "active" ? this.provider : undefined;
92
+ }
93
+
94
+ /** Aborts when the lease is revoked — the root's own closure. */
95
+ get closureSignal(): AbortSignal {
96
+ return this.closure.signal;
97
+ }
98
+
99
+ /** How many direct child handles are still open (diagnostics and tests). */
100
+ get openChildCount(): number {
101
+ return this.children.size;
102
+ }
103
+
104
+ /**
105
+ * Register the single provider this root's tree will consult. Only an
106
+ * unconfigured root accepts one: a second registration on an active lease
107
+ * and any registration on a revoked lease are both refused — a closed
108
+ * generation cannot reactivate itself.
109
+ */
110
+ register(provider: SpawnSelectionProvider): SpawnSelectionRegistration {
111
+ if (this.leaseState === "active") {
112
+ throw new Error("A spawn selection provider is already registered for this session.");
113
+ }
114
+ if (this.leaseState === "revoked") {
115
+ throw new Error(
116
+ "This session's spawn selection scope is closed; a new session is required to register a provider.",
117
+ );
118
+ }
119
+ this.provider = provider;
120
+ this.leaseState = "active";
121
+ let disposed = false;
122
+ return {
123
+ kind: "owned",
124
+ dispose: () => {
125
+ if (disposed) return;
126
+ disposed = true;
127
+ this.revoke();
128
+ },
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Revoke the lease: deny every later registration and construction, and
134
+ * close all descendant handles. Idempotent. Aborts the closure signal so a
135
+ * pending chooser is dismissed rather than awaited by manager disposal.
136
+ */
137
+ revoke(): void {
138
+ if (this.leaseState === "revoked") return;
139
+ this.leaseState = "revoked";
140
+ this.provider = undefined;
141
+ this.closure.abort();
142
+ const children = [...this.children];
143
+ this.children.clear();
144
+ for (const child of children) child.close();
145
+ }
146
+
147
+ /** The root lease's provider — selection is required while it is defined. */
148
+ activeSelectionProvider(): SpawnSelectionProvider | undefined {
149
+ return this.activeProvider;
150
+ }
151
+
152
+ constructChild<T>(thunk: () => Promise<T>): Promise<T> {
153
+ if (this.leaseState === "revoked") return Promise.reject(closedScopeError());
154
+ return this.adoptChild(new ChildSelectionScope(this), thunk);
155
+ }
156
+
157
+ close(): void {
158
+ this.revoke();
159
+ }
160
+
161
+ /** A root is never retained by a construction wrapper — it owns itself. */
162
+ retain(): void {
163
+ /* no-op: the root's lifetime is the runtime's, not a thunk's */
164
+ }
165
+
166
+ /** Wrap a freshly created child handle and track it as open. */
167
+ private adoptChild<T>(child: ChildSelectionScope, thunk: () => Promise<T>): Promise<T> {
168
+ this.children.add(child);
169
+ return runInConstructionContext(child, thunk).finally(() => {
170
+ if (child.isClosed()) this.children.delete(child);
171
+ });
172
+ }
173
+
174
+ /** Detach a child that closed itself (its own shutdown path). */
175
+ detach(child: ChildSelectionScope): void {
176
+ this.children.delete(child);
177
+ }
178
+ }
179
+
180
+ /**
181
+ * A descendant's non-owning handle to the root's lease. Captured during the
182
+ * child core factory's initialization and retained for the child runtime's
183
+ * lifetime; registration is denied here (`inherited`) whatever the root's
184
+ * state, so a child can neither install nor revoke the root's provider.
185
+ */
186
+ export class ChildSelectionScope implements ConstructedHandle {
187
+ readonly rootId: string;
188
+
189
+ private closed = false;
190
+ private retained = false;
191
+ private readonly children = new Set<ChildSelectionScope>();
192
+ private readonly closure = new AbortController();
193
+
194
+ /** Created only by a construction wrapper — the root or a parent child handle. */
195
+ constructor(private readonly root: SpawnSelectionScope) {
196
+ this.rootId = root.rootId;
197
+ }
198
+
199
+ /** Aborts when this handle closes — its own shutdown or the root's revocation. */
200
+ // fallow-ignore-next-line unused-class-member -- reached via SelectionScopeHandle dispatch (gated run abort)
201
+ get closureSignal(): AbortSignal {
202
+ return this.closure.signal;
203
+ }
204
+
205
+ /** The root lease's provider — a descendant asks with the same authority. */
206
+ // fallow-ignore-next-line unused-class-member -- reached via SelectionScopeHandle dispatch (gated run consults the root lease)
207
+ activeSelectionProvider(): SpawnSelectionProvider | undefined {
208
+ return this.root.activeSelectionProvider();
209
+ }
210
+
211
+ // fallow-ignore-next-line unused-class-member -- reached via SelectionScopeHandle dispatch (a descendant's own spawns)
212
+ constructChild<T>(thunk: () => Promise<T>): Promise<T> {
213
+ if (this.closed) return Promise.reject(closedScopeError());
214
+ const grandchild = new ChildSelectionScope(this.root);
215
+ this.children.add(grandchild);
216
+ return runInConstructionContext(grandchild, thunk).finally(() => {
217
+ if (grandchild.isClosed()) this.children.delete(grandchild);
218
+ });
219
+ }
220
+
221
+ /**
222
+ * The denied registration: installs nothing, touches nothing. Applies to a
223
+ * live handle and a closed one alike — a closed inherited lease stays
224
+ * denied rather than becoming installable or throwing.
225
+ */
226
+ // fallow-ignore-next-line unused-class-member -- reached via SelectionScopeHandle dispatch (runtime.registerSpawnSelectionProvider)
227
+ register(_provider: SpawnSelectionProvider): SpawnSelectionRegistration {
228
+ return { kind: "inherited", dispose: () => {} };
229
+ }
230
+
231
+ // fallow-ignore-next-line unused-class-member -- reached via SelectionScopeHandle dispatch (captureInheritedSelectionScope)
232
+ retain(): void {
233
+ this.retained = true;
234
+ }
235
+
236
+ wasRetained(): boolean {
237
+ return this.retained;
238
+ }
239
+
240
+ /** True once this handle (or the root's revocation) closed it. */
241
+ isClosed(): boolean {
242
+ return this.closed;
243
+ }
244
+
245
+ /**
246
+ * Close this handle and its subtree only. The root's lease and unrelated
247
+ * siblings are untouched; the root stops tracking this child. Aborts the
248
+ * closure signal so this subtree's pending selections are invalidated.
249
+ */
250
+ close(): void {
251
+ if (this.closed) return;
252
+ this.closed = true;
253
+ this.closure.abort();
254
+ const children = [...this.children];
255
+ this.children.clear();
256
+ for (const child of children) child.close();
257
+ this.root.detach(this);
258
+ }
259
+ }