@sublang/playbook 17.2.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.
Files changed (43) hide show
  1. package/README.md +5 -2
  2. package/docs/cli.md +6 -5
  3. package/docs/configuration.md +72 -18
  4. package/docs/embedding.md +94 -0
  5. package/package.json +34 -5
  6. package/reference/sdlc/captain.md +6 -4
  7. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +5 -0
  8. package/reference/sdlc/captain.playbook/captain.fsm.js +4 -3
  9. package/reference/sdlc/captain.playbook/captain.fsm.ts +9 -4
  10. package/reference/sdlc/captain.playbook/captain.gears.md +6 -3
  11. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +2 -0
  12. package/reference/sdlc/captain.playbook/captain.playbook.js +7 -3
  13. package/reference/sdlc/captain.playbook/captain.playbook.ts +8 -4
  14. package/reference/sdlc/code.playbook/asset-types.d.ts +32 -0
  15. package/reference/sdlc/code.playbook/bin/launch-config.js +84 -31
  16. package/reference/sdlc/code.playbook/bin/playbook.js +5 -3
  17. package/reference/sdlc/code.playbook/bin/portable-codec.js +12 -5
  18. package/reference/sdlc/code.playbook/bin/run.js +74 -7
  19. package/reference/sdlc/code.playbook/bin/session-host.js +16 -4
  20. package/reference/sdlc/code.playbook/bin/session-media.js +46 -0
  21. package/reference/sdlc/code.playbook/bin/session-store.js +164 -25
  22. package/reference/sdlc/code.playbook/playbook-captain.d.ts +23 -4
  23. package/reference/sdlc/code.playbook/playbook-captain.js +188 -23
  24. package/reference/sdlc/code.playbook/playbook-captain.ts +223 -31
  25. package/reference/sdlc/code.playbook/playbook.config.template.yaml +22 -5
  26. package/reference/sdlc/code.playbook/session-assets.d.ts +53 -0
  27. package/reference/sdlc/code.playbook/session-assets.js +302 -0
  28. package/reference/sdlc/code.playbook/session-host.d.ts +9 -1
  29. package/reference/sdlc/code.playbook/session-store.d.ts +26 -2
  30. package/reference/sdlc/code.playbook/worker-evidence.d.ts +8 -0
  31. package/reference/sdlc/code.playbook/worker-evidence.js +51 -0
  32. package/reference/sdlc/inspect.md +40 -0
  33. package/reference/sdlc/inspect.playbook/inspect.fsm.d.ts +168 -0
  34. package/reference/sdlc/inspect.playbook/inspect.fsm.js +354 -0
  35. package/reference/sdlc/inspect.playbook/inspect.fsm.ts +455 -0
  36. package/reference/sdlc/inspect.playbook/inspect.gears.md +36 -0
  37. package/reference/sdlc/inspect.playbook/inspect.playbook.d.ts +17 -0
  38. package/reference/sdlc/inspect.playbook/inspect.playbook.js +142 -0
  39. package/reference/sdlc/inspect.playbook/inspect.playbook.ts +184 -0
  40. package/reference/sdlc/inspect.playbook/inspect.registry.d.ts +41 -0
  41. package/reference/sdlc/inspect.playbook/inspect.registry.js +51 -0
  42. package/reference/sdlc/inspect.playbook/inspect.registry.ts +110 -0
  43. package/slc/workflow-contracts.json +43 -1
@@ -226,6 +226,9 @@ function readDecisionReply(reply, options, selfPlaybookId, declaredActions) {
226
226
  const nonEmpty = (key) => typeof parsed[key] === 'string' && parsed[key].trim().length > 0
227
227
  ? undefined
228
228
  : `the ${action} selection's \`${key}\` must be a non-empty string`;
229
+ const attachments = parsed.attachmentIds;
230
+ if (attachments !== undefined && (!Array.isArray(attachments) || attachments.some((id) => typeof id !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(id)) || new Set(attachments).size !== attachments.length))
231
+ return { reason: 'attachmentIds must be unique supplied asset identifiers' };
229
232
  switch (action) {
230
233
  case 'respond': {
231
234
  const shape = requireKeys(['text']) ?? nonEmpty('text');
@@ -252,7 +255,7 @@ function readDecisionReply(reply, options, selfPlaybookId, declaredActions) {
252
255
  }
253
256
  case 'start':
254
257
  case 'switch': {
255
- const shape = requireKeys(['playbookId', 'input']) ??
258
+ const shape = requireKeys(['playbookId', 'input'], ['attachmentIds']) ??
256
259
  nonEmpty('playbookId') ??
257
260
  nonEmpty('input');
258
261
  if (shape !== undefined)
@@ -273,6 +276,7 @@ function readDecisionReply(reply, options, selfPlaybookId, declaredActions) {
273
276
  action,
274
277
  playbookId,
275
278
  input: parsed.input,
279
+ ...(attachments === undefined ? {} : { attachmentIds: attachments }),
276
280
  },
277
281
  };
278
282
  }
@@ -287,10 +291,10 @@ function readDecisionReply(reply, options, selfPlaybookId, declaredActions) {
287
291
  // A deliver selection carries no text payload: the host is
288
292
  // authoritative for the delivered text, so a carried `text` is
289
293
  // ignored and never delivered (CAPPLAY-9).
290
- const shape = requireKeys([], ['text']);
294
+ const shape = requireKeys([], ['text', 'attachmentIds']);
291
295
  if (shape !== undefined)
292
296
  return { reason: shape };
293
- return { selection: { action } };
297
+ return { selection: { action, ...(attachments === undefined ? {} : { attachmentIds: attachments }) } };
294
298
  }
295
299
  case 'runtime': {
296
300
  const shape = requireKeys(['actionId']) ?? nonEmpty('actionId');
@@ -135,9 +135,10 @@ export type CaptainControllerSelection =
135
135
  readonly playbookId: string;
136
136
  /** Complete standalone request synthesized from the remembered Boss conversation. */
137
137
  readonly input: CaptainControllerInput;
138
+ readonly attachmentIds?: readonly string[];
138
139
  }
139
140
  | { readonly action: 'dismiss' | 'recover' }
140
- | { readonly action: 'deliver' }
141
+ | { readonly action: 'deliver'; readonly attachmentIds?: readonly string[] }
141
142
  | { readonly action: 'runtime'; readonly actionId: string };
142
143
 
143
144
  /**
@@ -444,6 +445,8 @@ function readDecisionReply(
444
445
  typeof parsed[key] === 'string' && parsed[key].trim().length > 0
445
446
  ? undefined
446
447
  : `the ${action} selection's \`${key}\` must be a non-empty string`;
448
+ const attachments = parsed.attachmentIds;
449
+ if (attachments !== undefined && (!Array.isArray(attachments) || attachments.some((id) => typeof id !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(id)) || new Set(attachments).size !== attachments.length)) return { reason: 'attachmentIds must be unique supplied asset identifiers' };
447
450
  switch (action) {
448
451
  case 'respond': {
449
452
  const shape = requireKeys(['text']) ?? nonEmpty('text');
@@ -471,7 +474,7 @@ function readDecisionReply(
471
474
  case 'start':
472
475
  case 'switch': {
473
476
  const shape =
474
- requireKeys(['playbookId', 'input']) ??
477
+ requireKeys(['playbookId', 'input'], ['attachmentIds']) ??
475
478
  nonEmpty('playbookId') ??
476
479
  nonEmpty('input');
477
480
  if (shape !== undefined) return { reason: shape };
@@ -493,6 +496,7 @@ function readDecisionReply(
493
496
  action,
494
497
  playbookId,
495
498
  input: parsed.input as string,
499
+ ...(attachments === undefined ? {} : { attachmentIds: attachments as string[] }),
496
500
  },
497
501
  };
498
502
  }
@@ -506,9 +510,9 @@ function readDecisionReply(
506
510
  // A deliver selection carries no text payload: the host is
507
511
  // authoritative for the delivered text, so a carried `text` is
508
512
  // ignored and never delivered (CAPPLAY-9).
509
- const shape = requireKeys([], ['text']);
513
+ const shape = requireKeys([], ['text', 'attachmentIds']);
510
514
  if (shape !== undefined) return { reason: shape };
511
- return { selection: { action } };
515
+ return { selection: { action, ...(attachments === undefined ? {} : { attachmentIds: attachments as string[] }) } };
512
516
  }
513
517
  case 'runtime': {
514
518
  const shape = requireKeys(['actionId']) ?? nonEmpty('actionId');
@@ -0,0 +1,32 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+
4
+ /** Cancellation only needs the standard synchronous abort check. */
5
+ export interface AssetAbortSignal { throwIfAborted(): void; }
6
+ export type AssetId = `sha256:${string}`;
7
+ export interface SessionAssetRef {
8
+ readonly assetId: AssetId;
9
+ readonly byteLength: number;
10
+ readonly mimeType: string;
11
+ readonly name?: string;
12
+ }
13
+ export type AssetImport = (
14
+ | { readonly bytes: Uint8Array; readonly path?: never }
15
+ | { readonly path: string; readonly bytes?: never }
16
+ ) & { readonly mimeType: string; readonly name?: string; readonly signal?: AssetAbortSignal };
17
+ export interface AssetFile {
18
+ readonly assetId: AssetId;
19
+ /** Relative filename within this owner's asset directory. */
20
+ readonly path: string;
21
+ readonly byteLength: number;
22
+ readonly metadataPath: string;
23
+ }
24
+ export interface AssetReader {
25
+ readonly reference: SessionAssetRef;
26
+ read(options: { readonly offset: number; readonly length: number; readonly signal?: AssetAbortSignal }): Promise<Uint8Array>;
27
+ close(): Promise<void>;
28
+ }
29
+ export interface SessionTurnInput {
30
+ readonly text: string;
31
+ readonly attachments?: readonly SessionAssetRef[];
32
+ }
@@ -28,6 +28,7 @@ import {
28
28
  parseDocument as parseYamlDocument,
29
29
  stringify as stringifyYaml,
30
30
  } from "yaml";
31
+ import { isSubagentModelSupported } from "@sublang/cligent";
31
32
  import { loadTmuxPlayConfig } from "@sublang/cligent/tmux-play";
32
33
  import {
33
34
  defaultCaptainSessionsDir,
@@ -379,12 +380,14 @@ export async function normalizeSelectedLaunchPlanDataOnly(
379
380
  const configuredAgents = new Map();
380
381
  for (const playerId of expectedMembers.playerIds) {
381
382
  assertPlayerId(playerId, `players.${playerId}`);
382
- const agent = resolveAgent(playersCfg[playerId], `players.${playerId}`, [
383
- "id",
384
- ]);
383
+ const agent = resolveDelegationDefault(
384
+ resolveAgent(playersCfg[playerId], `players.${playerId}`, ["id"]),
385
+ );
385
386
  configuredAgents.set(playerId, agent);
386
387
  }
387
- let captain = resolveAgent(top.captain, "captain", ["from", "options"]);
388
+ let captain = resolveDelegationDefault(
389
+ resolveAgent(top.captain, "captain", ["from", "options"]),
390
+ );
388
391
 
389
392
  const playbooksCfg = requireObject(top.playbooks, "playbooks");
390
393
  const tuningChecks = [];
@@ -443,7 +446,8 @@ export async function normalizeSelectedLaunchPlanDataOnly(
443
446
  binding.model !== undefined ||
444
447
  binding.effort !== undefined ||
445
448
  binding.fastMode !== undefined ||
446
- binding.subagentModel !== undefined
449
+ binding.subagentModel !== undefined ||
450
+ binding.subagentEffort !== undefined
447
451
  ) {
448
452
  let checkId;
449
453
  do {
@@ -559,7 +563,8 @@ export async function normalizeSelectedLaunchPlanDataOnly(
559
563
  ? {}
560
564
  : { fastMode: agent.fastMode }
561
565
  : { fastMode: binding.fastMode }),
562
- ...effectiveSubagentModel(binding, agent),
566
+ ...effectiveOptionalTuning(binding, agent, "subagentModel"),
567
+ ...effectiveOptionalTuning(binding, agent, "subagentEffort"),
563
568
  },
564
569
  ];
565
570
  }),
@@ -689,9 +694,9 @@ export async function normalizeLaunchPlan(
689
694
  const configuredAgents = new Map();
690
695
  for (const playerId of allPlayerIds) {
691
696
  assertPlayerId(playerId, `players.${playerId}`);
692
- const agent = resolveAgent(playersCfg[playerId], `players.${playerId}`, [
693
- "id",
694
- ]);
697
+ const agent = resolveDelegationDefault(
698
+ resolveAgent(playersCfg[playerId], `players.${playerId}`, ["id"]),
699
+ );
695
700
  if (
696
701
  typeof agent.adapter !== "string" ||
697
702
  agent.adapter.trim().length === 0
@@ -710,7 +715,9 @@ export async function normalizeLaunchPlan(
710
715
  throw new Error("playbooks keys must be canonical trimmed nonblank ids");
711
716
  }
712
717
 
713
- let captain = resolveAgent(top.captain, "captain", ["from", "options"]);
718
+ let captain = resolveDelegationDefault(
719
+ resolveAgent(top.captain, "captain", ["from", "options"]),
720
+ );
714
721
  if (
715
722
  typeof captain.adapter !== "string" ||
716
723
  captain.adapter.trim().length === 0
@@ -790,7 +797,8 @@ export async function normalizeLaunchPlan(
790
797
  binding.model !== undefined ||
791
798
  binding.effort !== undefined ||
792
799
  binding.fastMode !== undefined ||
793
- binding.subagentModel !== undefined
800
+ binding.subagentModel !== undefined ||
801
+ binding.subagentEffort !== undefined
794
802
  ) {
795
803
  let checkId;
796
804
  do {
@@ -1002,7 +1010,8 @@ export async function normalizeLaunchPlan(
1002
1010
  ? {}
1003
1011
  : { fastMode: agent.fastMode }
1004
1012
  : { fastMode: binding.fastMode }),
1005
- ...effectiveSubagentModel(binding, agent),
1013
+ ...effectiveOptionalTuning(binding, agent, "subagentModel"),
1014
+ ...effectiveOptionalTuning(binding, agent, "subagentEffort"),
1006
1015
  },
1007
1016
  ];
1008
1017
  }),
@@ -1488,7 +1497,8 @@ function migrateUserConfigIfRetired(userConfigPath, onNotice) {
1488
1497
  throw new Error(
1489
1498
  `cannot migrate the retired profiles config at ${userConfigPath}: ` +
1490
1499
  `${errorMessage(error)} — edit it by hand: each agent takes its own ` +
1491
- "adapter, model, effort, fast mode, subagent model, and permissions",
1500
+ "adapter, model, effort, fast mode, subagent model, subagent effort, " +
1501
+ "and permissions",
1492
1502
  );
1493
1503
  }
1494
1504
  if (migrated === undefined) return;
@@ -1624,7 +1634,8 @@ function assertNoRetiredProfiles(top, configPath) {
1624
1634
  throw new Error(
1625
1635
  `top-level "profiles" was removed${where}: write each agent's settings ` +
1626
1636
  "inline under captain and each top-level players.<player-id> " +
1627
- "(adapter, model, effort, fast mode, subagent model, permissions)",
1637
+ "(adapter, model, effort, fast mode, subagent model, subagent effort, " +
1638
+ "permissions)",
1628
1639
  );
1629
1640
  }
1630
1641
  const legacyPath = findLegacyPlayersPath(top);
@@ -1640,7 +1651,7 @@ function assertNoRetiredProfiles(top, configPath) {
1640
1651
  throw new Error(
1641
1652
  `${path}.profile was removed${where}: write the agent's settings ` +
1642
1653
  "inline in that block (adapter, model, effort, fast mode, " +
1643
- "subagent model, permissions)",
1654
+ "subagent model, subagent effort, permissions)",
1644
1655
  );
1645
1656
  }
1646
1657
  }
@@ -2040,13 +2051,20 @@ function resolveRoleBinding(value, path) {
2040
2051
  const block = requireObject(value, path);
2041
2052
  const unknown = Object.keys(block).filter(
2042
2053
  (key) =>
2043
- !["player", "model", "effort", "fastMode", "subagentModel"].includes(key),
2054
+ ![
2055
+ "player",
2056
+ "model",
2057
+ "effort",
2058
+ "fastMode",
2059
+ "subagentModel",
2060
+ "subagentEffort",
2061
+ ].includes(key),
2044
2062
  );
2045
2063
  if (unknown.length > 0) {
2046
2064
  throw new Error(`${path} has unknown ${formatKeyList(unknown)}`);
2047
2065
  }
2048
2066
  assertPlayerId(block.player, `${path}.player`);
2049
- for (const field of ["model", "effort", "subagentModel"]) {
2067
+ for (const field of ["model", "effort", "subagentModel", "subagentEffort"]) {
2050
2068
  if (
2051
2069
  block[field] !== undefined &&
2052
2070
  block[field] !== false &&
@@ -2071,19 +2089,41 @@ function resolveRoleBinding(value, path) {
2071
2089
  ...(block.subagentModel === undefined
2072
2090
  ? {}
2073
2091
  : { subagentModel: block.subagentModel }),
2092
+ ...(block.subagentEffort === undefined
2093
+ ? {}
2094
+ : { subagentEffort: block.subagentEffort }),
2074
2095
  };
2075
2096
  }
2076
2097
 
2077
- // A binding's subagent model overrides the player's; `false` selects the
2078
- // provider default, which a complete call expresses by omission.
2079
- function effectiveSubagentModel(binding, agent) {
2080
- if (binding.subagentModel === false) return {};
2081
- if (binding.subagentModel !== undefined) {
2082
- return { subagentModel: binding.subagentModel };
2098
+ // A binding's subagent model or subagent effort overrides the player's;
2099
+ // `false` selects the provider default, which a complete call expresses by
2100
+ // omission (for the effort: the agent chooses per task).
2101
+ function effectiveOptionalTuning(binding, agent, field) {
2102
+ if (binding[field] === false) return {};
2103
+ if (binding[field] !== undefined) return { [field]: binding[field] };
2104
+ return agent[field] === undefined ? {} : { [field]: agent[field] };
2105
+ }
2106
+
2107
+ // DR-076: an agent whose adapter serves a subagent model delegates by
2108
+ // default. Resolved on the launcher's detached copy of a Captain or player
2109
+ // block — never the user's file — before cligent normalization, so cligent
2110
+ // validates a subagent effort against the resolved model, and both front
2111
+ // ends and the headless run share the result. A block's `false` opts out:
2112
+ // nothing is sent and no delegation directive is composed.
2113
+ function resolveDelegationDefault(agent) {
2114
+ if (!isObject(agent)) return agent;
2115
+ if (agent.subagentModel === false) {
2116
+ const { subagentModel: _off, ...rest } = agent;
2117
+ return rest;
2083
2118
  }
2084
- return agent.subagentModel === undefined
2085
- ? {}
2086
- : { subagentModel: agent.subagentModel };
2119
+ if (
2120
+ agent.subagentModel === undefined &&
2121
+ typeof agent.adapter === "string" &&
2122
+ isSubagentModelSupported(agent.adapter)
2123
+ ) {
2124
+ return { ...agent, subagentModel: "inherit" };
2125
+ }
2126
+ return agent;
2087
2127
  }
2088
2128
 
2089
2129
  function applyTuningOverrides(agent, binding) {
@@ -2096,9 +2136,9 @@ function applyTuningOverrides(agent, binding) {
2096
2136
  else effective.effort = binding.effort;
2097
2137
  }
2098
2138
  if (binding.fastMode !== undefined) effective.fastMode = binding.fastMode;
2099
- if (binding.subagentModel === false) delete effective.subagentModel;
2100
- else if (binding.subagentModel !== undefined) {
2101
- effective.subagentModel = binding.subagentModel;
2139
+ for (const field of ["subagentModel", "subagentEffort"]) {
2140
+ if (binding[field] === false) delete effective[field];
2141
+ else if (binding[field] !== undefined) effective[field] = binding[field];
2102
2142
  }
2103
2143
  return effective;
2104
2144
  }
@@ -2109,6 +2149,7 @@ function sessionAgentFromHostAgent(agent, path) {
2109
2149
  }
2110
2150
  return {
2111
2151
  adapter: agent.adapter,
2152
+ ...(agent.browser === undefined ? {} : { browser: fastModeSelection(agent.browser, `${path}.browser`) }),
2112
2153
  model: tuningSelection(agent.model, `${path}.model`),
2113
2154
  effort: tuningSelection(agent.effort, `${path}.effort`),
2114
2155
  ...(agent.fastMode === undefined
@@ -2117,11 +2158,19 @@ function sessionAgentFromHostAgent(agent, path) {
2117
2158
  ...(agent.subagentModel === undefined
2118
2159
  ? {}
2119
2160
  : {
2120
- subagentModel: subagentModelSelection(
2161
+ subagentModel: nonblankSelection(
2121
2162
  agent.subagentModel,
2122
2163
  `${path}.subagentModel`,
2123
2164
  ),
2124
2165
  }),
2166
+ ...(agent.subagentEffort === undefined
2167
+ ? {}
2168
+ : {
2169
+ subagentEffort: nonblankSelection(
2170
+ agent.subagentEffort,
2171
+ `${path}.subagentEffort`,
2172
+ ),
2173
+ }),
2125
2174
  ...(agent.instruction === undefined
2126
2175
  ? {}
2127
2176
  : { instruction: agent.instruction }),
@@ -2138,6 +2187,7 @@ export function projectHostAgent(agent, path = "agent") {
2138
2187
  const normalized = cloneJson(agent, path);
2139
2188
  return {
2140
2189
  adapter: normalized.adapter,
2190
+ ...(normalized.browser === undefined ? {} : { browser: fastModeSelection(normalized.browser, `${path}.browser`) }),
2141
2191
  ...(normalized.model?.kind === "value"
2142
2192
  ? { model: normalized.model.value }
2143
2193
  : {}),
@@ -2150,6 +2200,9 @@ export function projectHostAgent(agent, path = "agent") {
2150
2200
  ...(normalized.subagentModel === undefined
2151
2201
  ? {}
2152
2202
  : { subagentModel: normalized.subagentModel }),
2203
+ ...(normalized.subagentEffort === undefined
2204
+ ? {}
2205
+ : { subagentEffort: normalized.subagentEffort }),
2153
2206
  ...(normalized.instruction === undefined
2154
2207
  ? {}
2155
2208
  : { instruction: normalized.instruction }),
@@ -2166,7 +2219,7 @@ function fastModeSelection(value, path) {
2166
2219
  return value;
2167
2220
  }
2168
2221
 
2169
- function subagentModelSelection(value, path) {
2222
+ function nonblankSelection(value, path) {
2170
2223
  if (typeof value !== "string" || value.trim().length === 0) {
2171
2224
  throw new Error(`${path} must be a nonblank string`);
2172
2225
  }
@@ -967,9 +967,11 @@ function helpText({
967
967
  " adapter shorthand (claude, codex) or an inline agent block",
968
968
  " - bind every playbooks.<id>.roles.<role> explicitly to a player id;",
969
969
  " a scalar names the id, while { player, model?, effort?, fastMode?,",
970
- " subagentModel? } may retune one role;",
971
- " false selects provider-default model/effort/subagentModel,",
972
- " while fastMode false is a literal disabled request",
970
+ " subagentModel?, subagentEffort? } may retune one role;",
971
+ " false selects provider-default model/effort/subagentModel/",
972
+ " subagentEffort, while fastMode false is a literal disabled request",
973
+ " - a Claude agent delegates by default: subagentModel resolves to",
974
+ " inherit (its own model) unless the block sets false",
973
975
  " - reusing one id deliberately shares that provider conversation;",
974
976
  " distinct ids stay isolated even when their agent blocks are equal",
975
977
  " - the launcher injects captain.from and retains referenced player ids",
@@ -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?.(projected);
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) shell.selectInterruptedReport(interrupted.uncertain.input, interruptedReport);
1197
- return { shell, host, snapshot, reconcileRepositoryEffects, getInterruptedSettlement: (attemptId) => attemptId !== undefined && interruptedSettlement?.attemptId === attemptId ? interruptedSettlement.record : undefined };
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.
@@ -1367,11 +1431,14 @@ export async function validateFrozenExecutionConfig(
1367
1431
  return config;
1368
1432
  }
1369
1433
 
1370
- // Fast mode and the subagent model are adapter-scoped tuning (DR-075): a
1371
- // frozen record re-validates each present value against its adapter.
1434
+ // Fast mode, the subagent model and the subagent effort are adapter-scoped
1435
+ // tuning (DR-075, DR-076): a frozen record re-validates each present value
1436
+ // against its adapter. Cligent serves the subagent effort exactly where it
1437
+ // serves a subagent model, so one capability assertion covers both.
1372
1438
  const FROZEN_ADAPTER_SCOPED_TUNING = [
1373
1439
  ["fastMode", assertFastModeSupported],
1374
1440
  ["subagentModel", assertSubagentModelSupported],
1441
+ ["subagentEffort", assertSubagentModelSupported],
1375
1442
  ];
1376
1443
 
1377
1444
  function assertFrozenTuningSupported(config) {
@@ -1887,7 +1954,7 @@ function runHelpText(userConfigPath) {
1887
1954
  "working directory and reports when it uses the global newest fallback.",
1888
1955
  "An ordinary continued run restores that stored structure and working",
1889
1956
  "directory, then reads current config and overlays for model, effort,",
1890
- "fast mode, and subagent model.",
1957
+ "fast mode, subagent model, and subagent effort.",
1891
1958
  "Uncertain retry only restores and reports; later work uses current settings.",
1892
1959
  "",
1893
1960
  "Options:",