@sublang/playbook 17.3.0 → 17.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/docs/configuration.md +45 -4
- package/docs/embedding.md +94 -0
- package/package.json +34 -5
- package/reference/sdlc/captain.md +6 -4
- package/reference/sdlc/captain.playbook/captain.fsm.d.ts +5 -0
- package/reference/sdlc/captain.playbook/captain.fsm.js +4 -3
- package/reference/sdlc/captain.playbook/captain.fsm.ts +9 -4
- package/reference/sdlc/captain.playbook/captain.gears.md +6 -3
- package/reference/sdlc/captain.playbook/captain.playbook.d.ts +2 -0
- package/reference/sdlc/captain.playbook/captain.playbook.js +7 -3
- package/reference/sdlc/captain.playbook/captain.playbook.ts +8 -4
- package/reference/sdlc/code.playbook/asset-types.d.ts +32 -0
- package/reference/sdlc/code.playbook/bin/launch-config.js +2 -0
- package/reference/sdlc/code.playbook/bin/portable-codec.js +12 -5
- package/reference/sdlc/code.playbook/bin/run.js +68 -4
- package/reference/sdlc/code.playbook/bin/session-host.js +16 -4
- package/reference/sdlc/code.playbook/bin/session-media.js +46 -0
- package/reference/sdlc/code.playbook/bin/session-store.js +139 -13
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +17 -1
- package/reference/sdlc/code.playbook/playbook-captain.js +159 -18
- package/reference/sdlc/code.playbook/playbook-captain.ts +176 -23
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +16 -3
- package/reference/sdlc/code.playbook/session-assets.d.ts +53 -0
- package/reference/sdlc/code.playbook/session-assets.js +302 -0
- package/reference/sdlc/code.playbook/session-host.d.ts +9 -1
- package/reference/sdlc/code.playbook/session-store.d.ts +26 -2
- package/reference/sdlc/code.playbook/worker-evidence.d.ts +8 -0
- package/reference/sdlc/code.playbook/worker-evidence.js +51 -0
- package/reference/sdlc/inspect.md +40 -0
- package/reference/sdlc/inspect.playbook/inspect.fsm.d.ts +168 -0
- package/reference/sdlc/inspect.playbook/inspect.fsm.js +354 -0
- package/reference/sdlc/inspect.playbook/inspect.fsm.ts +455 -0
- package/reference/sdlc/inspect.playbook/inspect.gears.md +36 -0
- package/reference/sdlc/inspect.playbook/inspect.playbook.d.ts +17 -0
- package/reference/sdlc/inspect.playbook/inspect.playbook.js +142 -0
- package/reference/sdlc/inspect.playbook/inspect.playbook.ts +184 -0
- package/reference/sdlc/inspect.playbook/inspect.registry.d.ts +41 -0
- package/reference/sdlc/inspect.playbook/inspect.registry.js +51 -0
- package/reference/sdlc/inspect.playbook/inspect.registry.ts +110 -0
- package/slc/workflow-contracts.json +43 -1
|
@@ -2149,6 +2149,7 @@ function sessionAgentFromHostAgent(agent, path) {
|
|
|
2149
2149
|
}
|
|
2150
2150
|
return {
|
|
2151
2151
|
adapter: agent.adapter,
|
|
2152
|
+
...(agent.browser === undefined ? {} : { browser: fastModeSelection(agent.browser, `${path}.browser`) }),
|
|
2152
2153
|
model: tuningSelection(agent.model, `${path}.model`),
|
|
2153
2154
|
effort: tuningSelection(agent.effort, `${path}.effort`),
|
|
2154
2155
|
...(agent.fastMode === undefined
|
|
@@ -2186,6 +2187,7 @@ export function projectHostAgent(agent, path = "agent") {
|
|
|
2186
2187
|
const normalized = cloneJson(agent, path);
|
|
2187
2188
|
return {
|
|
2188
2189
|
adapter: normalized.adapter,
|
|
2190
|
+
...(normalized.browser === undefined ? {} : { browser: fastModeSelection(normalized.browser, `${path}.browser`) }),
|
|
2189
2191
|
...(normalized.model?.kind === "value"
|
|
2190
2192
|
? { model: normalized.model.value }
|
|
2191
2193
|
: {}),
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
3
|
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
|
+
import { validateAssetRef } from '../session-assets.js';
|
|
5
6
|
import { posix, win32 } from 'node:path';
|
|
6
7
|
import { isDeepStrictEqual } from 'node:util';
|
|
7
8
|
import {
|
|
@@ -16,7 +17,7 @@ export const EMPTY_REPLAY_SHA256 = createHash('sha256').digest('hex');
|
|
|
16
17
|
export const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
17
18
|
const COMMON = ['schemaVersion', 'kind', 'sessionId', 'cwd', 'createdAt', 'updatedAt', 'state', 'replay', 'contextSeq'];
|
|
18
19
|
const RECOVERY = ['structuralProjection', 'lastAppliedExecutionProjection', 'snapshot', 'effectLedger', 'unresolvedEffects'];
|
|
19
|
-
const OPTIONAL = ['retainedGenerations', 'settledAbandonment'];
|
|
20
|
+
const OPTIONAL = ['retainedGenerations', 'settledAbandonment', 'assets'];
|
|
20
21
|
const HEX = /^[0-9a-f]{64}$/;
|
|
21
22
|
const clone = (value) => structuredClone(value);
|
|
22
23
|
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
@@ -44,11 +45,17 @@ export function validateReplayCheckpoint(value) {
|
|
|
44
45
|
export function validateSessionManifest(value) {
|
|
45
46
|
if (!object(value) || value.schemaVersion !== 7) throw new Error(`unsupported session manifest schema ${value?.schemaVersion}`);
|
|
46
47
|
const history = value.state === 'history-only';
|
|
47
|
-
exact(value, [...COMMON, ...(history ? ['reason'] : RECOVERY), ...(value.state === 'uncertain' ? ['uncertain'] : [])], history ? [] : OPTIONAL);
|
|
48
|
+
exact(value, [...COMMON, ...(history ? ['reason'] : RECOVERY), ...(value.state === 'uncertain' ? ['uncertain'] : [])], history ? ['assets'] : OPTIONAL);
|
|
48
49
|
if (value.kind !== 'captain-session' || !SESSION_ID_PATTERN.test(value.sessionId) || !isRecordedAbsolutePath(value.cwd)) throw new Error('invalid session manifest identity');
|
|
49
50
|
iso(value.createdAt); iso(value.updatedAt);
|
|
50
51
|
if (Date.parse(value.updatedAt) < Date.parse(value.createdAt)) throw new Error('session update precedes creation');
|
|
51
52
|
validateReplayCheckpoint(value.replay);
|
|
53
|
+
if (value.assets !== undefined) {
|
|
54
|
+
exact(value.assets, ['version', 'entries']);
|
|
55
|
+
if (value.assets.version !== 1 || !Array.isArray(value.assets.entries)) throw new Error('unsupported session asset manifest');
|
|
56
|
+
const ids = new Set();
|
|
57
|
+
for (const item of value.assets.entries) { const reference = validateAssetRef(item); if (ids.has(reference.assetId)) throw new Error('duplicate session asset reference'); ids.add(reference.assetId); }
|
|
58
|
+
}
|
|
52
59
|
if (!(history && value.contextSeq === null) && (!Number.isSafeInteger(value.contextSeq) || value.contextSeq <= 0 || value.contextSeq > value.replay.seq)) throw new Error('invalid session context reference');
|
|
53
60
|
if (history) {
|
|
54
61
|
if (!nonempty(value.reason)) throw new Error('history-only session needs a reason');
|
|
@@ -60,7 +67,7 @@ export function validateSessionManifest(value) {
|
|
|
60
67
|
return clone(value);
|
|
61
68
|
}
|
|
62
69
|
function recoveryFromManifestUnchecked(value) {
|
|
63
|
-
const { replay, contextSeq, ...recovery } = value;
|
|
70
|
+
const { replay, contextSeq, assets, ...recovery } = value;
|
|
64
71
|
return { ...recovery, schemaVersion: 6 };
|
|
65
72
|
}
|
|
66
73
|
export function recoveryFromManifest(value) {
|
|
@@ -68,9 +75,9 @@ export function recoveryFromManifest(value) {
|
|
|
68
75
|
if (manifest.state === 'history-only') throw new Error(manifest.reason);
|
|
69
76
|
return validateCaptainSessionRecord(recoveryFromManifestUnchecked(manifest));
|
|
70
77
|
}
|
|
71
|
-
export function manifestFromRecovery(value, replay, contextSeq) {
|
|
78
|
+
export function manifestFromRecovery(value, replay, contextSeq, assets) {
|
|
72
79
|
const recovery = projectRecovery(validateCaptainSessionRecord(value));
|
|
73
|
-
return validateSessionManifest({ ...recovery, schemaVersion: 7, replay, contextSeq });
|
|
80
|
+
return validateSessionManifest({ ...recovery, schemaVersion: 7, replay, contextSeq, ...(assets === undefined ? {} : { assets }) });
|
|
74
81
|
}
|
|
75
82
|
export function projectRecovery(value) {
|
|
76
83
|
const source = clone(value);
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// presenter; it does not construct a registry runtime or PlaybookPorts itself.
|
|
8
8
|
|
|
9
9
|
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { workerReportExcerpt, workerRecoveryReportBlock } from "../worker-evidence.js";
|
|
11
|
+
import { createSessionMediaProjector } from "./session-media.js";
|
|
10
12
|
import { attachSessionHints, validateSessionContext } from "./portable-codec.js";
|
|
11
13
|
import { homedir } from "node:os";
|
|
12
14
|
import { resolve } from "node:path";
|
|
@@ -995,6 +997,13 @@ export function restoreInterruptedProgress(record, ledger) {
|
|
|
995
997
|
: `Captain preparation for ${name}: ${step.result.summary}`;
|
|
996
998
|
return `${name} ${step.kind} call: ${step.result === undefined ? 'started; no result was saved' : step.result?.status === undefined ? 'result saved' : `returned ${step.result.status}`}.`;
|
|
997
999
|
});
|
|
1000
|
+
const workerReports = changedBoundaries.flatMap((boundary) => {
|
|
1001
|
+
const completedStep = steps.find((step) => step.kind === 'player' &&
|
|
1002
|
+
step.workerEvidence?.boundaryId === boundary.boundaryId);
|
|
1003
|
+
if (!completedStep) return [];
|
|
1004
|
+
const report = workerReportExcerpt(boundary, completedStep.workerEvidence.playerId, completedStep.result.guard);
|
|
1005
|
+
return report === undefined ? [] : [report];
|
|
1006
|
+
});
|
|
998
1007
|
const completedRoots = steps.filter((step) => step.kind === 'completion' && step.result.retention !== 'keep').map((step) => ({ kind: 'clear', rootPlaybookId: step.playbookId }));
|
|
999
1008
|
const report = {
|
|
1000
1009
|
retentionUpdates: completedRoots,
|
|
@@ -1008,7 +1017,7 @@ export function restoreInterruptedProgress(record, ledger) {
|
|
|
1008
1017
|
? completion && !saved.frames?.length
|
|
1009
1018
|
? 'The completed run is restored; no work was repeated.'
|
|
1010
1019
|
: 'The run stopped. Its saved position is restored; no work was repeated. Choose how to continue. Before repeating unfinished work, confirm that the earlier worker has stopped and check any outside actions it may have taken.'
|
|
1011
|
-
: 'The exact stopping point was not saved. Files and repository evidence are preserved; no work was repeated. Check the saved work and stop any earlier worker before starting another attempt.', ...facts].join('\n\n'),
|
|
1020
|
+
: 'The exact stopping point was not saved. Files and repository evidence are preserved; no work was repeated. Check the saved work and stop any earlier worker before starting another attempt.', ...facts, ...(workerReports.length ? [workerRecoveryReportBlock(workerReports)] : [])].join('\n\n'),
|
|
1012
1021
|
};
|
|
1013
1022
|
if (noWork) return { snapshot: base, report };
|
|
1014
1023
|
if (saved) {
|
|
@@ -1068,6 +1077,7 @@ export async function createCaptainSessionHost({
|
|
|
1068
1077
|
signal,
|
|
1069
1078
|
graphs = [],
|
|
1070
1079
|
initialVisible = [],
|
|
1080
|
+
approvalHandler,
|
|
1071
1081
|
}) {
|
|
1072
1082
|
if (restoreSnapshot !== undefined) await sessionLease.assertContinuable({ cwd, executionProjection: config });
|
|
1073
1083
|
const hostCapabilities = await createRepositoryEffectCapabilities({
|
|
@@ -1115,23 +1125,30 @@ export async function createCaptainSessionHost({
|
|
|
1115
1125
|
if (sourceSnapshot !== undefined && !interrupted && typeof sessionLease.consumeHints === "function") {
|
|
1116
1126
|
sourceSnapshot = attachSessionHints(sourceSnapshot, await sessionLease.consumeHints());
|
|
1117
1127
|
}
|
|
1128
|
+
let presentationAttachments = [];
|
|
1129
|
+
const mediaProjector = createSessionMediaProjector({ lease: sessionLease, onEvidence: (asset, origin) => shell.recordMediaEvidence(asset, origin.runtimeSessionId) });
|
|
1118
1130
|
const bufferedRecords = [];
|
|
1119
1131
|
let presentationReady = false;
|
|
1120
1132
|
const presentationTurnOffset = restoreSnapshot?.sequences.turn ?? 0;
|
|
1133
|
+
const approvals = createHostApprovalScope(approvalHandler, presentationTurnOffset);
|
|
1121
1134
|
const forwardRecord = async (record) => {
|
|
1135
|
+
if (record.type === 'turn_started' && presentationAttachments.length) record = { ...record, turn: { ...record.turn, attachments: presentationAttachments } };
|
|
1122
1136
|
const projected = presentationTurnOffset > 0 && typeof record.turnId === "number"
|
|
1123
1137
|
? { ...record, turnId: record.turnId + presentationTurnOffset, ...(record.type === "turn_started" ? { turn: { ...record.turn, id: record.turn.id + presentationTurnOffset } } : {}) }
|
|
1124
1138
|
: record;
|
|
1125
|
-
for (const observer of observers ?? []) await observer.onRecord?.(
|
|
1139
|
+
for (const value of await mediaProjector.project(projected)) for (const observer of observers ?? []) await observer.onRecord?.(value);
|
|
1126
1140
|
};
|
|
1127
1141
|
const bufferedObservers = [{ async onRecord(record) {
|
|
1128
1142
|
if (!presentationReady) bufferedRecords.push(record);
|
|
1129
1143
|
else await forwardRecord(record);
|
|
1130
1144
|
} }];
|
|
1131
1145
|
const shell = createPlaybookCaptainShell(captainOptionsFromConfig(config), {
|
|
1146
|
+
onTurnAttachments: (references) => { presentationAttachments = references; },
|
|
1147
|
+
beginPreparationMedia: (runtimeSessionId) => mediaProjector.beginPreparation(runtimeSessionId),
|
|
1132
1148
|
abortPreparation: (reason) => host?.abortActiveTurn(typeof reason === 'string' ? reason : 'Captain preparation exceeded its 150-second limit'),
|
|
1133
1149
|
loadModule,
|
|
1134
1150
|
hostCapabilities,
|
|
1151
|
+
...(typeof sessionLease.resolveAttachments === 'function' ? { resolveAttachments: (references, signal) => sessionLease.resolveAttachments(references, { signal }) } : {}),
|
|
1135
1152
|
...(typeof sessionLease.recordProgress === 'function' ? {
|
|
1136
1153
|
recordProgress: (change) => sessionLease.recordProgress(change),
|
|
1137
1154
|
} : {}),
|
|
@@ -1166,6 +1183,7 @@ export async function createCaptainSessionHost({
|
|
|
1166
1183
|
})),
|
|
1167
1184
|
cwd,
|
|
1168
1185
|
observers: bufferedObservers,
|
|
1186
|
+
...(approvals.handler ? { approvalHandler: approvals.handler } : {}),
|
|
1169
1187
|
...(signal ? { signal } : {}),
|
|
1170
1188
|
...(adapterImports ? { adapterImports } : {}),
|
|
1171
1189
|
});
|
|
@@ -1193,9 +1211,13 @@ export async function createCaptainSessionHost({
|
|
|
1193
1211
|
}
|
|
1194
1212
|
presentationReady = true;
|
|
1195
1213
|
for (const record of bufferedRecords) await forwardRecord(record);
|
|
1196
|
-
if (interruptedReport)
|
|
1197
|
-
|
|
1214
|
+
if (interruptedReport) {
|
|
1215
|
+
shell.setTurnAttachments(interrupted.uncertain.attachments ?? []);
|
|
1216
|
+
shell.selectInterruptedReport(interrupted.uncertain.input, interruptedReport);
|
|
1217
|
+
}
|
|
1218
|
+
return { shell, host, snapshot, reconcileRepositoryEffects, cancelPendingApprovals: approvals.close, getInterruptedSettlement: (attemptId) => attemptId !== undefined && interruptedSettlement?.attemptId === attemptId ? interruptedSettlement.record : undefined };
|
|
1198
1219
|
} catch (error) {
|
|
1220
|
+
approvals.close();
|
|
1199
1221
|
let cleanupError;
|
|
1200
1222
|
try {
|
|
1201
1223
|
if (host !== undefined) await host.dispose();
|
|
@@ -1213,6 +1235,48 @@ export async function createCaptainSessionHost({
|
|
|
1213
1235
|
}
|
|
1214
1236
|
}
|
|
1215
1237
|
|
|
1238
|
+
// Callback liveness belongs to the host, independently of durable turn state.
|
|
1239
|
+
// Closing this scope denies waits without cancelling ordinary graceful work.
|
|
1240
|
+
function createHostApprovalScope(handler, turnOffset) {
|
|
1241
|
+
const lifetime = new AbortController();
|
|
1242
|
+
return {
|
|
1243
|
+
close: () => lifetime.abort('session host closing'),
|
|
1244
|
+
handler: typeof handler !== 'function' ? undefined : (envelope, { signal }) => {
|
|
1245
|
+
if (lifetime.signal.aborted || signal.aborted) return Promise.resolve('deny');
|
|
1246
|
+
const controller = new AbortController();
|
|
1247
|
+
return new Promise((resolve, reject) => {
|
|
1248
|
+
let settled = false;
|
|
1249
|
+
const finish = (decision) => {
|
|
1250
|
+
if (settled) return;
|
|
1251
|
+
settled = true;
|
|
1252
|
+
signal.removeEventListener('abort', cancel);
|
|
1253
|
+
lifetime.signal.removeEventListener('abort', cancel);
|
|
1254
|
+
if (!envelope.request.choices.includes(decision)) controller.abort('Invalid approval decision');
|
|
1255
|
+
resolve(decision);
|
|
1256
|
+
};
|
|
1257
|
+
const fail = (error) => {
|
|
1258
|
+
if (settled) return;
|
|
1259
|
+
settled = true;
|
|
1260
|
+
signal.removeEventListener('abort', cancel);
|
|
1261
|
+
lifetime.signal.removeEventListener('abort', cancel);
|
|
1262
|
+
controller.abort(error);
|
|
1263
|
+
reject(error);
|
|
1264
|
+
};
|
|
1265
|
+
const cancel = () => {
|
|
1266
|
+
controller.abort(signal.aborted ? signal.reason : lifetime.signal.reason);
|
|
1267
|
+
finish('deny');
|
|
1268
|
+
};
|
|
1269
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
1270
|
+
lifetime.signal.addEventListener('abort', cancel, { once: true });
|
|
1271
|
+
Promise.resolve().then(() => {
|
|
1272
|
+
if (settled) return 'deny';
|
|
1273
|
+
return handler({ ...envelope, turnId: envelope.turnId + turnOffset }, { signal: controller.signal });
|
|
1274
|
+
}).then(finish, fail);
|
|
1275
|
+
});
|
|
1276
|
+
},
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1216
1280
|
// PBCLI-55: both presentations install one authoritative retention map at the
|
|
1217
1281
|
// same post-shell, pre-turn boundary. Fresh-session guarded initialization
|
|
1218
1282
|
// publishes either the intentional empty boundary or the transferred map.
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
3
|
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { normalizeSessionTurnInput } from '../session-assets.js';
|
|
5
6
|
import { createCaptainSessionStore, isUncertainTurnDiscardable, projectCaptainSessionStructure } from './session-store.js';
|
|
6
7
|
import { createCaptainSessionHost, executionConfigFromPlan, installRetainedGenerationsForLaunch, validateFrozenExecutionConfig } from './run.js';
|
|
7
8
|
import { createReplayRecordObserver } from './replay-observer.js';
|
|
@@ -33,9 +34,10 @@ export async function openSessionHost(options) {
|
|
|
33
34
|
if (record !== undefined) await lease.assertContinuable({ cwd, executionProjection: selected });
|
|
34
35
|
const config = await validateFrozenExecutionConfig(structure, selected, { loadModule, prepareRegistryModule: options.prepareRegistryModule });
|
|
35
36
|
const replay = createReplayRecordObserver({ lease, onIncomplete: options.onIncomplete ?? (() => {}), onStored: options.onStoredRecord });
|
|
36
|
-
let terminal, replies = [];
|
|
37
|
+
let terminal, replies = [], submittedAttachments = [];
|
|
37
38
|
const observe = {
|
|
38
39
|
async onRecord(value) {
|
|
40
|
+
if (value.type === 'turn_started' && submittedAttachments.length) value = { ...value, turn: { ...value.turn, attachments: submittedAttachments } };
|
|
39
41
|
if (value.type === "turn_finished" || value.type === "turn_aborted") terminal = value;
|
|
40
42
|
if (value.type === "captain_reply") replies.push(value);
|
|
41
43
|
await replay.observer.onRecord(value);
|
|
@@ -53,14 +55,21 @@ export async function openSessionHost(options) {
|
|
|
53
55
|
const execute = async (input, retry, actionId, shellActionId) => {
|
|
54
56
|
assertIdle();
|
|
55
57
|
let attemptId;
|
|
58
|
+
let attachments = [];
|
|
56
59
|
const operation = (async () => {
|
|
57
60
|
let prior = await lease.read();
|
|
58
61
|
if (retry) {
|
|
59
62
|
if (prior?.state !== 'uncertain') throw new Error('session has no uncertain turn to retry');
|
|
60
63
|
input = prior.uncertain.input;
|
|
64
|
+
attachments = prior.uncertain.attachments ?? [];
|
|
61
65
|
if (!retryPending) throw new Error(RECOVERY_REOPEN);
|
|
62
66
|
} else if (prior?.state !== 'settled') throw new Error(RECOVERY_REOPEN);
|
|
63
|
-
if (actionId === undefined && shellActionId === undefined
|
|
67
|
+
if (actionId === undefined && shellActionId === undefined) {
|
|
68
|
+
const normalized = normalizeSessionTurnInput(retry ? { text: input, attachments } : input);
|
|
69
|
+
input = normalized.text; attachments = normalized.attachments;
|
|
70
|
+
if (!input.trim() && attachments.length === 0) throw new Error('session input must contain text or attachments');
|
|
71
|
+
if (attachments.length) await lease.resolveAttachments(attachments, { signal: options.signal });
|
|
72
|
+
}
|
|
64
73
|
await created.reconcileRepositoryEffects();
|
|
65
74
|
// The selection is made after reconciliation, so the advertised
|
|
66
75
|
// reading it is validated against is the one this turn settles
|
|
@@ -74,11 +83,13 @@ export async function openSessionHost(options) {
|
|
|
74
83
|
}
|
|
75
84
|
terminal = undefined; replies = [];
|
|
76
85
|
attemptId = options.createAttemptId?.() ?? randomUUID();
|
|
77
|
-
const marked = retry ? await lease.beginRetry({ expectedAttemptId: prior.uncertain.attemptId, nextAttemptId: attemptId }) : await lease.beginTurn({ input, attemptId, attemptedExecutionProjection: config });
|
|
86
|
+
const marked = retry ? await lease.beginRetry({ expectedAttemptId: prior.uncertain.attemptId, nextAttemptId: attemptId }) : await lease.beginTurn({ input, attachments, attemptId, attemptedExecutionProjection: config });
|
|
78
87
|
retryPending = false;
|
|
79
88
|
await replay.flushStoredRecords();
|
|
80
89
|
await options.onCheckpoint?.(marked);
|
|
81
90
|
await lease.assertOwner();
|
|
91
|
+
submittedAttachments = attachments;
|
|
92
|
+
created.shell.setTurnAttachments?.(attachments);
|
|
82
93
|
await created.host.runBossTurn(input);
|
|
83
94
|
if (terminal?.type !== "turn_finished" || replies.length !== 1 || typeof replies[0].text !== "string" || replies[0].text.trim().length === 0) throw new Error("Captain turn did not finish with one reply; session remains uncertain");
|
|
84
95
|
const settlement = created.shell.exportSettlement();
|
|
@@ -101,12 +112,13 @@ export async function openSessionHost(options) {
|
|
|
101
112
|
throw error;
|
|
102
113
|
});
|
|
103
114
|
active = operation;
|
|
104
|
-
try { return await operation; } finally { active = undefined; }
|
|
115
|
+
try { return await operation; } finally { active = undefined; submittedAttachments = []; }
|
|
105
116
|
};
|
|
106
117
|
|
|
107
118
|
const dispose = () => {
|
|
108
119
|
if (closed) return Promise.resolve();
|
|
109
120
|
if (closing) return closing;
|
|
121
|
+
created.cancelPendingApprovals();
|
|
110
122
|
closing = (async () => {
|
|
111
123
|
try { await active; } catch { /* Preserve the durable uncertain marker. */ }
|
|
112
124
|
await created.host.dispose();
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
|
+
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { externalizeAgentEvent } from '../session-assets.js';
|
|
6
|
+
|
|
7
|
+
/** Externalize authentic observed media before the opaque replay writer sees it. */
|
|
8
|
+
export function createSessionMediaProjector({ lease, onEvidence }) {
|
|
9
|
+
const players = new Map();
|
|
10
|
+
let preparation;
|
|
11
|
+
const seen = new Map();
|
|
12
|
+
const beginPreparation = (runtimeSessionId) => {
|
|
13
|
+
if (preparation) throw new Error('a preparation media scope is already active');
|
|
14
|
+
const scope = { callId: randomUUID(), origin: { kind: 'preparation', actorId: 'captain', runtimeSessionId } };
|
|
15
|
+
preparation = scope;
|
|
16
|
+
return () => { if (preparation === scope) preparation = undefined; seen.delete(scope.callId); };
|
|
17
|
+
};
|
|
18
|
+
const project = async (record) => {
|
|
19
|
+
if (record.type === 'captain_telemetry' && record.topic === 'playbook.trace') {
|
|
20
|
+
const trace = record.payload;
|
|
21
|
+
if (trace?.schemaVersion === 4 && trace.type === 'player.call.started' && typeof trace.callId === 'string' && typeof trace.sessionId === 'string' && typeof trace.payload?.playerId === 'string') {
|
|
22
|
+
players.set(trace.payload.playerId, { callId: trace.callId, origin: { kind: 'player', actorId: trace.payload.playerId, runtimeSessionId: trace.sessionId } });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (record.type === 'player_prompt' && !players.has(record.playerId)) players.set(record.playerId, { callId: randomUUID(), origin: { kind: 'player', actorId: record.playerId } });
|
|
26
|
+
if (record.type === 'player_finished') { const scope = players.get(record.playerId); if (scope) seen.delete(scope.callId); players.delete(record.playerId); }
|
|
27
|
+
if (record.type !== 'player_event' && record.type !== 'captain_event') return [record];
|
|
28
|
+
const event = record.event;
|
|
29
|
+
const scope = record.type === 'player_event' ? players.get(record.playerId) : preparation;
|
|
30
|
+
try {
|
|
31
|
+
const { event: projected, asset } = await externalizeAgentEvent(lease, event);
|
|
32
|
+
const stored = projected === event ? record : { ...record, event: projected };
|
|
33
|
+
if (!asset || !scope || typeof record.turnId !== 'number') return [stored];
|
|
34
|
+
let emitted = seen.get(scope.callId);
|
|
35
|
+
if (!emitted) { emitted = new Set(); seen.set(scope.callId, emitted); }
|
|
36
|
+
if (emitted.has(asset.assetId)) return [stored];
|
|
37
|
+
emitted.add(asset.assetId);
|
|
38
|
+
const origin = { ...scope.origin, ...(event.payload.toolUseId ? { toolUseId: event.payload.toolUseId } : {}) };
|
|
39
|
+
await onEvidence?.(asset, origin);
|
|
40
|
+
return [stored, { type: 'playbook_evidence', timestamp: record.timestamp, turnId: record.turnId, callId: scope.callId, origin, asset }];
|
|
41
|
+
} catch (error) {
|
|
42
|
+
return [{ type: 'runtime_error', timestamp: record.timestamp, turnId: record.turnId, sourceRecordType: record.type, message: `Media could not be saved: ${error instanceof Error ? error.message : String(error)}` }];
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
return Object.freeze({ project, beginPreparation });
|
|
46
|
+
}
|