@staix/agent-hub 0.12.43 → 0.12.44

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 (89) hide show
  1. package/CHANGELOG.md +16 -3
  2. package/README.md +3 -3
  3. package/docs/adding-an-ai-tool.md +140 -0
  4. package/docs/agent-notes/README.md +5 -0
  5. package/docs/agent-notes/adapters.md +8 -3
  6. package/docs/agent-notes/budget.md +2 -2
  7. package/docs/agent-notes/daemon.md +9 -5
  8. package/docs/agent-notes/tasks.md +6 -2
  9. package/docs/agent-notes/tests.md +3 -1
  10. package/docs/operations.md +64 -5
  11. package/docs/quickstart.md +6 -2
  12. package/docs/security.md +5 -3
  13. package/docs/specs/2026-09-19-agent-hub-design.md +160 -4
  14. package/docs/verification/2026-10-21-0.12.44.md +54 -0
  15. package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
  16. package/docs/verified.json +34 -19
  17. package/package.json +1 -1
  18. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  19. package/plugins/agent-hub/server.js +743 -229
  20. package/src/adapters/acp-owned-group.ts +124 -0
  21. package/src/adapters/acp.ts +92 -25
  22. package/src/adapters/agy/definition.ts +1 -3
  23. package/src/adapters/agy/entry.ts +4 -1
  24. package/src/adapters/channel-peer.ts +143 -0
  25. package/src/adapters/claude-channel.ts +7 -7
  26. package/src/adapters/codex-appserver.ts +16 -2
  27. package/src/adapters/configured-acp.ts +124 -0
  28. package/src/adapters/definitions/builtin-metadata.ts +37 -33
  29. package/src/adapters/definitions/native-channel-state.ts +41 -0
  30. package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -1
  31. package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
  32. package/src/adapters/definitions/native-identity-defaults.ts +14 -0
  33. package/src/adapters/definitions/qwen.ts +3 -8
  34. package/src/adapters/definitions/reviewer-variants.ts +3 -7
  35. package/src/adapters/jcode.ts +4 -0
  36. package/src/adapters/local-worker.ts +4 -0
  37. package/src/adapters/pi.ts +4 -0
  38. package/src/adapters/registry-types.ts +18 -16
  39. package/src/adapters/registry.ts +68 -6
  40. package/src/adapters/reviewer-print.ts +4 -0
  41. package/src/cli/agy-mcp.ts +3 -2
  42. package/src/cli/configured-acp-entry.ts +58 -0
  43. package/src/cli/console-state.ts +9 -4
  44. package/src/cli/facts-hook.ts +2 -1
  45. package/src/cli/help.ts +6 -0
  46. package/src/cli/identity.ts +7 -6
  47. package/src/cli/launch.ts +5 -4
  48. package/src/cli/main.ts +78 -27
  49. package/src/cli/memory.ts +13 -10
  50. package/src/cli/output.ts +1 -0
  51. package/src/cli/preview.ts +9 -0
  52. package/src/cli/recovery-guidance.ts +1 -1
  53. package/src/cli/terminal-recovery.ts +16 -13
  54. package/src/cli/upgrade-restore-state.ts +3 -2
  55. package/src/cli/upgrade-runtime.ts +21 -6
  56. package/src/cli/upgrade.ts +3 -2
  57. package/src/hub/board.ts +9 -6
  58. package/src/hub/budget.ts +9 -2
  59. package/src/hub/bus.ts +6 -2
  60. package/src/hub/claude-state.ts +2 -37
  61. package/src/hub/conductor.ts +8 -6
  62. package/src/hub/config-apply.ts +26 -14
  63. package/src/hub/config-reload.ts +39 -11
  64. package/src/hub/config-trust.ts +1 -1
  65. package/src/hub/context-window.ts +6 -0
  66. package/src/hub/control-client.ts +1 -1
  67. package/src/hub/crash.ts +8 -6
  68. package/src/hub/daemon.ts +341 -313
  69. package/src/hub/hub-tools.ts +36 -0
  70. package/src/hub/instance-config.ts +20 -11
  71. package/src/hub/instance-execution.ts +4 -4
  72. package/src/hub/instances.ts +45 -21
  73. package/src/hub/lifecycle-schema.ts +5 -3
  74. package/src/hub/memory-profile.ts +16 -3
  75. package/src/hub/no-writes.ts +50 -0
  76. package/src/hub/permission-mode.ts +9 -7
  77. package/src/hub/roster.ts +7 -4
  78. package/src/hub/routing.ts +21 -20
  79. package/src/hub/settings.ts +50 -16
  80. package/src/hub/snapshots.ts +26 -2
  81. package/src/hub/start-mode.ts +10 -8
  82. package/src/hub/task-sweep.ts +8 -2
  83. package/src/hub/tasks.ts +85 -17
  84. package/src/memory/capture.ts +2 -1
  85. package/src/ui/index.html +5 -0
  86. package/templates/AGENTS.block.md +1 -1
  87. package/templates/profiles/general.json +1 -1
  88. package/templates/profiles/research.json +1 -1
  89. package/templates/profiles/writing.json +1 -1
package/src/hub/board.ts CHANGED
@@ -15,9 +15,9 @@ export type TaskState = "backlog" | "proposed" | "in_progress" | "in_review" | "
15
15
  export const isClosedTaskState = (state: string): boolean => state === "approved" || state === "cancelled";
16
16
  export interface TaskOffer { envelopeId: string; owner: PeerId; assignmentFlowRevision: number; pullOnly?: boolean }
17
17
  export interface TaskCancellation { reason: string; supersededBy?: string; flowRevision: number; withheld?: true }
18
- export const TASK_STALL_CODES = ["tests", "spec", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable"] as const;
18
+ export const TASK_STALL_CODES = ["tests", "spec", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable", "no-writes"] as const;
19
19
  export type TaskStallCode = (typeof TASK_STALL_CODES)[number];
20
- export interface TaskStall { task: number; signature: string; replanRevision: number; cause: { kind: "reservation" | "no-eligible-peer" | "cancelled-dependency"; peer?: string; dependency?: number; code: TaskStallCode; refusals?: { peer: string; code: TaskStallCode }[] }; at: number }
20
+ export interface TaskStall { task: number; signature: string; replanRevision: number; cause: { kind: "reservation" | "no-eligible-peer" | "cancelled-dependency" | "no-writes"; peer?: string; dependency?: number; code: TaskStallCode; turns?: number; acceptance?: number; refusals?: { peer: string; code: TaskStallCode }[] }; at: number }
21
21
 
22
22
  export interface TaskRefs {
23
23
  repo?: string;
@@ -55,6 +55,8 @@ export interface HistoryEntry {
55
55
  sweep?: SweepRecord;
56
56
  /** The PII screen did not clear this entry's note (#198): only the console reads it. */
57
57
  withheld?: true;
58
+ /** Host evidence at done, never inferred from the owner's summary. */
59
+ noWrites?: true;
58
60
  /** Host-recorded expected cost and exact review head, never narrative. */
59
61
  reviewCost?: ReviewCost;
60
62
  /** #223 R4: on a verdict, whether the reviewer's context was new for it; set by the hub, never by the reviewer. */
@@ -287,10 +289,11 @@ export class Board {
287
289
  const value = JSON.parse(row.payload) as TaskStall, cause = value.cause;
288
290
  const peer = (id: unknown) => typeof id === "string" && /^[a-z][a-z0-9_-]{0,63}$/i.test(id);
289
291
  if (Object.keys(value).some(key => !["task", "signature", "replanRevision", "cause", "at"].includes(key)) || !Number.isSafeInteger(value.task) || value.task < 1 || !Number.isSafeInteger(value.replanRevision) || value.replanRevision < 0 || !Number.isFinite(value.at)
290
- || !cause || !["reservation", "no-eligible-peer", "cancelled-dependency"].includes(cause.kind) || !TASK_STALL_CODES.includes(cause.code)
292
+ || !cause || !["reservation", "no-eligible-peer", "cancelled-dependency", "no-writes"].includes(cause.kind) || !TASK_STALL_CODES.includes(cause.code)
293
+ || cause.kind === "no-writes" && (cause.code !== "no-writes" || !peer(cause.peer) || !Number.isSafeInteger(cause.turns) || cause.turns! < 1 || !Number.isSafeInteger(cause.acceptance) || cause.acceptance! < 0)
291
294
  || cause.peer !== undefined && !peer(cause.peer) || cause.dependency !== undefined && (!Number.isSafeInteger(cause.dependency) || cause.dependency < 1)
292
295
  || cause.refusals !== undefined && (!Array.isArray(cause.refusals) || cause.refusals.length > 64 || cause.refusals.some(row => Object.keys(row).some(key => !["peer", "code"].includes(key)) || !peer(row.peer) || !TASK_STALL_CODES.includes(row.code)))
293
- || Object.keys(cause).some(key => !["kind", "peer", "dependency", "code", "refusals"].includes(key)) || value.signature !== JSON.stringify(cause)) return [];
296
+ || Object.keys(cause).some(key => !["kind", "peer", "dependency", "code", "refusals", ...(cause.kind === "no-writes" ? ["turns", "acceptance"] : [])].includes(key)) || value.signature !== JSON.stringify(cause)) return [];
294
297
  return [value];
295
298
  } catch { return []; }
296
299
  });
@@ -304,7 +307,7 @@ export class Board {
304
307
 
305
308
  /** The only way a task changes. Validates the move, records who did what, returns the new row. */
306
309
  /** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
307
- update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "capacity" | "withheld" | "reviewCost" | "reviewContext" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number): Task {
310
+ update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "capacity" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number): Task {
308
311
  const task = this.get(id);
309
312
  if (!task) throw new Error(`no task #${id}`);
310
313
  if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
@@ -320,7 +323,7 @@ export class Board {
320
323
  }
321
324
  if (patch.state === "backlog" && taskHasStarted(task) && !untouchedReturn) throw new Error(`task #${id}: started work cannot return to backlog`);
322
325
  const reviewMetadata = reviewerHistory(extra);
323
- const history = [...task.history, { at: Date.now(), by, event, ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.withheld && note ? { withheld: true as const } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}) }];
326
+ const history = [...task.history, { at: Date.now(), by, event, ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.withheld && note ? { withheld: true as const } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}) }];
324
327
  this.taskWrite(() => this.db
325
328
  .query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ? WHERE id = ?")
326
329
  .run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), id));
package/src/hub/budget.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { definitionOf } from "../adapters/registry.ts";
1
+ import { definitionOf, type AdapterDefinition } from "../adapters/registry.ts";
2
2
  import { Database } from "bun:sqlite";
3
3
  import type { PeerId } from "./envelope.ts";
4
4
  import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
@@ -13,6 +13,10 @@ export interface UsageWindow {
13
13
  windowMins?: number;
14
14
  source: string;
15
15
  }
16
+ /** No declared source is unknown, not a synthetic empty provider reading. */
17
+ export function readNativeQuota(definition: AdapterDefinition | undefined, value: unknown): UsageWindow[] | undefined {
18
+ return definition?.budget?.reader?.read(value);
19
+ }
16
20
  interface Reading extends UsageWindow {
17
21
  at: number;
18
22
  }
@@ -46,6 +50,8 @@ export interface BudgetConfig {
46
50
  export const DEFAULT_BUDGET: BudgetConfig = { gate: 0.9, stale_min: 30, poll_min: 10, checkpoint_timeout_s: 90, kimi_tokens_5h: 0, wait_max_min: 0 };
47
51
 
48
52
  export interface BudgetHooks {
53
+ /** Startup-bound project resolver; undefined keeps builtin-only compatibility. */
54
+ definitionOf?(peer: PeerId): AdapterDefinition | undefined;
49
55
  /** Stable/idempotent for this Budget lifetime. All lifecycle hooks receive this canonical account id. */
50
56
  account?(peer: PeerId): PeerId;
51
57
  pause(peer: PeerId): void;
@@ -202,7 +208,8 @@ export class Budget {
202
208
  // Explicit quota exemption or absent budget metadata exempts registered kinds; external ids keep accounting.
203
209
  const reporter = peer;
204
210
  peer = this.account(peer);
205
- const adapter = definitionOf(reporter) ?? definitionOf(peer);
211
+ const resolve = this.hooks.definitionOf ?? definitionOf;
212
+ const adapter = resolve(reporter) ?? resolve(peer);
206
213
  if (reporter === "user" || reporter === "hub" || peer === "user" || peer === "hub" || adapter && (!adapter.budget || adapter.budget.quotaExempt === true) || this.closed) return;
207
214
  // An override ignores soft readings, not a provider that has actually refused another turn.
208
215
  if (hard && this.ignoreUntil.delete(peer)) {
package/src/hub/bus.ts CHANGED
@@ -322,7 +322,7 @@ export class Bus {
322
322
  try {
323
323
  this.deferredHub.delete(env.id);
324
324
  this.publishNow(env);
325
- } catch (error) {
325
+ } catch {
326
326
  // Only publication state is transactional. A live peer's preface, pauses, attempts and
327
327
  // recovery mode are independent state, not something a failed checkpoint may reload.
328
328
  this.deferredHub.clear(); for (const [id, pending] of beforeDeferred) this.deferredHub.set(id, pending);
@@ -673,7 +673,11 @@ export class Bus {
673
673
  this.prefaces.set(id, !now ? { ...env, body: uniqueMemoryLines(env.body) } : now.id === env.id ? { ...now, body: uniqueMemoryLines(now.body) } : { ...env, body: keepNotes(mergePrefaces(env.body, now.body), NOTES_KEPT) });
674
674
  }
675
675
 
676
- /** Stopped-runtime reset only: audit every deferred recipient without publishing or releasing work. */
676
+ /**
677
+ * Reset only: audit every deferred recipient without publishing or releasing work. The guard below is what it
678
+ * checks: no attached peers, no flush or replay in progress and a readable journal. It cannot see a running
679
+ * daemon, so the caller (`resetRuntime`) must hold the stopped-hub proof; a bare Bus has none.
680
+ */
677
681
  discardDeferredHub(reason: string): string[] {
678
682
  if (this.peers.size || this.flushingHub || this.replayingHub || !this.journal || this.storageError) throw new Error("deferred reset requires a stopped, readable delivery journal");
679
683
  const pending = [...this.deferredHub];
@@ -1,37 +1,2 @@
1
- // No imports: the statusline tee and the CLI load this without the daemon (#450 I3).
2
- const PEER = /^[a-z][a-z0-9-]{0,31}$/;
3
-
4
- /** The peer a Claude process writes for: its AGENTHUB_PEER_ID when well formed, otherwise the builtin claude peer. */
5
- export function claudePeer(value: string | undefined): string {
6
- return value !== undefined && PEER.test(value) ? value : "claude";
7
- }
8
-
9
- /** Per-peer Claude state file names. The builtin claude peer keeps its original names, so nothing migrates. */
10
- export function claudeStateFile(base: "claude-launch" | "claude-session" | "claude-context", peer: string): string {
11
- // Instance ids are always <kind>-<name>; a builtin id has no hyphen.
12
- return peer.includes("-") && PEER.test(peer) ? `${base}.${peer}.json` : `${base}.json`;
13
- }
14
-
15
- /** A Claude peer's own row in terminal-recovery.json for this daemon, never a sibling instance's (row order is arbitrary). */
16
- export function ownTerminalRow(records: unknown, peer: string, projectRoot: string, instanceId: string): { launchId?: unknown } | undefined {
17
- if (!Array.isArray(records)) return undefined;
18
- return records.find((row) => row?.peer === peer && row?.projectRoot === projectRoot && row?.instanceId === instanceId);
19
- }
20
-
21
- /**
22
- * Whether a Claude session's transcript exists: the path the peer's own status line reported for that session, else
23
- * the builtin's default location. An instance may run under its own CLAUDE_CONFIG_DIR, which the hub cannot see:
24
- * without its reported path the answer is unknown, and unknown counts as persisted (it never waives session identity).
25
- */
26
- export function transcriptPersisted(reported: { sessionId?: string; transcriptPath?: string }, sessionId: string, instance: boolean, fallbackPath: string, exists: (path: string) => boolean | "unknown"): boolean {
27
- // Only a positive "missing" (ENOENT) is not persisted; an unreadable path is unknown and keeps the identity strict.
28
- const found = (path: string) => exists(path) !== false;
29
- if (reported.sessionId === sessionId && reported.transcriptPath) return found(reported.transcriptPath);
30
- return instance || found(fallbackPath);
31
- }
32
-
33
- /** lstat evidence: true when present, false only on ENOENT/ENOTDIR, "unknown" for any other failure (EACCES included). */
34
- export function pathEvidence(path: string, lstat: (path: string) => unknown): boolean | "unknown" {
35
- try { lstat(path); return true; }
36
- catch (error) { const code = (error as { code?: string }).code; return code === "ENOENT" || code === "ENOTDIR" ? false : "unknown"; }
37
- }
1
+ // Stable leaf import for released statusline/CLI callers. Native policy belongs to the adapter.
2
+ export { claudePeer, claudeStateFile, ownTerminalRow, transcriptPersisted, pathEvidence } from "../adapters/definitions/native-channel-state.ts";
@@ -1,4 +1,4 @@
1
- import { definitionOf, type KindMap } from "../adapters/registry.ts";
1
+ import { definitionOf, type KindMap, type ProjectAdapterCatalog } from "../adapters/registry.ts";
2
2
  import { Database } from "bun:sqlite";
3
3
  import type { Task } from "./board.ts";
4
4
  import { CONDUCTOR_TOOL_NAMES } from "./hub-tools.ts";
@@ -10,6 +10,7 @@ import type { HubEvent } from "./events.ts";
10
10
  import type { SupervisionFeed } from "./supervision.ts";
11
11
  import type { Budget } from "./budget.ts";
12
12
  import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
13
+ import { supportsHeadlessInstances } from "./instances.ts";
13
14
 
14
15
  /** The production ProgressObserver sink forwards structured verdicts, never its observations/reasoning. */
15
16
  export function conductorProgressSink(record: (event: HubEvent) => void, feed: Pick<SupervisionFeed, "milestone">): (event: HubEvent) => void {
@@ -109,14 +110,14 @@ export type ConductorStart = { peer: string; mode: "headless" } | { peer: "claud
109
110
  * What a conductor's start of `peer` is (#269): the peer's own start mode, which a person sets. A `mode` argument may
110
111
  * only repeat it. Preview commands come from the same launch planner the CLI uses, never caller-supplied shell text.
111
112
  */
112
- export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string, preEffect: boolean, startMode: (peer: string) => StartMode, instances?: KindMap): ConductorStart {
113
+ export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string, preEffect: boolean, startMode: (peer: string) => StartMode, instances?: KindMap, adapterCatalog?: ProjectAdapterCatalog): ConductorStart {
113
114
  const refuse = (text: string) => new (preEffect ? PreEffectToolRefusal : Error)(text);
114
115
  if (mode !== undefined && mode !== "headless" && mode !== "tui") throw refuse("mode must be headless or tui");
115
- const adapter = typeof peer === "string" ? definitionOf(peer, instances) : undefined;
116
+ const adapter = typeof peer === "string" ? (adapterCatalog?.definitionOf ?? definitionOf)(peer, instances) : undefined;
116
117
  if (typeof peer !== "string" || !adapter?.start.conductorStartable) throw refuse("only claude, codex, kimi, pi and local, plus opt-in qwen/jcode, may be started by the conductor");
117
118
  const selected = peer;
118
119
  const extra = adapter.kind !== selected;
119
- if (extra && (!adapter.start.create || !adapter.start.forms.includes("headless") || !["env", "acp-session"].includes(adapter.identity.mcp))) throw refuse(`${peer}: this native transport does not support extra headless instances`);
120
+ if (extra && !supportsHeadlessInstances(adapter)) throw refuse(`${peer}: this native transport does not support extra headless instances`);
120
121
  const own = startMode(selected);
121
122
  if (extra && own !== "headless") throw refuse(`${peer}: extra instances require headless mode in I2`);
122
123
  if (mode !== undefined && mode !== own) throw refuse(`${peer} starts ${own} here; a person sets a start mode (ahub settings), the conductor cannot choose one`);
@@ -156,6 +157,7 @@ export interface ConductEvent {
156
157
  kind: "conduct"; actor: string; action: string; task?: number; peer?: string;
157
158
  }
158
159
  export interface ConductorHooks {
160
+ adapterCatalog?: ProjectAdapterCatalog;
159
161
  roles(): unknown;
160
162
  capabilities(): Record<string, unknown>;
161
163
  status(): ConductorStatusInput;
@@ -236,8 +238,8 @@ export class Conductor {
236
238
  }
237
239
  if (tool === "hub_peer_start") {
238
240
  const instances = this.hooks.instances?.();
239
- const start = conductorStart(args.peer, args.mode, this.hooks.preview, true, this.hooks.startMode, instances);
240
- if (definitionOf(start.peer, instances)?.kind !== start.peer && !this.hooks.known(start.peer)) throw new PreEffectToolRefusal(`unknown peer: ${start.peer}`);
241
+ const start = conductorStart(args.peer, args.mode, this.hooks.preview, true, this.hooks.startMode, instances, this.hooks.adapterCatalog);
242
+ if ((this.hooks.adapterCatalog?.definitionOf ?? definitionOf)(start.peer, instances)?.kind !== start.peer && !this.hooks.known(start.peer)) throw new PreEffectToolRefusal(`unknown peer: ${start.peer}`);
241
243
  const result = await this.hooks.start(start.peer);
242
244
  const opened = result && typeof result === "object" && typeof (result as { opened?: unknown }).opened === "string" ? { opened: (result as { opened: string }).opened } : {};
243
245
  const already = result && typeof result === "object" && (result as { already?: unknown }).already === true ? { already: true } : {};
@@ -1,7 +1,7 @@
1
1
  import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
2
2
  import type { HubConfig } from "./daemon.ts";
3
3
  import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked } from "./config-trust.ts";
4
- import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, SUPPORTED_PEERS, PLANNER_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
4
+ import { createProjectAdapterCatalog, type ProjectAdapterCatalog, ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, SUPPORTED_PEERS, PLANNER_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
5
5
  import { CLASSES } from "./board.ts";
6
6
  import { permissionDefaults } from "./permission-mode.ts";
7
7
  import { peerStartConfig, terminalTemplate } from "./start-mode.ts";
@@ -16,6 +16,12 @@ import { petPalette } from "../pet/config.ts";
16
16
 
17
17
  export type ApplyClass = "live" | "next use" | "restart";
18
18
  export interface ApplyRule { applies: ApplyClass; reason?: string }
19
+ export interface ConfigIdentityScope { catalog: ProjectAdapterCatalog; instances: InstancesConfig }
20
+ /** Plain captured metadata stays cloneable with ConfigSources; native callbacks are compiled outside it. */
21
+ export interface ConfigIdentitySnapshot { adapters?: HubConfig["adapters"]; instances: InstancesConfig }
22
+ export function configIdentityScope(snapshot: ConfigIdentitySnapshot): ConfigIdentityScope {
23
+ return { catalog: createProjectAdapterCatalog(snapshot.adapters), instances: snapshot.instances };
24
+ }
19
25
  export interface ConfigChange extends ApplyRule { key: string; before: unknown; after: unknown }
20
26
  export interface ConfigLayer { name: "config.json" | "config.local.json"; raw: Record<string, unknown> }
21
27
  export interface ReloadGuard { key: string; path: readonly string[]; risk: "safe" | "raises"; floor?: string | boolean | number; store: "runtime" | "config" | "routing" }
@@ -128,7 +134,7 @@ const schema: Record<string, Check> = {
128
134
  ...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.schema?.(nativeValidators) ?? {})),
129
135
  watchdog_ms: number(1, 2_147_483_647), kimi_cmd: argv, qwen_cmd: argv, jcode_cmd: argv, codex_bin: text, jcode_home: text, jcode_provider_profile: text,
130
136
  permission_modes: v => { permissionDefaults(v); }, protect_instruction_files: bool,
131
- instances: v => { validateInstances(v); }, pet: validatePetConfig,
137
+ adapters: v => { createProjectAdapterCatalog(v); }, instances: v => { validateInstances(v, createProjectAdapterCatalog()); }, pet: validatePetConfig,
132
138
  batch_max: number(1, 100_000, true), batch_ms: number(0, 2_147_483_647), queue_cap: number(1, 1_000_000, true),
133
139
  memory: block({ enabled: bool, worker_url: text, inject_tokens: number(0, 1_000_000, true), brief_items: number(0, 10_000, true), native: bool,
134
140
  short_ttl_days: number(1, 3650, true), sync_interval_min: number(0, 1440, true), suggest_edges: bool }),
@@ -145,7 +151,7 @@ const schema: Record<string, Check> = {
145
151
  mlx: block({ enabled: bool, provider: words(["legacy", "ollama"]), host: text, runtimeDir: text, modelPath: text, bin: text, port: number(1, 65535, true), model: text, sourceModel: text,
146
152
  contextWindow: number(1), maxInputTokens: number(1), maxTokens: number(1), maxConcurrency: number(1, 1024, true) }),
147
153
  approvals: block({ timeout_s: timerSeconds, notify: bool }), tasks: block({ release_after_min: number(0), wip_limits: map(number(0, 32, true)), escalate_after: number(1, 10, true) }),
148
- task_sweep: (v, k) => { block({ enabled: bool, auto_reassign: bool, interval_s: timerSeconds, unaccepted_min: timerMinutes, idle_min: timerMinutes, review_min: timerMinutes, ready_min: timerMinutes, ladder_min: timerMinutes })(v, k); taskSweepConfig(v); },
154
+ task_sweep: (v, k) => { block({ enabled: bool, auto_reassign: bool, no_writes_turns: number(0, Number.MAX_SAFE_INTEGER, true), interval_s: timerSeconds, unaccepted_min: timerMinutes, idle_min: timerMinutes, review_min: timerMinutes, ready_min: timerMinutes, ladder_min: timerMinutes })(v, k); taskSweepConfig(v); },
149
155
  checks: (v, k) => { for (const [key, value] of Object.entries(object(v, k))) {
150
156
  if (value === undefined) continue;
151
157
  if (key === "timeout_s") timerSeconds(value, `${k}.${key}`);
@@ -160,12 +166,15 @@ const schema: Record<string, Check> = {
160
166
  capabilities: map(strings), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
161
167
  };
162
168
  export const SCHEMA_KEYS: readonly string[] = Object.freeze(Object.keys(schema));
163
- export function validateRawConfig(raw: unknown, declarations?: InstancesConfig): asserts raw is Record<string, unknown> {
169
+ export function validateRawConfig(raw: unknown, declarations?: InstancesConfig, context?: ProjectAdapterCatalog): asserts raw is Record<string, unknown> {
164
170
  const value = object(raw, "config");
165
- const instances = validateInstances(value.instances ?? declarations);
166
- if (!Object.keys(instances).length) return block(schema)(raw, "config");
167
- block({ ...schema, permission_modes: v => { permissionDefaults(v, instances); },
168
- roster: v => { peerRosterConfig(v, instances); }, peers: v => { peerStartConfig(v, instances); },
171
+ const catalog = value.adapters !== undefined ? createProjectAdapterCatalog(value.adapters) : context ?? createProjectAdapterCatalog();
172
+ const instances = validateInstances(value.instances ?? declarations, catalog);
173
+ block({ ...schema,
174
+ instances: v => { validateInstances(v, catalog); },
175
+ permission_modes: v => { permissionDefaults(v, instances, catalog); },
176
+ roster: v => { peerRosterConfig(v, instances, catalog); },
177
+ peers: v => { peerStartConfig(v, instances, catalog); },
169
178
  })(raw, "config");
170
179
  }
171
180
  export function validateEffectiveConfig(value: unknown): asserts value is HubConfig {
@@ -178,6 +187,7 @@ export function validateEffectiveConfig(value: unknown): asserts value is HubCon
178
187
  }
179
188
 
180
189
  const restart: Record<string, string> = {
190
+ adapters: "Configured adapter identity, commands and capability policy are fixed at daemon startup",
181
191
  instances: "Native instance identity, worktree, ports and account bindings are fixed at daemon startup",
182
192
  ...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.restartReasons ?? {})),
183
193
  memory: "Open memory consumers, selected profile, client and sync timer must be quiesced together",
@@ -190,21 +200,21 @@ const restart: Record<string, string> = {
190
200
  const live = new Set(["permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "approvals.notify", "pet"]);
191
201
  const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
192
202
  /** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
193
- export function configApply(key: string): ApplyRule {
203
+ export function configApply(key: string, scopes: readonly ConfigIdentityScope[] = []): ApplyRule {
194
204
  const top = key.split(".")[0]!;
195
205
  if (!(Object.hasOwn(schema, top) || top === "permission" || top === "routing")) return fail(key, "unknown configuration key");
196
206
  if (top !== "routing" && top !== "permission" && key.includes(".")) {
197
207
  const special: Record<string, readonly string[]> = {
198
- task_sweep: ["enabled", "auto_reassign", "interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"],
208
+ task_sweep: ["enabled", "auto_reassign", "no_writes_turns", "interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"],
199
209
  checks: ["timeout_s", "runner", ...CLASSES], terminal: ["open"],
200
210
  };
201
211
  let check = schema[top];
202
212
  for (const [index, part] of key.split(".").slice(1).entries()) {
203
213
  const shape = check && shapes.get(check);
204
214
  if (shape?.fields) { if (!Object.hasOwn(shape.fields, part)) return fail(key, "unknown configuration key"); check = shape.fields[part]; }
205
- else if (shape?.item) { if (shape.keys && !shape.keys.includes(part)) return fail(key, "unknown configuration key"); check = shape.item; }
215
+ else if (shape?.item) { if (shape.keys && !shape.keys.includes(part) && !(top === "roster" && scopes.some(scope => scope.catalog.definitionOf(part, scope.instances)))) return fail(key, "unknown configuration key"); check = shape.item; }
206
216
  else if (index === 0 && (special[top]?.includes(part) || top === "checks" && /^[a-z][a-z0-9_]{0,47}$/.test(part))) break;
207
- else if (["permission_modes", "catalog_roles", "profile_overrides", "text_projection", "peers", "instances"].includes(top)) break;
217
+ else if (["permission_modes", "catalog_roles", "profile_overrides", "text_projection", "peers", "instances", "adapters"].includes(top)) break;
208
218
  else return fail(key, "unknown configuration key");
209
219
  }
210
220
  }
@@ -219,13 +229,15 @@ export function configApply(key: string): ApplyRule {
219
229
  const canonical = (v: unknown): unknown => Array.isArray(v) ? v.map(canonical) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical((v as Record<string, unknown>)[k])])) : v;
220
230
  const same = (a: unknown, b: unknown): boolean => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
221
231
  const at = (raw: unknown, path: readonly string[]): unknown => path.reduce<unknown>((v, k) => v && typeof v === "object" && Object.hasOwn(v, k) ? (v as Record<string, unknown>)[k] : undefined, raw);
222
- export function configChanges(running: HubConfig, next: HubConfig): ConfigChange[] {
232
+ export function configChanges(running: HubConfig, next: HubConfig, scopes: readonly ConfigIdentityScope[]): ConfigChange[] {
223
233
  const changes: ConfigChange[] = [];
234
+ // Scopes are captured/validated by the host. Their union classifies removed/added keys only; it cannot
235
+ // authorize a declaration or turn a restart-retained hybrid into a fresh startup configuration.
224
236
  const walk = (before: unknown, after: unknown, key: string) => {
225
237
  if (same(before, after)) return;
226
238
  if (before && after && typeof before === "object" && typeof after === "object" && !Array.isArray(before) && !Array.isArray(after)) {
227
239
  for (const child of new Set([...Object.keys(before), ...Object.keys(after)])) walk(at(before, [child]), at(after, [child]), key ? `${key}.${child}` : child);
228
- } else changes.push({ key, before, after, ...configApply(key) });
240
+ } else changes.push({ key, before, after, ...configApply(key, scopes) });
229
241
  };
230
242
  const clean = (v: HubConfig) => Object.fromEntries(Object.entries(v).filter(([key]) => !["ignored", "retired", "permission_default_sources"].includes(key)));
231
243
  walk(clean(running), clean(next), ""); return changes.sort((a, b) => a.key.localeCompare(b.key));
@@ -1,10 +1,11 @@
1
- import { ADAPTERS } from "../adapters/registry.ts";
1
+ import { ADAPTERS, createProjectAdapterCatalog } from "../adapters/registry.ts";
2
2
  import { createHash } from "node:crypto";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
- import { validateRawConfig, type ConfigLayer } from "./config-apply.ts";
5
+ import { validateRawConfig, type ConfigLayer, type ConfigIdentitySnapshot } from "./config-apply.ts";
6
6
  import { parseOverlay, parseRouting, type Routing } from "./routing.ts";
7
7
  import { CLASSES } from "./board.ts";
8
+ import { readInstanceConfig } from "./instance-config.ts";
8
9
  import { validateInstances } from "./instances.ts";
9
10
 
10
11
  export const CONFIG_SOURCE_FILES = ["config.json", "config.local.json", "routing.toml", "routing.local.toml"] as const;
@@ -16,6 +17,8 @@ export interface ConfigSources {
16
17
  trustLayers: { name: ConfigSourceName; raw: Record<string, unknown> }[];
17
18
  routing: Routing;
18
19
  fingerprint: string;
20
+ /** Captured trusted declarations, never a peer-name prefix or a request value. */
21
+ identity: ConfigIdentitySnapshot;
19
22
  }
20
23
  const MAX_CONFIG_BYTES = 1024 * 1024;
21
24
 
@@ -83,16 +86,36 @@ function validateRoutingRaw(value: unknown): void {
83
86
  }
84
87
 
85
88
  /** Strict reloads never use routing's last-good fallback. Startup can retain its existing fallback explicitly. */
86
- export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: Routing): ConfigSources {
89
+ export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: Routing, cwd?: string): ConfigSources {
87
90
  const layers: ConfigLayer[] = [], trustLayers: ConfigSources["trustLayers"] = [];
88
- let localRaw: Record<string, unknown> = {};
89
- try { localRaw = JSON.parse(texts["config.local.json"] ?? "{}"); }
90
- catch { if (!fallbackRouting) throw new Error(".agenthub/config.local.json: invalid JSON object"); }
91
+ const jsonLayers: ConfigLayer[] = [];
92
+ for (const name of ["config.json", "config.local.json"] as const) {
93
+ const text = texts[name];
94
+ if (text === null) continue;
95
+ try {
96
+ const value: unknown = JSON.parse(text);
97
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error();
98
+ jsonLayers.push({ name, raw: value as Record<string, unknown> });
99
+ } catch {
100
+ if (!fallbackRouting) throw new Error(`.agenthub/${name}: invalid JSON object`);
101
+ }
102
+ }
103
+ const localRaw = jsonLayers.find(layer => layer.name === "config.local.json")?.raw ?? {};
91
104
  let declared;
92
- try { declared = validateInstances(localRaw?.instances); }
93
- catch (error) {
105
+ let identityAdapters: ConfigIdentitySnapshot["adapters"];
106
+ let catalog = createProjectAdapterCatalog();
107
+ try {
108
+ if (cwd) {
109
+ const accepted = readInstanceConfig(cwd, jsonLayers);
110
+ catalog = accepted.catalog; declared = accepted.instances; identityAdapters = accepted.adapters;
111
+ } else {
112
+ catalog = createProjectAdapterCatalog(localRaw?.adapters);
113
+ identityAdapters = localRaw?.adapters as ConfigIdentitySnapshot["adapters"];
114
+ declared = validateInstances(localRaw?.instances, catalog);
115
+ }
116
+ } catch (error) {
94
117
  if (!fallbackRouting) throw new Error(`.agenthub/config.local.json: ${(error as Error).message}`);
95
- declared = validateInstances(undefined);
118
+ declared = validateInstances(undefined, catalog);
96
119
  }
97
120
  for (const name of CONFIG_SOURCE_FILES) {
98
121
  const text = texts[name];
@@ -108,7 +131,12 @@ export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: R
108
131
  }
109
132
  if (name === "config.json" || name === "config.local.json") {
110
133
  if (!fallbackRouting) {
111
- try { validateRawConfig(raw, declared); }
134
+ try {
135
+ const structural = { ...raw };
136
+ // Declaration provenance was checked before compilation; ignored source data is never re-admitted.
137
+ delete structural.adapters; delete structural.instances;
138
+ validateRawConfig(structural, declared, catalog);
139
+ }
112
140
  catch (error) { throw new Error(`.agenthub/${name}: ${(error as Error).message}`); }
113
141
  }
114
142
  layers.push({ name, raw });
@@ -125,5 +153,5 @@ export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: R
125
153
  try { routing = parseRouting(base, overlay); }
126
154
  catch { throw new Error(".agenthub/routing.toml / routing.local.toml: invalid effective routing"); }
127
155
  }
128
- return { texts, layers, trustLayers, routing, fingerprint: configFingerprint(texts) };
156
+ return { texts, layers, trustLayers, routing, fingerprint: configFingerprint(texts), identity: { ...(identityAdapters ? { adapters: structuredClone(identityAdapters) } : {}), instances: declared ?? validateInstances(undefined, catalog) } };
129
157
  }
@@ -29,7 +29,7 @@ export const MACHINE_LOCAL = [
29
29
  "terminal.open", // the command a hub-made TUI start runs to open a terminal (#269)
30
30
  ] as const;
31
31
  /** Legacy exported field list stays compatible; new policies use the complete list below. */
32
- export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "text_projection", "instances"] as const;
32
+ export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "adapters", "text_projection", "instances"] as const;
33
33
 
34
34
  /** A known repository, including an unreadable marker, must never be mistaken for a plain directory. */
35
35
  export function configRepositoryPresent(cwd: string): boolean {
@@ -1,6 +1,12 @@
1
1
  import { nonnegative, positive } from "../adapters/definitions/native-context.ts";
2
+ import type { AdapterDefinition } from "../adapters/registry-types.ts";
2
3
  export { claudeContext, codexContext, acpContext } from "../adapters/definitions/native-context.ts";
3
4
 
5
+ /** The host retains session/time/privacy fences; the declared reader owns normalization. */
6
+ export function readNativeContext(definition: AdapterDefinition | undefined, value: unknown, sessionId: string, measuredAt: number): ContextReading | undefined {
7
+ return definition?.budget?.context?.read(value, sessionId, measuredAt);
8
+ }
9
+
4
10
  /** Native active-context occupancy, separate from accumulated billable usage (#185). */
5
11
  export interface ContextReading {
6
12
  source: "claude_statusline" | "codex_token_usage" | "acp_usage_update";
@@ -8,7 +8,7 @@ export function stateDirFor(cwd: string): string {
8
8
 
9
9
  /** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). 15 = conductor tools and permission deadlines/closed events (issues #190, #194). 16 = a Claude session without channel evidence: hello `channel: false` and `inbox` (issue #205). The plugin is installed apart from the daemon, so they can drift. */
10
10
  export const PROTOCOL = 16;
11
- export const DAEMON_FEATURES = ["native_hook_launch", "task_cancel", "recovery_deferred_hub", "peer_instances", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated"] as const;
11
+ export const DAEMON_FEATURES = ["configured_acp", "native_hook_launch", "task_cancel", "recovery_deferred_hub", "peer_instances", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated"] as const;
12
12
  /** Protocols a current coordinator may authenticate while upgrading a running source. */
13
13
  export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL] as const;
14
14
 
package/src/hub/crash.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { definitionOf, type KindMap } from "../adapters/registry.ts";
1
+ import { definitionOf, type ProjectAdapterCatalog, type KindMap } from "../adapters/registry.ts";
2
2
  import { chmodSync, existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import type { JournalDelivery } from "./delivery-journal.ts";
@@ -56,17 +56,19 @@ const str = (v: unknown) => (typeof v === "string" && v ? v : undefined);
56
56
  * What can come back after a crash. `resume` holds the start arguments for peers the hub launches itself (Kimi through
57
57
  * ACP `session/load`, Pi through its session file, the local worker afresh); the others say what the user has to do.
58
58
  */
59
- export function crashPlan(records: SessionRecord[], instances?: KindMap): { peer: string; resume?: Record<string, string>; how: string; fresh?: Record<string, string>; tui?: true }[] {
59
+ export function crashPlan(records: SessionRecord[], instances?: KindMap, catalog?: ProjectAdapterCatalog): { peer: string; resume?: Record<string, string>; how: string; fresh?: Record<string, string>; tui?: true }[] {
60
60
  return records.map(({ peer, meta }) => {
61
61
  const launch = (meta.launch && typeof meta.launch === "object" ? meta.launch : {}) as Record<string, unknown>;
62
62
  const model = str(launch.model);
63
- const adapter = definitionOf(peer, instances);
64
- const extra = adapter !== undefined && definitionOf(peer) === undefined;
63
+ const adapter = (catalog?.definitionOf ?? definitionOf)(peer, instances);
64
+ const extra = adapter !== undefined && adapter.kind !== peer;
65
65
  const command = extra ? `ahub ${adapter.cli.command} --as ${peer}` : `ahub ${peer}`;
66
66
  if (adapter?.session.resumeById) {
67
- const sessionId = adapter.session.crashIdentity?.(meta).sessionId;
67
+ const rawId = meta[adapter.session.idField];
68
+ const sessionId = adapter.session.crashIdentity ? adapter.session.crashIdentity(meta).sessionId
69
+ : adapter.transport === "acp" && typeof rawId === "string" && rawId.length > 0 && Buffer.byteLength(rawId) <= 4096 && !/[\p{Cc}\p{Cf}]/u.test(rawId) ? rawId : undefined;
68
70
  if (!sessionId) return { peer, how: `${peer}: saved ACP session identity unavailable; start it deliberately with ${command}` };
69
- return { peer, resume: { sessionId, ...(model ? { model } : {}) }, how: `${peer}: the recorded conversation can be loaded again (ACP session/load)` };
71
+ return { peer, resume: { sessionId, ...(model && definitionOf(adapter.kind) ? { model } : {}) }, how: `${peer}: the recorded conversation can be loaded again (ACP session/load)` };
70
72
  }
71
73
  const native = adapter?.session.crashPlan?.(meta);
72
74
  if (!native) return { peer, how: `${peer}: reconnects by itself if its client is still running` };