@sublang/playbook 7.0.0 → 8.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 (61) hide show
  1. package/README.md +17 -4
  2. package/docs/cli.md +74 -29
  3. package/docs/configuration.md +209 -112
  4. package/docs/embedding.md +71 -25
  5. package/package.json +4 -3
  6. package/reference/sdlc/captain.playbook/captain.playbook.js +3 -3
  7. package/reference/sdlc/captain.playbook/captain.playbook.ts +3 -3
  8. package/reference/sdlc/code.md +1 -1
  9. package/reference/sdlc/code.playbook/bin/interactive-session.js +816 -0
  10. package/reference/sdlc/code.playbook/bin/launch-config.js +1078 -116
  11. package/reference/sdlc/code.playbook/bin/playbook.js +489 -34
  12. package/reference/sdlc/code.playbook/bin/run.js +283 -298
  13. package/reference/sdlc/code.playbook/bin/session-store.js +818 -26
  14. package/reference/sdlc/code.playbook/code.fsm.d.ts +5 -5
  15. package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
  17. package/reference/sdlc/code.playbook/code.fsm.js +7 -11
  18. package/reference/sdlc/code.playbook/code.fsm.ts +9 -17
  19. package/reference/sdlc/code.playbook/code.gears.md +1 -1
  20. package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
  21. package/reference/sdlc/code.playbook/code.playbook.js +12 -13
  22. package/reference/sdlc/code.playbook/code.playbook.ts +22 -15
  23. package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
  24. package/reference/sdlc/code.playbook/code.registry.js +3 -10
  25. package/reference/sdlc/code.playbook/code.registry.ts +7 -32
  26. package/reference/sdlc/code.playbook/playbook-captain.d.ts +39 -14
  27. package/reference/sdlc/code.playbook/playbook-captain.js +970 -289
  28. package/reference/sdlc/code.playbook/playbook-captain.ts +1403 -396
  29. package/reference/sdlc/code.playbook/playbook.config.template.yaml +41 -49
  30. package/reference/sdlc/decide.md +4 -4
  31. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +9 -9
  32. package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
  33. package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
  34. package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
  35. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +9 -13
  36. package/reference/sdlc/decide.playbook/decide.playbook.js +171 -134
  37. package/reference/sdlc/decide.playbook/decide.playbook.ts +238 -162
  38. package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
  39. package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
  40. package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
  41. package/reference/sdlc/review.md +4 -5
  42. package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
  43. package/reference/sdlc/review.playbook/review.fsm.js +30 -24
  44. package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
  45. package/reference/sdlc/review.playbook/review.gears.md +6 -5
  46. package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
  47. package/reference/sdlc/review.playbook/review.playbook.js +16 -21
  48. package/reference/sdlc/review.playbook/review.playbook.ts +26 -26
  49. package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
  50. package/reference/sdlc/review.playbook/review.registry.js +3 -16
  51. package/reference/sdlc/review.playbook/review.registry.ts +7 -38
  52. package/slc/gears2fsm.md +27 -23
  53. package/slc/link.md +113 -93
  54. package/slc/text2gears.md +19 -18
  55. package/src/runtime.d.ts +20 -16
  56. package/src/runtime.ts +19 -23
  57. package/src/xstate-playbook-runtime.d.ts +21 -17
  58. package/src/xstate-playbook-runtime.js +241 -149
  59. package/src/xstate-playbook-runtime.ts +331 -178
  60. package/src/xstate-runtime.js +63 -24
  61. package/src/xstate-runtime.ts +96 -28
@@ -0,0 +1,816 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+
4
+ // PBCLI-49: the tmux pane process owns the durable interactive-session
5
+ // transaction. The outer launcher supplies a private immutable launch payload;
6
+ // this child acquires the writer lease, initializes or restores the shared
7
+ // Captain host, and brackets every visible reply with durable store updates.
8
+
9
+ import { randomUUID } from 'node:crypto';
10
+ import { constants, realpathSync } from 'node:fs';
11
+ import { lstat, open, readFile, realpath, unlink } from 'node:fs/promises';
12
+ import {
13
+ basename,
14
+ dirname,
15
+ isAbsolute,
16
+ join,
17
+ relative,
18
+ resolve,
19
+ sep,
20
+ } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { isDeepStrictEqual } from 'node:util';
23
+ import { runManagedTmuxPlaySession } from '@sublang/cligent/tmux-play';
24
+ import { shellQuote } from './adapter-sdk.js';
25
+ import {
26
+ PLAYBOOK_CAPTAIN_MODULE,
27
+ projectHostAgent,
28
+ } from './launch-config.js';
29
+ import {
30
+ CaptainSessionHostCleanupError,
31
+ captainOptionsFromConfig,
32
+ createCaptainSessionHost,
33
+ validateFrozenExecutionConfig,
34
+ } from './run.js';
35
+ import { prepareConfiguredRegistries } from './provision.js';
36
+ import {
37
+ assertCaptainSessionExecutionCompatible,
38
+ createCaptainSessionStore,
39
+ projectCaptainSessionStructure,
40
+ SESSION_ID_PATTERN,
41
+ validateCaptainSessionExecutionProjection,
42
+ validateCaptainSessionRecord,
43
+ } from './session-store.js';
44
+
45
+ export const MANAGED_INTERACTIVE_PAYLOAD_FILE =
46
+ 'playbook-managed-interactive.json';
47
+ export const MANAGED_INTERACTIVE_PAYLOAD_SCHEMA_VERSION = 1;
48
+ export const MANAGED_INTERACTIVE_PAYLOAD_KIND =
49
+ 'playbook-managed-interactive-launch';
50
+
51
+ const PAYLOAD_KEYS = [
52
+ 'schemaVersion',
53
+ 'kind',
54
+ 'mode',
55
+ 'sessionId',
56
+ 'cwd',
57
+ 'sessionsDir',
58
+ 'noProvision',
59
+ 'executionProjection',
60
+ 'workDir',
61
+ 'workDirOwnedByLauncher',
62
+ 'readinessPath',
63
+ 'inputGatePath',
64
+ 'inputActivePath',
65
+ 'shutdownRequestPath',
66
+ 'shutdownCompletePath',
67
+ ];
68
+
69
+ export function validateManagedInteractivePayload(value) {
70
+ if (!isPlainRecord(value)) {
71
+ throw new Error('managed interactive launch payload must be a plain object');
72
+ }
73
+ assertExactKeys(value, PAYLOAD_KEYS, 'managed interactive launch payload');
74
+ if (value.schemaVersion !== MANAGED_INTERACTIVE_PAYLOAD_SCHEMA_VERSION) {
75
+ throw new Error('managed interactive launch payload schema is not supported');
76
+ }
77
+ if (value.kind !== MANAGED_INTERACTIVE_PAYLOAD_KIND) {
78
+ throw new Error('managed interactive launch payload kind is not supported');
79
+ }
80
+ if (value.mode !== 'fresh' && value.mode !== 'selected') {
81
+ throw new Error('managed interactive launch payload mode is not supported');
82
+ }
83
+ assertUuid(value.sessionId, 'managed interactive session id');
84
+ assertCanonicalAbsolutePath(value.cwd, 'managed interactive working directory');
85
+ assertCanonicalAbsolutePath(
86
+ value.sessionsDir,
87
+ 'managed interactive sessions directory',
88
+ );
89
+ if (typeof value.noProvision !== 'boolean') {
90
+ throw new Error('managed interactive noProvision must be a boolean');
91
+ }
92
+ if (typeof value.workDirOwnedByLauncher !== 'boolean') {
93
+ throw new Error(
94
+ 'managed interactive workDirOwnedByLauncher must be a boolean',
95
+ );
96
+ }
97
+ for (const key of [
98
+ 'workDir',
99
+ 'readinessPath',
100
+ 'inputGatePath',
101
+ 'inputActivePath',
102
+ 'shutdownRequestPath',
103
+ 'shutdownCompletePath',
104
+ ]) {
105
+ assertCanonicalAbsolutePath(
106
+ value[key],
107
+ `managed interactive launch payload ${key}`,
108
+ );
109
+ }
110
+ return Object.freeze({
111
+ schemaVersion: value.schemaVersion,
112
+ kind: value.kind,
113
+ mode: value.mode,
114
+ sessionId: value.sessionId,
115
+ cwd: value.cwd,
116
+ sessionsDir: value.sessionsDir,
117
+ noProvision: value.noProvision,
118
+ workDir: value.workDir,
119
+ workDirOwnedByLauncher: value.workDirOwnedByLauncher,
120
+ readinessPath: value.readinessPath,
121
+ inputGatePath: value.inputGatePath,
122
+ inputActivePath: value.inputActivePath,
123
+ shutdownRequestPath: value.shutdownRequestPath,
124
+ shutdownCompletePath: value.shutdownCompletePath,
125
+ executionProjection: validateCaptainSessionExecutionProjection(
126
+ value.executionProjection,
127
+ 'managed interactive execution projection',
128
+ ),
129
+ });
130
+ }
131
+
132
+ export async function writeManagedInteractivePayload(workDir, value) {
133
+ assertCanonicalAbsolutePath(workDir, 'managed interactive work directory');
134
+ const payload = validateManagedInteractivePayload(value);
135
+ const path = join(workDir, MANAGED_INTERACTIVE_PAYLOAD_FILE);
136
+ const handle = await open(path, 'wx', 0o600);
137
+ try {
138
+ await handle.chmod(0o600);
139
+ const stat = await handle.stat();
140
+ if (!stat.isFile() || (stat.mode & 0o7777) !== 0o600) {
141
+ throw new Error(
142
+ 'managed interactive launch payload is not a private regular file',
143
+ );
144
+ }
145
+ if (
146
+ typeof process.getuid === 'function' &&
147
+ stat.uid !== process.getuid()
148
+ ) {
149
+ throw new Error('managed interactive launch payload has a foreign owner');
150
+ }
151
+ await handle.writeFile(`${JSON.stringify(payload)}\n`, 'utf8');
152
+ await handle.sync();
153
+ } finally {
154
+ await handle.close();
155
+ }
156
+ return path;
157
+ }
158
+
159
+ export async function readManagedInteractivePayload(path) {
160
+ assertCanonicalAbsolutePath(path, 'managed interactive launch descriptor');
161
+ assertManagedDescriptorPath(path);
162
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
163
+ try {
164
+ const stat = await handle.stat();
165
+ assertPrivateDescriptorStat(stat);
166
+ return await readManagedInteractivePayloadHandle(handle);
167
+ } finally {
168
+ await handle.close();
169
+ }
170
+ }
171
+
172
+ export async function createManagedInteractiveSessionCommand(
173
+ context,
174
+ payload,
175
+ { selfBin = process.argv[1], execPath = process.execPath } = {},
176
+ ) {
177
+ if (typeof selfBin !== 'string' || selfBin.length === 0) {
178
+ throw new Error('managed interactive child executable path is missing');
179
+ }
180
+ if (context.sessionId !== payload.sessionId || context.cwd !== payload.cwd) {
181
+ throw new Error('managed interactive launch context is mismatched');
182
+ }
183
+ if (typeof context.workDirOwnedByLauncher !== 'boolean') {
184
+ throw new Error(
185
+ 'managed interactive launch context work-directory ownership is missing',
186
+ );
187
+ }
188
+ const descriptorPath = await writeManagedInteractivePayload(
189
+ context.workDir,
190
+ {
191
+ ...payload,
192
+ workDir: context.workDir,
193
+ workDirOwnedByLauncher: context.workDirOwnedByLauncher,
194
+ readinessPath: context.readinessPath,
195
+ inputGatePath: context.inputGatePath,
196
+ inputActivePath: context.inputActivePath,
197
+ shutdownRequestPath: context.shutdownRequestPath,
198
+ shutdownCompletePath: context.shutdownCompletePath,
199
+ },
200
+ );
201
+ const args = [execPath, resolve(selfBin), descriptorPath];
202
+ return args.map((value) => shellQuote(String(value))).join(' ');
203
+ }
204
+
205
+ export async function runManagedInteractiveSessionChild(options = {}) {
206
+ const descriptorPath = parseManagedInteractiveChildArgs(options.argv ?? []);
207
+ const payload = await consumeManagedInteractivePayload(descriptorPath, {
208
+ ...(options.beforeDescriptorUnlink
209
+ ? { beforeUnlink: options.beforeDescriptorUnlink }
210
+ : {}),
211
+ });
212
+ if (options.startupSignal?.aborted) {
213
+ throw options.startupSignal.reason ?? new Error('managed session startup aborted');
214
+ }
215
+ const lifecycle = createManagedInteractiveLifecycle(payload, options);
216
+ await (options.runManagedSession ?? runManagedTmuxPlaySession)({
217
+ sessionId: payload.sessionId,
218
+ workDir: payload.workDir,
219
+ workDirOwnedByLauncher: payload.workDirOwnedByLauncher,
220
+ cwd: payload.cwd,
221
+ readinessPath: payload.readinessPath,
222
+ inputGatePath: payload.inputGatePath,
223
+ inputActivePath: payload.inputActivePath,
224
+ shutdownRequestPath: payload.shutdownRequestPath,
225
+ shutdownCompletePath: payload.shutdownCompletePath,
226
+ lifecycle,
227
+ ...(options.input ? { input: options.input } : {}),
228
+ ...(options.output ? { output: options.output } : {}),
229
+ ...(options.signalTarget ? { signalTarget: options.signalTarget } : {}),
230
+ ...(options.adapterImports
231
+ ? { adapterImports: options.adapterImports }
232
+ : {}),
233
+ });
234
+ }
235
+
236
+ export async function runManagedInteractiveSessionChildEntry(options = {}) {
237
+ const signalTarget = options.signalTarget ?? process;
238
+ const startupController = new AbortController();
239
+ let receivedSignal;
240
+ const handlers = {};
241
+ const removeHandlers = () => {
242
+ for (const [signal, handler] of Object.entries(handlers)) {
243
+ signalTarget.off(signal, handler);
244
+ }
245
+ };
246
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
247
+ handlers[signal] = () => {
248
+ if (receivedSignal !== undefined) {
249
+ removeHandlers();
250
+ signalTarget.kill(signalTarget.pid, signal);
251
+ return;
252
+ }
253
+ // Cligent owns the first signal and joins runtime disposal, lifecycle
254
+ // shutdown, lease retirement, and pane cleanup. This listener only
255
+ // remembers its identity so the private child can re-raise afterwards.
256
+ receivedSignal = signal;
257
+ startupController.abort(new Error(`received ${signal}`));
258
+ };
259
+ }
260
+ for (const [signal, handler] of Object.entries(handlers)) {
261
+ signalTarget.on(signal, handler);
262
+ }
263
+ try {
264
+ try {
265
+ await runManagedInteractiveSessionChild({
266
+ ...options,
267
+ startupSignal: startupController.signal,
268
+ });
269
+ return receivedSignal === undefined ? {} : { signal: receivedSignal };
270
+ } catch (error) {
271
+ if (receivedSignal === undefined) throw error;
272
+ return { signal: receivedSignal, error };
273
+ }
274
+ } finally {
275
+ removeHandlers();
276
+ }
277
+ }
278
+
279
+ export function createManagedInteractiveLifecycle(payloadValue, options = {}) {
280
+ const payload = validateManagedInteractivePayload(payloadValue);
281
+ const store =
282
+ options.sessionStore ??
283
+ createCaptainSessionStore({
284
+ sessionsDir: payload.sessionsDir,
285
+ ...(options.now ? { now: options.now } : {}),
286
+ ...(options.createSessionTempId
287
+ ? { createTempId: options.createSessionTempId }
288
+ : {}),
289
+ });
290
+ const loadModule = memoizedModuleLoader(
291
+ options.loadModule ?? ((specifier) => import(specifier)),
292
+ );
293
+ const createSessionHost =
294
+ options.createSessionHost ?? createCaptainSessionHost;
295
+ const createAttemptId = options.createAttemptId ?? randomUUID;
296
+ let lease;
297
+ let shell;
298
+ let initialized = false;
299
+ let released = false;
300
+ let leaseQuarantined = false;
301
+ let activeTurn;
302
+ let executionProjection;
303
+
304
+ const release = async () => {
305
+ if (released || lease === undefined) return;
306
+ const owned = lease;
307
+ await owned.release();
308
+ if (lease === owned) lease = undefined;
309
+ released = true;
310
+ };
311
+
312
+ return Object.freeze({
313
+ async initializeRuntime(context) {
314
+ if (initialized || lease !== undefined || released) {
315
+ throw new Error('managed interactive lifecycle was already initialized');
316
+ }
317
+ if (context.sessionId !== payload.sessionId) {
318
+ throw new Error('managed interactive runtime session id is mismatched');
319
+ }
320
+ if (context.cwd !== payload.cwd) {
321
+ throw new Error('managed interactive runtime working directory is mismatched');
322
+ }
323
+ assertTmuxConfigMatchesExecution(
324
+ context.config,
325
+ payload.executionProjection,
326
+ );
327
+
328
+ let host;
329
+ try {
330
+ lease = await store.acquire(payload.sessionId);
331
+ const authoritative = await lease.read();
332
+ let restoreSnapshot;
333
+ const prepareRegistryModule =
334
+ options.prepareRegistryModule ??
335
+ (options.createRegistryPreparer ?? prepareConfiguredRegistries)({
336
+ enabled: !payload.noProvision,
337
+ stderr: options.stderr ?? process.stderr,
338
+ hostRoots: options.hostRoots,
339
+ commandName: 'playbook',
340
+ });
341
+ if (payload.mode === 'fresh') {
342
+ if (authoritative !== undefined) {
343
+ throw new Error('fresh managed interactive session already exists');
344
+ }
345
+ // Module and filesystem dependency state can drift after the outer
346
+ // advisory plan. Repeat the complete transaction under ownership,
347
+ // before host or turn-zero work.
348
+ executionProjection = await validateFrozenExecutionConfig(
349
+ projectCaptainSessionStructure(payload.executionProjection),
350
+ payload.executionProjection,
351
+ { loadModule, prepareRegistryModule },
352
+ );
353
+ } else {
354
+ if (authoritative === undefined) {
355
+ throw new Error(
356
+ `Captain session ${JSON.stringify(payload.sessionId)} does not exist`,
357
+ );
358
+ }
359
+ const record = validateCaptainSessionRecord(authoritative);
360
+ if (record.state !== 'settled') {
361
+ throw new Error(
362
+ `Captain session ${JSON.stringify(payload.sessionId)} has an uncertain turn; recover it with playbook run before reopening interactively`,
363
+ );
364
+ }
365
+ if (record.cwd !== payload.cwd) {
366
+ throw new Error(
367
+ 'selected Captain session working directory changed before attachment',
368
+ );
369
+ }
370
+ const compatibleExecution = assertCaptainSessionExecutionCompatible(
371
+ record.structuralProjection,
372
+ payload.executionProjection,
373
+ );
374
+ // PBCLI-22/49: the parent projection is advisory and data-only.
375
+ // Once this child owns the canonical writer lease, prepare the
376
+ // complete retained catalog transaction and verify its recorded
377
+ // manifest identity before shell construction can import anything.
378
+ executionProjection = await validateFrozenExecutionConfig(
379
+ record.structuralProjection,
380
+ compatibleExecution,
381
+ { loadModule, prepareRegistryModule },
382
+ );
383
+ restoreSnapshot = record.snapshot;
384
+ }
385
+
386
+ const created = await createSessionHost({
387
+ config: executionProjection,
388
+ sessionId: payload.sessionId,
389
+ cwd: payload.cwd,
390
+ loadModule,
391
+ observers: context.observers,
392
+ ...(options.adapterImports
393
+ ? { adapterImports: options.adapterImports }
394
+ : {}),
395
+ ...(options.createCaptainRuntime
396
+ ? { createCaptainRuntime: options.createCaptainRuntime }
397
+ : {}),
398
+ ...(options.createCaptainSessionId
399
+ ? { createCaptainSessionId: options.createCaptainSessionId }
400
+ : {}),
401
+ ...(options.createHostRuntime
402
+ ? { createHostRuntime: options.createHostRuntime }
403
+ : {}),
404
+ ...(restoreSnapshot !== undefined ? { restoreSnapshot } : {}),
405
+ });
406
+ ({ host, shell } = created);
407
+ if (payload.mode === 'fresh') {
408
+ await lease.initializeSettled({
409
+ cwd: payload.cwd,
410
+ structuralProjection:
411
+ projectCaptainSessionStructure(executionProjection),
412
+ executionProjection,
413
+ snapshot: created.snapshot,
414
+ });
415
+ }
416
+ initialized = true;
417
+ return {
418
+ abortActiveTurn: (...args) => host.abortActiveTurn(...args),
419
+ runBossTurn: (...args) => host.runBossTurn(...args),
420
+ async dispose() {
421
+ try {
422
+ await host.dispose();
423
+ } catch (error) {
424
+ // Runtime disposal is the semantic ownership boundary. If it
425
+ // cannot be proved, quarantine the writer lease until process
426
+ // death rather than admitting another presentation.
427
+ leaseQuarantined = true;
428
+ throw error;
429
+ }
430
+ },
431
+ };
432
+ } catch (error) {
433
+ const failures = [error];
434
+ if (error instanceof CaptainSessionHostCleanupError) {
435
+ leaseQuarantined = true;
436
+ }
437
+ if (host !== undefined) {
438
+ try {
439
+ await host.dispose();
440
+ } catch (disposeError) {
441
+ // A host that cannot prove disposal may still perform effects.
442
+ // Keep its canonical writer lease until process death so another
443
+ // presentation cannot start concurrently with that failed host.
444
+ leaseQuarantined = true;
445
+ failures.push(disposeError);
446
+ }
447
+ }
448
+ shell = undefined;
449
+ if (!leaseQuarantined) {
450
+ try {
451
+ await release();
452
+ } catch (releaseError) {
453
+ failures.push(releaseError);
454
+ }
455
+ }
456
+ if (failures.length > 1) {
457
+ throw new AggregateError(
458
+ failures,
459
+ `managed interactive initialization failed (${message(error)}) and cleanup could not prove complete host disposal and lease retirement`,
460
+ );
461
+ }
462
+ throw error;
463
+ }
464
+ },
465
+
466
+ async beforeNonEmptyTurn({ sessionId, prompt }) {
467
+ assertActiveLifecycle(initialized, lease, released, sessionId, payload);
468
+ if (activeTurn !== undefined) {
469
+ throw new Error('managed interactive turn transaction is already active');
470
+ }
471
+ const attemptId = createAttemptId();
472
+ assertUuid(attemptId, 'managed interactive turn attempt id');
473
+ await lease.beginTurn({
474
+ input: prompt,
475
+ attemptId,
476
+ attemptedExecutionProjection: executionProjection,
477
+ });
478
+ activeTurn = { attemptId, prompt };
479
+ await lease.assertOwner();
480
+ },
481
+
482
+ async afterTurn({ sessionId, prompt, replies, terminal }) {
483
+ assertActiveLifecycle(initialized, lease, released, sessionId, payload);
484
+ if (activeTurn === undefined) {
485
+ throw new Error('managed interactive turn has no write-ahead marker');
486
+ }
487
+ if (prompt !== activeTurn.prompt) {
488
+ throw new Error('managed interactive turn prompt changed after write-ahead');
489
+ }
490
+ if (terminal.type !== 'turn_finished') {
491
+ throw new Error(
492
+ 'managed interactive Captain turn aborted; durable state remains uncertain',
493
+ );
494
+ }
495
+ if (
496
+ replies.length !== 1 ||
497
+ replies[0]?.type !== 'captain_reply' ||
498
+ typeof replies[0]?.text !== 'string' ||
499
+ replies[0].text.trim().length === 0 ||
500
+ replies[0].turnId !== terminal.turnId
501
+ ) {
502
+ throw new Error(
503
+ `managed interactive Captain turn produced ${replies.length} usable Boss-visible replies; expected exactly one`,
504
+ );
505
+ }
506
+ const snapshot = shell?.exportSnapshot();
507
+ if (snapshot === undefined) {
508
+ throw new Error(
509
+ 'managed interactive Captain turn settled without an exportable session snapshot',
510
+ );
511
+ }
512
+ await lease.settle({ attemptId: activeTurn.attemptId, snapshot });
513
+ activeTurn = undefined;
514
+ },
515
+
516
+ async shutdown() {
517
+ // Cligent invokes lifecycle shutdown only after runtime disposal and the
518
+ // complete turn transaction, so ownership retirement cannot race either.
519
+ if (leaseQuarantined) return;
520
+ await release();
521
+ },
522
+ });
523
+ }
524
+
525
+ export function parseManagedInteractiveChildArgs(argv) {
526
+ if (argv.length !== 1) {
527
+ throw new Error(
528
+ 'managed interactive child requires exactly one launch descriptor',
529
+ );
530
+ }
531
+ assertCanonicalAbsolutePath(argv[0], 'managed interactive launch descriptor');
532
+ return argv[0];
533
+ }
534
+
535
+ export async function validateManagedInteractiveControlBoundary(
536
+ args,
537
+ descriptorPath,
538
+ ) {
539
+ const coordinatePaths = [
540
+ ['readinessPath', 'status.json'],
541
+ ['inputGatePath', 'input-ready'],
542
+ ['inputActivePath', 'input-active'],
543
+ ['shutdownRequestPath', 'shutdown-request'],
544
+ ['shutdownCompletePath', 'shutdown-complete'],
545
+ ];
546
+ const coordinationDir = dirname(args.readinessPath);
547
+ if (
548
+ descriptorPath !== join(args.workDir, MANAGED_INTERACTIVE_PAYLOAD_FILE)
549
+ ) {
550
+ throw new Error('managed interactive launch descriptor is outside its work directory');
551
+ }
552
+ for (const [key, expectedName] of coordinatePaths) {
553
+ if (
554
+ dirname(args[key]) !== coordinationDir ||
555
+ basename(args[key]) !== expectedName
556
+ ) {
557
+ throw new Error(
558
+ 'managed interactive coordination paths are not one exact Cligent boundary',
559
+ );
560
+ }
561
+ }
562
+ const [workStat, coordinationStat] = await Promise.all([
563
+ lstat(args.workDir),
564
+ lstat(coordinationDir),
565
+ ]);
566
+ if (!workStat.isDirectory()) {
567
+ throw new Error('managed interactive work directory is not a real directory');
568
+ }
569
+ if ((workStat.mode & 0o7777) !== 0o700) {
570
+ throw new Error('managed interactive work directory is not private');
571
+ }
572
+ if (
573
+ !coordinationStat.isDirectory() ||
574
+ (coordinationStat.mode & 0o7777) !== 0o700
575
+ ) {
576
+ throw new Error('managed interactive coordination directory is not private');
577
+ }
578
+ const currentUid =
579
+ typeof process.getuid === 'function' ? process.getuid() : undefined;
580
+ if (
581
+ currentUid !== undefined &&
582
+ (workStat.uid !== currentUid || coordinationStat.uid !== currentUid)
583
+ ) {
584
+ throw new Error('managed interactive control directories have a foreign owner');
585
+ }
586
+ const [workRealPath, coordinationRealPath, sessionsRealPath] =
587
+ await Promise.all([
588
+ realpath(args.workDir),
589
+ realpath(coordinationDir),
590
+ realpathAllowMissing(args.sessionsDir),
591
+ ]);
592
+ if (pathsOverlap(workRealPath, coordinationRealPath)) {
593
+ throw new Error(
594
+ 'managed interactive work and coordination directories overlap',
595
+ );
596
+ }
597
+ if (
598
+ pathsOverlap(sessionsRealPath, workRealPath) ||
599
+ pathsOverlap(sessionsRealPath, coordinationRealPath)
600
+ ) {
601
+ throw new Error(
602
+ 'durable Captain session storage overlaps managed interactive ephemeral cleanup',
603
+ );
604
+ }
605
+ }
606
+
607
+ async function consumeManagedInteractivePayload(path, options = {}) {
608
+ assertCanonicalAbsolutePath(path, 'managed interactive launch descriptor');
609
+ assertManagedDescriptorPath(path);
610
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
611
+ try {
612
+ const openedStat = await handle.stat();
613
+ assertPrivateDescriptorStat(openedStat);
614
+ const payload = await readManagedInteractivePayloadHandle(handle);
615
+ await validateManagedInteractiveControlBoundary(payload, path);
616
+ await options.beforeUnlink?.({ path, payload });
617
+
618
+ const pathStat = await lstat(path);
619
+ assertPrivateDescriptorStat(pathStat);
620
+ if (
621
+ pathStat.dev !== openedStat.dev ||
622
+ pathStat.ino !== openedStat.ino
623
+ ) {
624
+ throw new Error(
625
+ 'managed interactive launch descriptor changed during validation',
626
+ );
627
+ }
628
+ // The exact execution projection may contain private instructions and
629
+ // permissions. Unlink only the inode that survived the complete private
630
+ // Cligent boundary check, while the O_NOFOLLOW handle is still open.
631
+ await unlink(path);
632
+ const consumedStat = await handle.stat();
633
+ if (consumedStat.nlink !== 0) {
634
+ throw new Error(
635
+ 'managed interactive launch descriptor was not consumed exactly once',
636
+ );
637
+ }
638
+ return payload;
639
+ } finally {
640
+ await handle.close();
641
+ }
642
+ }
643
+
644
+ async function readManagedInteractivePayloadHandle(handle) {
645
+ const text = await handle.readFile('utf8');
646
+ let parsed;
647
+ try {
648
+ parsed = JSON.parse(text);
649
+ } catch (cause) {
650
+ throw new Error(
651
+ `managed interactive launch payload is not valid JSON: ${message(cause)}`,
652
+ );
653
+ }
654
+ return validateManagedInteractivePayload(parsed);
655
+ }
656
+
657
+ function assertPrivateDescriptorStat(stat) {
658
+ if (
659
+ !stat.isFile() ||
660
+ stat.nlink !== 1 ||
661
+ (stat.mode & 0o7777) !== 0o600
662
+ ) {
663
+ throw new Error(
664
+ 'managed interactive launch payload is not a singly linked private regular file',
665
+ );
666
+ }
667
+ if (
668
+ typeof process.getuid === 'function' &&
669
+ stat.uid !== process.getuid()
670
+ ) {
671
+ throw new Error('managed interactive launch payload has a foreign owner');
672
+ }
673
+ }
674
+
675
+ async function realpathAllowMissing(path) {
676
+ const suffix = [];
677
+ let cursor = path;
678
+ for (;;) {
679
+ try {
680
+ return join(await realpath(cursor), ...suffix.reverse());
681
+ } catch (error) {
682
+ if (error?.code !== 'ENOENT') throw error;
683
+ const parent = dirname(cursor);
684
+ if (parent === cursor) throw error;
685
+ suffix.push(basename(cursor));
686
+ cursor = parent;
687
+ }
688
+ }
689
+ }
690
+
691
+ function pathsOverlap(left, right) {
692
+ return isPathWithin(left, right) || isPathWithin(right, left);
693
+ }
694
+
695
+ function isPathWithin(parent, candidate) {
696
+ const remainder = relative(parent, candidate);
697
+ return (
698
+ remainder === '' ||
699
+ (!isAbsolute(remainder) &&
700
+ remainder !== '..' &&
701
+ !remainder.startsWith(`..${sep}`))
702
+ );
703
+ }
704
+
705
+ function assertTmuxConfigMatchesExecution(tmuxConfig, execution) {
706
+ const expectedCaptain = {
707
+ ...projectHostAgent(execution.captain, 'execution captain'),
708
+ from: PLAYBOOK_CAPTAIN_MODULE,
709
+ options: captainOptionsFromConfig(execution),
710
+ };
711
+ const expectedPlayers = execution.players.map(({ id, ...agent }) => ({
712
+ id,
713
+ ...projectHostAgent(agent, `execution player ${id}`),
714
+ }));
715
+ if (!isDeepStrictEqual(tmuxConfig.captain, expectedCaptain)) {
716
+ throw new Error(
717
+ 'managed interactive tmux Captain config does not match its durable execution projection',
718
+ );
719
+ }
720
+ if (!isDeepStrictEqual(tmuxConfig.players, expectedPlayers)) {
721
+ throw new Error(
722
+ 'managed interactive tmux player roster does not match its durable execution projection',
723
+ );
724
+ }
725
+ }
726
+
727
+ function assertActiveLifecycle(initialized, lease, released, sessionId, payload) {
728
+ if (!initialized || lease === undefined || released) {
729
+ throw new Error('managed interactive lifecycle is not active');
730
+ }
731
+ if (sessionId !== payload.sessionId) {
732
+ throw new Error('managed interactive turn session id is mismatched');
733
+ }
734
+ }
735
+
736
+ function memoizedModuleLoader(loadModule) {
737
+ const modules = new Map();
738
+ return (specifier) => {
739
+ if (!modules.has(specifier)) {
740
+ modules.set(specifier, Promise.resolve().then(() => loadModule(specifier)));
741
+ }
742
+ return modules.get(specifier);
743
+ };
744
+ }
745
+
746
+ function assertUuid(value, path) {
747
+ if (typeof value !== 'string' || !SESSION_ID_PATTERN.test(value)) {
748
+ throw new Error(`${path} must be a canonical lowercase UUID`);
749
+ }
750
+ }
751
+
752
+ function assertCanonicalAbsolutePath(value, path) {
753
+ if (
754
+ typeof value !== 'string' ||
755
+ !isAbsolute(value) ||
756
+ resolve(value) !== value
757
+ ) {
758
+ throw new Error(`${path} must be a normalized absolute path`);
759
+ }
760
+ }
761
+
762
+ function assertManagedDescriptorPath(path) {
763
+ if (basename(path) !== MANAGED_INTERACTIVE_PAYLOAD_FILE) {
764
+ throw new Error(
765
+ 'managed interactive launch descriptor has an invalid basename',
766
+ );
767
+ }
768
+ }
769
+
770
+ function assertExactKeys(value, keys, path) {
771
+ const actual = Object.keys(value).sort();
772
+ const expected = [...keys].sort();
773
+ if (!isDeepStrictEqual(actual, expected)) {
774
+ throw new Error(`${path} has unknown or missing fields`);
775
+ }
776
+ }
777
+
778
+ function isPlainRecord(value) {
779
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
780
+ return false;
781
+ }
782
+ const prototype = Object.getPrototypeOf(value);
783
+ return prototype === Object.prototype || prototype === null;
784
+ }
785
+
786
+ function message(error) {
787
+ return error instanceof Error ? error.message : String(error);
788
+ }
789
+
790
+ function isCliEntry(argv1 = process.argv[1], moduleUrl = import.meta.url) {
791
+ if (!argv1) return false;
792
+ try {
793
+ return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
794
+ } catch {
795
+ return false;
796
+ }
797
+ }
798
+
799
+ if (isCliEntry()) {
800
+ try {
801
+ const result = await runManagedInteractiveSessionChildEntry({
802
+ argv: process.argv.slice(2),
803
+ });
804
+ if (result.signal) {
805
+ if (result.error) {
806
+ process.stderr.write(
807
+ `playbook: managed session failed during ${result.signal}: ${message(result.error)}\n`,
808
+ );
809
+ }
810
+ process.kill(process.pid, result.signal);
811
+ }
812
+ } catch (error) {
813
+ process.stderr.write(`playbook: managed session failed: ${message(error)}\n`);
814
+ process.exitCode = 1;
815
+ }
816
+ }