@arnilo/prism 0.9.0 → 0.11.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 (103) hide show
  1. package/CHANGELOG.md +50 -1
  2. package/README.md +19 -16
  3. package/dist/agent-approval.d.ts +7 -1
  4. package/dist/agent-approval.js +15 -6
  5. package/dist/agent-run-lifecycle.d.ts +2 -1
  6. package/dist/agent-run-lifecycle.js +20 -6
  7. package/dist/agent-run-state.d.ts +26 -5
  8. package/dist/agent-run-state.js +97 -1
  9. package/dist/agent-session/event-subscriber.d.ts +2 -0
  10. package/dist/agent-session/event-subscriber.js +3 -0
  11. package/dist/agent-session/session/assemble.js +165 -16
  12. package/dist/agent-session/session/persist.js +11 -5
  13. package/dist/agent-session/session/provider-round.js +54 -13
  14. package/dist/agent-session/session/tool-round.d.ts +2 -2
  15. package/dist/agent-session/session/tool-round.js +86 -23
  16. package/dist/agent-session/session/types.d.ts +21 -2
  17. package/dist/agent-session/session.d.ts +66 -4
  18. package/dist/agent-session/session.js +159 -18
  19. package/dist/checkpoint-restore.d.ts +50 -14
  20. package/dist/checkpoint-restore.js +104 -28
  21. package/dist/context-budget.d.ts +11 -0
  22. package/dist/context-budget.js +33 -2
  23. package/dist/contracts-core/agent.d.ts +26 -5
  24. package/dist/contracts-core/extensions.d.ts +3 -0
  25. package/dist/contracts-core/guardrail-packs.d.ts +8 -3
  26. package/dist/contracts-core/loop.d.ts +36 -0
  27. package/dist/contracts-core/provider.d.ts +6 -1
  28. package/dist/contracts-core/run-limits.d.ts +10 -1
  29. package/dist/contracts-core/session.d.ts +2 -1
  30. package/dist/contracts-protocol.d.ts +6 -4
  31. package/dist/contracts-run-state.d.ts +48 -6
  32. package/dist/contributions.d.ts +2 -1
  33. package/dist/contributions.js +1 -0
  34. package/dist/extensions.d.ts +15 -1
  35. package/dist/extensions.js +68 -0
  36. package/dist/guardrail-packs/types.d.ts +10 -0
  37. package/dist/guardrail-packs/validation-respect.js +16 -0
  38. package/dist/guardrails.d.ts +42 -1
  39. package/dist/guardrails.js +124 -15
  40. package/dist/index.d.ts +7 -7
  41. package/dist/index.js +4 -4
  42. package/dist/leases.js +32 -6
  43. package/dist/middleware.d.ts +1 -1
  44. package/dist/node/contribution-discovery.d.ts +16 -1
  45. package/dist/node/contribution-discovery.js +47 -0
  46. package/dist/node/session-store-jsonl.js +67 -17
  47. package/dist/run-bundle.d.ts +6 -1
  48. package/dist/run-bundle.js +4 -1
  49. package/dist/run-limits.d.ts +11 -5
  50. package/dist/run-limits.js +13 -0
  51. package/dist/session-stores.js +61 -12
  52. package/dist/testing/prefix-stability-conformance.d.ts +73 -1
  53. package/dist/testing/prefix-stability-conformance.js +158 -27
  54. package/dist/tools.js +10 -3
  55. package/dist/usage-estimation.d.ts +7 -1
  56. package/dist/usage-estimation.js +16 -10
  57. package/docs/acp.md +2 -2
  58. package/docs/agent-events.md +15 -10
  59. package/docs/agent-session-runtime.md +10 -7
  60. package/docs/coding-agent-tools.md +1 -1
  61. package/docs/coding-tools.md +7 -11
  62. package/docs/compaction-llm.md +2 -0
  63. package/docs/compaction-observational-memory.md +21 -1
  64. package/docs/context-and-skills.md +6 -7
  65. package/docs/contribution-discovery.md +13 -0
  66. package/docs/durable-runs.md +14 -6
  67. package/docs/embeddings.md +7 -1
  68. package/docs/execution-timeline.md +9 -2
  69. package/docs/extensions.md +21 -5
  70. package/docs/guardrails.md +16 -6
  71. package/docs/hooks.md +282 -0
  72. package/docs/impeccable.md +1 -2
  73. package/docs/index.md +28 -21
  74. package/docs/input-and-prompt-assembly.md +1 -1
  75. package/docs/instruction-injection.md +1 -0
  76. package/docs/live-testing.md +3 -2
  77. package/docs/memory-fabric.md +29 -0
  78. package/docs/middleware-hooks.md +54 -4
  79. package/docs/migrate-to-0.11.md +65 -0
  80. package/docs/migration.md +24 -0
  81. package/docs/node-jsonl-session-store.md +4 -3
  82. package/docs/operations.md +1 -1
  83. package/docs/options-index.md +3 -1
  84. package/docs/peer-dependencies.md +3 -5
  85. package/docs/policy-and-audit.md +15 -2
  86. package/docs/prefix-stability-conformance.md +82 -9
  87. package/docs/provider-packages.md +20 -20
  88. package/docs/public-contracts.md +2 -1
  89. package/docs/rag.md +94 -7
  90. package/docs/release-and-install.md +62 -59
  91. package/docs/runs-and-usage.md +21 -10
  92. package/docs/scoped-agent-memory.md +17 -9
  93. package/docs/scoped-memory.md +138 -0
  94. package/docs/session-stores.md +2 -2
  95. package/docs/supervisors.md +14 -6
  96. package/docs/testing.md +17 -9
  97. package/docs/tools.md +1 -1
  98. package/docs/wiki.md +4 -2
  99. package/docs/workflows.md +2 -2
  100. package/package.json +8 -5
  101. package/docs/caveman.md +0 -130
  102. package/docs/graft.md +0 -149
  103. package/docs/ponytail.md +0 -129
@@ -5,9 +5,46 @@
5
5
  // Throws plain Error; no test runner, no network, no credentials.
6
6
  import assert from "node:assert/strict";
7
7
  import { createAgent } from "../agent-session/create-agent.js";
8
- import { providerDone, toolCallContent } from "../provider-events.js";
8
+ import { providerDone, providerThinkingDelta, toolCallContent } from "../provider-events.js";
9
9
  import { createLoadSkillTool } from "../skill-load.js";
10
10
  import { createSkillRegistry } from "../skills.js";
11
+ /**
12
+ * Score an already-captured request list. One pass, no session, no provider call.
13
+ * A wrong `tailSegments` map yields a wrong number, not a throw.
14
+ */
15
+ export function scorePrefixStability(requests, options) {
16
+ const first = requests[0];
17
+ if (requests.length < 2 || first === undefined) {
18
+ throw new Error(`scorePrefixStability needs at least 2 captured requests, got ${requests.length}`);
19
+ }
20
+ const minContinuity = options?.minContinuity ?? 0.95;
21
+ const assertOn = options?.assertOn ?? "providerPrefix";
22
+ const isTail = tailClassifier(options?.tailSegments ?? new Map());
23
+ let observed = 1;
24
+ let cacheableObserved = 1;
25
+ const resetDetails = [];
26
+ let previous = measureRequest(first, isTail);
27
+ for (let index = 1; index < requests.length; index += 1) {
28
+ const current = requests[index];
29
+ if (current === undefined)
30
+ continue;
31
+ const next = measureRequest(current, isTail);
32
+ const fraction = sharedPrefixFraction(previous.providerPrefix, next.providerPrefix);
33
+ const cacheableFraction = sharedPrefixFraction(previous.cacheablePrefix, next.cacheablePrefix);
34
+ observed = Math.min(observed, fraction);
35
+ cacheableObserved = Math.min(cacheableObserved, cacheableFraction);
36
+ const measured = assertOn === "cacheablePrefix" ? cacheableFraction : fraction;
37
+ if (measured < minContinuity)
38
+ resetDetails.push(projectResetDetail(index + 1, fraction, cacheableFraction, assertOn));
39
+ previous = next;
40
+ }
41
+ return {
42
+ minContinuity: observed,
43
+ cacheableContinuity: cacheableObserved,
44
+ resets: resetDetails.map((gap) => gap.request),
45
+ resetDetails,
46
+ };
47
+ }
11
48
  /**
12
49
  * Drive a real session through two staggered skill loads and assert that each
13
50
  * provider request keeps a byte-identical leading prefix (messages **and** tool
@@ -15,6 +52,10 @@ import { createSkillRegistry } from "../skills.js";
15
52
  * after the stable prefix, so the shared prefix stays intact; a host that
16
53
  * rewrites the context block, the skill catalog, or any leading message per
17
54
  * request fails with the offending request pair and the measured fraction.
55
+ * Reports both the provider-visible fraction and the same fraction with the
56
+ * session's tail segments removed; `assertOn` picks which one gates the run.
57
+ * A gap below the minimum is collected as a reset instead of failing in the
58
+ * loop, so `allowedResets` can permit the one boundary an assembly folds at.
18
59
  */
19
60
  export async function runPrefixStabilityConformance(options) {
20
61
  const { host, skills } = options;
@@ -28,13 +69,19 @@ export async function runPrefixStabilityConformance(options) {
28
69
  bodies.push(instructions);
29
70
  }
30
71
  const requests = [];
72
+ const foldableBytes = options.foldableToolResultBytes;
73
+ assert.ok(foldableBytes === undefined || (Number.isSafeInteger(foldableBytes) && foldableBytes > 0), "prefix stability conformance foldableToolResultBytes must be a positive safe integer");
31
74
  const registry = createSkillRegistry([...skills]);
32
75
  const hostTools = host.tools && "list" in host.tools ? host.tools.list() : (host.tools ?? []);
33
76
  const agent = createAgent({
34
77
  ...host,
35
78
  skills: registry,
36
- tools: [...hostTools, createLoadSkillTool({ registry })],
37
- provider: fixtureProvider(requests, [first.name, second.name]),
79
+ tools: [
80
+ ...hostTools,
81
+ createLoadSkillTool({ registry }),
82
+ ...(foldableBytes === undefined ? [] : [createFoldableToolResultTool(foldableBytes)]),
83
+ ],
84
+ provider: fixtureProvider(requests, [first.name, second.name], host.attentionCompiler === true || typeof host.attentionCompiler === "object", foldableBytes !== undefined),
38
85
  });
39
86
  const session = agent.createSession();
40
87
  const [firstInput, secondInput] = options.inputs ?? ["Prefix stability turn one", "Prefix stability turn two"];
@@ -42,27 +89,81 @@ export async function runPrefixStabilityConformance(options) {
42
89
  await session.run(firstInput, runOptions);
43
90
  await session.run(secondInput, runOptions);
44
91
  assert.equal(requests.length, 4, `prefix stability conformance expected 4 provider requests (2 per staggered turn), captured ${requests.length}`);
45
- const serialized = requests.map(serializeRequest);
92
+ // The session's own map holds the exact `Message` objects `appendTailSegment` allocated, so the
93
+ // classification is exact rather than a heuristic over host-authored content. (`tailSegments` is
94
+ // runtime-session state, not part of the public `AgentSession` contract, hence the narrow above.)
95
+ const assertOn = options.assertOn ?? "providerPrefix";
96
+ // One measurement. The assertion below reads the sample; it does not serialize again.
97
+ const sample = scorePrefixStability(requests, { tailSegments: session.tailSegments, minContinuity, assertOn });
46
98
  // Guard against a vacuous pass: both bodies must have been disclosed by the end.
47
- const last = serialized.at(-1) ?? "";
99
+ const last = (requests.at(-1)?.messages ?? []).map((message) => JSON.stringify(message)).join("\n");
48
100
  for (const [index, skill] of skills.entries()) {
49
101
  assert.ok(last.includes(bodies[index] ?? ""), `prefix stability conformance: skill ${skill.name} body never reached the provider request — progressive disclosure did not expand it`);
50
102
  }
51
- let observed = 1;
52
- let previous = serialized.at(0) ?? "";
53
- for (let index = 1; index < serialized.length; index += 1) {
54
- const next = serialized[index] ?? "";
55
- const fraction = sharedPrefixFraction(previous, next);
56
- observed = Math.min(observed, fraction);
57
- assert.ok(fraction >= minContinuity, `prefix stability conformance: request ${index} → ${index + 1} kept ${(fraction * 100).toFixed(1)}% of the previous provider prefix ` +
58
- `(minimum ${(minContinuity * 100).toFixed(1)}%). Late skill bodies must append after the stable prefix; ` +
59
- "recomposed context, an in-place skill-catalog rewrite, or any leading-message mutation invalidates it.");
60
- previous = next;
103
+ const allowedResets = options.allowedResets ?? 0;
104
+ assert.ok(Number.isSafeInteger(allowedResets) && allowedResets >= 0, "prefix stability conformance allowedResets must be a non-negative safe integer");
105
+ const observed = sample.minContinuity;
106
+ const cacheableObserved = sample.cacheableContinuity;
107
+ const gaps = sample.resetDetails;
108
+ const resets = sample.resets;
109
+ const measuredLabel = assertOn === "cacheablePrefix" ? "previous cacheable prefix (tail segments excluded)" : "previous provider prefix";
110
+ const minimum = (minContinuity * 100).toFixed(1);
111
+ const observedResets = `resets ${formatResets(resets)} of ${requests.length - 1} request pairs`;
112
+ const firstGap = gaps[0];
113
+ if (firstGap !== undefined && gaps.length > allowedResets) {
114
+ const measured = firstGap.fraction;
115
+ assert.fail(`prefix stability conformance: request ${firstGap.request - 1} → ${firstGap.request} kept ${(measured * 100).toFixed(1)}% of the ${measuredLabel} ` +
116
+ `(minimum ${minimum}%), and ${gaps.length} pair(s) broke below it (${observedResets}, allowedResets ${allowedResets}). ` +
117
+ "Late skill bodies must append after the stable prefix; recomposed context, an in-place skill-catalog rewrite, or any leading-message mutation invalidates it. " +
118
+ "Pass allowedResets for the fold, compaction, or eviction the assembly performs per run, or fix the assembly so every other gap stays byte-stable.");
61
119
  }
62
- return { requests: serialized.length, minContinuity: observed };
120
+ if (gaps.length < allowedResets) {
121
+ assert.fail(`prefix stability conformance: allowedResets is ${allowedResets} but only ${gaps.length} pair(s) broke below the minimum (${minimum}% of the ${measuredLabel}); ${observedResets}. ` +
122
+ "The fixture was supposed to invalidate the prefix at those boundaries — drop allowedResets for an append-only assembly, or check the fold trigger or eviction condition actually fired.");
123
+ }
124
+ return {
125
+ requests: requests.length,
126
+ minContinuity: observed,
127
+ cacheableContinuity: cacheableObserved,
128
+ resets,
129
+ resetDetails: sample.resetDetails,
130
+ };
131
+ }
132
+ /** One gap row. `fraction` is the metric the caller selected; `cacheableFraction` stays the tail-excluded pair. */
133
+ function projectResetDetail(request, providerFraction, cacheableFraction, assertOn) {
134
+ return {
135
+ request,
136
+ fraction: assertOn === "cacheablePrefix" ? cacheableFraction : providerFraction,
137
+ cacheableFraction,
138
+ };
63
139
  }
64
- /** Fixture provider: turn 1 loads `skillNames[0]`, turn 2 loads `skillNames[1]`, everything else completes. */
65
- function fixtureProvider(requests, skillNames) {
140
+ /**
141
+ * Deterministic reasoning block the fixture provider emits before each skill load when the host
142
+ * runs an attention compiler. Sized to be a real fraction of the request so the compiler's
143
+ * thinking stage (`thinkingKeepTurns`) has something to strip and the resulting fold is visible
144
+ * in the measured prefix.
145
+ */
146
+ const FIXTURE_THINKING = "Prefix-stability fixture reasoning: the harness measures a byte-shared provider prefix, so this block exists only to give the attention-compiler thinking stage deterministic content to strip. ".repeat(17);
147
+ /** Runner-owned fixture tool (plan 110 Task 4): the tool-result stage needs a row worth stubbing. */
148
+ const PREFIX_STABILITY_BULK_TOOL_NAME = "prefix_stability_bulk";
149
+ function createFoldableToolResultTool(bytes) {
150
+ return {
151
+ name: PREFIX_STABILITY_BULK_TOOL_NAME,
152
+ description: "Deterministic bulk payload for the tool-result fold fixture.",
153
+ parameters: { type: "object", properties: {} },
154
+ execute(_args, context) {
155
+ const text = "x".repeat(bytes);
156
+ return { toolCallId: context.toolCallId, name: PREFIX_STABILITY_BULK_TOOL_NAME, value: text, content: [{ type: "text", text }] };
157
+ },
158
+ };
159
+ }
160
+ /**
161
+ * Fixture provider: turn 1 loads `skillNames[0]`, turn 2 loads `skillNames[1]`, everything else
162
+ * completes. With `reasoning` (the host runs an attention compiler) each skill-load round also
163
+ * carries a thinking block, so the compiler's thinking stage has real content to fold. With `bulk`
164
+ * the same round also calls the runner-owned bulk tool, so the tool-result stage has a foldable row.
165
+ */
166
+ function fixtureProvider(requests, skillNames, reasoning, bulk) {
66
167
  let call = 0;
67
168
  return {
68
169
  id: "prefix-stability-fixture",
@@ -72,22 +173,52 @@ function fixtureProvider(requests, skillNames) {
72
173
  call += 1;
73
174
  const skillName = skillNames[index >> 1];
74
175
  if (index % 2 === 0 && skillName !== undefined) {
176
+ if (reasoning)
177
+ yield providerThinkingDelta(FIXTURE_THINKING);
75
178
  yield { type: "tool_call", call: toolCallContent(`prefix-stability-${index}`, "load_skill", { name: skillName }) };
179
+ if (bulk) {
180
+ yield {
181
+ type: "tool_call",
182
+ call: toolCallContent(`prefix-stability-bulk-${index}`, PREFIX_STABILITY_BULK_TOOL_NAME, {}),
183
+ };
184
+ }
76
185
  return;
77
186
  }
78
187
  yield providerDone();
79
188
  },
80
189
  };
81
190
  }
82
- /** Provider-visible payload only: messages plus the tool schema fields sent on the wire. */
83
- function serializeRequest(request) {
84
- // One JSON fragment per message/tool so a structural array boundary never reads as a byte
85
- // divergence: an appended message list stays an exact prefix of the next request.
86
- const parts = [
87
- ...(request.tools ?? []).map((tool) => JSON.stringify({ name: tool.name, description: tool.description, parameters: tool.parameters })),
88
- ...request.messages.map((message) => JSON.stringify(message)),
89
- ];
90
- return parts.join("\n");
191
+ /**
192
+ * Classifies a captured message as a tail segment: by object identity first (the default builder
193
+ * passes the session's own `Message` objects through), then by serialized equality for builders
194
+ * that clone messages. Takes the pre-serialized fragment so each message is serialized once.
195
+ */
196
+ function tailClassifier(tailSegments) {
197
+ const identities = new Set(tailSegments.values());
198
+ const values = new Set([...identities].map((message) => JSON.stringify(message)));
199
+ return (message, fragment) => identities.has(message) || values.has(fragment);
200
+ }
201
+ /**
202
+ * Provider-visible payload only — messages plus the tool schema fields sent on the wire — measured
203
+ * twice: whole, and with tail segments dropped. One JSON fragment per message/tool so a structural
204
+ * array boundary never reads as a byte divergence: an appended message list stays an exact prefix
205
+ * of the next request.
206
+ */
207
+ function measureRequest(request, isTail) {
208
+ const toolParts = (request.tools ?? []).map((tool) => JSON.stringify({ name: tool.name, description: tool.description, parameters: tool.parameters }));
209
+ const providerParts = [...toolParts];
210
+ const cacheableParts = [...toolParts];
211
+ for (const message of request.messages) {
212
+ const fragment = JSON.stringify(message);
213
+ providerParts.push(fragment);
214
+ if (!isTail(message, fragment))
215
+ cacheableParts.push(fragment);
216
+ }
217
+ return { providerPrefix: providerParts.join("\n"), cacheablePrefix: cacheableParts.join("\n") };
218
+ }
219
+ /** Bracket form for reset lists, e.g. `[3]` or `[3, 4]`. */
220
+ function formatResets(resets) {
221
+ return `[${resets.join(", ")}]`;
91
222
  }
92
223
  /** Byte-shared prefix as a fraction of the previous request, so a shrink is a cache miss. */
93
224
  function sharedPrefixFraction(previous, next) {
package/dist/tools.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { isJsonObject } from "./config.js";
2
- import { GuardrailError, runGuardrails } from "./guardrails.js";
2
+ import { GuardrailError, guardrailRefusalText, runGuardrails } from "./guardrails.js";
3
3
  import { assertIdentityActive, assertIdentityMatchesOwnership, ownershipFromIdentity } from "./identity.js";
4
4
  import { createId } from "./ids.js";
5
5
  import { errorToErrorInfo, redactRunLedgerRecord, redactSecrets } from "./redaction.js";
@@ -133,7 +133,7 @@ export async function dispatchToolCall(options) {
133
133
  if (inputGuards.terminal) {
134
134
  if (inputGuards.terminal.action !== "block")
135
135
  throw new GuardrailError(inputGuards.terminal);
136
- return blocked(mediatedCall, options.context, "guardrail_blocked", { message: "Tool call blocked by guardrail" }, options, startedAt);
136
+ return blocked(mediatedCall, options.context, "guardrail_blocked", { message: guardrailBlockMessage(inputGuards.terminal) }, options, startedAt);
137
137
  }
138
138
  const tool = options.registry.get(mediatedCall.name);
139
139
  const postcheck = await checkCall(mediatedCall, options, startedAt);
@@ -240,7 +240,7 @@ export async function dispatchToolCall(options) {
240
240
  throw new GuardrailError(outputGuards.terminal);
241
241
  if (effect)
242
242
  return finishUnknownEffect(effect, mediatedCall, context, options, startedAt);
243
- return blocked(mediatedCall, context, "guardrail_blocked", { message: "Tool result blocked by guardrail" }, options, startedAt);
243
+ return blocked(mediatedCall, context, "guardrail_blocked", { message: guardrailBlockMessage(outputGuards.terminal) }, options, startedAt);
244
244
  }
245
245
  if (effect && mediatedResult.error)
246
246
  return finishUnknownEffect(effect, mediatedCall, context, options, startedAt);
@@ -450,6 +450,13 @@ function isSuspended(error) {
450
450
  function isLoopStateError(error) {
451
451
  return typeof error?.code === "string" && error.code.startsWith("ERR_PRISM_LOOP_");
452
452
  }
453
+ /**
454
+ * Plan 104 T3/T4: the model-visible refusal line for a terminal guardrail decision. `guardrailRefusalText`
455
+ * names a compiled pack rule (bounded, redacted); any other guardrail keeps the neutral stage text.
456
+ */
457
+ function guardrailBlockMessage(record) {
458
+ return (guardrailRefusalText(record) ?? (record.stage === "tool_output" ? "Tool result blocked by guardrail" : "Tool call blocked by guardrail"));
459
+ }
453
460
  function isDelegationSuspended(error) {
454
461
  return error?.code === "ERR_PRISM_DELEGATION_SUSPENDED";
455
462
  }
@@ -19,7 +19,13 @@ export interface ModelFamilyTokens {
19
19
  readonly perMessageOverhead: number;
20
20
  readonly confidence: TokenEstimateConfidence;
21
21
  }
22
- /** Per-family chars/token tables (plan 091 Task 1). Estimates only, never billing. */
22
+ /** Per-family chars/token tables (plan 091 Task 1). Estimates only, never billing.
23
+ *
24
+ * Deep-frozen — each row, then the table. `Readonly<Record<…>>` is compile-time only, and a
25
+ * runtime write to a nested row silently changes token accounting: an under-counted input
26
+ * estimate is what `maxInputTokens`/`maxCost` are checked against, and a replaced row makes the
27
+ * estimate `NaN`, which compares false. Recalibration is a source change plus the live calibration
28
+ * leg (`scripts/usage-calibration-live.test.mjs`), never a runtime override. */
23
29
  export declare const MODEL_FAMILY_TOKENS: Readonly<Record<ModelFamily, ModelFamilyTokens>>;
24
30
  /** Resolve a model id (e.g. `"claude-sonnet-4.5"`), provider id, or family name
25
31
  * to a table key. Unmatched input is `"unknown"` — never a throw. */
@@ -2,16 +2,22 @@
2
2
  const CJK_CHARS_PER_TOKEN = 1.5;
3
3
  /** Fenced code tokenizes worse than prose: code ratio = prose ratio * this factor. */
4
4
  const CODE_RATIO_FACTOR = 0.88;
5
- /** Per-family chars/token tables (plan 091 Task 1). Estimates only, never billing. */
6
- export const MODEL_FAMILY_TOKENS = {
7
- anthropic: { charsPerToken: 3.7, perMessageOverhead: 4, confidence: "medium" },
8
- openai: { charsPerToken: 5.0, perMessageOverhead: 3, confidence: "medium" },
9
- google: { charsPerToken: 3.9, perMessageOverhead: 4, confidence: "medium" },
10
- deepseek: { charsPerToken: 3.8, perMessageOverhead: 4, confidence: "medium" },
11
- "openrouter-generic": { charsPerToken: 4.4, perMessageOverhead: 4, confidence: "medium" },
12
- mistral: { charsPerToken: 3.9, perMessageOverhead: 3, confidence: "medium" },
13
- unknown: { charsPerToken: 3.5, perMessageOverhead: 6, confidence: "low" },
14
- };
5
+ /** Per-family chars/token tables (plan 091 Task 1). Estimates only, never billing.
6
+ *
7
+ * Deep-frozen — each row, then the table. `Readonly<Record<…>>` is compile-time only, and a
8
+ * runtime write to a nested row silently changes token accounting: an under-counted input
9
+ * estimate is what `maxInputTokens`/`maxCost` are checked against, and a replaced row makes the
10
+ * estimate `NaN`, which compares false. Recalibration is a source change plus the live calibration
11
+ * leg (`scripts/usage-calibration-live.test.mjs`), never a runtime override. */
12
+ export const MODEL_FAMILY_TOKENS = Object.freeze({
13
+ anthropic: Object.freeze({ charsPerToken: 3.7, perMessageOverhead: 4, confidence: "medium" }),
14
+ openai: Object.freeze({ charsPerToken: 5.0, perMessageOverhead: 3, confidence: "medium" }),
15
+ google: Object.freeze({ charsPerToken: 3.9, perMessageOverhead: 4, confidence: "medium" }),
16
+ deepseek: Object.freeze({ charsPerToken: 3.8, perMessageOverhead: 4, confidence: "medium" }),
17
+ "openrouter-generic": Object.freeze({ charsPerToken: 4.4, perMessageOverhead: 4, confidence: "medium" }),
18
+ mistral: Object.freeze({ charsPerToken: 3.9, perMessageOverhead: 3, confidence: "medium" }),
19
+ unknown: Object.freeze({ charsPerToken: 3.5, perMessageOverhead: 6, confidence: "low" }),
20
+ });
15
21
  /** Model-id patterns per family. Family names themselves also resolve (see `resolveModelFamily`). */
16
22
  const FAMILY_PATTERNS = [
17
23
  ["anthropic", /claude|anthropic/i],
package/docs/acp.md CHANGED
@@ -57,7 +57,7 @@ In-stream `SessionUpdate`s:
57
57
  | `permission_denied` lifecycle | `tool_call_update` status `failed` (never raw args; synthesized id `prism:denied:<approvalId>` when no `toolCallId`) |
58
58
  | `configuration_changed` lifecycle | `config_option_update` with the full current set, per streaming session |
59
59
  | Plan lifecycle (F5, UNSTABLE-gated) | `plan_changed` → `plan_update` with `plan: { type: "items", planId = planPath, entries: [{ content, priority: "medium", status }] }` — the complete entry list per update (client replaces its plan wholesale); `plan_removed` → `plan_removed` with `planId = planPath`. Emitted only when the client advertised `ClientCapabilities.plan`; mapper stays capability-agnostic (gate in the agent wiring). Entries come from `writeCodingPlanFile`'s `onEvent` (parsed via `parseCodingPlanTodos`) or host-emitted through their `CodingLifecycleEmitter`; text passes the shared redactor and byte caps. |
60
- | Subagent lifecycle | `subagent_started` / `subagent_stopped` → `agent_message_chunk` with only redacted child/delegation ids and terminal status. Wire `observeSupervisorLifecycle()` to emit them; child inputs, outputs, paths, and error text stay absent. |
60
+ | Subagent lifecycle | `subagent_started` / `subagent_stopped` → `agent_message_chunk` with only redacted child/delegation ids and terminal status. Wire `observeSupervisorLifecycle()` to emit them; child inputs, outputs, paths, and error text stay absent. The coding event's opt-in `failure` / `recovery` fields are host-side only — this mapping reads ids, depth, and status. |
61
61
  | Session title (F6) | `sessions.title({ sessionId, prompt, signal })` resolves on `session/prompt`; a defined value differing from the last emitted title produces `session_info_update` with `{ sessionUpdate: "session_info_update", title }`. Best-effort: `undefined` or a throw means no title and no update (requests never fail on titles); the host owns title storage. Titles pass the shared redactor and are truncated at `maxTextBytes`/`maxEventBytes`. |
62
62
  | Slash commands (F9) | `commands.list({ sessionId, signal })` on `session/new`/`load`/`resume` produces `available_commands_update` with `{ name, description, input?: { hint } }` (SDK `AvailableCommand`; description is required). Names/descriptions/hints pass the shared redactor and `maxTextBytes`; the list is sliced at `acpCommandsPerUpdate`. Best-effort: a throw or non-array omits the update (session start never fails on commands). |
63
63
  | Session mode/config switch | `current_mode_update` / `config_option_update` |
@@ -121,7 +121,7 @@ const agent = createPrismAcpAgent({
121
121
 
122
122
  ### Persistence and ownership
123
123
 
124
- - **Active-run recovery (0.2.6, plan 026 Task 5).** When `sessionStore` and the `recovery` seam (checkpoints + leases + ownerId, all three together) are wired, the agent records a bounded `activeRun` reference on `PersistedAcpSession` while a durable run is live (first run event → `running`, suspension → `suspended` + version, finish/deny/error → `terminal`; frozen 512-byte cap; advisory only — the authoritative status is re-queried from `AgentRunLifecycle.status`). After a restart, `restore` re-attaches the ref to the live session and hosts re-resolve it with `createAcpRunRecovery` (exported from `@arnilo/prism-ag-ui/acp`): suspended runs report their pending approval ids and durable version, terminal runs report terminal, and unprovable in-flight streams report `unknown` — the prompt is never restarted automatically. Durable cancellation (`recovery.cancel`) is ownership/version/fence checked, terminal/idempotent, aborts no unrelated run, and never replays a pending/dispatched tool: a cancelled run reports `cancelled` and must not be resumed. `session/cancel` on a live agent aborts the controller (0.2.5 parity) and, for restored runs, writes the durable marker under the session's ownership. Cancel markers live in `prism.coding-agent.cancel.v1` (schemaVersion 1, CAS + lease fenced). A host-side terminal whose managed process is unattestable after restart reports `unknown` (exitCode null); the agent never fabricates an exit or replays input (`terminal-client`).
124
+ - **Active-run recovery (0.2.6, plan 026 Task 5).** When `sessionStore` and the `recovery` seam (checkpoints + leases + ownerId, all three together) are wired, the agent records a bounded `activeRun` reference on `PersistedAcpSession` while a durable run is live (first run event → `running`, suspension → `suspended` + version, finish/deny/error → `terminal`; frozen 512-byte cap; advisory only — the authoritative status is re-queried from `AgentRunLifecycle.status`). After a restart, `restore` re-attaches the ref to the live session and hosts re-resolve it with `createAcpRunRecovery` (exported from `@arnilo/prism-ag-ui/acp`): suspended runs report their pending approval ids and durable version, terminal runs report terminal, and unprovable in-flight streams report `unknown` — the prompt is never restarted automatically. Durable cancellation (`recovery.cancel`) is ownership/version/fence checked, terminal/idempotent, aborts no unrelated run, and never replays a pending/dispatched tool: a cancelled run reports `cancelled` and must not be resumed. `session/cancel` on a live agent aborts the controller (0.2.5 parity) and, for restored runs, writes the durable marker under the session's ownership; that write is not tied to the connection, so it lands even when the client disconnects immediately after `session/cancel`; `session/close` likewise does not abort a durable cancel write already in flight — it stays bounded by the cancel-lease TTL. Cancel markers live in `prism.coding-agent.cancel.v1` (schemaVersion 1, CAS + lease fenced). A host-side terminal whose managed process is unattestable after restart reports `unknown` (exitCode null); the agent never fabricates an exit or replays input (`terminal-client`).
125
125
 
126
126
  - **Without the durability seam the agent never persists `modeId`/`configValues`.** Defaults are recomputed per session from the `modes`/`configOptions` seams — a fresh `session/new`, `load`, or `resume` always starts from `defaultModeId` / option `defaultValue`, and the agent's per-session registry is in-memory only. Persisting mode/config across sessions is a **host** decision, and host-side persistence MUST be ownership-scoped.
127
127
  - **Host persistence MUST key by `sessions.ownership`.** `authorize` binds transport identity to ownership; a host store that persists `modeId`/`configValues` must refuse any restore whose stored ownership differs from the current session's ownership — a `sessionId` alone is never a sufficient key (session ids may collide across tenants). A cross-tenant restore rejects with `ERR_PRISM_ACP_INPUT` and never returns the other tenant's mode/config.
@@ -10,7 +10,7 @@ Events are emitted by the runtime and by loops through `LoopContext.emit`, both
10
10
 
11
11
  ## When to use it
12
12
 
13
- Subscribe via `session.stream()` for a single owned run, or `session.subscribe()` when a host needs a long-lived observer across runs: render streamed assistant text in a UI, react to tool execution, drive observability/telemetry, or audit artifact validation outcomes. Do not parse provider stream events directly for these — `AgentEvent` is the stable, normalized surface across providers and loops.
13
+ Subscribe via `session.stream()` for a single owned run, or `session.subscribe({ acrossRuns: true })` when a host needs a long-lived observer across runs (a default `session.subscribe()` is run-scoped: run end — finish, suspension, or denial — closes it): render streamed assistant text in a UI, react to tool execution, drive observability/telemetry, or audit artifact validation outcomes. Do not parse provider stream events directly for these — `AgentEvent` is the stable, normalized surface across providers and loops.
14
14
 
15
15
  Do not use live `session.subscribe()` for cross-replica reconnect — use durable `AgentEventSource` below. Live subscribe remains process-local.
16
16
 
@@ -97,7 +97,7 @@ Agent / turn / message events:
97
97
  | Variant | Fields |
98
98
  | --- | --- |
99
99
  | `agent_started` | `sessionId`, `runId` |
100
- | `agent_finished` | `sessionId`, `runId`, `usage?: Usage` (aggregate of all usage-bearing provider turns), `finishReason?: "turn_limit" \| "token_limit" \| "refusal"` (why a limit/ceiling ended the run cleanly — F4; absent = natural end) |
100
+ | `agent_finished` | `sessionId`, `runId`, `usage?: Usage` (aggregate of all usage-bearing provider turns), `finishReason?: "turn_limit" \| "token_limit" \| "refusal" \| "host_policy" \| "hook_limit"` (why a limit/ceiling/hook cap ended the run cleanly — F4, plan 106 R1; absent = natural end) |
101
101
  | `agent_suspended` | `sessionId`, `runId`, redacted `interruption`, checkpoint `version`; no tool side effect has started. |
102
102
  | `agent_resumed` | `sessionId`, `runId`, checkpoint `version`. |
103
103
  | `agent_denied` | `sessionId`, `runId`, redacted `interruption`, checkpoint `version`; no tool side effect runs. |
@@ -110,9 +110,9 @@ Agent / turn / message events:
110
110
 
111
111
  Adapters should call `createDelegatedAgentStep({ sessionId, runId, adapterId, externalConversationId, stepIndex, state, kind, usage })` rather than forwarding external JSON. The constructor allow-lists fields and fails closed on malformed or oversized identifiers/counters.
112
112
 
113
- Coding hosts call `observeSupervisorLifecycle(supervisor, { onEvent, delegatedAgentStep })` to turn supervisor milestones into `subagent_started` / `subagent_stopped` coding lifecycle events. Both carry only redacted `childId`, `delegationId`, and `depth`; stopped events add terminal `AgentRunStatus`. Supplying `delegatedAgentStep` emits the bounded `delegated_agent_step` records AG-UI already maps. Child inputs, outputs, paths, and delegation error text never cross either bridge.
113
+ Coding hosts call `observeSupervisorLifecycle(supervisor, { onEvent, delegatedAgentStep })` to turn supervisor milestones into `subagent_started` / `subagent_stopped` coding lifecycle events. Both carry only redacted `childId`, `delegationId`, and `depth`; stopped events add terminal `AgentRunStatus`. Two independent opt-ins extend the stopped event without changing the default: `includeFailure: true` attaches `failure: { reason, limit?, stopReason? }` to the stop a `child_failed` produced — the supervisor-redacted reason truncated to `DEFAULT_LIFECYCLE_MAX_REASON_BYTES`, the fired `RunLimitName`, and the child's stop reason (`delegation_finished` and `delegation_rejected` never carry it) — and `includeRecovery: true` attaches the child's `summary()` row as `recovery: { attempts, retries, failures, failureRadius, outcome }`, omitted when the source exposes no `summary()`. Supplying `delegatedAgentStep` emits the bounded `delegated_agent_step` records AG-UI already maps. Child inputs, outputs, paths, and delegation error text never cross either bridge: with both options off the stopped event is byte-identical to before, and the opt-in failure fields are counts, enums, and the already-redacted reason.
114
114
 
115
- Supervisor child reporting is opt-in per child (`SupervisorChild.policy.report` ceiling; a request can only lower it). With `report: "milestones"` the supervisor publishes `child_milestone` (`childId`, `delegationId`, `depth`, `turn`, redacted `childEvent`) at the configured `milestone.everyTurns` cadence or host predicate; with `report: "stream"` it publishes `delegation_child_event` for every per-turn provider/tool/turn child event (never per-token `message_delta`). Both are redacted, count/byte-capped, and rate-coalesced (`delegation_child_events_coalesced` reports dropped events); the cap marker is `delegation_child_events_capped`. `child_failed` carries failure attribution for any child that died on an error or a limit: the redacted `reason`, the terminal `status`/`stopReason`, and the plan-086/087 `RunLimitBreach` in `limit` when a configured ceiling fired. Host cancels, policy denials, and hook rejections are not failures and never emit it. Hosts that want recovery counters rather than events read `supervisor.summary()` (`attempts`, `retries`, `failures`, `failureRadius`, `outcome` per child). Child events stay on the supervisor stream unless the host passes `childEventSink`, which receives the identical payload tagged with `child: { childId, delegationId, depth }` (`ChildEventOrigin`) for routing onto a parent session stream; they are not native `AgentEvent`s of the parent session, and hosts that surface them there re-attach the parent `sessionId`/`runId` themselves if needed.
115
+ Supervisor child reporting is opt-in per child (`SupervisorChild.policy.report` ceiling; a request can only lower it). With `report: "milestones"` the supervisor publishes `child_milestone` (`childId`, `delegationId`, `depth`, `turn`, redacted `childEvent`) at the configured `milestone.everyTurns` cadence or host predicate; with `report: "stream"` it publishes `delegation_child_event` for every per-turn provider/tool/turn child event (never per-token `message_delta`). Both are redacted, count/byte-capped, and rate-coalesced (`delegation_child_events_coalesced` reports dropped events); the cap marker is `delegation_child_events_capped`. `child_failed` carries failure attribution for any child that died on an error or a limit: the redacted `reason`, the terminal `status`/`stopReason`, and the plan-086/087 `RunLimitBreach` in `limit` when a configured ceiling fired, plus that death's exhaustion attribution (`consumed`, `closestOtherAxes`, `recentToolCalls`, the payload the child's own `budget_exhausted` event and `AgentRunResult.attribution` carry). Host cancels, policy denials, and hook rejections are not failures and never emit it. Hosts that want recovery counters rather than events read `supervisor.summary()` (`attempts`, `retries`, `failures`, `failureRadius`, `outcome` per child), or pass `includeRecovery` to the lifecycle bridge, which attaches that row to each stopped event. Child events stay on the supervisor stream unless the host passes `childEventSink`, which receives the identical payload tagged with `child: { childId, delegationId, depth }` (`ChildEventOrigin`) for routing onto a parent session stream; they are not native `AgentEvent`s of the parent session, and hosts that surface them there re-attach the parent `sessionId`/`runId` themselves if needed.
116
116
 
117
117
  `message_delta.content.type === "tool_call_delta"` carries `{ index, id?, name?, argumentsText? }`. Treat it as a streaming fragment. The runtime reconstructs and persists a final `tool_call` before executing tools. Deltas missing `id`/`name` at stream end fail the provider turn with `ErrorInfo.code: "incomplete_delta"` (typed `ProviderTransportError`); they never throw a bare `Error`. Malformed JSON with id+name present recovers as a blocked tool result (`invalid_json_arguments`) instead.
118
118
 
@@ -124,7 +124,7 @@ Tool execution events:
124
124
  | `tool_execution_progress` | `sessionId`, `runId`, `toolCallId`, `name`, `progress?`, `metadata?` |
125
125
  | `tool_execution_finished` | `sessionId`, `runId`, `result: ToolResult`, `metadata: ToolExecutionMetadata` |
126
126
  | `tool_execution_error` | `sessionId`, `runId`, `call: ToolCallContent`, `error: ErrorInfo`, `metadata: ToolExecutionMetadata` |
127
- | `tool_execution_blocked` | `sessionId`, `runId`, `toolCallId`, `name`, `reason: string`, `error: ErrorInfo`, `metadata: ToolExecutionMetadata` |
127
+ | `tool_execution_blocked` | `sessionId`, `runId`, `toolCallId`, `name`, `reason: string` (machine code, e.g. `guardrail_blocked`), `error: ErrorInfo` (model-visible text — for a pack rule `Blocked by guardrail rule pack:<pack>/<rule>`, bounded and redacted), `metadata: ToolExecutionMetadata` |
128
128
  | `tool_narrowing_clamped` | `sessionId`, `runId`, `turn`, `dropped: readonly string[]` (names the host returned outside the run grant; no tool args) |
129
129
 
130
130
  Guardrail events:
@@ -197,11 +197,14 @@ value when the adapter saw a native reason.
197
197
  | `unknown` | Unmapped or absent native reason |
198
198
 
199
199
  `provider_turn_finished.metadata.budgets` is an O(1) snapshot from the run limit tracker:
200
- `{ inputTokens?, inputCap?, runInputBudget?, runInputUsed, turns, maxTurns }` — current-turn
201
- provider-reported input tokens against the resolved per-request input cap, cumulative run input
202
- against `limits.maxInputTokens`, and provider turns against `limits.maxTurns` (`null` when
203
- disabled). Optional fields are absent when the provider reported no usage or no input cap can be
204
- derived; hosts that ignore the fields are unaffected.
200
+ `{ inputTokens?, inputTokensSource?, inputCap?, runInputBudget?, runInputUsed, turns, maxTurns }` —
201
+ current-turn charged input tokens against the resolved per-request input cap, cumulative run input
202
+ against `limits.maxInputTokens`, and provider turns against `limits.maxTurns` (`null` when disabled).
203
+ `inputTokensSource` is `"reported"` or `"estimated"` and is absent together with `inputTokens`; what
204
+ produces each is documented in
205
+ [Runs and usage § Automatic fallback](runs-and-usage.md#automatic-fallback-agentconfigusageestimation).
206
+ Optional fields are absent when the provider reported no usage or no input cap can be derived; hosts
207
+ that ignore the fields are unaffected.
205
208
 
206
209
  `provider_turn_started` / `provider_turn_finished` metadata includes `tools: { count, idsHash }` for the
207
210
  effective menu sent on that request (after run scoping, per-turn `toolNarrowing`, and disclosure).
@@ -298,6 +301,8 @@ for await (const event of session.stream("draft", { loop: { strategy: "generate-
298
301
 
299
302
  ## Extension and configuration notes
300
303
 
304
+ Extension packages can subscribe to lifecycle events on the extension bus; `forwardAgentEvents(session.subscribe(), kernel.events)` maps this stream onto that bus as read-only notifications (`agent_started` → `before_agent_start`, turns → `turn`, tool execution → `tool_call`/`tool_result`). See [Extension kernel and event bus](extensions.md) and [Hooks](hooks.md).
305
+
301
306
  - All events flow through `redactAgentEvent(event, activeRedactor)` before subscribers observe them. Configure `AgentConfig.redactor` / `RunOptions.redactor` via `createSecretRedactor([...knownSecretStrings])` so secret values are redacted in `message` content, `errors[].message`, `metadata`, and artifact `result`/`failure` payloads.
302
307
  - The artifact variants are emitted only by `generateValidateReviseLoop`. `singleShotLoop` (the default when no `AgentConfig.loop` / `RunOptions.loop` is set) emits zero artifact events. See [Agent loops](agent-loops.md).
303
308
  - Subscribers are in-process; the broadcaster is in-memory and live-only. Multiple `subscribe()` calls receive the same stream. `resumeAgentRunStream()` and `AgentRunLifecycle.resumeStream()` subscribe before resumed execution and yield only their selected durable `runId`; approval emits the normal `agent_started` then `agent_resumed` envelope, denial emits only `agent_denied`.
@@ -14,6 +14,7 @@ The agent/session runtime adds the minimal shared SDK surface for running provid
14
14
  - `session.compact(options?)`
15
15
  - `session.contextMeter()` → `ContextMeter` (latest provider-turn input tokens, reported or labeled estimate, with cap/budget/ratio)
16
16
  - `session.subscribe(options?)`
17
+ - `session.close()` → dispatches `session_shutdown` middleware once, then closes every subscriber
17
18
  - `session.abort()`
18
19
  - `session.entries()`
19
20
  - `session.checkout(leafId?)`
@@ -58,13 +59,15 @@ string | Message | readonly Message[]
58
59
 
59
60
  `session.fork(options?)` / `session.clone(options?)` take `AgentSessionForkOptions` / `AgentSessionCloneOptions` (leaf id, new session id, metadata, and store overrides), and `session.steer(input, options?)` takes `SteerOptions`. See [Public contracts](public-contracts.md) for the field tables, and the [options index](options-index.md) for every session option surface.
60
61
 
61
- `session.run()` / `session.prompt()` resolve to an `AgentRunResult` with `sessionId`, `runId`, `status`, `text`, `content`, optional `message`/`usage`/`leafId`, and terminal `error`/`abortReason` when applicable. Callers may ignore the return value. Failed and aborted runs still emit their terminal events, then reject with `AgentRunError` whose `.result` carries the same shape.
62
+ `session.close()` is the session teardown seam: it dispatches `session_shutdown` middleware exactly once (idempotent — a second `close()` dispatches nothing) and then closes every subscriber, run-scoped and `acrossRuns` alike. It does not abort an active run, so call it after the run settles. `session_start` middleware, the mirror dispatch, runs once at the session's first run start (the two hooks are the only per-session middleware calls — every other hook is per turn or per boundary). See [Middleware hooks](middleware-hooks.md).
62
63
 
63
- `session.stream(input, options?)` subscribes first, starts exactly one run, yields only that run's events, and terminates when the run succeeds, fails, or aborts. Early consumer return aborts the owned run and releases the session. `SubscribeOptions.maxQueuedEvents` / `overflow` may be passed alongside `RunOptions`.
64
+ `session.run()` / `session.prompt()` resolve to an `AgentRunResult` with `sessionId`, `runId`, `status`, `text`, `content`, optional `message`/`usage`/`leafId`, `limit`/`attribution` when the run died on a configured ceiling, and terminal `error`/`abortReason` when applicable. Callers may ignore the return value. Failed and aborted runs still emit their terminal events, then reject with `AgentRunError` whose `.result` carries the same shape.
65
+
66
+ `session.stream(input, options?)` subscribes first, starts exactly one run, yields only that run's events, and terminates when the run succeeds, fails, or aborts. The subscription belongs to `stream()`: it closes it when the owned run settles, so even a run that fails before its first event (a pre-flight validation rejection returns before run-end cleanup) ends the consumer instead of parking it, and no run-end close is required for `stream()` to be correct. Early consumer return aborts the owned run and releases the session. `SubscribeOptions.maxQueuedEvents` / `overflow` may be passed alongside `RunOptions`.
64
67
 
65
68
  `resumeAgentRunStream(agent, ref, resume, options?)` does the same for one existing suspended durable run. It validates checkpoint ownership, revision/fingerprint, and `expectedVersion`, then subscribes before emitting `agent_started` / `agent_resumed` and resumed message/tool/terminal events. `AgentRunResumeStreamOptions` combines `AgentRunResumeOptions` (including the optional `onSession` observer seam a supervisor uses to attach a child event pump to the rebuilt session) with `maxQueuedEvents` and `overflow`; early return aborts only resumed execution. Since 0.8.0 (plan 080 Task 3), `AgentRunResumeOptions.signal` is inherited by both entrypoints, so `resumeAgentRun()` aborts a live resumed provider/tool turn the same way `resumeAgentRunStream()` does — checked before each preparation step and threaded into the resumed execution. It does not replay a claimed/dispatched tool, poll a ledger, or retain a worker. `createAgentRunLifecycle().resumeStream(ref, resume, request?)` adds the same behavior after host agent-capability resolution.
66
69
 
67
- `session.subscribe(options?)` remains available for hosts that want a long-lived subscriber across runs. Subscribe before `run()` to observe that run's events. The consumer loop and `session.run()` must run concurrently (e.g. start the `for await` consumer, then `await Promise.all([consumer, session.run("Hi")])`): events are only emitted during a live run, so awaiting the subscribe loop before calling `run()` deadlocks. Prefer `session.stream()` when you only need one run's events. `SubscribeOptions.maxQueuedEvents` defaults to `1024` (minimum `1`) and caps events queued while the consumer is not awaiting `next()`. `SubscribeOptions.overflow` defaults to `"close"`; it clears queued payload events, delivers one `event_subscriber_overflow` notice to that subscriber, then closes it. `"drop_oldest"` keeps newest events; `"drop_newest"` ignores new events while full.
70
+ `session.subscribe(options?)` returns an in-memory live subscription. By default it is **run-scoped**: the run-end cleanup (`cleanupRun`), a durable suspension, and a durable denial all close it, which is what `stream()` and the examples rely on. `SubscribeOptions.acrossRuns: true` opts one subscriber out of that close, so it keeps receiving the next run's events on the same session; it is then ended only by the host (`break` out of the `for await`, or the iterator's `return()`/`[Symbol.asyncIterator]().return()`), by `closeSubscribers()` on session teardown, or by an overflow under the default `close` policy. The run-scoped close is `closeRunSubscribers()`; `closeSubscribers()` still means every subscriber. Subscribe before `run()` to observe that run's events. The consumer loop and `session.run()` must run concurrently (e.g. start the `for await` consumer, then `await Promise.all([consumer, session.run("Hi")])`): events are only emitted during a live run, so awaiting the subscribe loop before calling `run()` deadlocks. Prefer `session.stream()` when you only need one run's events. `SubscribeOptions.maxQueuedEvents` defaults to `1024` (minimum `1`) and caps events queued while the consumer is not awaiting `next()`. `SubscribeOptions.overflow` defaults to `"close"`; it clears queued payload events, delivers one `event_subscriber_overflow` notice to that subscriber, then closes it. `"drop_oldest"` keeps newest events; `"drop_newest"` ignores new events while full.
68
71
 
69
72
  For a text-only provider turn, the runtime emits:
70
73
 
@@ -178,17 +181,17 @@ await agent.createSession().run("Hi", { model: overrideModel });
178
181
  - Compaction context contains branch entries and explicit compaction options only; it does not include provider objects, provider requests, credential resolvers, resolved credentials, settings, or hidden metadata.
179
182
  - Store entries contain explicit session data only; Prism does not store provider objects, credential resolvers, resolved credentials, full provider requests, settings, or hidden metadata.
180
183
  - Runtime events contain messages/content only; do not put secrets in prompts, metadata, provider events, session entries, or docs examples.
181
- - The event broadcaster is in-memory, live-only, and bounded per subscriber by `SubscribeOptions`. It adds no dependency, timer, filesystem/network discovery, worker, or durable queue.
184
+ - The event broadcaster is in-memory, live-only, and bounded per subscriber by `SubscribeOptions`. It adds no dependency, timer, filesystem/network discovery, worker, or durable queue. An `acrossRuns: true` subscriber holds that bounded queue (default `maxQueuedEvents` 1024) for the session's lifetime instead of one run, and it subscribes to no other session: the broadcaster stays session-scoped, so no subscriber can observe another session's or ownership scope's events.
182
185
 
183
186
  ## Durable interruption
184
187
 
185
- Set `runState` with a host-owned `CheckpointStore`, stable `definitionRevision`, and `interruptBeforeTool: true` to suspend at a persisted pre-side-effect boundary. A suspended result has `status: "suspended"`, a redacted `interruption`, and `runState.version`; it releases session resources before returning. When a provider turn requests several tools, the round is collected into **one** suspension whose `interruption.pendingDecisions` holds one redacted `PendingDecision` per gated call (`approvalId`, kind, scope with tool name/effect kind/identity/arguments hash — never raw arguments); ungated calls still dispatch.
188
+ Set `runState` with a host-owned `CheckpointStore`, stable `definitionRevision`, and `interruptBeforeTool: true` to suspend at a persisted pre-side-effect boundary. A compiled pack rule with `action: "ask"` gates exactly the calls it matches the same way, without the all-tools switch (see [Guardrails § ask rules](guardrails.md#asking-for-approval-ask-rules)). A suspended result has `status: "suspended"`, a redacted `interruption`, and `runState.version`; it releases session resources before returning. When a provider turn requests several tools, the round is collected into **one** suspension whose `interruption.pendingDecisions` holds one redacted `PendingDecision` per gated call (`approvalId`, kind, scope with tool name/effect kind/identity/arguments hash — never raw arguments); ungated calls still dispatch.
186
189
 
187
190
  `resumeAgentRun` accepts exactly one of:
188
191
 
189
192
  - `decision: "approve" | "deny"` — legacy single-approval path. `approve` allows every pending decision once; `deny` terminates the run as `denied`.
190
193
  - `decision: "continue"` — crash recovery for a running-state checkpoint written by [`checkpointPolicy: "every-turn"`](durable-runs.md): resumes from the last provider-turn boundary without re-dispatching tools. It requires a running state and never bypasses a gate — a suspended run still needs `approve`/`deny` or a decision batch.
191
- - `decisions: readonly RunDecision[]` — one atomic batch. Every entry validates against the recorded pending set (unknown/foreign `approvalId`, duplicates, stale `expectedVersion`, invalid outcomes fail the whole batch closed with `AgentDecisionError` and leave state and version untouched). Outcomes: `allow_once`, `allow_for_run`, `reject_once`, `reject_for_run`. `reject_*` continues the run with a blocked tool result carrying the bounded (2 KB) `reason`. `modifiedArguments` are revalidated (schema, then input guardrails; permission/trust re-run at dispatch) and produce a new arguments hash. `elicitation` payloads are validated against the pending decision's `elicitationSchema` (required keys plus the configured host validator) and resolve the suspended call without executing it. A batch deciding a strict subset persists the decided entries and re-suspends with the remainder pending at the bumped version.
194
+ - `decisions: readonly RunDecision[]` — one atomic batch. Every entry validates against the recorded pending set (unknown/foreign `approvalId`, duplicates, stale `expectedVersion`, invalid outcomes fail the whole batch closed with `AgentDecisionError` and leave state and version untouched). Outcomes: `allow_once`, `allow_for_run`, `reject_once`, `reject_for_run`. `reject_*` continues the run with a blocked tool result carrying the bounded (2 KB) `reason`. `modifiedArguments` are revalidated (schema, then input guardrails — including the session's restored guardrail pack rules, so an edit into a pack-violating state, a `deny` or an `ask` rule alike, is refused here with `ERR_PRISM_DECISION_INVALID` naming the rule instead of being accepted and only stopped at dispatch; permission/trust re-run at dispatch) and produce a new arguments hash. `elicitation` payloads are validated against the pending decision's `elicitationSchema` (required keys plus the configured host validator) and resolve the suspended call without executing it. A batch deciding a strict subset persists the decided entries and re-suspends with the remainder pending at the bumped version.
192
195
 
193
196
  `*_for_run` outcomes append a `StickyDecision` to the durable run state: later calls in the same run matching the scope exactly (all recorded fields) proceed or are blocked without a new suspension, policy still enforced at dispatch. Sticky decisions expire when the run reaches any terminal status. Caps: 32 pending decisions per run (hard 128), 64 sticky decisions (hard 256), 2 KB decision reasons, 16 KB elicitation payloads. Frontend adapters (such as AG-UI with `capabilities.humanInTheLoop.approveWithEdits`) and the server resume endpoint (`POST .../resume` with `modifiedArguments`) map human edits directly to `RunDecision` entries with `modifiedArguments` under single atomic CAS, revalidating tool parameter schemas and invalidating stale draft approvals.
194
197
 
@@ -205,7 +208,7 @@ if (result.status === "suspended") {
205
208
  }
206
209
  ```
207
210
 
208
- Resume requires exact checkpoint ownership, version, agent fingerprint, and revision. A checkpoint load or delete under a non-matching ownership scope reads as absent (`null`), and a save against a foreign-owned record fails as a generic `ERR_PRISM_CHECKPOINT_CONFLICT` (plan 080 Task 3) — a tenant cannot distinguish “another tenant owns this key” from “missing”, and callers that relied on the old `Checkpoint ownership mismatch` throw now see the same miss they would for an unknown key. The fingerprint hashes the agent id/name, `definitionRevision`, model, instructions, system-prompt contributions, skills (name/instructions/tool names), tool definitions (name/parameters/exclusive), guardrail definitions (name/stage/revision), and loop strategy — changing any of them without bumping `definitionRevision` fails resume closed instead of silently continuing with different agent semantics. Prism CAS-claims approval before work, rechecks normal guardrail/permission/validation/limit paths, and marks a pending tool dispatched before its side effect. `createAgentRunLifecycle()` wraps the same core path for server/MCP hosts: adapters pass only authorized ownership, status returns only `{ state, version }`, and `resolveAgent()` supplies current agent/revision. `resumeStream()` uses that same claim path and bounded subscriber, so adapters do not poll or duplicate resume logic. Remote restart requires both checkpoint and session stores to be durable. A crash after that mark is ambiguous and is never replayed automatically; use host tool idempotency keyed by `runId`/`toolCallId` or resolve it manually. Checkpoints contain bounded redacted state plus session/leaf references, never provider objects, callbacks, signals, credentials, or raw secrets. State is bounded at save by `runState.maxStateBytes` (default 256 KB, at most the 1 MB hard cap); load bounds against the 1 MB hard cap only, so state saved with a raised limit stays resumable while oversized records are still rejected. Since 0.1.3 (plan 015 Task 4), durable runs may opt in to session-state persistence with `persistSessionState: true` on both the run and resume options: the loaded-skill **name catalog** (≤64 names, ≤256 chars each) rides the checkpoint and is restored into the resumed session's `LoadedSkillSet`; skill **bodies are never persisted** and re-resolve from the live registry via `load_skill`. Since 0.1.6 (plan 018 closeout `checkpoint-bodies`), `includeSkillBodies: true` on BOTH the run and resume options additionally persists the exact loaded-skill **instructions** (`{name, instructions}` pairs, redacted at the checkpoint boundary like all state, ≤64 bodies / ≤256-char names / ≤262144-byte bodies / ≤1 MiB total) so resume re-renders them registry-independently — no `load_skill` round-trip and no dependence on the registry still serving the same text; `maxStateBytes` (default 256 KB) refuses oversize bodies with a recorded error, never silently truncates. Default off keeps the checkpoint shape byte-identical to 0.1.3. Since 0.7.0 (plan 074 P3), `persistSessionState: true` also carries the opt-in [attention compiler](attention-compiler.md)'s sticky frontier (`sessionState.attentionSticky`: 32-hex thinking keys plus tool-call ids, newest 256 of each, redacted like all state) so a resumed run keeps its thinking strips and tool stubs instead of re-deciding its first turn from the ratio; a malformed frontier is dropped entry by entry and never blocks a resume. Since 0.7.0, `onSession` hands the reconstructed session to a caller-supplied observer before the resumed run starts, so an observer (the supervisor's child-event pump) can subscribe while the run is still live; it is called for every resume outcome, a throw fails closed before any event or tool work, and the session is valid only for the duration of that resume. Built-in loop options are durable; custom `AgentLoopStrategy` instances are durable when they declare `snapshot`/`restore` hooks (see [Agent loops § Durable runs](agent-loops.md#durable-runs)) and reject before provider work otherwise. For mid-run crash recovery (`checkpointPolicy: "every-turn"` plus `decision: "continue"`), see [Durable runs](durable-runs.md).
211
+ Resume requires exact checkpoint ownership, version, agent fingerprint, and revision. A checkpoint load or delete under a non-matching ownership scope reads as absent (`null`), and a save against a foreign-owned record fails as a generic `ERR_PRISM_CHECKPOINT_CONFLICT` (plan 080 Task 3) — a tenant cannot distinguish “another tenant owns this key” from “missing”, and callers that relied on the old `Checkpoint ownership mismatch` throw now see the same miss they would for an unknown key. The fingerprint hashes the agent id/name, `definitionRevision`, model, instructions, system-prompt contributions, skills (name/instructions/tool names), tool definitions (name/parameters/exclusive), guardrail definitions (name/stage/revision), and loop strategy — changing any of them without bumping `definitionRevision` fails resume closed instead of silently continuing with different agent semantics. Prism CAS-claims approval before work, rechecks normal guardrail/permission/validation/limit paths, and marks a pending tool dispatched before its side effect. `createAgentRunLifecycle()` wraps the same core path for server/MCP hosts: adapters pass only authorized ownership, status returns only `{ state, version }`, and `resolveAgent()` supplies current agent/revision. `resumeStream()` uses that same claim path and bounded subscriber, so adapters do not poll or duplicate resume logic. Remote restart requires both checkpoint and session stores to be durable. A crash after that mark is ambiguous and is never replayed automatically; use host tool idempotency keyed by `runId`/`toolCallId` or resolve it manually. Checkpoints contain bounded redacted state plus session/leaf references, never provider objects, callbacks, signals, credentials, or raw secrets. State is bounded at save by `runState.maxStateBytes` (default 256 KB, at most the 1 MB hard cap); load bounds against the 1 MB hard cap only, so state saved with a raised limit stays resumable while oversized records are still rejected. Since 0.1.3 (plan 015 Task 4), durable runs may opt in to session-state persistence with `persistSessionState: true` on both the run and resume options: the loaded-skill **name catalog** (≤64 names, ≤256 chars each) rides the checkpoint and is restored into the resumed session's `LoadedSkillSet`; skill **bodies are never persisted** and re-resolve from the live registry via `load_skill`. Since 0.1.6 (plan 018 closeout `checkpoint-bodies`), `includeSkillBodies: true` on BOTH the run and resume options additionally persists the exact loaded-skill **instructions** (`{name, instructions}` pairs, redacted at the checkpoint boundary like all state, ≤64 bodies / ≤256-char names / ≤262144-byte bodies / ≤1 MiB total) so resume re-renders them registry-independently — no `load_skill` round-trip and no dependence on the registry still serving the same text; `maxStateBytes` (default 256 KB) refuses oversize bodies with a recorded error, never silently truncates. Default off keeps the checkpoint shape byte-identical to 0.1.3. Since 0.7.0 (plan 074 P3), `persistSessionState: true` also carries the opt-in [attention compiler](attention-compiler.md)'s sticky frontier (`sessionState.attentionSticky`: 32-hex thinking keys plus tool-call ids, newest 256 of each, redacted like all state) so a resumed run keeps its thinking strips and tool stubs instead of re-deciding its first turn from the ratio; a malformed frontier is dropped entry by entry and never blocks a resume. Since 0.9.0 (plan 104 Task 2/3), `persistSessionState: true` also carries the session's guardrail pack refs (`sessionState.guardrailPacks`: `id`, `version`, bounded host `options` — or, for an inline pack, its pattern `rules`, which Task 3 allows to ride the checkpoint while a `deny` predicate or `RegExp` pattern refuses the save) plus each pack's own state-codec snapshot, so a resumed run recompiles and reinstates exactly the packs the suspended run enforced — including the `ask` rules that gate its later calls. The key's presence is the opt-in on resume (the run that wrote it had already opted in), and an unresolvable block — unknown pack id, version mismatch against the installed definition, a pack with persisted state but no codec, more than 8 packs, or malformed/oversized state — fails closed with `AgentRunStateError` before any provider or tool turn rather than resuming unenforced; `session.guardrailPackRefs` then feeds `snapshotRunBundle({ packs })` so recorded identity matches enforcement. Since 0.7.0, `onSession` hands the reconstructed session to a caller-supplied observer before the resumed run starts, so an observer (the supervisor's child-event pump) can subscribe while the run is still live; it is called for every resume outcome, a throw fails closed before any event or tool work, and the session is valid only for the duration of that resume. Built-in loop options are durable; custom `AgentLoopStrategy` instances are durable when they declare `snapshot`/`restore` hooks (see [Agent loops § Durable runs](agent-loops.md#durable-runs)) and reject before provider work otherwise. For mid-run crash recovery (`checkpointPolicy: "every-turn"` plus `decision: "continue"`), see [Durable runs](durable-runs.md).
209
212
 
210
213
  ## Secure composition
211
214
 
@@ -614,5 +614,5 @@ Every configurable value is a positive safe integer (context may be zero); Prism
614
614
  - [Public contracts](public-contracts.md): `ToolDefinition`, `ToolResult`, `ToolExecutionContext`, `ContentBlock`, and `JsonObject` shapes.
615
615
  - [Host security guide](host-security.md): fail-closed checklist for permission policies, tool validation, and trust boundaries that must gate these tools.
616
616
  - [Tool conformance](tool-conformance.md): assertions for the tool-dispatch blocked-reason matrix these tools participate in.
617
- - [ACP coding-host interop](acp.md): host editors drive these tools through stable ACP v1 — client fs/terminal adapters, `CodingLifecycleEvent` emission (`file_changed` etc. via the `onEvent` options; `plan_changed` also fires from `writeCodingPlanFile`'s `onEvent`, F5), redacted supervisor `subagent_started` / `subagent_stopped` via `observeSupervisorLifecycle`, and permission/elicitation through the shared four-outcome decision model.
617
+ - [ACP coding-host interop](acp.md): host editors drive these tools through stable ACP v1 — client fs/terminal adapters, `CodingLifecycleEvent` emission (`file_changed` etc. via the `onEvent` options; `plan_changed` also fires from `writeCodingPlanFile`'s `onEvent`, F5), redacted supervisor `subagent_started` / `subagent_stopped` via `observeSupervisorLifecycle` (the opt-in `includeFailure` / `includeRecovery` options add the redacted reason/limit/stop reason and the child's `summary()` counters to stopped events), and permission/elicitation through the shared four-outcome decision model.
618
618
  - [LLM compaction package](compaction-llm.md): optional `createCodingCompactionStrategy()` retains bounded paths, patch intent, checks, plan/todo state, blockers, and next verification—not complete diffs or raw command output.
@@ -8,14 +8,11 @@ The `@arnilo/prism-coding-tools` family package unifies Prism's coding agent too
8
8
  npm install @arnilo/prism @arnilo/prism-coding-tools
9
9
  ```
10
10
 
11
- For document reading or specialized persona integrations, install the optional peer dependencies as needed:
11
+ For document reading or specialized integrations, install the optional peer dependencies as needed:
12
12
 
13
13
  ```bash
14
14
  # PDF and DOCX document extraction
15
15
  npm install pdf-parse mammoth
16
-
17
- # Ponytail upstream integration
18
- npm install @dietrichgebert/ponytail
19
16
  ```
20
17
 
21
18
  ## Subpaths Map
@@ -28,8 +25,6 @@ npm install @dietrichgebert/ponytail
28
25
  | `@arnilo/prism-coding-tools/computer-use-linux` | Linux desktop observation and targeting tool bridge | — |
29
26
  | `@arnilo/prism-coding-tools/dev` | Loopback-only developer inspector, event timeline visualizer, and local replay server | — |
30
27
  | `@arnilo/prism-coding-tools/dev/cli` | Command-line entrypoint for `prism dev` | — |
31
- | `@arnilo/prism-coding-tools/caveman` | Caveman ultra-terse engineering persona extension | — |
32
- | `@arnilo/prism-coding-tools/ponytail` | Ponytail multi-agent planning and delegation persona extension | `@dietrichgebert/ponytail` |
33
28
  | `@arnilo/prism-coding-tools/impeccable` | Impeccable high-precision frontend engineering persona extension | — |
34
29
 
35
30
  ## CLI
@@ -65,17 +60,18 @@ const composition = createSandboxCodingComposition({
65
60
 
66
61
  ### Persona Extensions
67
62
  ```ts
68
- import { createCavemanExtension } from "@arnilo/prism-coding-tools/caveman";
69
- import { createPonytailExtension } from "@arnilo/prism-coding-tools/ponytail";
70
63
  import { createImpeccableExtension } from "@arnilo/prism-coding-tools/impeccable";
71
64
 
72
- const caveman = createCavemanExtension();
73
- const ponytail = createPonytailExtension();
74
65
  const impeccable = createImpeccableExtension();
75
66
  ```
76
67
 
68
+ Host-owned personas (any upstream `SKILL.md` tree) need no package subpath: load it with
69
+ `loadSkillDirectory` from `@arnilo/prism/node/contribution-discovery`, register the skills and an
70
+ instruction injector from a host extension, and persist the active mode in session entries — see
71
+ [`examples/caveman-ponytail.ts`](../examples/caveman-ponytail.ts).
72
+
77
73
  ## Security & Import Isolation
78
74
 
79
75
  - Importing `@arnilo/prism-coding-tools/agent` never loads Docker sandbox adapters, desktop MCP bridges, document parser peers, or Dev inspector modules.
80
- - Document parser peers (`pdf-parse`, `mammoth`) and Ponytail optional peer fail closed when absent.
76
+ - Document parser peers (`pdf-parse`, `mammoth`) fail closed when absent.
81
77
  - Persona extensions are pure prompt and behavior modifiers and never gain implicit host privileges.
@@ -122,6 +122,8 @@ const agent = createAgent({ model, provider, compaction: { strategy, thresholdEn
122
122
 
123
123
  Registration only contributes an inert strategy. The host must resolve and pass it to runtime config.
124
124
 
125
+ Both compaction routes (`session.compact()` and auto-compaction) hand this strategy its `CompactionContext` through the pre-strategy `compaction_request` middleware hook, so a host can rewrite the entries the strategy summarizes; the strategy itself needs no change — see [Middleware hooks](middleware-hooks.md).
126
+
125
127
  ## Security and performance notes
126
128
  Preparation is O(n) over branch entries and uses only arrays, strings, and JSON serialization. Limit options must be positive safe integers at or below their hard caps and reject during strategy creation. Missing output options use a 16,384-token summary ceiling; reserve ratio/model metadata may narrow the provider request, never remove its finite `maxTokens`. A request policy that replaces `maxTokens` with NaN, Infinity, zero, an unsafe integer, or above-hard-cap input fails before provider generation.
127
129