@aeondave/pi-persona 1.17.1 → 1.17.4

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 (146) hide show
  1. package/AGENTS.md +103 -64
  2. package/CHANGELOG.md +310 -1
  3. package/README.md +37 -23
  4. package/agents/architect.md +21 -0
  5. package/agents/balthasar.md +17 -22
  6. package/agents/casper.md +17 -23
  7. package/agents/evidence-verifier.md +31 -16
  8. package/agents/melchior.md +17 -22
  9. package/agents/operator.md +15 -55
  10. package/agents/research.md +14 -48
  11. package/agents/reviewer.md +20 -39
  12. package/agents/scout.md +14 -15
  13. package/agents/verifier.md +15 -16
  14. package/docs/ARCHITECTURE.md +280 -92
  15. package/docs/COLLABORATION.md +110 -0
  16. package/docs/EXPERIENCE.md +52 -17
  17. package/docs/MONITORS.md +62 -3
  18. package/docs/README.md +12 -0
  19. package/docs/REFERENCE.md +139 -38
  20. package/docs/SPINE.md +2 -2
  21. package/docs/STRATEGIES.md +23 -8
  22. package/docs/TELEMETRY.md +1 -0
  23. package/flows/deep-review.flow.json +1 -1
  24. package/package.json +15 -8
  25. package/personas/audit.md +28 -17
  26. package/personas/dev.md +30 -32
  27. package/personas/elite.md +223 -221
  28. package/personas/judge.md +25 -19
  29. package/personas/magi.md +26 -29
  30. package/personas/planner.md +31 -24
  31. package/personas/researcher.md +25 -26
  32. package/personas/swarm.md +35 -25
  33. package/personas/verify.md +30 -19
  34. package/src/agents/agent.ts +26 -0
  35. package/src/bridge.ts +28 -10
  36. package/src/bus/broker/client.ts +119 -21
  37. package/src/bus/broker/host.ts +123 -8
  38. package/src/bus/broker/messages.ts +31 -4
  39. package/src/bus/contact.ts +113 -38
  40. package/src/bus/peers.ts +15 -0
  41. package/src/bus/result-reader.ts +88 -0
  42. package/src/core/brief.ts +436 -353
  43. package/src/core/capabilities.ts +1 -1
  44. package/src/core/config.ts +15 -10
  45. package/src/core/leg-thinking.ts +57 -0
  46. package/src/core/model-catalog.ts +171 -0
  47. package/src/core/model-tier.ts +89 -0
  48. package/src/core/models.ts +13 -4
  49. package/src/core/monitor-output.ts +85 -0
  50. package/src/core/monitor.ts +34 -2
  51. package/src/core/nudge.ts +176 -23
  52. package/src/core/ownership.ts +1 -2
  53. package/src/core/pi-compat.ts +5 -12
  54. package/src/core/seed.ts +99 -6
  55. package/src/core/session-runs.ts +87 -0
  56. package/src/core/settings.ts +1 -1
  57. package/src/core/timer.ts +34 -9
  58. package/src/core/types.ts +1 -1
  59. package/src/engine/adapter.ts +95 -27
  60. package/src/engine/async.ts +213 -155
  61. package/src/engine/child.ts +12 -4
  62. package/src/engine/host-module.ts +12 -0
  63. package/src/engine/inproc.ts +99 -53
  64. package/src/engine/result-grant.ts +62 -0
  65. package/src/engine/session-tools.ts +6 -0
  66. package/src/engine/stream.ts +10 -20
  67. package/src/engine/worktree.ts +17 -7
  68. package/src/exocom/collaboration-delivery.ts +29 -0
  69. package/src/exocom/collaboration.ts +148 -0
  70. package/src/exocom/envelope.ts +43 -4
  71. package/src/exocom/gate.ts +7 -2
  72. package/src/exocom/install.ts +47 -18
  73. package/src/exocom/ledger.ts +58 -8
  74. package/src/exocom/plane.ts +32 -1
  75. package/src/exocom/profile.ts +40 -0
  76. package/src/exocom/registry.ts +5 -0
  77. package/src/exocom/status.ts +11 -1
  78. package/src/exocom/write-guard.ts +7 -0
  79. package/src/extension/broker-host.ts +61 -9
  80. package/src/extension/engine.ts +54 -28
  81. package/src/extension/hooks.ts +952 -868
  82. package/src/extension/identity.ts +41 -75
  83. package/src/extension/persona-model-settings.ts +31 -13
  84. package/src/extension/results.ts +136 -0
  85. package/src/extension/shared.ts +32 -7
  86. package/src/extension.ts +2965 -2611
  87. package/src/loader.ts +11 -3
  88. package/src/monitor/archive.ts +235 -0
  89. package/src/monitor/session.ts +21 -1
  90. package/src/orchestration/flow-journal.ts +33 -6
  91. package/src/orchestration/flow-run.ts +160 -32
  92. package/src/orchestration/flow.ts +20 -2
  93. package/src/orchestration/reducers.ts +9 -5
  94. package/src/orchestration/render.ts +1 -1
  95. package/src/orchestration/roster.ts +71 -23
  96. package/src/orchestration/sdk.ts +61 -11
  97. package/src/orchestration/strategies/compete.ts +5 -1
  98. package/src/orchestration/strategies/council-rounds.ts +2 -0
  99. package/src/orchestration/strategies/critic-loop.ts +3 -0
  100. package/src/orchestration/strategies/debate.ts +2 -0
  101. package/src/orchestration/strategies/fanout.ts +3 -2
  102. package/src/orchestration/strategies/judge.ts +5 -1
  103. package/src/orchestration/strategies/magi.ts +40 -23
  104. package/src/orchestration/strategies/map.ts +13 -3
  105. package/src/orchestration/strategies/pair.ts +2 -0
  106. package/src/orchestration/strategies/pipeline.ts +2 -0
  107. package/src/orchestration/strategies/synthesize.ts +27 -7
  108. package/src/orchestration/strategy.ts +31 -0
  109. package/src/orchestration/types.ts +10 -0
  110. package/src/persona/model-participants.ts +20 -7
  111. package/src/persona/orchestrate.ts +2 -0
  112. package/src/persona/persona.ts +8 -4
  113. package/src/results/archive.ts +281 -0
  114. package/src/results/record.ts +166 -0
  115. package/src/results/retrieval.ts +142 -0
  116. package/src/results/types.ts +128 -0
  117. package/src/telemetry/contract.ts +1 -1
  118. package/src/tools/background-run.ts +151 -0
  119. package/src/tools/council.ts +108 -66
  120. package/src/tools/delegate-tool.ts +349 -283
  121. package/src/tools/delegate.ts +35 -216
  122. package/src/tools/exocom-collaboration-input.ts +168 -0
  123. package/src/tools/exocom-collaboration.ts +315 -0
  124. package/src/tools/exocom-profile.ts +102 -0
  125. package/src/tools/exocom-work.ts +18 -53
  126. package/src/tools/exocom.ts +35 -22
  127. package/src/tools/flow.ts +103 -30
  128. package/src/tools/intercom-tool.ts +202 -128
  129. package/src/tools/intercom.ts +1 -1
  130. package/src/tools/models.ts +34 -9
  131. package/src/tools/monitor.ts +58 -6
  132. package/src/tools/read-result.ts +52 -0
  133. package/src/tools/timer.ts +34 -15
  134. package/src/ui/agent-overlay.ts +65 -16
  135. package/src/ui/agent-tree.ts +127 -19
  136. package/src/ui/compact-widget.ts +25 -0
  137. package/src/ui/failure-summary.ts +55 -0
  138. package/src/ui/literal-tool-result.ts +24 -0
  139. package/src/ui/model-picker.ts +158 -81
  140. package/src/ui/orchestration-launch.ts +49 -0
  141. package/src/ui/persona-settings.ts +9 -3
  142. package/src/ui/presentation.ts +24 -0
  143. package/src/ui/report-body.ts +63 -0
  144. package/src/ui/session-cards.ts +366 -0
  145. package/src/ui/wake-rows.ts +262 -0
  146. package/teams.yaml +5 -1
@@ -8,7 +8,8 @@ import { dirname } from "node:path";
8
8
 
9
9
  import { attributePeer } from "../core/fence.ts";
10
10
  import { findWriteSetOverlaps, writeSetPathError } from "../core/ownership.ts";
11
- import { isSemanticFrame, truncateForInject, type ExocomAsk, type ExocomClaim, type ExocomSemanticFrame } from "./envelope.ts";
11
+ import { frameSigningPayload, isSemanticFrame, truncateForInject, type ExocomAsk, type ExocomClaim, type ExocomSemanticFrame } from "./envelope.ts";
12
+ import { applyCollaborationEvent, collaborationStatus, emptyCollaboration, parseCollaboration, pruneCollaboration, releaseCollaboration, retainedCollaborationEvent, type CollaborationState } from "./collaboration.ts";
12
13
 
13
14
  export type LedgerEvent = ExocomSemanticFrame;
14
15
 
@@ -33,6 +34,8 @@ export interface LedgerState {
33
34
  /** Accepted ask ids, including completed asks, so ids cannot be recycled in this retained ledger window. */
34
35
  askIds: string[];
35
36
  seen: string[];
37
+ /** Optional only for older in-memory callers; current snapshots retain the bounded peer-work state. */
38
+ collaboration?: CollaborationState;
36
39
  }
37
40
 
38
41
  export type ApplyResult =
@@ -56,7 +59,7 @@ export const LEDGER_LIMITS = {
56
59
  } as const;
57
60
 
58
61
  export function emptyLedger(): LedgerState {
59
- return { claims: [], asks: [], answers: [], askIds: [], seen: [] };
62
+ return { claims: [], asks: [], answers: [], askIds: [], seen: [], collaboration: emptyCollaboration() };
60
63
  }
61
64
 
62
65
  function remember(state: LedgerState, msgId: string): LedgerState {
@@ -99,8 +102,31 @@ export function applyLedgerEvent(state: LedgerState, event: LedgerEvent): ApplyR
99
102
  }
100
103
  return { ok: false, error: "malformed ledger event" };
101
104
  }
105
+ const retained = retainedCollaborationEvent(state.collaboration ?? emptyCollaboration(), event.msg_id);
106
+ if (retained) return frameSigningPayload(retained) === frameSigningPayload(event)
107
+ ? { ok: true, state, duplicate: true } : { ok: false, error: "msg_id conflicts with a retained collaboration fact" };
102
108
  if (state.seen.includes(event.msg_id)) return { ok: true, state, duplicate: true };
103
109
  switch (event.kind) {
110
+ case "task_create":
111
+ case "task_take":
112
+ case "task_finish":
113
+ case "offer":
114
+ case "publish": {
115
+ const collaboration = state.collaboration ?? emptyCollaboration();
116
+ const applied = applyCollaborationEvent(collaboration, event);
117
+ if (!applied.ok) return applied;
118
+ let claims = state.claims;
119
+ const task = collaboration.tasks.find((candidate) => candidate.created.work_key === event.work_key);
120
+ if (event.kind === "task_take" && task?.created.write_set.length) {
121
+ if (claims.some((claim) => claim.work_key === event.work_key && claim.from_session === event.from_session)) return { ok: false, error: "task pickup conflicts with an existing owned claim on this work_key" };
122
+ const claimed = applyLedgerEvent(state, { kind: "claim", work_key: event.work_key, from_session: event.from_session, from_name: event.from_name,
123
+ write_set: [...task.created.write_set], slice: task.created.description.slice(0, 256), msg_id: event.msg_id, ts: event.ts });
124
+ if (!claimed.ok) return claimed;
125
+ claims = claimed.state.claims;
126
+ }
127
+ if (event.kind === "task_finish" && task?.taken) claims = claims.filter((claim) => claim.msg_id !== task.taken!.msg_id);
128
+ return { ok: true, state: remember({ ...state, claims, collaboration: applied.state }, event.msg_id) };
129
+ }
104
130
  case "claim": {
105
131
  if (state.claims.length >= LEDGER_LIMITS.maxClaims) return { ok: false, error: "ledger claim limit reached" };
106
132
  for (const path of event.write_set) {
@@ -138,12 +164,18 @@ export function applyLedgerEvent(state: LedgerState, event: LedgerEvent): ApplyR
138
164
  case "release": {
139
165
  const owned = state.claims.filter((claim) => claim.work_key === event.work_key && claim.from_session === event.from_session);
140
166
  const outbound = state.asks.filter((ask) => ask.work_key === event.work_key && ask.from_session === event.from_session);
141
- if (owned.length === 0 && outbound.length === 0) return { ok: false, error: "release: no owned claim or outbound ask on that work_key" };
167
+ const collaboration = state.collaboration ?? emptyCollaboration();
168
+ const ownsTask = collaboration.tasks.some((task) => task.created.work_key === event.work_key && !task.finished && !task.stale
169
+ && (task.taken?.from_session ?? task.created.from_session) === event.from_session);
170
+ if (owned.length === 0 && outbound.length === 0 && !ownsTask) return { ok: false, error: "release: no owned claim or outbound ask on that work_key" };
142
171
  const claims = state.claims.filter((claim) => !(claim.work_key === event.work_key && claim.from_session === event.from_session));
143
172
  const asks = state.asks.filter((ask) => !(ask.work_key === event.work_key && ask.from_session === event.from_session));
144
- return { ok: true, state: remember({ ...state, claims, asks }, event.msg_id) };
173
+ return { ok: true, state: remember({ ...state, claims, asks, collaboration: releaseCollaboration(collaboration, event.from_session, event.work_key, Date.parse(event.ts)) }, event.msg_id) };
174
+ }
175
+ case "progress": {
176
+ const applied = applyCollaborationEvent(state.collaboration ?? emptyCollaboration(), event);
177
+ return applied.ok ? { ok: true, state: remember({ ...state, collaboration: applied.state }, event.msg_id) } : applied;
145
178
  }
146
- case "progress": return { ok: true, state: remember(state, event.msg_id) };
147
179
  }
148
180
  }
149
181
 
@@ -154,6 +186,7 @@ interface LedgerSnapshot {
154
186
  answers: LedgerAnswer[];
155
187
  ask_ids: string[];
156
188
  seen: string[];
189
+ collaboration?: CollaborationState;
157
190
  }
158
191
 
159
192
  function token(value: unknown): value is string {
@@ -216,7 +249,15 @@ function parseSnapshot(raw: unknown): LedgerState | undefined {
216
249
  }
217
250
  const requiredAskIds = new Set(state.askIds);
218
251
  if (![...requiredAskIds].every((askId) => snapshot.ask_ids!.includes(askId))) return undefined;
219
- return { ...state, askIds: [...snapshot.ask_ids], seen: [...snapshot.seen] };
252
+ const collaboration = snapshot.collaboration === undefined ? emptyCollaboration() : parseCollaboration(snapshot.collaboration);
253
+ if (!collaboration) return undefined;
254
+ for (const task of collaboration.tasks) {
255
+ if (collaborationStatus(task) !== "taken" || !task.created.write_set.length) continue;
256
+ const claim = state.claims.find((candidate) => candidate.msg_id === task.taken?.msg_id);
257
+ if (!claim || claim.from_session !== task.taken?.from_session || claim.work_key !== task.created.work_key
258
+ || JSON.stringify(claim.write_set) !== JSON.stringify(task.created.write_set)) return undefined;
259
+ }
260
+ return { ...state, collaboration, askIds: [...snapshot.ask_ids], seen: [...snapshot.seen] };
220
261
  }
221
262
 
222
263
  function snapshotOf(state: LedgerState): LedgerSnapshot {
@@ -227,6 +268,7 @@ function snapshotOf(state: LedgerState): LedgerSnapshot {
227
268
  answers: state.answers,
228
269
  ask_ids: state.askIds,
229
270
  seen: state.seen,
271
+ collaboration: state.collaboration ?? emptyCollaboration(),
230
272
  };
231
273
  }
232
274
 
@@ -246,6 +288,8 @@ export function parseLedger(text: string): LedgerState {
246
288
  state = restored;
247
289
  continue;
248
290
  }
291
+ if (raw && typeof raw === "object" && typeof (raw as { kind?: unknown }).kind === "string"
292
+ && (raw as { kind: string }).kind.startsWith("ledger_snapshot_")) throw new Error("unsupported ledger snapshot version");
249
293
  if (!isSemanticFrame(raw)) continue;
250
294
  const result = applyLedgerEvent(state, raw);
251
295
  if (result.ok) state = result.state;
@@ -276,7 +320,7 @@ export function pruneLedger(state: LedgerState, options: LedgerPruneOptions): Le
276
320
  const asks = state.asks.filter((ask) => options.liveSessions.has(ask.from_session) && options.liveSessions.has(ask.to_session));
277
321
  const answers = state.answers.filter((answer) => isFresh(answer.ts, now, ttlMs)).slice(-LEDGER_LIMITS.maxResults);
278
322
  const askIds = [...new Set([...asks.map((ask) => ask.ask_id), ...answers.map((answer) => answer.ask_id)])];
279
- return { ...state, claims, asks, answers, askIds };
323
+ return { ...state, claims, asks, answers, askIds, collaboration: pruneCollaboration(state.collaboration ?? emptyCollaboration(), now, ttlMs, (session) => options.liveSessions.has(session)) };
280
324
  }
281
325
 
282
326
  /** The pending asks a prune dropped (a party left the pool) — the ids a waiter must be woken for. */
@@ -565,6 +609,8 @@ export function appendLedgerEvent(path: string, event: LedgerEvent): void {
565
609
  export interface LedgerCommitOptions {
566
610
  /** Applied under the same transaction lock before validating the new event. */
567
611
  prune?: LedgerPruneOptions;
612
+ /** Authoritative write admission, evaluated against the locked task definition (not a preflight read). */
613
+ canClaim?: boolean;
568
614
  /** Test/operations override; defaults to half the maximum journal size. */
569
615
  compactAtBytes?: number;
570
616
  /** Fault-injection seam for short-write and Windows replacement regression tests. */
@@ -578,7 +624,8 @@ export interface LedgerFileIO {
578
624
 
579
625
  function pruningChanged(before: LedgerState, after: LedgerState): boolean {
580
626
  return before.claims.length !== after.claims.length || before.asks.length !== after.asks.length
581
- || before.answers.length !== after.answers.length || before.askIds.length !== after.askIds.length;
627
+ || before.answers.length !== after.answers.length || before.askIds.length !== after.askIds.length
628
+ || JSON.stringify(before.collaboration) !== JSON.stringify(after.collaboration);
582
629
  }
583
630
 
584
631
  export function commitLedgerEvent(path: string, event: LedgerEvent, options: LedgerCommitOptions = {}): ApplyResult {
@@ -588,6 +635,9 @@ export function commitLedgerEvent(path: string, event: LedgerEvent, options: Led
588
635
  const sizeBeforeRecovery = recoverIncompleteTrailingRecordLocked(path);
589
636
  const loaded = loadLedger(path);
590
637
  const pruned = options.prune ? pruneLedger(loaded, options.prune) : loaded;
638
+ const writes = event.kind === "claim" || event.kind === "task_create" ? event.write_set.length > 0
639
+ : event.kind === "task_take" && (pruned.collaboration?.tasks.find((task) => task.created.work_key === event.work_key)?.created.write_set.length ?? 0) > 0;
640
+ if (options.canClaim === false && writes) return { ok: false, error: "this session cannot claim write paths in the selected scope" };
591
641
  const result = applyLedgerEvent(pruned, event);
592
642
  const compactAt = Number.isFinite(options.compactAtBytes) && options.compactAtBytes !== undefined
593
643
  ? Math.max(1, Math.floor(options.compactAtBytes)) : Math.floor(LEDGER_LIMITS.maxBytes / 2);
@@ -33,6 +33,7 @@ import { createFrameReader, encodeFrame } from "../bus/broker/framing.ts";
33
33
  import { frameSigningPayload, isExocomFrame, isSemanticFrame, nextHops, parseExocomArtifactDescriptor, truncateForInject, type AgentCard, type ExocomAck, type ExocomArtifactDescriptor, type ExocomBye, type ExocomFrame, type ExocomMessage, type ExocomNack, type ExocomSemanticFrame } from "./envelope.ts";
34
34
  import { EXOCOM } from "./limits.ts";
35
35
  import { exocomRoot } from "./paths.ts";
36
+ import { normalizeExocomProfile, type ExocomProfile } from "./profile.ts";
36
37
  import { isAlive, normalizePeerName, prune, readAll, removeEntryIfMatches, writeEntry, type RegistryEntry, type RegistryOwnership } from "./registry.ts";
37
38
 
38
39
  export interface ExocomIdentity {
@@ -398,6 +399,9 @@ export class ExocomPlane {
398
399
  private server: net.Server | undefined;
399
400
  private readonly unlinkEndpoint: () => void;
400
401
  private endpointOwned = false;
402
+ private profile: ExocomProfile | undefined;
403
+ /** Last successfully published metadata, so editing a profile cannot restore startup labels. */
404
+ private presence: RegistryEntry | undefined;
401
405
  /** Ownership of the registry entry successfully written by this plane. A plane
402
406
  * may share a session_id with a replacement, so session_id alone is not enough
403
407
  * to clean up safely. */
@@ -443,6 +447,17 @@ export class ExocomPlane {
443
447
  return normalizePeerName(this.deps.identity.name);
444
448
  }
445
449
 
450
+ /** The plane owns this transient, optional self-report for its live session. */
451
+ getProfile(): ExocomProfile | undefined {
452
+ return this.profile ? { ...this.profile, capabilities: [...this.profile.capabilities] } : undefined;
453
+ }
454
+
455
+ /** Update the live registry immediately; `undefined` withdraws the entire self-report. */
456
+ setProfile(profile: ExocomProfile | undefined): void {
457
+ this.profile = profile === undefined ? undefined : normalizeExocomProfile(profile);
458
+ if (this.registeredOwnership) this.heartbeat({ ...(this.presence ?? this.buildEntry()), heartbeat_at: new Date(this.now()).toISOString() });
459
+ }
460
+
446
461
  /** Messages this instance has successfully `send()`'d TO the given peer (ack settled), keyed by
447
462
  * the peer's session_id — 0 for a peer never sent to. The plane is the SINGLE source of these
448
463
  * counters — the widget reads them, it never keeps its own. */
@@ -480,6 +495,7 @@ export class ExocomPlane {
480
495
  prune(agentDir, hash, { now: this.now(), staleMs: EXOCOM.STALE_AFTER_MS });
481
496
  const entry = this.buildEntry();
482
497
  writeEntry(agentDir, hash, entry);
498
+ this.presence = entry;
483
499
  this.registeredOwnership = {
484
500
  session_id: entry.session_id,
485
501
  endpoint: entry.endpoint,
@@ -524,6 +540,7 @@ export class ExocomPlane {
524
540
  workspace_code: identity.workspace.code,
525
541
  workspace_label: identity.workspace.label,
526
542
  } : {}),
543
+ ...(this.profile ? { profile: this.getProfile()! } : {}),
527
544
  };
528
545
  }
529
546
 
@@ -535,8 +552,15 @@ export class ExocomPlane {
535
552
  * key exists only in this process. `session_id` is ours by construction, never the caller's. */
536
553
  heartbeat(entry: Omit<RegistryEntry, "public_key">): void {
537
554
  const { agentDir, hash, identity } = this.deps;
538
- const owned = { ...entry, session_id: identity.session_id, public_key: this.publicKey };
555
+ const { profile: _callerProfile, ...entryWithoutProfile } = entry;
556
+ const owned = {
557
+ ...entryWithoutProfile,
558
+ session_id: identity.session_id,
559
+ public_key: this.publicKey,
560
+ ...(this.profile ? { profile: this.getProfile()! } : {}),
561
+ };
539
562
  writeEntry(agentDir, hash, owned);
563
+ this.presence = owned;
540
564
  this.registeredOwnership = { session_id: owned.session_id, endpoint: owned.endpoint, public_key: owned.public_key };
541
565
  }
542
566
 
@@ -864,6 +888,11 @@ export class ExocomPlane {
864
888
  return;
865
889
  }
866
890
  case "claim":
891
+ case "task_create":
892
+ case "task_take":
893
+ case "task_finish":
894
+ case "offer":
895
+ case "publish":
867
896
  case "ask":
868
897
  case "answer":
869
898
  case "progress":
@@ -1112,5 +1141,7 @@ export class ExocomPlane {
1112
1141
  // spill. In a scope where Exocom is never enabled again, that is never: sweeping on the
1113
1142
  // way out bounds the retention without touching anything still inside its TTL.
1114
1143
  this.cleanupArtifacts();
1144
+ this.profile = undefined;
1145
+ this.presence = undefined;
1115
1146
  }
1116
1147
  }
@@ -0,0 +1,40 @@
1
+ /** Self-reported presence only; these fields carry no identity or authorization. */
2
+ export interface ExocomProfile {
3
+ capabilities: readonly string[];
4
+ availability?: "available" | "busy" | "away";
5
+ }
6
+
7
+ const UNSAFE_PROFILE_TEXT = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u200b-\u200f\u202a-\u202e\u2060-\u206f<>]/g;
8
+ const AVAILABILITIES = new Set(["available", "busy", "away"]);
9
+
10
+ function normalizeCapability(value: unknown): string | undefined {
11
+ if (typeof value !== "string") return undefined;
12
+ const clean = Array.from(value.normalize("NFKC").replace(UNSAFE_PROFILE_TEXT, " ").replace(/\s+/g, " ").trim())
13
+ .slice(0, 48).join("");
14
+ return clean || undefined;
15
+ }
16
+
17
+ /** Normalize untrusted registry or tool data into the bounded, display-safe profile contract. */
18
+ export function normalizeExocomProfile(value: unknown): ExocomProfile | undefined {
19
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
20
+ const input = value as Record<string, unknown>;
21
+ const capabilities: string[] = [];
22
+ const seen = new Set<string>();
23
+ if (Array.isArray(input.capabilities)) {
24
+ for (const value of input.capabilities) {
25
+ const capability = normalizeCapability(value);
26
+ if (!capability || seen.has(capability.toLowerCase())) continue;
27
+ seen.add(capability.toLowerCase());
28
+ capabilities.push(capability);
29
+ if (capabilities.length === 8) break;
30
+ }
31
+ }
32
+ const availability = typeof input.availability === "string" && AVAILABILITIES.has(input.availability)
33
+ ? input.availability as ExocomProfile["availability"]
34
+ : undefined;
35
+ if (capabilities.length === 0 && availability === undefined) return undefined;
36
+ return {
37
+ capabilities,
38
+ ...(availability ? { availability } : {}),
39
+ };
40
+ }
@@ -2,6 +2,7 @@
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { existsSync, lstatSync, linkSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { agentsDir, registryPath } from "./paths.ts";
5
+ import { normalizeExocomProfile, type ExocomProfile } from "./profile.ts";
5
6
 
6
7
  export interface RegistryEntry {
7
8
  /** `name` is display-only — NOT unique, NOT a lookup key. The registry FILE is keyed by
@@ -16,6 +17,8 @@ export interface RegistryEntry {
16
17
  workspace_id?: string;
17
18
  workspace_code?: string;
18
19
  workspace_label?: string;
20
+ /** Optional self-reported presence; it is descriptive and never grants capability. */
21
+ profile?: ExocomProfile;
19
22
  }
20
23
 
21
24
  /** The fields that prove which process owns a registry slot. `session_id` is the
@@ -57,6 +60,7 @@ export function normalizeRegistryEntry(value: unknown): RegistryEntry | undefine
57
60
  const contextPct = typeof e.context_pct === "number" && Number.isFinite(e.context_pct) ? Math.max(0, Math.min(100, e.context_pct)) : 0;
58
61
  const inbox = typeof e.inbox === "number" && Number.isFinite(e.inbox) ? Math.max(0, Math.min(1_000_000, Math.floor(e.inbox))) : 0;
59
62
  const publicKey = opaqueString(e.public_key, 256);
63
+ const profile = normalizeExocomProfile(e.profile);
60
64
  const hasWorkspaceMetadata = e.workspace_id !== undefined || e.workspace_code !== undefined || e.workspace_label !== undefined;
61
65
  let workspace: Pick<RegistryEntry, "workspace_id" | "workspace_code" | "workspace_label"> = {};
62
66
  if (hasWorkspaceMetadata) {
@@ -83,6 +87,7 @@ export function normalizeRegistryEntry(value: unknown): RegistryEntry | undefine
83
87
  inbox,
84
88
  heartbeat_at: new Date(Date.parse(heartbeatAt)).toISOString(),
85
89
  ...(publicKey && /^[A-Za-z0-9+/]+={0,2}$/.test(publicKey) ? { public_key: publicKey } : {}),
90
+ ...(profile ? { profile } : {}),
86
91
  ...workspace,
87
92
  };
88
93
  }
@@ -20,6 +20,7 @@ import type { LedgerAsk, LedgerClaim, LedgerState } from "./ledger.ts";
20
20
  import { formatDuration } from "../core/time.ts";
21
21
  import { fencePeer } from "../core/fence.ts";
22
22
  import { renderWriteSet, untrusted, UNTRUSTED_MAX } from "./untrusted.ts";
23
+ import { collaborationOfferStatus, collaborationStatus } from "./collaboration.ts";
23
24
 
24
25
  /** No section may hand the model an unbounded ledger — a long-lived scope with many claims stays
25
26
  * one bounded read, same discipline as the pending-ask block and the peer roster. */
@@ -72,8 +73,12 @@ export function formatLedgerStatus(
72
73
  const peerClaims = state.claims.filter((claim) => claim.from_session !== self).sort(byTsAscending);
73
74
  const asksForYou = state.asks.filter((ask) => ask.to_session === self).sort(byTsAscending);
74
75
  const yourAsks = state.asks.filter((ask) => ask.from_session === self).sort(byTsAscending);
76
+ const collaboration = state.collaboration;
77
+ const tasks = collaboration?.tasks ?? [];
78
+ const offers = collaboration?.offers ?? [];
79
+ const evidence = collaboration?.evidence ?? [];
75
80
 
76
- if (ownClaims.length === 0 && peerClaims.length === 0 && asksForYou.length === 0 && yourAsks.length === 0) {
81
+ if (ownClaims.length === 0 && peerClaims.length === 0 && asksForYou.length === 0 && yourAsks.length === 0 && tasks.length === 0 && offers.length === 0 && evidence.length === 0) {
77
82
  return "nothing claimed or pending";
78
83
  }
79
84
 
@@ -82,5 +87,10 @@ export function formatLedgerStatus(
82
87
  section("peer claims", peerClaims.map((claim) => claimRow(claim, self, labelFor, now)), { fence: true }),
83
88
  section("asks waiting for you", asksForYou.map((ask) => askRow(ask, ask.from_session, labelFor, now)), { fence: true }),
84
89
  section("your open asks", yourAsks.map((ask) => askRow(ask, ask.to_session, labelFor, now))),
90
+ ...(tasks.length ? [section("shared tasks (exocom_task list for detail)", tasks.map((task) =>
91
+ `${task.created.work_key} · ${collaborationStatus(task)} · ${resolveLabel(task.taken?.from_session ?? task.created.from_session, labelFor)}`), { fence: true })] : []),
92
+ ...(offers.length ? [section("optional invitations", offers.map((offer) =>
93
+ `${offer.offer_id} · task ${offer.work_key} · ${collaborationOfferStatus(offer, tasks, now)} · ${offer.to_session ? resolveLabel(offer.to_session, labelFor) : "open invitation"}`), { fence: true })] : []),
94
+ ...(evidence.length ? [`${evidence.length} retained evidence excerpt(s); discover with exocom_publish { action: "read", work_key: "<task>" }.`] : []),
85
95
  ].join("\n\n");
86
96
  }
@@ -64,6 +64,13 @@ export function writeWarningReason(path: string, claim: LedgerClaim, label: stri
64
64
  return `exocom: ${path} is inside ${who}'s open claim — coordinate with exocom_ask or claim it; call the tool again to proceed (this warning shows once per path)\n${fencePeer(`${slice}: ${writeSet}`)}`;
65
65
  }
66
66
 
67
+ /** The operator-TOAST variant of the same warning: its single authoritative line, sanitized and
68
+ * bounded. The fenced peer-authored detail below the newline stays model-facing (the tool
69
+ * result); a toast is one line and never carries the claim block. */
70
+ export function writeWarningToastReason(path: string, claim: LedgerClaim, label: string): string {
71
+ return untrusted(writeWarningReason(path, claim, label).split("\n")[0] ?? "", 300);
72
+ }
73
+
67
74
  /** Warn-once memory for the write guard. A bare unbounded set would grow for the life of the
68
75
  * install on a long session with many distinct claim+path pairs; caps at `maxEntries` (like the
69
76
  * sibling `SeenMessages` in guards.ts) and drops the oldest insertion first once exceeded. */
@@ -2,7 +2,8 @@
2
2
  * Supervisor-side broker lifecycle (spec B1-B7): the ONE cross-process host every child-engine
3
3
  * leg connects to. Started lazily on the first child-engine build; a failed bind is reported to
4
4
  * the user ONCE, children built while it is failed spawn without a bus endpoint (so they never
5
- * burn the connect backoff against a dead socket), and the next build retries.
5
+ * burn the connect backoff against a dead socket), and the next build retries. Result readers
6
+ * use the separate strict awaited preflight on this SAME host, never bus-less degradation.
6
7
  */
7
8
 
8
9
  import { mkdirSync } from "node:fs";
@@ -11,6 +12,7 @@ import { dirname } from "node:path";
11
12
  import type { EngineAdapterBroker } from "../engine/adapter.ts";
12
13
  import { type BrokerHost, startBrokerHost } from "../bus/broker/host.ts";
13
14
  import type { InProcessBus } from "../bus/inproc.ts";
15
+ import { RESULT_READ_TIMEOUT_MS, type PreparedResultReader, type ScopedReader } from "../bus/result-reader.ts";
14
16
 
15
17
  export interface SupervisorBrokerDeps {
16
18
  bus: InProcessBus;
@@ -49,6 +51,8 @@ export class SupervisorBroker {
49
51
  private readonly expected = new Set<string>();
50
52
  private readonly preHostSteers = new Map<string, string[]>();
51
53
  private readonly deps: SupervisorBrokerDeps;
54
+ private generation = 0;
55
+ private readonly preparingReaders = new Set<() => void>();
52
56
 
53
57
  constructor(deps: SupervisorBrokerDeps) {
54
58
  this.deps = deps;
@@ -82,6 +86,7 @@ export class SupervisorBroker {
82
86
  }
83
87
  }
84
88
  const start = this.deps.startHost ?? startBrokerHost;
89
+ const generation = this.generation;
85
90
  this.promise = start({
86
91
  bus: this.deps.bus,
87
92
  supervisorHandle: this.deps.supervisorHandle,
@@ -90,6 +95,7 @@ export class SupervisorBroker {
90
95
  });
91
96
  this.promise.then(
92
97
  (h) => {
98
+ if (generation !== this.generation) return; // close() owns the late host's teardown
93
99
  this.hostRef = h;
94
100
  this.lastError = undefined;
95
101
  this.warned = false;
@@ -98,6 +104,7 @@ export class SupervisorBroker {
98
104
  this.preHostSteers.clear();
99
105
  },
100
106
  (err) => {
107
+ if (generation !== this.generation) return;
101
108
  this.promise = undefined; // a later build retries
102
109
  this.lastError = err instanceof Error ? err.message : String(err);
103
110
  const dropped = [...this.preHostSteers.values()].reduce((n, texts) => n + texts.length, 0);
@@ -112,6 +119,46 @@ export class SupervisorBroker {
112
119
  );
113
120
  }
114
121
 
122
+ /** Strict reader preflight, unlike ordinary bus-less degradation. Call ensure(endpoint)
123
+ * first, then await this BEFORE adapter registration/spawn. The owner must revoke on
124
+ * settlement/spawn failure and close this broker when the owning session changes. */
125
+ async prepareResultReader(handle: string, reader: ScopedReader, signal?: AbortSignal): Promise<PreparedResultReader> {
126
+ const generation = this.generation;
127
+ let revoked = false;
128
+ const revoke = (): void => {
129
+ if (revoked) return;
130
+ revoked = true;
131
+ try { reader.revoke(); } catch { /* fixed diagnostic, even if cleanup throws */ }
132
+ };
133
+ const fail = (): Error => new Error("Result reader preparation failed.");
134
+ const hostPromise = this.promise;
135
+ if (!hostPromise || signal?.aborted) { revoke(); throw fail(); }
136
+ return new Promise<PreparedResultReader>((resolve, reject) => {
137
+ let settled = false;
138
+ const cleanup = (): void => {
139
+ settled = true;
140
+ clearTimeout(timer);
141
+ this.preparingReaders.delete(cancel);
142
+ signal?.removeEventListener("abort", cancel);
143
+ };
144
+ const cancel = (): void => {
145
+ if (settled) return;
146
+ revoke(); cleanup(); reject(fail());
147
+ };
148
+ const timer = setTimeout(cancel, RESULT_READ_TIMEOUT_MS);
149
+ timer.unref?.();
150
+ this.preparingReaders.add(cancel);
151
+ signal?.addEventListener("abort", cancel, { once: true });
152
+ void hostPromise.then(async (host) => {
153
+ if (settled) return;
154
+ const grant = await host.prepareResultReader(handle, { read: (request, s) => reader.read(request, s), revoke }, signal);
155
+ if (settled || generation !== this.generation || signal?.aborted) { grant.revoke(); cancel(); return; }
156
+ cleanup();
157
+ resolve(grant);
158
+ }).catch(cancel);
159
+ });
160
+ }
161
+
115
162
  /** The adapter-facing deps, or undefined while the last start failed (children spawn bus-less). */
116
163
  adapterDeps(endpoint: string): EngineAdapterBroker | undefined {
117
164
  const failedBefore = this.state() === "failed";
@@ -141,6 +188,7 @@ export class SupervisorBroker {
141
188
  return true;
142
189
  },
143
190
  hasPendingAskFrom: (handle) => this.deps.bus.hasPendingAskFrom(handle),
191
+ prepareResultReader: (handle, reader, signal) => this.prepareResultReader(handle, reader, signal),
144
192
  };
145
193
  }
146
194
 
@@ -164,18 +212,22 @@ export class SupervisorBroker {
164
212
  }
165
213
 
166
214
  async close(): Promise<void> {
167
- if (this.promise) {
168
- try {
169
- const h = await this.promise;
170
- await h.close();
171
- } catch {
172
- /* best-effort — never block shutdown on a broker teardown error */
173
- }
174
- }
215
+ this.generation++;
216
+ for (const cancel of this.preparingReaders) cancel();
217
+ const host = this.hostRef;
218
+ const pending = this.promise;
175
219
  this.hostRef = undefined;
176
220
  this.promise = undefined;
221
+ this.lastError = undefined;
177
222
  this.peers.clear();
178
223
  this.expected.clear();
179
224
  this.preHostSteers.clear();
225
+ try {
226
+ // Invoke immediately: bound readers revoke before the first await.
227
+ if (host) await host.close();
228
+ else if (pending) await (await pending).close();
229
+ } catch {
230
+ /* best-effort — never block shutdown on a broker teardown error */
231
+ }
180
232
  }
181
233
  }
@@ -8,6 +8,7 @@ import { type ContractDef, DEFAULT_CONTRACT, installedContractNames } from "../c
8
8
  import type { RunLimits } from "../core/capabilities.ts";
9
9
  import type { PiPersonaConfig } from "../core/config.ts";
10
10
  import { isThinkingLevel } from "../core/types.ts";
11
+ import type { ModelCostMetadata } from "../core/model-tier.ts";
11
12
  import { personaModels, type PersonaConfigStore } from "../persona/config-store.ts";
12
13
  import { participantKey } from "../persona/model-participants.ts";
13
14
  import type { PersonaController, PersonaHost } from "../persona/controller.ts";
@@ -20,6 +21,7 @@ import { emptyUsage, type ProgressSnapshot } from "../engine/stream.ts";
20
21
  import type { AgentRunSpec, StrategyEngine } from "../orchestration/sdk.ts";
21
22
  import type { AgentResult } from "../orchestration/types.ts";
22
23
  import type { InProcessBus } from "../bus/inproc.ts";
24
+ import type { ResultArchiveView } from "../results/retrieval.ts";
23
25
 
24
26
  export interface EngineFactories {
25
27
  makeEngine: typeof makeEngine;
@@ -44,6 +46,10 @@ export interface BuildEngineDeps {
44
46
  runLimits: RunLimits;
45
47
  bus: InProcessBus;
46
48
  supervisorHandle: string;
49
+ /** The live terminal-result archive (a GETTER: the owning session can change across runs).
50
+ * Feeds both engines' reference-handoff preflight + scoped `read_result` readers.
51
+ * Absent ⇒ reference-handoff legs fail closed on both engines (no archive to page). */
52
+ resultArchive?: () => ResultArchiveView | undefined;
47
53
  /** Breadcrumb for a provider reroute (toast + log); absent ⇒ silent, as before. */
48
54
  onFallback?: (info: { from: string; to: string; agent: string }) => void;
49
55
  /** Injected git runner for the non-worktree files-changed report (tests only); defaults to
@@ -68,7 +74,7 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
68
74
  const {
69
75
  agents, contractDefs, controller, host, config, personaConfigs, lastCtx, workerSpineText,
70
76
  engineFactories, makeBrokerDeps, userAgentDir, childPiSettingsEnv, runLimits: RUN_LIMITS,
71
- bus, supervisorHandle: SUPERVISOR, onFallback,
77
+ bus, supervisorHandle: SUPERVISOR, onFallback, resultArchive,
72
78
  } = d();
73
79
  const resolveAgent = (n: string): AgentConfig | undefined => agents.find((a) => a.name === n);
74
80
  // A named contract file (contracts/<name>.contract.json) wins; "default" is the built-in.
@@ -110,7 +116,7 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
110
116
  const getBrokerDeps = (): EngineAdapterBroker | undefined => {
111
117
  if (!brokerDepsBuilt) {
112
118
  brokerDepsBuilt = true;
113
- const sessionId = (lastCtx as { sessionManager?: { getSessionId?: () => string } } | undefined)?.sessionManager?.getSessionId?.();
119
+ const sessionId = lastCtx?.sessionManager.getSessionId();
114
120
  if (config.broker && lastCtx && sessionId) brokerDepsMemo = makeBrokerDeps(lastCtx);
115
121
  }
116
122
  return brokerDepsMemo;
@@ -136,6 +142,9 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
136
142
  if (onProgress) deps.childOptions.onProgress = onProgress;
137
143
  const brokerDeps = getBrokerDeps();
138
144
  if (brokerDeps) deps.broker = brokerDeps;
145
+ // Reference-handoff legs page the owning session's terminal archive (strict preflight
146
+ // + scoped reader); the getter is read at LEG time so a mid-run session switch is seen.
147
+ if (resultArchive !== undefined) deps.resultArchive = resultArchive;
139
148
  // Peer messaging obeys the persona's bus capability, and blocking asks are honoured
140
149
  // only for async runs — same guards as the inproc engine below (spec B7 / §4.9).
141
150
  const caps = controller.capabilities;
@@ -163,6 +172,7 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
163
172
  // can't answer, so blocking there would deadlock; the tool downgrades to one-way).
164
173
  ideps.bus = bus;
165
174
  ideps.supervisorHandle = SUPERVISOR;
175
+ if (resultArchive !== undefined) ideps.resultArchive = resultArchive;
166
176
  if (controller.activePersona?.coaching) ideps.coaching = true;
167
177
  // Peer messaging obeys the persona's bus capability (canUseBus; Task: sibling peer comm).
168
178
  const caps = controller.capabilities;
@@ -186,6 +196,7 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
186
196
  if (onProgress) deps.childOptions.onProgress = onProgress;
187
197
  const brokerDeps = getBrokerDeps();
188
198
  if (brokerDeps) deps.broker = brokerDeps;
199
+ if (resultArchive !== undefined) deps.resultArchive = resultArchive;
189
200
  // Peer messaging obeys the persona's bus capability, and blocking asks are honoured
190
201
  // only for async runs — same guards as the inproc engine above (spec B7 / §4.9).
191
202
  const caps = controller.capabilities;
@@ -247,9 +258,9 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
247
258
  const preflight = await worktreePreflight(root, defaultGitExec);
248
259
  if (!preflight.ok) return isolatedWorktreeFailure(spec, preflight.error);
249
260
  try {
250
- return await withWorktree(root, defaultGitExec, async (dir) => {
261
+ return await withWorktree(root, defaultGitExec, async (dir, baseCommit) => {
251
262
  const result = await childEngineAt(dir).run({ ...spec, isolation: "none" }, perProgress, perSignal, perSteer);
252
- const artifact = await captureWorktreeArtifact(dir, defaultGitExec);
263
+ const artifact = await captureWorktreeArtifact(dir, baseCommit, defaultGitExec);
253
264
  const hasOutputArtifact = hasUnifiedDiff(result.output);
254
265
  if (!result.ok) {
255
266
  return artifact.ok && artifact.diff.trim().length > 0
@@ -310,33 +321,48 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
310
321
  }
311
322
  }
312
323
 
313
- // The models the user can intentionally route to — NOT every built-in catalog entry.
314
- // `getAvailable()` is normally the authenticated set, but extension-native providers
315
- // (for example a local subscription/CLI bridge) can be runnable without appearing in
316
- // that auth snapshot. Union their registered catalogs, plus the active session provider,
317
- // while keeping unrelated unauthenticated built-ins hidden. If an older Pi lacks the
318
- // availability API, retain its historical getAll() compatibility behavior.
319
- export function configuredModels(ctx: ExtensionContext): Array<{ provider: string; id: string }> {
324
+ // The host's model-level availability snapshot, plus the exact active model. A provider's
325
+ // credentials do not grant every model in its catalog. Extension-native bridges can be
326
+ // absent from an auth snapshot: restore only their explicit registered model lists in that
327
+ // case, never every inherited model under their provider id.
328
+ export function configuredModels(ctx: ExtensionContext): ModelCostMetadata[] {
320
329
  const reg = ctx.modelRegistry;
321
330
  const all = reg.getAll();
322
- let available: typeof all;
323
- try {
324
- available = reg.getAvailable();
325
- } catch {
326
- return all.map((m) => ({ provider: m.provider, id: m.id }));
327
- }
328
- const trustedProviders = new Set<string>();
329
- if (ctx.model?.provider) trustedProviders.add(ctx.model.provider);
330
- try {
331
- const registered = (reg as typeof reg & { getRegisteredProviderIds?: () => string[] }).getRegisteredProviderIds?.() ?? [];
332
- for (const provider of registered) trustedProviders.add(provider);
333
- } catch {
334
- /* a third-party/older registry facade may not expose extension provider ids */
335
- }
331
+ const available = reg.getAvailable();
336
332
  const byRef = new Map(available.map((m) => [`${m.provider}\0${m.id}`, m]));
333
+ if (ctx.model) byRef.set(`${ctx.model.provider}\0${ctx.model.id}`, ctx.model);
334
+ const availableProviders = new Set(available.map((m) => m.provider));
335
+ const declaredRefs = new Set<string>();
336
+ const registered = reg.getRegisteredProviderIds();
337
+ for (const provider of registered) {
338
+ // A populated snapshot can intentionally exclude individual models for this credential.
339
+ if (availableProviders.has(provider)) continue;
340
+ let native: ReturnType<typeof reg.getRegisteredNativeProvider>;
341
+ try {
342
+ native = reg.getRegisteredNativeProvider(provider);
343
+ // An empty credential-filtered list is authoritative too; it need not be a stale snapshot.
344
+ if (reg.getProvider(provider)?.filterModels || native?.filterModels) continue;
345
+ } catch { continue; }
346
+ try {
347
+ const models = reg.getRegisteredProviderConfig(provider)?.models;
348
+ if (Array.isArray(models)) {
349
+ for (const model of models) {
350
+ if ((model.type === undefined || model.type === "chat") && typeof model.id === "string") declaredRefs.add(`${provider}\0${model.id}`);
351
+ }
352
+ }
353
+ } catch { /* one broken extension's metadata must not hide other providers */ }
354
+ try {
355
+ const models = native?.getModels();
356
+ if (Array.isArray(models)) {
357
+ for (const model of models) {
358
+ if (model.provider === provider && typeof model.id === "string") declaredRefs.add(`${provider}\0${model.id}`);
359
+ }
360
+ }
361
+ } catch { /* a native catalog may be temporarily unavailable */ }
362
+ }
337
363
  for (const model of all) {
338
- if (trustedProviders.has(model.provider)) byRef.set(`${model.provider}\0${model.id}`, model);
364
+ const ref = `${model.provider}\0${model.id}`;
365
+ if (declaredRefs.has(ref)) byRef.set(ref, model);
339
366
  }
340
- const list = [...byRef.values()];
341
- return list.map((m) => ({ provider: m.provider, id: m.id }));
367
+ return [...byRef.values()];
342
368
  }