@aeondave/pi-persona 1.17.3 → 1.17.5

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 (93) hide show
  1. package/AGENTS.md +57 -36
  2. package/CHANGELOG.md +106 -1
  3. package/README.md +32 -16
  4. package/docs/ARCHITECTURE.md +199 -48
  5. package/docs/COLLABORATION.md +110 -0
  6. package/docs/EXPERIENCE.md +33 -8
  7. package/docs/README.md +12 -0
  8. package/docs/REFERENCE.md +65 -14
  9. package/docs/STRATEGIES.md +6 -0
  10. package/package.json +12 -6
  11. package/src/agents/agent.ts +6 -0
  12. package/src/bridge.ts +33 -23
  13. package/src/bus/broker/client.ts +151 -30
  14. package/src/bus/broker/host.ts +130 -11
  15. package/src/bus/broker/messages.ts +35 -5
  16. package/src/bus/contact-transport.ts +8 -0
  17. package/src/bus/contact.ts +118 -40
  18. package/src/bus/peers.ts +23 -6
  19. package/src/bus/result-reader.ts +88 -0
  20. package/src/core/brief.ts +14 -4
  21. package/src/core/capabilities.ts +1 -1
  22. package/src/core/companion.ts +64 -0
  23. package/src/core/leg-thinking.ts +57 -0
  24. package/src/core/ownership.ts +1 -2
  25. package/src/core/pi-compat.ts +5 -12
  26. package/src/core/session-runs.ts +1 -1
  27. package/src/core/types.ts +1 -1
  28. package/src/engine/adapter.ts +100 -20
  29. package/src/engine/async.ts +53 -11
  30. package/src/engine/child.ts +11 -2
  31. package/src/engine/inproc.ts +98 -47
  32. package/src/engine/result-grant.ts +62 -0
  33. package/src/engine/session-tools.ts +6 -0
  34. package/src/engine/stream.ts +6 -0
  35. package/src/engine/worktree.ts +17 -7
  36. package/src/exocom/collaboration-delivery.ts +29 -0
  37. package/src/exocom/collaboration.ts +148 -0
  38. package/src/exocom/envelope.ts +43 -4
  39. package/src/exocom/gate.ts +7 -2
  40. package/src/exocom/install.ts +45 -17
  41. package/src/exocom/ledger.ts +58 -8
  42. package/src/exocom/plane.ts +32 -1
  43. package/src/exocom/profile.ts +40 -0
  44. package/src/exocom/registry.ts +5 -0
  45. package/src/exocom/status.ts +11 -1
  46. package/src/exocom/write-guard.ts +7 -0
  47. package/src/extension/broker-host.ts +61 -9
  48. package/src/extension/companion.ts +38 -0
  49. package/src/extension/engine.ts +40 -29
  50. package/src/extension/hooks.ts +954 -931
  51. package/src/extension/identity.ts +41 -75
  52. package/src/extension/memory-owner.ts +54 -0
  53. package/src/extension/mind-worker.ts +129 -0
  54. package/src/extension/results.ts +138 -0
  55. package/src/extension/shared.ts +17 -0
  56. package/src/extension.ts +2922 -2849
  57. package/src/loader.ts +11 -3
  58. package/src/orchestration/agent-attempt.ts +15 -0
  59. package/src/orchestration/flow-journal.ts +7 -1
  60. package/src/orchestration/flow-run.ts +125 -19
  61. package/src/orchestration/flow.ts +11 -0
  62. package/src/orchestration/roster.ts +71 -23
  63. package/src/orchestration/sdk.ts +61 -19
  64. package/src/orchestration/types.ts +10 -0
  65. package/src/persona/model-participants.ts +20 -7
  66. package/src/persona/orchestrate.ts +6 -7
  67. package/src/persona/persona.ts +1 -1
  68. package/src/results/archive.ts +281 -0
  69. package/src/results/record.ts +166 -0
  70. package/src/results/retrieval.ts +142 -0
  71. package/src/results/types.ts +128 -0
  72. package/src/telemetry/contract.ts +1 -1
  73. package/src/tools/council.ts +40 -23
  74. package/src/tools/delegate-tool.ts +50 -14
  75. package/src/tools/delegate.ts +13 -6
  76. package/src/tools/exocom-collaboration-input.ts +168 -0
  77. package/src/tools/exocom-collaboration.ts +315 -0
  78. package/src/tools/exocom-profile.ts +102 -0
  79. package/src/tools/exocom.ts +20 -3
  80. package/src/tools/flow.ts +24 -8
  81. package/src/tools/intercom-tool.ts +98 -14
  82. package/src/tools/models.ts +15 -0
  83. package/src/tools/monitor.ts +20 -1
  84. package/src/tools/read-result.ts +52 -0
  85. package/src/tools/timer.ts +23 -2
  86. package/src/ui/activity-rows.ts +34 -0
  87. package/src/ui/agent-overlay.ts +23 -13
  88. package/src/ui/agent-tree.ts +67 -6
  89. package/src/ui/literal-tool-result.ts +1 -1
  90. package/src/ui/model-picker.ts +1 -1
  91. package/src/ui/orchestration-launch.ts +49 -0
  92. package/src/ui/session-cards.ts +191 -14
  93. package/src/ui/strategy-leg.ts +40 -0
@@ -5,6 +5,7 @@
5
5
  * exact same surface as future user-authored strategy files.
6
6
  */
7
7
 
8
+ import type { ThinkingLevel } from "../core/types.ts";
8
9
  import type { RunLimits } from "../core/capabilities.ts";
9
10
  import { type ChildUsage, emptyUsage, type ToolEvent } from "../engine/stream.ts";
10
11
  import { type JudgePrep, prepareJudge } from "./judge.ts";
@@ -12,6 +13,7 @@ import { planModelRecovery } from "./model-retry.ts";
12
13
  import { mapWithConcurrency, Semaphore } from "./parallel.ts";
13
14
  import { aggregateResults, sumUsage } from "./reducers.ts";
14
15
  import { roleHint, type RosterMember } from "./roster.ts";
16
+ import { agentAttemptIdentity, type AgentAttemptIdentity } from "./agent-attempt.ts";
15
17
  import type { AgentResult, ModelRecovery } from "./types.ts";
16
18
  import { type ReducerResult, type VoteOpts, voteReduce } from "./voting.ts";
17
19
 
@@ -22,6 +24,8 @@ export interface AgentRunSpec {
22
24
  name?: string;
23
25
  task: string;
24
26
  model?: string;
27
+ /** Requested per-leg effort, before Pi applies the selected model's capability clamp. */
28
+ thinking?: ThinkingLevel;
25
29
  tools?: string[];
26
30
  /** Skills the sub-agent must load first (dynamic specialisation). */
27
31
  skills?: string[];
@@ -49,6 +53,22 @@ export interface AgentRunSpec {
49
53
  peers?: boolean;
50
54
  /** Declared ownership (from delegate `writeSet`), used only to annotate the files-changed report. */
51
55
  writeSet?: string[];
56
+ /** INTERNAL (flow reference handoff): the archived `result:<UUID>` refs granted to THIS leg
57
+ * — exactly its phase's DIRECT upstream needs, nothing more. Not a public delegate
58
+ * argument: the SDK strips any strategy-supplied value (strategies cannot enlarge a
59
+ * grant); only the trusted engine-seam wrapper (`withResultRefs`, set by the flow wiring)
60
+ * or an equivalently trusted host path may set it. Both engines preflight + scope the
61
+ * `read_result` tool on it. */
62
+ resultRefs?: readonly string[];
63
+ }
64
+
65
+ /** Pin a leg's granted result refs at the ENGINE seam — the trusted wiring path for flow
66
+ * reference mode. Overrides (never unions) whatever the caller passed, so a strategy cannot
67
+ * smuggle extra refs past the SDK's strip. An empty array is an explicit "none". */
68
+ export function withResultRefs(engine: StrategyEngine, resultRefs: readonly string[]): StrategyEngine {
69
+ return {
70
+ run: (spec, onProgress, signal, onSteer) => engine.run({ ...spec, resultRefs }, onProgress, signal, onSteer),
71
+ };
52
72
  }
53
73
 
54
74
  /** Is `x` a genuine positive, finite `timeoutMs` override — vs. absent/junk (undefined, NaN,
@@ -180,15 +200,17 @@ export interface SDKDeps {
180
200
  /** Per-agent lifecycle, for live UI. The result is passed on done/failed so the
181
201
  * UI can capture each agent's output/usage. `key` is a run-unique display id (the
182
202
  * `agent` name for a solo member, disambiguated by role/occurrence when the same agent
183
- * runs several times in one roster) — key the UI node by it, not by `agent`, or three
184
- * same-agent roster-role members collapse into one node. Falls back to `agent`. */
185
- onAgentStatus?: (agent: string, status: AgentStatus, result?: AgentResult, key?: string) => void;
203
+ * runs several times in one roster). The frozen `attempt` carries exact full-role/model/effort
204
+ * identity: never recover those fields by parsing this shortened display key. */
205
+ onAgentStatus?: (agent: string, status: AgentStatus, result: AgentResult | undefined, key: string, attempt: AgentAttemptIdentity) => void;
206
+ /** One final logical leg after model recovery, never intermediate attempts. Storage is non-fatal. */
207
+ onAgentResult?: (result: AgentResult, key: string, attempt: AgentAttemptIdentity) => void;
186
208
  /** Per-agent streaming progress (rolling output), for live UI. See `onAgentStatus.key`. */
187
- onAgentProgress?: (agent: string, progress: AgentProgress, key?: string) => void;
209
+ onAgentProgress?: (agent: string, progress: AgentProgress, key: string, attempt: AgentAttemptIdentity) => void;
188
210
  /** Called as each agent starts with a handle to abort just that agent (for UI stop). */
189
- onAgentStart?: (agent: string, abort: () => void, key?: string) => void;
211
+ onAgentStart?: (agent: string, abort: () => void, key: string, attempt: AgentAttemptIdentity) => void;
190
212
  /** Called once an agent is live with a handle to steer it (in-process engine only). */
191
- onAgentSteerable?: (agent: string, steer: SteerFn, key?: string) => void;
213
+ onAgentSteerable?: (agent: string, steer: SteerFn, key: string, attempt: AgentAttemptIdentity) => void;
192
214
  /** Emitted immediately BEFORE a leg is re-run on the main model after a model-caused failure,
193
215
  * so live UI can say so while it happens (`key` is the same logical node as the attempt it
194
216
  * replaces). Purely cosmetic: a throw here is contained and can never discard the billed
@@ -241,6 +263,13 @@ function abortedLeg(agent: string): AgentResult {
241
263
  return { agent, output: "", usage: emptyUsage(), ok: false, error: "agent aborted", failureKind: "abort" };
242
264
  }
243
265
 
266
+ /** Copy of a spec with its `resultRefs` removed — the SDK's one strip point, so a strategy's
267
+ * own value never reaches an engine (only `withResultRefs`-style trusted wiring sets it). */
268
+ function stripResultRefs(spec: AgentRunSpec): AgentRunSpec {
269
+ const { resultRefs: _untrusted, ...rest } = spec;
270
+ return rest;
271
+ }
272
+
244
273
  export function makeSDK(deps: SDKDeps): StrategySDK {
245
274
  // Run-scoped enforcement of the declared limits — applied here so NO strategy can
246
275
  // exceed them, however it calls agent() (I2: safety from runtime limits, not isolation).
@@ -269,18 +298,27 @@ export function makeSDK(deps: SDKDeps): StrategySDK {
269
298
  };
270
299
 
271
300
  return {
272
- agent: async (spec, options) => {
301
+ agent: async (requested, options) => {
302
+ // Reference-handoff grants are trusted wiring, never strategy input: drop any
303
+ // strategy-supplied `resultRefs` here (the flow path re-pins them at the engine
304
+ // seam via `withResultRefs`, which runs BELOW this layer).
305
+ const spec = requested.resultRefs === undefined ? requested : stripResultRefs(requested);
306
+ const key = uiKeyFor(spec);
307
+ let currentAttempt = agentAttemptIdentity(spec);
308
+ const finish = (result: AgentResult): AgentResult => {
309
+ try { deps.onAgentResult?.(result, key, currentAttempt); } catch { /* storage must not discard a completed leg */ }
310
+ return result;
311
+ };
273
312
  if (deps.canSpawn && !deps.canSpawn(spec.agent)) {
274
- return {
313
+ return finish({
275
314
  agent: spec.agent,
276
315
  output: "",
277
316
  usage: emptyUsage(),
278
317
  ok: false,
279
318
  error: `persona may not spawn "${spec.agent}"`,
280
319
  failureKind: "contract",
281
- };
320
+ });
282
321
  }
283
- const key = uiKeyFor(spec);
284
322
  const pending = Symbol();
285
323
  pendingAgents.add(pending);
286
324
  let started = false;
@@ -302,6 +340,8 @@ export function makeSDK(deps: SDKDeps): StrategySDK {
302
340
  const detachRoot: Array<() => void> = [];
303
341
  let announcedStart = false;
304
342
  const attempt = async (aSpec: AgentRunSpec): Promise<AgentResult> => {
343
+ const identity = agentAttemptIdentity(aSpec);
344
+ currentAttempt = identity;
305
345
  const ac = current ?? new AbortController();
306
346
  current = ac;
307
347
  if (deps.signal) {
@@ -312,9 +352,9 @@ export function makeSDK(deps: SDKDeps): StrategySDK {
312
352
  }
313
353
  if (!announcedStart) {
314
354
  announcedStart = true;
315
- deps.onAgentStart?.(aSpec.agent, () => current?.abort(), key);
355
+ deps.onAgentStart?.(aSpec.agent, () => current?.abort(), key, identity);
316
356
  }
317
- deps.onAgentStatus?.(aSpec.agent, "running", undefined, key);
357
+ deps.onAgentStatus?.(aSpec.agent, "running", undefined, key, identity);
318
358
  // A slot grant is not an engine start. Root or local cancellation in a lifecycle
319
359
  // callback must prevent the engine invocation, not merely hand it an aborted signal.
320
360
  if (ac.signal.aborted) {
@@ -330,9 +370,9 @@ export function makeSDK(deps: SDKDeps): StrategySDK {
330
370
  try {
331
371
  result = await deps.engine.run(
332
372
  aSpec,
333
- onProgress ? (p) => onProgress(aSpec.agent, p, key) : undefined,
373
+ onProgress ? (p) => onProgress(aSpec.agent, p, key, identity) : undefined,
334
374
  ac.signal,
335
- deps.onAgentSteerable ? (steer) => deps.onAgentSteerable?.(aSpec.agent, steer, key) : undefined,
375
+ deps.onAgentSteerable ? (steer) => deps.onAgentSteerable?.(aSpec.agent, steer, key, identity) : undefined,
336
376
  );
337
377
  } catch (err) {
338
378
  // PER-LEG failure — contained, never rethrown: one blown engine call must not discard
@@ -349,7 +389,7 @@ export function makeSDK(deps: SDKDeps): StrategySDK {
349
389
  };
350
390
  try {
351
391
  const first = await attempt(spec);
352
- deps.onAgentStatus?.(spec.agent, first.ok ? "done" : "failed", first, key);
392
+ deps.onAgentStatus?.(spec.agent, first.ok ? "done" : "failed", first, key, currentAttempt);
353
393
 
354
394
  // ── Main-only model recovery ──────────────────────────────────────────────
355
395
  // A model-caused failure (`provider`/`unknown-model`) gets exactly ONE re-run on the
@@ -391,7 +431,7 @@ export function makeSDK(deps: SDKDeps): StrategySDK {
391
431
  // a fresh controller or spend another child slot after it has been stopped.
392
432
  if (deps.signal?.aborted || current?.signal.aborted) {
393
433
  const stopped = { ...abortedLeg(spec.agent), usage: first.usage };
394
- deps.onAgentStatus?.(spec.agent, "failed", stopped, key);
434
+ deps.onAgentStatus?.(spec.agent, "failed", stopped, key, currentAttempt);
395
435
  return stopped;
396
436
  }
397
437
  // The retry is a REAL attempt: admitted and charged like any other child, but inside
@@ -408,18 +448,20 @@ export function makeSDK(deps: SDKDeps): StrategySDK {
408
448
  modelRecovery: recovery,
409
449
  ...(!second.ok ? { error: `Initial model ${plan.from ?? "(unresolved)"} failed: ${first.error ?? first.failureKind ?? "unknown cause"}; main model ${plan.to} failed: ${second.error ?? second.failureKind ?? "unknown cause"}` } : {}),
410
450
  };
411
- deps.onAgentStatus?.(spec.agent, settled.ok ? "done" : "failed", settled, key);
451
+ deps.onAgentStatus?.(spec.agent, settled.ok ? "done" : "failed", settled, key, currentAttempt);
412
452
  return settled;
413
453
  } finally {
414
454
  for (const detach of detachRoot) detach();
415
455
  }
416
456
  };
417
457
  try {
418
- return await agentSlots.with(run, deps.signal);
458
+ return finish(await agentSlots.with(run, deps.signal));
419
459
  } catch (error) {
420
460
  // A queued leg has no engine result to report. Settle it as an abort when the
421
461
  // run signal removed its waiter; all other errors retain their terminal semantics.
422
- if (!started && deps.signal?.aborted) return abortedLeg(spec.agent);
462
+ if (!started && deps.signal?.aborted) {
463
+ return finish(abortedLeg(spec.agent));
464
+ }
423
465
  throw error;
424
466
  } finally {
425
467
  pendingAgents.delete(pending);
@@ -2,7 +2,9 @@
2
2
  * Orchestration-layer types shared by the SDK, strategies, and reducers.
3
3
  */
4
4
 
5
+ import type { ThinkingLevel } from "../core/types.ts";
5
6
  import type { ChildUsage } from "../engine/stream.ts";
7
+ import type { ResultReceipt } from "../results/retrieval.ts";
6
8
 
7
9
  export type { ChildUsage };
8
10
 
@@ -33,11 +35,19 @@ export interface AgentResult {
33
35
  output: string;
34
36
  /** Validated structured fields (when the agent ran against a contract). */
35
37
  structured?: Record<string, unknown>;
38
+ /** Terminal archive observation; memory-only is not a durable receipt. */
39
+ resultReceipt?: ResultReceipt;
36
40
  usage: ChildUsage;
37
41
  ok: boolean;
38
42
  error?: string;
39
43
  /** The canonical `provider/id` the run actually used (drives provider fallback + UI). */
40
44
  modelUsed?: string;
45
+ /** Selected effort before Pi's model-capability clamp, when a selection is known. */
46
+ thinkingRequested?: ThinkingLevel;
47
+ /** Observed Pi effort (session readback or final assistant-message thinkingLevel).
48
+ * This is Pi's requested effort, NOT evidence of the provider's actual cognition.
49
+ * Absent when the host exposes no validated observation; never inferred from the request. */
50
+ thinkingEffective?: ThinkingLevel;
41
51
  /** Why it failed (set only when `ok` is false) — drives the model-fallback decision. */
42
52
  failureKind?: FailureKind;
43
53
  /** Set when this result comes from a recovered retry (see {@link ModelRecovery}). Its `usage`
@@ -29,6 +29,7 @@ import { createHash } from "node:crypto";
29
29
 
30
30
  import { memberBaseLabel, parseRosterMember, type RosterMember, type RosterSpec } from "../orchestration/roster.ts";
31
31
  import { knownParams } from "../orchestration/strategy.ts";
32
+ import type { ThinkingLevel } from "../core/types.ts";
32
33
  import type { StrategyParam } from "../orchestration/sdk.ts";
33
34
 
34
35
  /** Where a participant's effective model came from — in precedence order. */
@@ -58,6 +59,12 @@ export interface ModelParticipant {
58
59
  model?: string;
59
60
  /** Which step of the precedence supplied `model`. */
60
61
  source: ModelSource;
62
+ /** The member's SPEC-level effort — exactly the value that lands in `AgentRunSpec.thinking`
63
+ * when a strategy spreads `rosterSpec(member)`. Never resolved against agent defaults or the
64
+ * configured child effort (the engines own that precedence): the row shows what the launch
65
+ * spec carried, the same rule the delegate launch rows follow. Absent when none was declared
66
+ * (absence renders nothing); an explicit `off` is a declared value and is kept. */
67
+ thinking?: ThinkingLevel;
61
68
  /** Someone already chose this participant's model (inline, a saved assignment, or the agent's own
62
69
  * frontmatter) ⇒ never a picker candidate. The session default is deliberately NOT counted. */
63
70
  assigned: boolean;
@@ -135,6 +142,7 @@ function fromSpec(spec: RosterSpec, origin: "roster" | "param", param: string |
135
142
  ...(param ? { param } : {}),
136
143
  ...(model ? { model } : {}),
137
144
  source,
145
+ ...(spec.thinking ? { thinking: spec.thinking } : {}),
138
146
  assigned: source === "inline" || source === "assignment" || source === "agent",
139
147
  };
140
148
  }
@@ -191,16 +199,13 @@ function isAgentName(value: unknown): boolean {
191
199
  return typeof value === "string" && value.trim().length > 0 && !/\s/.test(value.trim());
192
200
  }
193
201
 
194
- /** Every participant a run really spawns: the roster first (in run order), then auxiliary actors. */
195
- export function collectModelParticipants(input: ParticipantInput): ModelParticipant[] {
202
+ /** Every declared execution occurrence, including repeated assignment keys with different pins. */
203
+ export function collectModelParticipantOccurrences(input: ParticipantInput): ModelParticipant[] {
196
204
  const out: ModelParticipant[] = [];
197
- const seen = new Set<string>();
198
205
  for (const raw of input.members ?? []) {
199
206
  const spec = toSpec(raw);
200
207
  if (!spec) continue;
201
208
  const p = fromSpec(spec, "roster", undefined, input);
202
- if (seen.has(p.key)) continue;
203
- seen.add(p.key);
204
209
  out.push(p);
205
210
  }
206
211
  const schema = input.schema ?? (input.strategy ? knownParams(input.strategy) : undefined);
@@ -210,14 +215,22 @@ export function collectModelParticipants(input: ParticipantInput): ModelParticip
210
215
  const spec = auxActorSpec(input, paramName, param);
211
216
  if (!spec) continue;
212
217
  const p = fromSpec(spec, "param", paramName, input);
213
- if (seen.has(p.key)) continue;
214
- seen.add(p.key);
215
218
  out.push(p);
216
219
  }
217
220
  }
218
221
  return out;
219
222
  }
220
223
 
224
+ /** One row per assignment key for model setup. Repeated execution occurrences share that choice. */
225
+ export function collectModelParticipants(input: ParticipantInput): ModelParticipant[] {
226
+ const seen = new Set<string>();
227
+ return collectModelParticipantOccurrences(input).filter((p) => {
228
+ if (seen.has(p.key)) return false;
229
+ seen.add(p.key);
230
+ return true;
231
+ });
232
+ }
233
+
221
234
  /** The participants a picker may still ask about: nobody has chosen a model for them, and this
222
235
  * session has not already asked (and been dismissed) for that key. The caller records the keys it
223
236
  * asks about BEFORE asking, so a dismissal is remembered too. */
@@ -7,11 +7,8 @@
7
7
  import type { RunLimits } from "../core/capabilities.ts";
8
8
  import { makeRoster, type RosterMember } from "../orchestration/roster.ts";
9
9
  import {
10
- type AgentProgress,
11
- type AgentStatus,
12
10
  makeSDK,
13
11
  type SDKDeps,
14
- type SteerFn,
15
12
  type StrategyEngine,
16
13
  type StrategyInput,
17
14
  } from "../orchestration/sdk.ts";
@@ -72,13 +69,14 @@ export interface RunStrategyDeps {
72
69
  log?: (message: string) => void;
73
70
  /** Per-agent lifecycle, for live UI (which roster agent is running/done + its result).
74
71
  * `key` is a run-unique display id (disambiguates same-agent roster-role members). */
75
- onAgentStatus?: (agent: string, status: AgentStatus, result?: AgentResult, key?: string) => void;
72
+ onAgentStatus?: SDKDeps["onAgentStatus"];
73
+ onAgentResult?: SDKDeps["onAgentResult"];
76
74
  /** Per-agent streaming progress (rolling output), for live UI. */
77
- onAgentProgress?: (agent: string, progress: AgentProgress, key?: string) => void;
75
+ onAgentProgress?: SDKDeps["onAgentProgress"];
78
76
  /** Called as each agent starts with a handle to abort just that agent (UI stop). */
79
- onAgentStart?: (agent: string, abort: () => void, key?: string) => void;
77
+ onAgentStart?: SDKDeps["onAgentStart"];
80
78
  /** Called once an agent is live with a handle to steer it (in-process engine only). */
81
- onAgentSteerable?: (agent: string, steer: SteerFn, key?: string) => void;
79
+ onAgentSteerable?: SDKDeps["onAgentSteerable"];
82
80
  /** See `SDKDeps.canSpawn` — every strategy spawn, not only `delegate`. */
83
81
  canSpawn?: (agent: string) => boolean;
84
82
  }
@@ -110,6 +108,7 @@ export async function runPersonaStrategy(
110
108
  if (deps.onModelFallback) sdkDeps.onModelFallback = deps.onModelFallback;
111
109
  if (deps.log) sdkDeps.log = deps.log;
112
110
  if (deps.onAgentStatus) sdkDeps.onAgentStatus = deps.onAgentStatus;
111
+ if (deps.onAgentResult) sdkDeps.onAgentResult = deps.onAgentResult;
113
112
  if (deps.onAgentProgress) sdkDeps.onAgentProgress = deps.onAgentProgress;
114
113
  if (deps.onAgentStart) sdkDeps.onAgentStart = deps.onAgentStart;
115
114
  if (deps.onAgentSteerable) sdkDeps.onAgentSteerable = deps.onAgentSteerable;
@@ -244,7 +244,7 @@ export function expandCouncilPreset(draft: CouncilDraft, presets: Record<string,
244
244
  /** Per-call council selection. `persona` borrows only that persona's expanded
245
245
  * council declaration; it never activates the persona or imports its prompt,
246
246
  * model, tools, or permissions. Explicit strategy/roster/params remain local
247
- * overrides for backward compatibility. */
247
+ * overrides of those defaults. */
248
248
  export interface CouncilInvocation {
249
249
  persona?: string | undefined;
250
250
  strategy?: string | undefined;
@@ -0,0 +1,281 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ createTerminalResultRecord, isResultRef, pageStoredBody, parseTerminalResultInput,
4
+ parseTerminalResultRecord, payloadBytes,
5
+ } from "./record.ts";
6
+ import {
7
+ TERMINAL_RESULT_ENTRY, RESULT_MAX_ENTRIES, RESULT_MAX_PAYLOAD_BYTES, RESULT_LIST_DEFAULT, RESULT_LIST_MAX,
8
+ type ArchiveEvidence, type ArchiveFailure, type ResultListRequest, type ResultPage, type ResultReadRequest,
9
+ type ResultStorage, type ResultSummary, type ResultSummaryPage, type ResultWarning, type SaveOutcome,
10
+ type TerminalResultArchiveDeps, type TerminalResultInput, type TerminalResultRecord,
11
+ } from "./types.ts";
12
+
13
+ interface RetainedResult {
14
+ record: TerminalResultRecord;
15
+ storage: ResultStorage;
16
+ storageError?: string;
17
+ }
18
+ interface SourceResult { data: unknown; index: number; retained?: RetainedResult }
19
+ type Lookup = { kind: "missing" } | { kind: "ambiguous" | "corrupt" } | {
20
+ kind: "found"; record: TerminalResultRecord; index: number; retained?: RetainedResult;
21
+ };
22
+
23
+ function identity(data: unknown): { ref?: string; originKey?: string } {
24
+ try {
25
+ if (data === null || typeof data !== "object") return {};
26
+ const value = data as Record<string, unknown>;
27
+ return {
28
+ ...(isResultRef(value.ref) ? { ref: value.ref } : {}),
29
+ ...(typeof value.originKey === "string" && value.originKey.length <= 1024 ? { originKey: value.originKey } : {}),
30
+ };
31
+ } catch { return {}; }
32
+ }
33
+
34
+ function summary(record: TerminalResultRecord, storage: ResultStorage): ResultSummary {
35
+ // Parse again to detach optional usage too; callers never receive our mutable state.
36
+ const copy = parseTerminalResultRecord(record)!;
37
+ const { body: _body, payloadHash: _hash, ...metadata } = copy;
38
+ return { ...metadata, storage };
39
+ }
40
+
41
+ function validQuery(value: unknown, keys: readonly string[]): boolean {
42
+ try {
43
+ if (value === null || typeof value !== "object") return false;
44
+ const proto: unknown = Object.getPrototypeOf(value);
45
+ if (proto !== Object.prototype && proto !== null) return false;
46
+ return Reflect.ownKeys(value).every((key) => {
47
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
48
+ return typeof key === "string" && keys.includes(key) && descriptor?.enumerable === true && "value" in descriptor;
49
+ });
50
+ } catch { return false; }
51
+ }
52
+
53
+ function errorDetail(error: unknown): string {
54
+ try {
55
+ const text = error instanceof Error ? error.message : String(error);
56
+ return text.slice(0, 2048);
57
+ } catch { return "Storage callback threw an unreadable error"; }
58
+ }
59
+
60
+ /** One session-file owner, fixed at construction by the adapter. No filesystem, Pi imports,
61
+ * replay, wake or worker lifecycle. Native history is scanned without caching report bodies;
62
+ * even over-quota/corrupt histories leave valid unrelated reports queryable. Local accepted
63
+ * records and uncertain append reservations are bounded by the SAME count/byte ceilings.
64
+ * The native entry array is a host-owned immutable snapshot: its initial length is pinned so
65
+ * a host that appends to that same array cannot double-charge our own reserved writes.
66
+ */
67
+ export class TerminalResultArchive {
68
+ private readonly deps: TerminalResultArchiveDeps;
69
+ private hydrated = false;
70
+ private hydrating = false;
71
+ private history: readonly unknown[] = [];
72
+ private historyLength = 0;
73
+ private historyCount = 0;
74
+ private chargedEntries = 0;
75
+ private chargedPayloadBytes: number | null = 0;
76
+ private localBytes = 0;
77
+ private readonly retained: RetainedResult[] = [];
78
+ private readonly warnings = new Set<ResultWarning>();
79
+ private saving = false;
80
+
81
+ constructor(deps: TerminalResultArchiveDeps) {
82
+ // Copy callbacks/flags: mutating a caller's dependency object cannot retarget this owner.
83
+ this.deps = { ...deps };
84
+ }
85
+
86
+ save(input: TerminalResultInput): SaveOutcome {
87
+ if (this.saving || this.hydrating) return this.failure("busy", "A terminal result save or history accounting is already in progress");
88
+ this.saving = true;
89
+ try { this.hydrate(); return this.saveInput(input); }
90
+ finally { this.saving = false; }
91
+ }
92
+
93
+ read(request: ResultReadRequest): ResultPage {
94
+ if (this.hydrating) return this.failure("busy", "Terminal result history accounting is in progress");
95
+ this.hydrate();
96
+ if (!validQuery(request, ["ref", "offset", "limit"]) || !isResultRef(request.ref)) {
97
+ return this.failure("invalid", "Expected result:<UUID> and an optional stored-body offset/limit");
98
+ }
99
+ const found = this.lookup("ref", request.ref);
100
+ if (found.kind === "missing") return this.failure("unavailable", "Result reference is unavailable in this owning session");
101
+ if (found.kind !== "found") return this.failure(found.kind, "Result identity is ambiguous or corrupt in this owning session");
102
+ const page = pageStoredBody(found.record, request.offset, request.limit);
103
+ if (!page) return this.failure("invalid", "Invalid stored-body UTF-16 offset or read limit (2..16000); surrogate pairs cannot be split");
104
+ return {
105
+ ok: true, summary: summary(found.record, this.storage(found.retained)), ...page,
106
+ ...this.evidence(found.record, found.retained),
107
+ };
108
+ }
109
+
110
+ list(request: ResultListRequest = {}): ResultSummaryPage {
111
+ if (this.hydrating) return this.failure("busy", "Terminal result history accounting is in progress");
112
+ this.hydrate();
113
+ if (!validQuery(request, ["cursor", "limit"])) return this.failure("invalid", "Expected a result-list query");
114
+ const cursor = request.cursor === undefined ? 0 : request.cursor;
115
+ const limit = request.limit === undefined ? RESULT_LIST_DEFAULT : request.limit;
116
+ if (!Number.isSafeInteger(cursor) || cursor < 0 || cursor > this.historyCount + this.retained.length
117
+ || !Number.isInteger(limit) || limit < 1 || limit > RESULT_LIST_MAX) {
118
+ return this.failure("invalid", "Invalid result list cursor or limit (1..50)");
119
+ }
120
+ const results: ResultSummary[] = [];
121
+ let nextCursor: number | undefined;
122
+ for (const source of this.sources()) {
123
+ if (source.index < cursor) continue;
124
+ const record = parseTerminalResultRecord(source.data);
125
+ if (!record) continue;
126
+ const found = this.lookup("ref", record.ref);
127
+ if (found.kind !== "found" || found.index !== source.index) continue;
128
+ if (results.length === limit) { nextCursor = source.index; break; }
129
+ results.push(summary(record, this.storage(found.retained)));
130
+ }
131
+ return { ok: true, results, ...(nextCursor === undefined ? {} : { nextCursor }), ...this.evidence() };
132
+ }
133
+
134
+ private saveInput(value: unknown): SaveOutcome {
135
+ const input = parseTerminalResultInput(value);
136
+ if (!input) return this.failure("invalid", "Invalid terminal result projection or metadata limits");
137
+ const existing = this.lookup("originKey", input.originKey);
138
+ if (existing.kind === "ambiguous" || existing.kind === "corrupt") {
139
+ return this.failure(existing.kind, "The origin already has an ambiguous or corrupt result");
140
+ }
141
+ if (existing.kind === "found") {
142
+ const candidate = createTerminalResultRecord(input, existing.record.ref, input.createdAt ?? existing.record.createdAt);
143
+ if (JSON.stringify(parseTerminalResultRecord(candidate)) !== JSON.stringify(existing.record)) {
144
+ return this.failure("conflict", "The immutable origin already belongs to a different terminal payload");
145
+ }
146
+ return this.receipt(existing.record, existing.retained, true);
147
+ }
148
+ let record: TerminalResultRecord;
149
+ try {
150
+ const ref = this.deps.makeRef === undefined ? `result:${randomUUID()}` : this.deps.makeRef();
151
+ if (!isResultRef(ref)) return this.failure("invalid", "Reference factory must return result:<UUID>");
152
+ const collision = this.lookup("ref", ref);
153
+ if (collision.kind !== "missing") return this.failure(collision.kind === "found" ? "conflict" : collision.kind, "Generated result reference is already occupied");
154
+ record = createTerminalResultRecord(input, ref, input.createdAt ?? (this.deps.clock === undefined ? Date.now() : this.deps.clock()));
155
+ } catch (error) { return this.failure("invalid", errorDetail(error)); }
156
+ const cost = payloadBytes(record);
157
+ if (this.chargedEntries >= RESULT_MAX_ENTRIES || this.localBytes + cost > RESULT_MAX_PAYLOAD_BYTES
158
+ || (this.chargedPayloadBytes !== null && this.chargedPayloadBytes + cost > RESULT_MAX_PAYLOAD_BYTES)) {
159
+ return this.failure("quota", "Terminal result lifetime payload/count quota exhausted; append-only storage is not reclaimed");
160
+ }
161
+ // Reserve before invoking host code. A throw may occur AFTER a physical/session commit.
162
+ // Never retry this origin into another entry or treat memory retention as proof of durability.
163
+ this.chargedEntries++;
164
+ if (this.chargedPayloadBytes !== null) this.chargedPayloadBytes += cost;
165
+ this.localBytes += cost;
166
+ const retained: RetainedResult = { record, storage: "memory-only" };
167
+ this.retained.push(retained);
168
+ if (this.deps.persistent && this.chargedPayloadBytes !== null) {
169
+ try {
170
+ this.deps.append(TERMINAL_RESULT_ENTRY, parseTerminalResultRecord(record)!);
171
+ retained.storage = "session-entry";
172
+ } catch (error) { retained.storageError = errorDetail(error); }
173
+ }
174
+ return this.receipt(record, retained, false);
175
+ }
176
+
177
+ private storage(retained: RetainedResult | undefined): ResultStorage {
178
+ return retained?.storage ?? (this.deps.persistent ? "session-entry" : "memory-only");
179
+ }
180
+
181
+ private receipt(record: TerminalResultRecord, retained: RetainedResult | undefined, idempotent: boolean): SaveOutcome {
182
+ const storage = this.storage(retained);
183
+ return {
184
+ ok: true, ref: record.ref, storage, summary: summary(record, storage), idempotent,
185
+ ...(retained?.storageError === undefined ? {} : { storageError: retained.storageError }),
186
+ ...this.evidence(record, retained),
187
+ };
188
+ }
189
+
190
+ private hydrate(): void {
191
+ if (this.hydrated) return;
192
+ this.hydrated = true;
193
+ this.hydrating = true;
194
+ if (!this.deps.persistent) this.warnings.add("no-session-storage");
195
+ try {
196
+ const entries = this.deps.entries();
197
+ if (!Array.isArray(entries)) throw new Error("Invalid owning-session entry list");
198
+ this.history = entries;
199
+ this.historyLength = entries.length;
200
+ for (const source of this.sources()) {
201
+ this.chargedEntries++;
202
+ try {
203
+ const cost = payloadBytes(source.data);
204
+ if (this.chargedPayloadBytes !== null) this.chargedPayloadBytes += cost;
205
+ if (!Number.isSafeInteger(this.chargedPayloadBytes)) this.disablePersistence();
206
+ } catch { this.disablePersistence(); }
207
+ if (!parseTerminalResultRecord(source.data)) this.warnings.add("invalid-history");
208
+ }
209
+ this.historyCount = this.chargedEntries;
210
+ } catch { this.disablePersistence(); }
211
+ finally { this.hydrating = false; }
212
+ }
213
+
214
+ private *sources(): Generator<SourceResult> {
215
+ let index = 0;
216
+ for (let i = 0; i < this.historyLength; i++) {
217
+ try {
218
+ const raw = this.history[i];
219
+ if (raw === null || typeof raw !== "object") continue;
220
+ const entry = raw as Record<string, unknown>;
221
+ if (entry.type !== "custom" || entry.customType !== TERMINAL_RESULT_ENTRY) continue;
222
+ // Count the entry even when reading its data throws.
223
+ let data: unknown;
224
+ try { data = entry.data; } catch { this.disablePersistence(); }
225
+ yield { data, index: index++ };
226
+ } catch { this.disablePersistence(); }
227
+ }
228
+ for (const retained of this.retained) yield { data: retained.record, index: index++, retained };
229
+ }
230
+
231
+ /** Two bounded-memory scans, not a report cache or an unbounded identity index. */
232
+ private lookup(key: "ref" | "originKey", value: string): Lookup {
233
+ let first: { record: TerminalResultRecord; source: SourceResult } | undefined;
234
+ for (const source of this.sources()) {
235
+ if (identity(source.data)[key] !== value) continue;
236
+ const record = parseTerminalResultRecord(source.data);
237
+ if (!record) return { kind: "corrupt" };
238
+ first = { record, source };
239
+ break;
240
+ }
241
+ if (!first) return { kind: "missing" };
242
+ const canonical = JSON.stringify(first.record);
243
+ for (const source of this.sources()) {
244
+ if (source.index === first.source.index) continue;
245
+ const id = identity(source.data);
246
+ if (id.ref !== first.record.ref && id.originKey !== first.record.originKey) continue;
247
+ const record = parseTerminalResultRecord(source.data);
248
+ if (!record) return { kind: "corrupt" };
249
+ if (JSON.stringify(record) !== canonical) {
250
+ this.warnings.add("ambiguous-history");
251
+ return { kind: "ambiguous" };
252
+ }
253
+ this.warnings.add("duplicate-history");
254
+ }
255
+ return {
256
+ kind: "found", record: first.record, index: first.source.index,
257
+ ...(first.source.retained === undefined ? {} : { retained: first.source.retained }),
258
+ };
259
+ }
260
+
261
+ private disablePersistence(): void {
262
+ this.chargedPayloadBytes = null;
263
+ this.warnings.add("accounting-unavailable");
264
+ }
265
+
266
+ private evidence(record?: TerminalResultRecord, retained?: RetainedResult): ArchiveEvidence {
267
+ const warnings = new Set(this.warnings);
268
+ if (record?.truncated) warnings.add("body-truncated");
269
+ if (retained?.storageError !== undefined) warnings.add("append-failed");
270
+ return {
271
+ accounting: {
272
+ chargedEntries: this.chargedEntries, chargedPayloadBytes: this.chargedPayloadBytes,
273
+ persistenceDisabled: !this.deps.persistent || this.chargedPayloadBytes === null,
274
+ }, warnings: [...warnings],
275
+ };
276
+ }
277
+
278
+ private failure(reason: ArchiveFailure["reason"], message: string): ArchiveFailure {
279
+ return { ok: false, reason, message, ...this.evidence() };
280
+ }
281
+ }