aiterm-mcp 0.60.0 → 0.61.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 (48) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/README.ja.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/claude-operation-marker.js +11 -0
  5. package/dist/claude-stop-hook.js +5 -9
  6. package/dist/core.js +538 -45
  7. package/dist/execution-checkpoint.js +83 -0
  8. package/dist/execution-close.js +82 -0
  9. package/dist/execution-context.js +19 -0
  10. package/dist/execution-contract.js +263 -0
  11. package/dist/execution-dispatch.js +285 -0
  12. package/dist/execution-effect.js +411 -0
  13. package/dist/execution-immediate-stop.js +68 -0
  14. package/dist/execution-interruption.js +150 -0
  15. package/dist/execution-inventory.js +100 -0
  16. package/dist/execution-lock.js +136 -0
  17. package/dist/execution-native-server.js +140 -0
  18. package/dist/execution-native-setup.js +64 -0
  19. package/dist/execution-parent-alias.js +75 -0
  20. package/dist/execution-parent-claude-records.js +136 -0
  21. package/dist/execution-parent-endpoint.js +92 -0
  22. package/dist/execution-parent-manager.js +89 -0
  23. package/dist/execution-parent-native.js +179 -0
  24. package/dist/execution-parent-process.js +29 -0
  25. package/dist/execution-parent-producer.js +129 -0
  26. package/dist/execution-parent-queue.js +84 -0
  27. package/dist/execution-parent-source.js +200 -0
  28. package/dist/execution-parent-worker.js +89 -0
  29. package/dist/execution-parent.js +345 -0
  30. package/dist/execution-participant-store.js +107 -0
  31. package/dist/execution-participant.js +78 -0
  32. package/dist/execution-runtime.js +182 -0
  33. package/dist/execution-service.js +1334 -0
  34. package/dist/execution-steer.js +37 -0
  35. package/dist/execution-store.js +206 -0
  36. package/dist/execution-tools.js +151 -0
  37. package/dist/execution-wire.js +129 -0
  38. package/dist/harnesses/claude.js +2 -0
  39. package/dist/harnesses/codex.js +111 -0
  40. package/dist/harnesses/cursor.js +13 -0
  41. package/dist/harnesses/grok.js +2 -0
  42. package/dist/index.js +1196 -1122
  43. package/dist/parent-delivery.js +408 -41
  44. package/dist/process-runtime.js +28 -0
  45. package/dist/tmux-runtime.js +30 -4
  46. package/docs/DESIGN.md +60 -0
  47. package/docs/RELEASE.md +19 -1
  48. package/package.json +4 -3
@@ -0,0 +1,83 @@
1
+ // Existing execution metadata only. Completion means the OS acknowledged the
2
+ // file and directory sync operations, not native interruption/fence evidence.
3
+ import * as fs from "node:fs";
4
+ import path from "node:path";
5
+ import { randomBytes } from "node:crypto";
6
+ import { ExecutionError } from "./execution-wire.js";
7
+ export const executionCheckpoint = "file_and_directory_sync.v1";
8
+ /** Existing metadata directory entries only; no native effect or recovery. */
9
+ export function syncExecutionDirectories(...directories) {
10
+ if (process.platform === "win32")
11
+ throw new ExecutionError("EXECUTION_UNSUPPORTED");
12
+ const synced = new Set();
13
+ for (const input of directories) {
14
+ let directory = path.resolve(input);
15
+ for (;;) {
16
+ if (synced.has(directory))
17
+ break;
18
+ let descriptor;
19
+ try {
20
+ descriptor = fs.openSync(directory, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY);
21
+ fs.fsyncSync(descriptor);
22
+ fs.closeSync(descriptor);
23
+ descriptor = undefined;
24
+ }
25
+ catch {
26
+ throw new ExecutionError("EXECUTION_STATE_UNAVAILABLE", "possible");
27
+ }
28
+ finally {
29
+ if (descriptor !== undefined) {
30
+ try {
31
+ fs.closeSync(descriptor);
32
+ }
33
+ catch { /* no native receipt */ }
34
+ }
35
+ }
36
+ synced.add(directory);
37
+ const parent = path.dirname(directory);
38
+ if (parent === directory)
39
+ break;
40
+ directory = parent;
41
+ }
42
+ }
43
+ }
44
+ /** A native operation must not run if any checkpoint step fails, including a
45
+ * directory flush after rename. The renamed state may then already be visible:
46
+ * do not roll it back, erase an issued slot or replay the native operation.
47
+ * Flush ancestors too: a newly-created scope directory must not depend on an
48
+ * unflushed parent entry. Unsupported filesystems/platforms fail closed.
49
+ */
50
+ export function writeExecutionCheckpoint(file, value) {
51
+ if (process.platform === "win32")
52
+ throw new ExecutionError("EXECUTION_UNSUPPORTED");
53
+ const temporary = `${file}.${randomBytes(6).toString("hex")}.tmp`;
54
+ let descriptor, renamed = false;
55
+ try {
56
+ descriptor = fs.openSync(temporary, "wx", 0o600);
57
+ fs.writeFileSync(descriptor, JSON.stringify(value, null, 2) + "\n");
58
+ fs.fchmodSync(descriptor, 0o600);
59
+ fs.fsyncSync(descriptor);
60
+ fs.closeSync(descriptor);
61
+ descriptor = undefined;
62
+ fs.renameSync(temporary, file);
63
+ renamed = true;
64
+ syncExecutionDirectories(path.dirname(file));
65
+ }
66
+ catch {
67
+ throw new ExecutionError("EXECUTION_STATE_UNAVAILABLE", "possible");
68
+ }
69
+ finally {
70
+ if (descriptor !== undefined) {
71
+ try {
72
+ fs.closeSync(descriptor);
73
+ }
74
+ catch { /* unavailable, never a native receipt */ }
75
+ }
76
+ if (!renamed) {
77
+ try {
78
+ fs.unlinkSync(temporary);
79
+ }
80
+ catch { /* no rollback of the committed state */ }
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,82 @@
1
+ // Synchronous expected-generation close. The serving adapter must already own
2
+ // the shared issued close slot; this helper does not replace admission/drain.
3
+ import { executionLaunchSchema } from "./execution-contract.js";
4
+ import { ExecutionError } from "./execution-wire.js";
5
+ import { z } from "zod";
6
+ export const executionNativeBindingSchema = z.object({ schema: z.literal("aiterm.execution-native.v1"),
7
+ native_id: z.string().regex(/^\$\d+$/),
8
+ server: z.object({ pid: z.number().int().positive(), started_identity: z.string().min(1).max(256) }).strict(),
9
+ }).strict();
10
+ export function parseExecutionNativeSessions(text) {
11
+ const sessions = [];
12
+ for (const line of text.split(/\r?\n/).filter(Boolean)) {
13
+ const parts = line.split("\t");
14
+ if (parts.length !== 2 || !/^\$\d+$/.test(parts[0])
15
+ || !executionLaunchSchema.shape.session_id.safeParse(parts[1]).success
16
+ || sessions.some(value => value.session_id === parts[1] || value.native_id === parts[0]))
17
+ return { state: "unknown" };
18
+ sessions.push({ native_id: parts[0], session_id: parts[1] });
19
+ }
20
+ return { state: "known", sessions };
21
+ }
22
+ export function closeExpectedExecution(input, bindings) {
23
+ const expected = executionLaunchSchema.parse(input);
24
+ const snapshot = () => {
25
+ try {
26
+ return bindings.sessions();
27
+ }
28
+ catch {
29
+ return { state: "unknown" };
30
+ }
31
+ };
32
+ const current = () => {
33
+ try {
34
+ const result = bindings.launch(expected.session_id);
35
+ return result ? executionLaunchSchema.parse(result) : null;
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ };
41
+ const before = snapshot();
42
+ if (before.state !== "known")
43
+ throw new ExecutionError("EXECUTION_UNCONFIRMED", "not_started");
44
+ const target = before.sessions.find(value => value.session_id === expected.session_id);
45
+ if (!target)
46
+ return "already_closed";
47
+ const launch = current();
48
+ if (!launch)
49
+ throw new ExecutionError("EXECUTION_UNCONFIRMED", "not_started");
50
+ if (launch.session_id !== expected.session_id || launch.launch_id !== expected.launch_id)
51
+ throw new ExecutionError("EXECUTION_STALE_GENERATION", "not_started");
52
+ const ready = snapshot();
53
+ if (ready.state !== "known")
54
+ throw new ExecutionError("EXECUTION_UNCONFIRMED", "not_started");
55
+ const still = ready.sessions.find(value => value.session_id === expected.session_id);
56
+ if (!still)
57
+ return "already_closed";
58
+ if (still.native_id !== target.native_id)
59
+ throw new ExecutionError("EXECUTION_STALE_GENERATION", "not_started");
60
+ // Metadata must still agree after collecting native identity. No await or
61
+ // idle wait separates this check and the single synchronous native effect.
62
+ const last = current();
63
+ if (!last || last.session_id !== expected.session_id || last.launch_id !== expected.launch_id)
64
+ throw new ExecutionError(last ? "EXECUTION_STALE_GENERATION" : "EXECUTION_UNCONFIRMED", "not_started");
65
+ try {
66
+ if (bindings.kill(target.native_id).code !== 0)
67
+ throw new Error("native close unconfirmed");
68
+ }
69
+ catch (error) {
70
+ // The single native primitive's gate can refuse before dispatch. No
71
+ // earlier native effect exists inside this kill callback to hide here.
72
+ if (error instanceof ExecutionError && error.effect === "not_started")
73
+ throw error;
74
+ throw new ExecutionError("EXECUTION_UNCONFIRMED", "possible");
75
+ }
76
+ const after = snapshot();
77
+ if (after.state !== "known" || after.sessions.some(value => value.native_id === target.native_id))
78
+ throw new ExecutionError("EXECUTION_UNCONFIRMED", "possible");
79
+ // Do not clean by reusable name: another generation may already exist there.
80
+ // The serving service also performs its normal expected-seat post-observation.
81
+ return "closed";
82
+ }
@@ -0,0 +1,19 @@
1
+ // Existing serving request reference, captured by product code, never a tool
2
+ // argument or a latest-active-job lookup. No issued native callback is stored.
3
+ import { AsyncLocalStorage } from "node:async_hooks";
4
+ import { executionRequestSchema, executionPointSchema } from "./execution-contract.js";
5
+ const requests = new AsyncLocalStorage();
6
+ export function withExecutionRequest(input, body, ingress) {
7
+ const fixed = ingress ? structuredClone(ingress) : null;
8
+ if (fixed?.source)
9
+ fixed.source = executionPointSchema.parse(fixed.source);
10
+ return requests.run({ request: executionRequestSchema.parse(input), ingress: fixed }, body);
11
+ }
12
+ export function currentExecutionRequest() {
13
+ const context = requests.getStore();
14
+ return context ? structuredClone(context.request) : null;
15
+ }
16
+ export function currentExecutionIngress() {
17
+ const context = requests.getStore();
18
+ return context ? structuredClone(context) : null;
19
+ }
@@ -0,0 +1,263 @@
1
+ // Contract and pure snapshot planning for the injectable execution service.
2
+ // Production MCP/core runtime evidence and mutation adapters are not mounted.
3
+ import { z } from "zod";
4
+ const sessionId = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/);
5
+ const launchId = z.string().regex(/^[0-9a-f]{32}$/);
6
+ const referenceId = z.string().min(1).max(256);
7
+ export const executionLaunchSchema = z.object({
8
+ session_id: sessionId, launch_id: launchId,
9
+ }).strict();
10
+ // Existing references, normalized by the runtime adapter to an immutable run
11
+ // boundary. A later native turn ID is an alias, not permission to rekey history.
12
+ export const executionBoundarySchema = z.object({
13
+ event_cursor: z.number().int().nonnegative(),
14
+ operation_id: z.string().regex(/^sha256:[0-9a-f]{64}$/).nullable(),
15
+ turn_id: referenceId.nullable(),
16
+ steer_id: z.string().regex(/^[0-9a-f]{16}$/).nullable(),
17
+ }).strict();
18
+ export const executionPointSchema = z.object({
19
+ launch: executionLaunchSchema, boundary: executionBoundarySchema.nullable(),
20
+ }).strict();
21
+ // Scope is the serving local Aiterm. Remote scopes require an explicit adapter.
22
+ // Room fan-out uses actual target, not just the requested responders.
23
+ export const executionRequestSchema = z.discriminatedUnion("kind", [
24
+ z.object({ kind: z.literal("delivery"), delivery_id: referenceId, target_session_id: sessionId }).strict(),
25
+ z.object({ kind: z.literal("room"), message_id: referenceId, target_session_id: sessionId }).strict(),
26
+ z.object({ kind: z.literal("call"), call_session_id: referenceId,
27
+ event: z.enum(["session.message", "session.reply"]), seq: z.number().int().nonnegative(),
28
+ target_session_id: sessionId }).strict(),
29
+ z.object({ kind: z.literal("aiterm"), launch: executionLaunchSchema, boundary: executionBoundarySchema }).strict(),
30
+ // Existing JSON-RPC call identity, scoped by the serving process's native
31
+ // identity. No new job/delivery ID is allocated for direct stdio operations.
32
+ z.object({ kind: z.literal("mcp"), process_pid: z.number().int().positive(),
33
+ process_started_identity: referenceId,
34
+ request_id: z.union([referenceId, z.number().int().safe()]),
35
+ tool: z.enum(["agent_launch", "claude_agent", "codex_agent", "grok_agent", "pty_open", "pty_send", "pty_key", "pty_close",
36
+ "agent_configure", "agent_auth.start", "agent_auth.status", "agent_auth.cancel", "agent_approval.respond",
37
+ "claude_approval.respond", "claude_turn.issue"]), target_session_id: sessionId }).strict(),
38
+ ]);
39
+ // Owner correction: interruption preserves the current session/launch and
40
+ // conversation. The former close result is deliberately a different version.
41
+ export const cascadeInterruptRequestSchema = z.object({
42
+ schema: z.literal("aiterm.cascade-stop-request.v2"), mode: z.literal("interrupt"),
43
+ roots: z.array(executionLaunchSchema).min(1),
44
+ }).strict();
45
+ export const cascadeInterruptResultObjectSchema = z.object({
46
+ schema: z.literal("aiterm.cascade-stop-result.v2"), mode: z.literal("interrupt"),
47
+ roots: z.array(executionLaunchSchema).min(1), status: z.enum(["confirmed", "partial", "unconfirmed"]),
48
+ relation_coverage: z.enum(["complete", "incomplete"]),
49
+ unresolved: z.array(z.object({ request: executionRequestSchema.nullable(), reason: z.string() }).strict()),
50
+ targets: z.array(z.object({ launch: executionLaunchSchema, run: executionPointSchema.nullable(),
51
+ key: z.enum(["Escape", "C-c"]).nullable(), key_receipt: z.enum(["not_sent", "accepted", "unknown"]),
52
+ interruption: z.enum(["interrupted", "already_idle", "unconfirmed", "unsupported", "replaced"]),
53
+ session_preserved: z.enum(["confirmed", "unconfirmed"]), reason: z.string(),
54
+ }).strict()),
55
+ }).strict();
56
+ export const cascadeInterruptResultSchema = cascadeInterruptResultObjectSchema.superRefine((value, context) => {
57
+ const confirmed = (target) => target.session_preserved === "confirmed"
58
+ && ["interrupted", "already_idle"].includes(target.interruption);
59
+ if (value.targets.some(target => (target.run && (target.run.launch.session_id !== target.launch.session_id
60
+ || target.run.launch.launch_id !== target.launch.launch_id))
61
+ || (target.key_receipt === "accepted" && !target.key)
62
+ || (target.interruption === "interrupted" && !target.run?.boundary)
63
+ || (target.interruption === "already_idle" && (target.key !== null || target.key_receipt !== "not_sent")))
64
+ || value.roots.some(root => !value.targets.some(target => target.launch.session_id === root.session_id && target.launch.launch_id === root.launch_id))
65
+ || (value.status === "confirmed" && (!value.targets.length || !value.targets.every(confirmed)
66
+ || value.relation_coverage !== "complete" || value.unresolved.length > 0))
67
+ || (value.status === "partial" && !value.targets.some(confirmed)))
68
+ context.addIssue({ code: "custom", message: "key receipt alone cannot confirm interruption or preserved-session coverage" });
69
+ });
70
+ // Mechanical operation type, never an interpretation of the message contents.
71
+ // Mutation admission still fences/drains non-work operations; they do not
72
+ // become an AI invocation edge merely because they touched another seat.
73
+ export function executionRequestStartsWork(request) {
74
+ return request.kind !== "mcp" || ["agent_launch", "claude_agent", "codex_agent", "grok_agent", "pty_send", "pty_key",
75
+ "agent_approval.respond", "claude_approval.respond", "claude_turn.issue"].includes(request.tool);
76
+ }
77
+ export const executionSourceClaimSchema = z.object({
78
+ launch: executionLaunchSchema.nullable(),
79
+ boundary: executionBoundarySchema.nullable(),
80
+ }).strict();
81
+ function equal(a, b) { return JSON.stringify(a) === JSON.stringify(b); }
82
+ export function assessExecutionSource(input, evidence) {
83
+ const claim = executionSourceClaimSchema.parse(input);
84
+ const runtime = evidence.runtime_launch && executionLaunchSchema.parse(evidence.runtime_launch);
85
+ const result = (status, reason) => ({ status, reason, point: null, boundary_status: "missing" });
86
+ if (!claim.launch || !runtime)
87
+ return result("unknown", "launch_unavailable");
88
+ if (!equal(claim.launch, runtime))
89
+ return result("stale", "launch_changed");
90
+ if (!evidence.caller_bound)
91
+ return result("unknown", "caller_unbound");
92
+ const boundary = evidence.invocation_boundary && executionBoundarySchema.parse(evidence.invocation_boundary);
93
+ const mismatch = !!boundary && !!claim.boundary && !equal(boundary, claim.boundary);
94
+ return { status: "verified", reason: "matched", point: { launch: runtime, boundary: mismatch ? null : boundary },
95
+ boundary_status: mismatch ? "mismatch" : boundary ? "matched" : "missing" };
96
+ }
97
+ function requestTarget(request) {
98
+ return request.kind === "aiterm" ? request.launch.session_id : request.target_session_id;
99
+ }
100
+ export const executionReservationSchema = z.object({
101
+ schema: z.literal("aiterm.execution-reservation.v1"), request: executionRequestSchema,
102
+ source: executionSourceClaimSchema,
103
+ target: z.object({ session_id: sessionId, expected_launch_id: launchId.nullable() }).strict(),
104
+ }).strict().superRefine((value, context) => {
105
+ if (requestTarget(value.request) !== value.target.session_id || (value.request.kind === "aiterm"
106
+ && value.target.expected_launch_id !== value.request.launch.launch_id))
107
+ context.addIssue({ code: "custom", message: "reservation reference and target must identify the same execution" });
108
+ });
109
+ // Runtime-owned snapshot, NOT a public register/finish request. Completion must
110
+ // be backed by the exact request/launch/boundary, not queue acceptance, a generic
111
+ // delivery status, an aborted waiter, a timeout, or an idle snapshot alone.
112
+ export const executionPhaseSchema = z.enum(["reserved", "invoked", "submitted_unknown", "unsent"]);
113
+ export const executionRelationSchema = z.object({
114
+ request: executionRequestSchema,
115
+ source_session_id: sessionId.nullable(), // hint for reporting an unbound source only
116
+ source: executionPointSchema.nullable(), target: executionPointSchema.nullable(),
117
+ phase: executionPhaseSchema,
118
+ completion: z.enum(["open", "completed", "interrupted", "unknown"]),
119
+ target_fence: z.enum(["open", "closed", "unknown"]),
120
+ // Set after the recorded invocation and its known return/descendant work finish.
121
+ // This is relation lifetime, not proof that a CLI process has terminated.
122
+ // A retired edge is never revived by unrelated work on the same seat.
123
+ lifecycle: z.enum(["tracking", "retired"]),
124
+ }).strict().superRefine((value, context) => {
125
+ if ((value.source && value.source_session_id !== value.source.launch.session_id)
126
+ || (value.target && requestTarget(value.request) !== value.target.launch.session_id)
127
+ || (value.target && value.request.kind === "aiterm" && !equal(value.request.launch, value.target.launch)))
128
+ context.addIssue({ code: "custom", message: "relation reference and bound execution disagree" });
129
+ if ((value.phase === "reserved" || value.phase === "unsent") && value.completion !== "open")
130
+ context.addIssue({ code: "custom", message: "an uninvoked reservation has no execution completion" });
131
+ if ((value.completion === "completed" && !value.target?.boundary)
132
+ || (value.completion === "interrupted" && !value.target))
133
+ context.addIssue({ code: "custom", message: "completion requires a bound run; generation-wide interruption requires a bound launch" });
134
+ if (value.lifecycle === "retired" && value.phase !== "unsent"
135
+ && (value.target_fence !== "closed" || (value.completion !== "completed" && value.completion !== "interrupted")))
136
+ context.addIssue({ code: "custom", message: "retirement requires terminal execution or confirmed unsent" });
137
+ });
138
+ /** Pure snapshot plan. No PTY, wall-clock cutoff, process, I/O or LLM judgment.
139
+ * Every reached launch is included even if shared with unrelated live work.
140
+ * Terminal history is excluded unless needed to reach unfinished descendants.
141
+ * Atomic runtime admission/draining and closure verification are separate work.
142
+ */
143
+ function relationActivity(relations) {
144
+ const active = new Set();
145
+ const uncertainRetention = new Set();
146
+ for (let i = 0; i < relations.length; i++) {
147
+ const edge = relations[i];
148
+ if (!executionRequestStartsWork(edge.request))
149
+ continue;
150
+ if (edge.lifecycle !== "retired" && edge.phase !== "unsent"
151
+ && (edge.phase === "reserved" || edge.completion === "open" || edge.completion === "unknown" || edge.target_fence !== "closed"))
152
+ active.add(i);
153
+ }
154
+ // Least fixed point: all-terminal cycles expire. Completed parents remain
155
+ // connectors only for unfinished descendants of the same runtime execution.
156
+ let changed = true;
157
+ while (changed) {
158
+ changed = false;
159
+ for (let i = 0; i < relations.length; i++) {
160
+ const edge = relations[i];
161
+ if (!executionRequestStartsWork(edge.request) || active.has(i) || edge.lifecycle === "retired" || edge.phase === "unsent" || !edge.target)
162
+ continue;
163
+ for (const j of active) {
164
+ const child = relations[j];
165
+ // A known unbound child candidate cannot silently prove the ancestor's
166
+ // branch complete. Retain only the already-recorded ancestor; do not
167
+ // fabricate or traverse that child's unbound generation edge.
168
+ const unboundChild = !child.source && child.source_session_id === edge.target.launch.session_id;
169
+ if (!unboundChild && (!child.source || !equal(edge.target.launch, child.source.launch)))
170
+ continue;
171
+ const missing = unboundChild || !edge.target.boundary || !child.source?.boundary;
172
+ if (!missing && !equal(edge.target.boundary, child.source?.boundary))
173
+ continue;
174
+ active.add(i);
175
+ if (missing)
176
+ uncertainRetention.add(i);
177
+ changed = true;
178
+ break;
179
+ }
180
+ }
181
+ }
182
+ return { active, uncertainRetention };
183
+ }
184
+ export function unfinishedExecutionRelations(input) {
185
+ const relations = z.array(executionRelationSchema).parse(input);
186
+ return [...relationActivity(relations).active].map(index => relations[index]);
187
+ }
188
+ export function planCascadeStop(rootInput, relationInput) {
189
+ const roots = z.array(executionLaunchSchema).min(1).parse(rootInput);
190
+ const relations = z.array(executionRelationSchema).parse(relationInput);
191
+ const { active, uncertainRetention } = relationActivity(relations);
192
+ const plan = { targets: [], retained: [], cancel_reservations: [], gaps: [] };
193
+ const visited = new Set();
194
+ const reported = new Set();
195
+ const gap = (edge, reason) => {
196
+ const key = JSON.stringify([edge.request, reason]);
197
+ if (!reported.has(key)) {
198
+ reported.add(key);
199
+ plan.gaps.push({ request: edge.request, reason });
200
+ }
201
+ };
202
+ const queue = [...roots];
203
+ for (let cursor = 0; cursor < queue.length; cursor++) {
204
+ const launch = queue[cursor];
205
+ const key = JSON.stringify(launch);
206
+ if (visited.has(key))
207
+ continue;
208
+ visited.add(key);
209
+ plan.targets.push(launch);
210
+ for (const i of active) {
211
+ const edge = relations[i];
212
+ if (!edge.source) {
213
+ if (edge.source_session_id === launch.session_id)
214
+ gap(edge, "source_generation_missing");
215
+ continue;
216
+ }
217
+ if (!equal(edge.source.launch, launch))
218
+ continue;
219
+ plan.retained.push(edge.request);
220
+ if (edge.phase === "reserved") {
221
+ plan.cancel_reservations.push(edge.request);
222
+ continue;
223
+ }
224
+ if (!edge.target) {
225
+ gap(edge, "target_generation_missing");
226
+ continue;
227
+ }
228
+ queue.push(edge.target.launch);
229
+ if (!edge.source.boundary || !edge.target.boundary)
230
+ gap(edge, "run_boundary_missing");
231
+ if (edge.completion === "unknown")
232
+ gap(edge, "completion_missing");
233
+ if ((edge.completion === "completed" || edge.completion === "interrupted") && edge.target_fence !== "closed")
234
+ gap(edge, "invocation_fence_unconfirmed");
235
+ if (edge.phase === "submitted_unknown")
236
+ gap(edge, "invocation_outcome_unknown");
237
+ if (uncertainRetention.has(i))
238
+ gap(edge, "descendant_binding_missing");
239
+ }
240
+ }
241
+ return plan;
242
+ }
243
+ export const cascadeStopRequestSchema = z.object({
244
+ schema: z.literal("aiterm.cascade-stop-request.v1"), roots: z.array(executionLaunchSchema).min(1),
245
+ }).strict();
246
+ export const cascadeStopResultObjectSchema = z.object({
247
+ schema: z.literal("aiterm.cascade-stop-result.v1"), roots: z.array(executionLaunchSchema).min(1),
248
+ status: z.enum(["stopped", "partial", "unconfirmed"]), relation_coverage: z.enum(["complete", "incomplete"]),
249
+ unresolved: z.array(z.object({ request: executionRequestSchema.nullable(), reason: referenceId }).strict()),
250
+ targets: z.array(z.object({
251
+ launch: executionLaunchSchema,
252
+ outcome: z.enum(["closed", "already_closed", "unknown", "unsupported", "replaced"]),
253
+ reason: referenceId,
254
+ }).strict()),
255
+ }).strict();
256
+ export const cascadeStopResultSchema = cascadeStopResultObjectSchema.superRefine((value, context) => {
257
+ if (value.status !== "stopped")
258
+ return;
259
+ const closed = value.targets.filter(target => target.outcome === "closed" || target.outcome === "already_closed");
260
+ if (value.relation_coverage !== "complete" || value.unresolved.length || closed.length !== value.targets.length
261
+ || value.roots.some(root => !closed.some(target => equal(root, target.launch))))
262
+ context.addIssue({ code: "custom", message: "stopped requires complete relations, no unresolved work and every root/target closed" });
263
+ });