@staix/agent-hub 0.34.1 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +35 -6
  3. package/docs/adding-an-ai-tool.md +2 -2
  4. package/docs/addons/claude-mem.md +4 -4
  5. package/docs/addons/open-code-review.md +1 -1
  6. package/docs/agent-notes/README.md +6 -0
  7. package/docs/agent-notes/adapters.md +9 -5
  8. package/docs/agent-notes/budget.md +1 -1
  9. package/docs/agent-notes/bus.md +1 -1
  10. package/docs/agent-notes/daemon.md +4 -3
  11. package/docs/agent-notes/local-worker.md +1 -1
  12. package/docs/agent-notes/models.md +2 -0
  13. package/docs/agent-notes/tasks.md +1 -1
  14. package/docs/agent-notes/tests.md +5 -0
  15. package/docs/design/adapters.md +110 -2
  16. package/docs/design/agent-hub-architecture.md +63 -1
  17. package/docs/design/agent-hub.md +6 -0
  18. package/docs/design/bus.md +18 -0
  19. package/docs/design/execution-reference.md +146 -0
  20. package/docs/design/task-board.md +10 -0
  21. package/docs/dogfood.md +33 -12
  22. package/docs/events.md +38 -20
  23. package/docs/memory.md +21 -7
  24. package/docs/operations.md +539 -24
  25. package/docs/quickstart.md +12 -4
  26. package/docs/research.md +71 -13
  27. package/docs/roles.md +1 -1
  28. package/docs/sdlc.md +9 -7
  29. package/docs/security.md +87 -11
  30. package/docs/smoke.md +14 -0
  31. package/docs/specs/2026-09-19-agent-hub-design.md +8 -3
  32. package/docs/verification/2026-11-07-compat-python-startup-1248.md +51 -0
  33. package/docs/verification/2026-11-07-compat-receipt-harness-1248.md +105 -0
  34. package/docs/verification/2026-11-07-compat-receipt-producers-1248.md +96 -0
  35. package/docs/verification/2026-11-07-compat-workflow-1248.md +94 -0
  36. package/docs/verification/2026-11-07-daemon-startup-1232.md +169 -0
  37. package/docs/verification/2026-11-07-jcode-receipt-reconstruction-1248.md +118 -0
  38. package/docs/verification/2026-11-07-kimi-delayed-usage.md +34 -0
  39. package/docs/verification/2026-11-07-kimi-review-watch-1284.md +115 -0
  40. package/docs/verification/2026-11-08-agy-1.5.3.md +96 -0
  41. package/docs/verification/2026-11-08-agy-acp-t0.md +56 -0
  42. package/docs/verification/2026-11-08-hub-visual-1058.md +161 -0
  43. package/docs/verification/2026-11-08-jcode-0991-model-setter-1317.md +143 -0
  44. package/docs/verification/2026-11-08-kimi-252-model-setter-1317.md +137 -0
  45. package/docs/verification/2026-11-08-kimi-tui-completion-1282.md +45 -0
  46. package/docs/verification/2026-11-08-kimi-wire-yield-1283.md +31 -0
  47. package/docs/verification/2026-11-08-live-model-override-1317.md +250 -0
  48. package/docs/verification/2026-11-08-qwen-0269-acp.md +138 -0
  49. package/docs/verification/2026-11-08-qwen-0269-model-setter-1317.md +91 -0
  50. package/docs/verification/2026-11-09-0.35.0.md +129 -0
  51. package/docs/verification/2026-11-09-headless-records-1320.md +157 -0
  52. package/docs/verification/claude-permission-transport-native.py +10 -3
  53. package/docs/verification/claude-shell-wrapper-native.py +8 -8
  54. package/docs/verified.json +245 -15
  55. package/package.json +3 -3
  56. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  57. package/plugins/agent-hub/kimi-hook.js +20397 -0
  58. package/plugins/agent-hub/kimi-review-policy-worker.js +217 -0
  59. package/plugins/agent-hub/server.js +2701 -664
  60. package/scripts/check-docs.mjs +12 -1
  61. package/src/adapters/acp-model-policy.ts +56 -0
  62. package/src/adapters/acp.ts +175 -5
  63. package/src/adapters/agy/entry.ts +8 -5
  64. package/src/adapters/agy/hooks.ts +65 -15
  65. package/src/adapters/channel-peer.ts +33 -1
  66. package/src/adapters/claude-implementer.ts +16 -3
  67. package/src/adapters/codex-appserver.ts +118 -8
  68. package/src/adapters/codex-review-isolation.ts +3 -1
  69. package/src/adapters/compat.ts +11 -6
  70. package/src/adapters/definitions/builtin-metadata.ts +8 -1
  71. package/src/adapters/definitions/native-cli-entry-kimi.ts +76 -7
  72. package/src/adapters/definitions/native-cli-preview-common.ts +2 -2
  73. package/src/adapters/definitions/native-cli-preview-kimi.ts +9 -2
  74. package/src/adapters/definitions/reviewer-variants.ts +3 -1
  75. package/src/adapters/kimi-review-policy-worker.js +2 -0
  76. package/src/adapters/kimi-review-policy-worker.ts +85 -0
  77. package/src/adapters/kimi-review-policy.ts +68 -0
  78. package/src/adapters/kimi-review.ts +41 -41
  79. package/src/adapters/kimi-tui-facts.ts +153 -0
  80. package/src/adapters/kimi-wire-usage.ts +33 -8
  81. package/src/adapters/local-worker.ts +59 -20
  82. package/src/adapters/model-token.ts +3 -0
  83. package/src/adapters/pi.ts +27 -5
  84. package/src/adapters/registry-types.ts +34 -3
  85. package/src/adapters/registry.ts +1 -0
  86. package/src/adapters/reviewer-policy-watch.ts +86 -0
  87. package/src/adapters/reviewer-print.ts +80 -18
  88. package/src/cli/agents.ts +1 -1
  89. package/src/cli/facts-hook.ts +4 -2
  90. package/src/cli/help.ts +3 -2
  91. package/src/cli/identity.ts +3 -0
  92. package/src/cli/kimi-facts-hook.ts +11 -0
  93. package/src/cli/kimi-plugin.ts +157 -0
  94. package/src/cli/main.ts +48 -9
  95. package/src/cli/model.ts +52 -0
  96. package/src/cli/output.ts +23 -1
  97. package/src/cli/reset.ts +1 -1
  98. package/src/cli/status-lines.ts +8 -4
  99. package/src/hub/budget.ts +1 -1
  100. package/src/hub/bus.ts +40 -2
  101. package/src/hub/code-facts.ts +10 -3
  102. package/src/hub/completions.ts +3 -1
  103. package/src/hub/config-apply.ts +2 -1
  104. package/src/hub/control-client.ts +1 -1
  105. package/src/hub/daemon.ts +1050 -106
  106. package/src/hub/deliverable-files.ts +38 -5
  107. package/src/hub/dogfood-profile.ts +1 -1
  108. package/src/hub/envelope.ts +1 -1
  109. package/src/hub/events.ts +28 -18
  110. package/src/hub/execution-lineage.ts +120 -0
  111. package/src/hub/execution-reference.ts +36 -0
  112. package/src/hub/hub-tools.ts +21 -2
  113. package/src/hub/instances.ts +4 -3
  114. package/src/hub/kimi-events.ts +46 -0
  115. package/src/hub/kimi-usage.ts +132 -29
  116. package/src/hub/lifecycle.ts +31 -15
  117. package/src/hub/limits.ts +13 -4
  118. package/src/hub/manager.ts +15 -2
  119. package/src/hub/memory-policy.ts +2 -1
  120. package/src/hub/memory-sync-attestation.ts +54 -0
  121. package/src/hub/memory-sync-state.ts +20 -5
  122. package/src/hub/model-overrides.ts +115 -0
  123. package/src/hub/native-hook-launch.ts +12 -3
  124. package/src/hub/operational-view.ts +2 -2
  125. package/src/hub/peers.ts +27 -0
  126. package/src/hub/permission-mode.ts +1 -1
  127. package/src/hub/person-figures.ts +88 -0
  128. package/src/hub/report.ts +38 -4
  129. package/src/hub/research-dataset.ts +158 -0
  130. package/src/hub/research-memory.ts +68 -0
  131. package/src/hub/research-quality.ts +118 -0
  132. package/src/hub/research-record.ts +279 -0
  133. package/src/hub/research.ts +126 -27
  134. package/src/hub/restart.ts +14 -0
  135. package/src/hub/settings.ts +3 -0
  136. package/src/hub/startup-event-seeds.ts +51 -0
  137. package/src/hub/startup-readiness.ts +65 -0
  138. package/src/hub/startup-timing.ts +38 -0
  139. package/src/hub/tasks.ts +29 -9
  140. package/src/hub/ui.ts +9 -5
  141. package/src/hub/visual-assets.ts +50 -0
  142. package/src/hub/visual.ts +257 -0
  143. package/src/memory/brief.ts +169 -14
  144. package/src/memory/capture.ts +2 -1
  145. package/src/memory/recall.ts +46 -5
  146. package/src/memory/record-provenance.ts +82 -0
  147. package/src/memory/records.ts +116 -0
  148. package/src/models/route/adec.ts +190 -0
  149. package/src/models/route/config.ts +37 -3
  150. package/src/models/route/decision-evidence.ts +83 -0
  151. package/src/models/route/decision.ts +143 -0
  152. package/src/models/route/runtime.ts +13 -6
  153. package/src/ui/index.html +134 -6
  154. package/src/ui/plugin.ts +65 -4
  155. package/templates/AGENTS.block.md +1 -1
  156. package/templates/config.json +1 -1
  157. package/templates/profiles/general.json +1 -1
  158. package/templates/profiles/research.json +1 -1
  159. package/templates/profiles/writing.json +1 -1
@@ -0,0 +1,52 @@
1
+ import { isNativeModelToken } from "../adapters/model-token.ts";
2
+ import type { RuntimeModelStatus } from "../hub/model-overrides.ts";
3
+
4
+ export const MODEL_USAGE = "usage: ahub model set <peer> <model> | clear <peer> | list [--json]";
5
+ const peerToken = (value: unknown): value is string => typeof value === "string" && /^[a-z][a-z0-9-]{0,31}$/.test(value);
6
+ export function parseModelCommand(args: readonly string[]): { op: "list" | "set" | "clear"; peer?: string; model?: string; json: boolean } {
7
+ const [op, peer, model, ...extra] = args;
8
+ if (op === "list" && (args.length === 1 || args.length === 2 && peer === "--json")) return { op, json: peer === "--json" };
9
+ if (op === "clear" && args.length === 2 && peerToken(peer)) return { op, peer, json: false };
10
+ if (op === "set" && !extra.length && args.length === 3 && peerToken(peer) && isNativeModelToken(model)) return { op, peer, model, json: false };
11
+ throw new Error(MODEL_USAGE);
12
+ }
13
+
14
+ /** Closed metadata projection: an older or malformed reply never supplies display prose. */
15
+ export function projectModelStatus(value: unknown): RuntimeModelStatus | null {
16
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
17
+ const row: Record<string, unknown> = {};
18
+ try {
19
+ for (const key of ["peer", "effectiveModel", "desiredModel", "source", "pendingNextTurn", "unsupported"]) {
20
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
21
+ if (descriptor && "value" in descriptor) row[key] = descriptor.value;
22
+ }
23
+ } catch { return null; }
24
+ if (!peerToken(row.peer) || !(row.effectiveModel === null || isNativeModelToken(row.effectiveModel)) || !(row.desiredModel === null || isNativeModelToken(row.desiredModel)) || !(typeof row.source === "string" && ["override", "instance", "launch", "native default", "native thread default (was override)"].includes(row.source)) || typeof row.pendingNextTurn !== "boolean") return null;
25
+ let unsupported: RuntimeModelStatus["unsupported"];
26
+ if (row.unsupported !== undefined) {
27
+ if (!row.unsupported || typeof row.unsupported !== "object" || Array.isArray(row.unsupported)) return null;
28
+ let reasonCode: unknown, kind: unknown;
29
+ try {
30
+ const code = Object.getOwnPropertyDescriptor(row.unsupported, "reasonCode"), adapter = Object.getOwnPropertyDescriptor(row.unsupported, "kind");
31
+ reasonCode = code && "value" in code ? code.value : undefined;
32
+ kind = adapter && "value" in adapter ? adapter.value : undefined;
33
+ } catch { return null; }
34
+ if (kind !== undefined && !peerToken(kind)) return null;
35
+ if (reasonCode === "unverified_setter") unsupported = { reasonCode, ...(kind ? { kind } : {}), reason: `unsupported live: ${kind ?? "retained peer"} (version unverified) has no verified model setter`, nextStep: `restart this peer with ahub restart ${row.peer}` };
36
+ else if (reasonCode === "unmanaged_attachment") unsupported = { reasonCode, reason: "unsupported live: this attachment has no verified live model setter", nextStep: `change the native setting or restart this peer with ahub restart ${row.peer}` };
37
+ else if (reasonCode === "acp_setter_unavailable") {
38
+ if (!peerToken(kind)) return null;
39
+ let version: unknown;
40
+ try {
41
+ const descriptor = Object.getOwnPropertyDescriptor(row.unsupported, "version");
42
+ if (descriptor && !("value" in descriptor)) return null;
43
+ version = descriptor?.value;
44
+ } catch { return null; }
45
+ if (version !== undefined && !(isNativeModelToken(version) && /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(version))) return null;
46
+ unsupported = { reasonCode, kind, ...(typeof version === "string" ? { version } : {}), reason: `unsupported live: ${kind} ${version ?? "unknown"} lacks a setter receipt or compat admission`, nextStep: `restart this peer with ahub restart ${row.peer}` };
47
+ }
48
+ else return null;
49
+ }
50
+ return { peer: row.peer, effectiveModel: row.effectiveModel, desiredModel: row.desiredModel, source: row.source as RuntimeModelStatus["source"], pendingNextTurn: row.pendingNextTurn,
51
+ ...(unsupported ? { unsupported } : {}) };
52
+ }
package/src/cli/output.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { projectModelStatus } from "./model.ts";
2
+ import type { RuntimeModelStatus } from "../hub/model-overrides.ts";
1
3
  import type { QueueBackpressureStatus } from "../hub/queue-backpressure.ts";
2
4
  import { definitionOf } from "../adapters/registry.ts";
3
5
  import { windowKind } from "../hub/window-kind.ts";
@@ -14,6 +16,8 @@ import type { SpecsStatus } from "../hub/specs-task.ts";
14
16
 
15
17
  export interface DoctorCheck { section: "Tools" | "Hub" | "Config" | "Models" | "Memory"; level: "ok" | "warn" | "fail" | "unknown"; name: string; detail: string }
16
18
  export interface StatusOutput {
19
+ runtimeModels?: RuntimeModelStatus[];
20
+ runtimeModelsUnavailable?: boolean;
17
21
  roomsUnavailable?: unknown;
18
22
  roomsOpening?: boolean;
19
23
  controlWriteTiming?: { active: number; slowRequests: number; blockedLoopSamples: number; droppedActive: number; droppedRecent: number; droppedLogs: number; droppedSpans?: number; droppedMetadata?: number; expiredRequests?: number };
@@ -33,7 +37,7 @@ export interface StatusOutput {
33
37
  pendingTaskOffers?: readonly { task: number; peer: string }[];
34
38
  isolatedChecks?: { pending?: number; orphans?: string[]; unknownReceipts?: readonly string[]; maintenanceUnavailable?: boolean };
35
39
  memoryRead?: { truncated: boolean; scanned: number; limit: number };
36
- memoryProfile?: { profile?: string; generation?: string; state?: string; agentMemVersion?: string; skippedRows?: number; error?: string; sync?: { state: "disabled" | "unavailable" | "notrun" | "not-configured" | "stopped" | "ok" | "failed" | "unknown"; at?: number; cause?: string; declinedCount?: number; declinedIdsKnown?: false; withheldCount?: number; exportedCount?: number; placementActive?: false; nextStep?: string } };
40
+ memoryProfile?: { profile?: string; generation?: string; state?: string; agentMemVersion?: string; skippedRows?: number; error?: string; sync?: { state: "disabled" | "unavailable" | "notrun" | "not-configured" | "stopped" | "ok" | "failed" | "unknown"; at?: number; cause?: string; declinedCount?: number; declinedIdsKnown?: false; withheldCount?: number; exportedCount?: number; placementActive?: false; writerCategory?: string; writerCauseClass?: string; nextStep?: string } };
37
41
  /** #784 AC3: `active`, `reviewWait` and `reserved` add up to `wip`; absent from an older hub. */
38
42
  taskWip?: Record<string, { wip: number; wipLimit: number; active?: number; reviewWait?: number; reserved?: number }>;
39
43
  pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; roleInfo?: Record<string, any>; actionPolicyInfo?: Record<string, { note: string; enforceable: boolean }>; budget?: Record<string, any>; planner?: { peers?: string[]; source?: string };
@@ -43,6 +47,14 @@ export interface StatusOutput {
43
47
  /** `peers`: one note per peer that shows no number, and why (no quota, nothing read yet); absent from an older hub. */
44
48
  export interface BudgetOutput { budget: Record<string, any>; gate?: number; peers?: { peer: string; kind?: string; note: string }[] }
45
49
  export interface RosterRow { peer: string; on: boolean; startMode: string; state: string; queued: number; tasks: number }
50
+ export function renderRuntimeModels(models: readonly RuntimeModelStatus[], columns?: number): Span[][] {
51
+ const out = rows(["PEER", "EFFECTIVE MODEL", "SOURCE", "STATE"], models.map(row => [span(row.peer, "info", true), span(row.effectiveModel ?? "not reported"), span(row.source), span(row.pendingNextTurn ? "pending next turn" : "current")]), columns);
52
+ for (const row of models) {
53
+ if (row.pendingNextTurn) out.push(...lines(`${row.peer}: next turn ${row.desiredModel ?? "native default"}; the active turn is unchanged.`, columns, "attention"));
54
+ if (row.unsupported) out.push(...lines(`${row.peer}: ${row.unsupported.reason}; ${row.unsupported.nextStep}`, columns, "attention"));
55
+ }
56
+ return out;
57
+ }
46
58
  export function renderRoster(data: RosterRow[], columns?: number): Span[][] {
47
59
  return rows(["PEER", "ROSTER", "START", "STATE", "Q", "TASKS"], data.map(row => [span(row.peer, "info", true), span(row.on ? "on" : "off", row.on ? "success" : "muted", true), span(row.startMode, undefined, true), span(row.state, stateTone(row.state), true), span(row.queued, undefined, true), span(row.tasks, undefined, true)]), columns);
48
60
  }
@@ -218,6 +230,8 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
218
230
  if (data.roleInfo) data = { ...data, peers: Object.fromEntries(Object.entries(data.peers).map(([peer, value]) => [peer, { ...value, ...data.roleInfo?.[peer] }])) };
219
231
  const out = lines(`agent-hub${data.version ? ` ${data.version}` : ""} pid ${data.pid ?? "unknown"} control 127.0.0.1:${data.controlPort ?? "unknown"}`, columns, "info");
220
232
  if (data.cwd) out.push(...lines(data.cwd, columns));
233
+ if (data.runtimeModelsUnavailable === true) out.push(...lines("Runtime models unavailable: could not read a bounded model catalog; run ahub model list for the cause. For a known peer, use ahub model set <peer> <model> or ahub model clear <peer>; repair storage or reduce the retained peer catalog as indicated before listing again.", columns, "attention"));
234
+ else if (Array.isArray(data.runtimeModels)) out.push(...renderRuntimeModels(data.runtimeModels.map(projectModelStatus).filter((row): row is RuntimeModelStatus => row !== null), columns));
221
235
  const timing = data.controlWriteTiming;
222
236
  if (full && timing && Object.values(timing).every(value => Number.isSafeInteger(value) && value >= 0)) {
223
237
  out.push(...lines(`control writes: active ${timing.active}, slow ${timing.slowRequests}, loop delays ${timing.blockedLoopSamples}, dropped ${timing.droppedActive + timing.droppedLogs + (timing.droppedSpans ?? 0) + (timing.droppedMetadata ?? 0)}, expired ${timing.expiredRequests ?? 0}; timings in hub.log`, columns, timing.slowRequests || timing.blockedLoopSamples ? "attention" : undefined));
@@ -290,6 +304,14 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
290
304
  let settling = false;
291
305
  const peerDetails = Object.entries(data.peers).map(([id, p]) => {
292
306
  const out: Span[][] = [];
307
+ // #1310: the current owner, when the hub reported one. Shown before the standby rows so the owner is never inferred from them. Every value is re-checked here: a pid is a positive integer, a time is
308
+ // finite, and a native session id is shown only when it is a short plain token; anything else is left out rather than cleaned up.
309
+ const owner = p.ownerAttachment;
310
+ if (owner && typeof owner === "object" && Number.isFinite(owner.attachedAt)) {
311
+ const pid = Number.isSafeInteger(owner.ownerPid) && owner.ownerPid > 0 ? `pid ${owner.ownerPid}` : "pid unknown";
312
+ const native = typeof owner.nativeSessionId === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(owner.nativeSessionId) ? `native session ${owner.nativeSessionId}` : "native session unverified";
313
+ out.push(...detail(`${id} owner`, `${pid}; attached ${relative(owner.attachedAt, now)}; ${native}`, columns, "muted"));
314
+ }
293
315
  for (const session of Array.isArray(p.standbySessions) ? p.standbySessions : []) {
294
316
  if (!session || typeof session.id !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(session.id)) continue;
295
317
  const pid = Number.isSafeInteger(session.pid) && session.pid > 0 ? String(session.pid) : "unknown";
package/src/cli/reset.ts CHANGED
@@ -149,7 +149,7 @@ export function resetRuntime(stateDir: string, project: Project): { settled: str
149
149
  const db = openHubDatabase(file);
150
150
  try {
151
151
  db.transaction(() => {
152
- for (const table of ["budget_pauses", "conductor_holds"]) {
152
+ for (const table of ["budget_pauses", "conductor_holds", "model_overrides"]) {
153
153
  if (db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table)) db.run(`DELETE FROM ${table}`);
154
154
  }
155
155
  })();
@@ -1,11 +1,11 @@
1
1
  import { projectRoomUnavailable } from "../rooms/status.ts";
2
2
  import type { ContextView } from "../hub/context-window.ts";
3
- import { memorySyncNextStep, type MemorySyncCause } from "../hub/memory-sync-state.ts";
3
+ import { memorySyncNextStep, WRITER_CATEGORIES, WRITER_CAUSE_CLASSES, type MemorySyncCause } from "../hub/memory-sync-state.ts";
4
4
  // What `ahub status` prints for a peer and for a model backend, kept pure so it can be checked.
5
5
 
6
6
  export interface PeerRow { permissionMode?: string; context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; reviewObserverHoldNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; importantWaitingSince?: number; importantWaitingNote?: string; attached?: boolean; attachMode?: "channel" | "tools-only"; attachEvidence?: string; toolsOnly?: string; pullOnly?: boolean; lastInboxAt?: number | null; permissionNote?: string; reviewerUnavailable?: string; paused?: string; servedBy?: string; requestedModel?: string }
7
7
  export interface BackendRow { kind?: string; alias?: string; state?: string; active?: number; requestedModel?: string; actualModel?: string; provider?: string; coolingUntil?: string; failures?: number; failingUntil?: string }
8
- export interface MemorySyncRow { state?: string; at?: number; cause?: string; declinedCount?: number; declinedIdsKnown?: false; withheldCount?: number; exportedCount?: number; placementActive?: false; nextStep?: string }
8
+ export interface MemorySyncRow { state?: string; at?: number; cause?: string; declinedCount?: number; declinedIdsKnown?: false; withheldCount?: number; exportedCount?: number; placementActive?: false; writerCategory?: string; writerCauseClass?: string; nextStep?: string }
9
9
  export interface ReviewInputHealthRow { state?: string; cause?: string | null }
10
10
 
11
11
  /** Aliases are already namespaced ("dgx/coding"); only an alias that is not gets its kind in front of it. */
@@ -32,11 +32,15 @@ export function memorySyncLine(sync: MemorySyncRow, time = (at: number) => new D
32
32
  && Number.isSafeInteger(sync.exportedCount) && sync.exportedCount! >= 0)
33
33
  return `ok${stamp}; ${sync.withheldCount} row${sync.withheldCount === 1 ? "" : "s"} withheld locally; ${sync.exportedCount} exported`;
34
34
  if (state !== "failed") return `${state}${stamp}`;
35
- const causes = ["declined", "remote", "binding", "stopped", "transport", "unknown"];
35
+ const causes = ["declined", "remote", "binding", "stopped", "transport", "writer", "unknown"];
36
36
  const cause = (causes.includes(sync.cause ?? "") ? sync.cause : "unknown") as MemorySyncCause;
37
37
  if (cause === "declined" && sync.placementActive === false) {
38
38
  const declined = Number.isSafeInteger(sync.declinedCount) && sync.declinedCount! >= 0 ? String(sync.declinedCount) : "unknown";
39
- return "machine sync blocked: " + declined + " declined rows; per-row placement is not active on this store. Activation is person-only: preview with `amem migrate --sync-placement --dry-run` and declare every writer via --writer-root. The hub does not yet supply the fresh placementAttestation required for active-placement sync (#1173). Inspect with `ahub memory declined`.";
39
+ return "machine sync blocked: " + declined + " declined rows; per-row placement is not active on this store. Activation is person-only: preview with `amem migrate --sync-placement --dry-run` and declare every writer via --writer-root. Inspect with `ahub memory declined`.";
40
+ }
41
+ if (cause === "writer") {
42
+ const category = WRITER_CATEGORIES.includes(sync.writerCategory ?? "") ? sync.writerCategory : "unknown", causeClass = WRITER_CAUSE_CLASSES.includes(sync.writerCauseClass ?? "") ? sync.writerCauseClass : "unavailable";
43
+ return `failed (writer attestation refused: ${category}, ${causeClass})${stamp}; next: ${memorySyncNextStep("writer")}`;
40
44
  }
41
45
  const count = Number.isSafeInteger(sync.declinedCount) && sync.declinedCount! >= 0 ? `, ${sync.declinedCount} declined` : "";
42
46
  const ids = cause === "declined" && sync.declinedIdsKnown === false ? "; declined ID set unknown" : "";
package/src/hub/budget.ts CHANGED
@@ -54,7 +54,7 @@ export interface BudgetConfig {
54
54
  wait_max_min: number;
55
55
  }
56
56
  // wait_max_min is off here, as approvals.notify is: a project config turns it on (30 unless it says otherwise).
57
- 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 };
57
+ export const DEFAULT_BUDGET: BudgetConfig = { gate: 0.99, stale_min: 30, poll_min: 10, checkpoint_timeout_s: 90, kimi_tokens_5h: 0, wait_max_min: 0 };
58
58
 
59
59
  export type BudgetLedgerEntry =
60
60
  | { type: "budget_pause"; peer: PeerId; reason: string; window: string; resetsAt: number; by: "hub" | "person" }
package/src/hub/bus.ts CHANGED
@@ -9,6 +9,7 @@ import { DELIVERY_RETRY_WARNING } from "./delivery-hints.ts";
9
9
  import { MAX_DEFERRED_HUB, RecoveryDeferredHubError, validateDeferredHub } from "./deferred-hub.ts";
10
10
  import { parseRemotePeerId } from "../rooms/application.ts";
11
11
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
12
+ import { stripRecordLines } from "../memory/records.ts";
12
13
 
13
14
  export type { DeliveryReceipt } from "./peers.ts";
14
15
 
@@ -214,6 +215,10 @@ export class Bus {
214
215
  * peer was handed (the request is in it, never condensed), so a reply that answers an invocation is found by identity, not by addressing.
215
216
  */
216
217
  onAnswer?: (peer: PeerId, text: string, answers: readonly Envelope[]) => boolean;
218
+ /** Completed adapter answers only, under the same public-input observer ceiling as memory delivery. */
219
+ onFinalAnswer?: (peer: PeerId, text: string, context: { out: readonly Envelope[]; originals: readonly Envelope[] }) => void;
220
+ /** Count-only forwarding telemetry; never emitted for a private, review-scoped or unbound answer. */
221
+ onFinalAnswerBytes?: (peer: PeerId, counts: { before: number; after: number }) => void;
217
222
  /** Host ledger authority; absent or unknown never permits invocation replay. */
218
223
  canRetryInvocation?: (id: string) => boolean;
219
224
  onInvocationUncertain?: (id: string) => void;
@@ -315,7 +320,10 @@ export class Bus {
315
320
  peer.onDelivery = (receipt) => { if (this.peers.get(peer.id) === peer) this.deliveryReceipt(peer.id, receipt); };
316
321
  peer.onDoing = (kind, text) => { if (kind === "reset") this.clearReviewProvenance(peer.id); if (!this.closed && this.peers.get(peer.id) === peer) this.opts.doing?.(peer.id, kind, text); };
317
322
  this.opts.doing?.(peer.id, "reset"); // a new adapter never shows what the one it replaces was doing
318
- peer.onMessage = (text, opts) => {
323
+ // A final still traverses the current onMessage observer exactly once. This one-shot
324
+ // marker is host-local, never a caller-supplied envelope or wire field.
325
+ let finalMessage: { body: string; options: EnvelopeOpts | undefined; complete: boolean } | undefined;
326
+ const receive = (text: string, opts?: EnvelopeOpts, final = false, complete = true) => {
319
327
  if (!this.isRostered(peer.id)) return `off roster: ${peer.id}`;
320
328
  // #445 C2: an answer to an invocation is found by what the peer was handed, never by how the answer is addressed, so a headless agent whose final answer is
321
329
  // normally shared with every peer cannot leak it. It is the invocation's answer when it names the request itself (an adapter may report its delivery complete before
@@ -331,12 +339,29 @@ export class Bus {
331
339
  : parentId === undefined && this.invocationTurnOpen(peer.id)
332
340
  ? pushedInvocation ? pushed : pulledInvocation ? pulled : undefined
333
341
  : undefined;
342
+ let finalPriority: Priority | undefined;
343
+ if (final) {
344
+ const context = parentId === undefined ? pulled ?? pushed : namedPush ? pushed : namedPull ? pulled : undefined;
345
+ const raw = text;
346
+ finalPriority = parseMarker(raw).priority;
347
+ // Strip before invocation routing, publication, journaling, digests or the console event tap.
348
+ text = stripRecordLines(raw);
349
+ let observable = !this.closed && this.peers.get(peer.id) === peer && !!context && opts?.private !== true && !remoteId(peer.id) && !opts?.to?.some(remoteId) && !answerParent;
350
+ try {
351
+ observable &&= this.opts.reviewObserverInputExcluded?.(peer.id, context!.originals) !== true &&
352
+ ![...context!.out, ...context!.originals].some(env => env.private || isReviewObserverExcluded(env) || isInvocationKind(env) || remoteId(env.from) || env.to?.some(remoteId));
353
+ } catch { observable = false; }
354
+ if (observable) {
355
+ if (complete) try { this.onFinalAnswer?.(peer.id, raw, context!); } catch { /* capture is optional and never changes answer routing */ }
356
+ try { this.onFinalAnswerBytes?.(peer.id, { before: Buffer.byteLength(parseMarker(raw).body), after: Buffer.byteLength(parseMarker(text).body) }); } catch { /* count-only observer */ }
357
+ }
358
+ }
334
359
  if (this.onAnswer && answerParent && (namedPush || namedPull || parentId === undefined && this.invocationTurnOpen(peer.id))) {
335
360
  let taken = false;
336
361
  try { taken = this.onAnswer(peer.id, text, answerParent.out) === true; } catch { taken = true; } // a throwing host never turns a callee's answer into a broadcast
337
362
  if (taken) return;
338
363
  }
339
- const { priority, body } = parseMarker(text);
364
+ const parsed = parseMarker(text), priority = finalPriority ?? parsed.priority, body = parsed.body;
340
365
  if (!body) return;
341
366
  // What the peer was handed differs from what it stands for once a delivery was condensed; both the audience
342
367
  // and the priority ceiling are about what it stands for.
@@ -393,6 +418,19 @@ export class Bus {
393
418
  if (hushed.length) this.note(peer.id, noteLine(HUB, "decision", `your message was not delivered to ${hushed.map((h) => h.peer).join(", ")}: ${hushed[0]!.reason}`));
394
419
  return heldBack ? hushed[0]!.reason : published.dropped === "hop" ? published : undefined;
395
420
  };
421
+ peer.onMessage = (text, opts) => {
422
+ if (this.peers.get(peer.id) !== peer) return;
423
+ const marker = finalMessage?.body === text && finalMessage.options === opts ? finalMessage : undefined;
424
+ if (marker) finalMessage = undefined; // Consume before observers can emit nested diagnostics.
425
+ return receive(text, opts, marker !== undefined, marker?.complete ?? true);
426
+ };
427
+ peer.onFinalAnswer = (text, opts, complete = true) => {
428
+ if (this.closed || this.peers.get(peer.id) !== peer) return;
429
+ const previous = finalMessage;
430
+ finalMessage = { body: text, options: opts, complete };
431
+ try { return peer.onMessage?.(text, opts); }
432
+ finally { finalMessage = previous; }
433
+ };
396
434
  peer.onFailed = (envs, failure) => {
397
435
  // The adapter got the condensed list; what has to come back is what that list replaced.
398
436
  const last = this.lastDelivery.get(peer.id);
@@ -95,9 +95,13 @@ export class CodeFacts {
95
95
  catch{throw Error("code facts cleanup unconfirmed");}
96
96
  }
97
97
  }
98
- constructor(private readonly options:{env?:NodeJS.ProcessEnv;timeoutMs?:number;now?:()=>number}={}) {}
98
+ /** #1261: `trace` is a read-only timing tap for tests (the step's name, its wall time in ms and its outcome). Absent in production, where it costs one undefined check per step; it never changes an answer, a bound or a cap. */
99
+ constructor(private readonly options:{env?:NodeJS.ProcessEnv;timeoutMs?:number;now?:()=>number;trace?:(step:string,ms:number,outcome:string)=>void}={}) {}
99
100
  private git(repo:string,args:string[]) {
100
- return hubGitSync(["-C",repo,...args],{encoding:"utf8",env:hubGitEnv(childEnv(),{repositoryIsolation:true}),timeout:5000,maxBuffer:1024*1024});
101
+ const trace=this.options.trace,t0=trace?performance.now():0;
102
+ const r=hubGitSync(["-C",repo,...args],{encoding:"utf8",env:hubGitEnv(childEnv(),{repositoryIsolation:true}),timeout:5000,maxBuffer:1024*1024});
103
+ trace?.(`git ${args.slice(0,2).join(" ")}`,performance.now()-t0,r.error?.message??`status ${r.status}`);
104
+ return r;
101
105
  }
102
106
  private value(repo:string,args:string[]):string {const r=this.git(repo,args);if(r.status!==0)throw Error();return r.stdout.trim();}
103
107
  private nativeEnv(cwd:string):NodeJS.ProcessEnv {
@@ -115,7 +119,7 @@ export class CodeFacts {
115
119
  private async run(bin:string,args:string[],cwd:string):Promise<{code:number;out:string}> {
116
120
  if(this.closing || this.active?.cancelled)return {code:1,out:""};
117
121
  return new Promise(resolve=>{
118
- let child:ReturnType<typeof spawn>;
122
+ let child:ReturnType<typeof spawn>;const trace=this.options.trace,t0=trace?performance.now():0;
119
123
  try{child=spawn(process.execPath,["-e",LAUNCHER,"--",bin,...args],{cwd,env:this.nativeEnv(cwd),detached:true,stdio:["ignore","pipe","ignore","pipe"]});}catch{resolve({code:1,out:""});return;}
120
124
  let size=0,done=false,failed=false,control="";const parts:Buffer[]=[];
121
125
  let ended=false;const outputEnd=new Promise<void>(resolveEnd=>child.stdout!.once("end",()=>{ended=true;resolveEnd();}));
@@ -125,6 +129,7 @@ export class CodeFacts {
125
129
  if(!ended)code=null;
126
130
  try{await stopOwnedProcess(child,{group:true,termMs:50,killMs:500});}catch{this.failedChild=child;code=null;}
127
131
  if(this.cancelActive===cancel)this.cancelActive=undefined;
132
+ trace?.(`ripwire ${args.find(a=>a.startsWith("--"))?.split("=")[0]??"launch"}`,performance.now()-t0,failed?"failed, capped or cancelled":`code ${code}`);
128
133
  resolve({code:failed?1:code??1,out:!failed && code!==null?Buffer.concat(parts).toString("utf8"):""});
129
134
  };
130
135
  const cancel=()=>{void finish(null);};this.cancelActive=cancel;
@@ -157,7 +162,9 @@ export class CodeFacts {
157
162
  if(this.git(repo,["worktree","add","--detach","--no-checkout",cwd,head]).status!==0)throw Error();registered=true;
158
163
  if(this.git(cwd,["read-tree",head]).status!==0)throw Error();
159
164
  // --batch needs explicit object ids; no working-tree filters or checkout hooks are invoked.
165
+ const catAt=this.options.trace?performance.now():0;
160
166
  const result=hubGitSync(["-C",repo,"cat-file","--batch"],{input:entries.map(e=>e.oid).join("\n")+"\n",encoding:"latin1",env:hubGitEnv(childEnv(),{repositoryIsolation:true}),timeout:5000,maxBuffer:MAX_TREE_BYTES+1024*1024});
167
+ this.options.trace?.("git cat-file --batch",performance.now()-catAt,result.error?.message??`status ${result.status}`);
161
168
  if(result.status!==0)throw Error();
162
169
  const bytes=Buffer.from(result.stdout,"latin1");let offset=0,total=0;
163
170
  for(const entry of entries){
@@ -26,6 +26,8 @@ export interface SettleFacts {
26
26
  runningChecks: number;
27
27
  /** Rows held or in `needs_review`: uncertain, so never settled. */
28
28
  heldDeliveries: number;
29
+ /** Native background work when the adapter exposes an inventory; null means the inventory exists but is unreadable or incomplete. */
30
+ nativeBackground?: number | null;
29
31
  }
30
32
 
31
33
  export type RunState = "working" | "needs-input" | "settled";
@@ -33,7 +35,7 @@ export type RunState = "working" | "needs-input" | "settled";
33
35
  /** The one rule. A permission or question wins over everything else: the person has to act, so the run is needs-input and never "done". */
34
36
  export function settleVerdict(facts: SettleFacts): RunState {
35
37
  if (facts.openPermissions > 0 || facts.openQuestions > 0) return "needs-input";
36
- if (!facts.signal || facts.queuedContinuations > 0 || facts.ownedChildren > 0 || facts.runningChecks > 0 || facts.heldDeliveries > 0) return "working";
38
+ if (!facts.signal || facts.queuedContinuations > 0 || facts.ownedChildren > 0 || facts.runningChecks > 0 || facts.heldDeliveries > 0 || facts.nativeBackground === null || (facts.nativeBackground ?? 0) > 0) return "working";
37
39
  return "settled";
38
40
  }
39
41
 
@@ -226,6 +226,7 @@ const schema: Record<string, Check> = {
226
226
  else if (key === "remote") remoteRunnerConfig(value);
227
227
  else if (/^[a-z][a-z0-9_]{0,47}$/.test(key)) text(value, `${k}.${key}`); else fail(`${k}.${key}`, "unknown key");
228
228
  } },
229
+ visuals: block({ retention_days: number(1, 3650, true) }),
229
230
  snapshots: block({ enabled: bool, keep: number(1, 1_000_000, true) }), research: block({ enabled: bool, runners: researchRunners }), bench: block({ enabled: bool }),
230
231
  worktrees: block({ quiet_days: number(0, 3650, true), alert_over: number(0, 100_000, true) }),
231
232
  // #1053: how long an unanswered question waits (`ttl_s`, 1 minute to 30 days) and, per adapter kind, how long a `hub_ask` call waits for the person before it answers `pending`.
@@ -276,7 +277,7 @@ const restart: Record<string, string> = {
276
277
  profile: "Session tool schemas and profile contracts are bound at startup", profile_overrides: "Session tool schemas and profile contracts are bound at startup",
277
278
  text_projection: "Converter and cache ownership are bound at startup", corpus: "The corpus index is bound to its roots at startup", terminal: "Terminal provider is supplied by the daemon lifecycle", bench: "Benchmark execution environment is selected before work starts",
278
279
  };
279
- const live = new Set(["action_policy", "permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "invocation", "research", "snapshots", "worktrees", "tasks.wip_limits", "tasks.escalate_after", "tasks.auto_pull", "tasks.queue_backpressure", "schedules", "approvals.notify", "questions", "pet", "specs"]);
280
+ const live = new Set(["visuals", "action_policy", "permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "invocation", "research", "snapshots", "worktrees", "tasks.wip_limits", "tasks.escalate_after", "tasks.auto_pull", "tasks.queue_backpressure", "schedules", "approvals.notify", "questions", "pet", "specs"]);
280
281
  const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
281
282
  /** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
282
283
  export function configApply(key: string, scopes: readonly ConfigIdentityScope[] = []): ApplyRule {
@@ -21,7 +21,7 @@ export const PROTOCOL = 17;
21
21
  /** The descriptor's legacy protocol field remains this floor for previous published clients. */
22
22
  export const PROTOCOL_MIN = 16;
23
23
  // Literal metadata is read without executing a recovery target; consistency is pinned in tests.
24
- export const DAEMON_FEATURES = ["project_autonomy", "conductor_recovery", "encrypted_rooms", "external_adapter_instances", "external_adapter_modules", "configured_acp", "native_hook_launch", "task_cancel", "invocation", "recovery_deferred_hub", "peer_instances", "role_stages", "task_planners", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated", "action_policy_floor", "calendar_schedules", "research_records", "research_dispatch", "research_dropped_offers", "research_experiments", "research_comparisons", "research_runners", "task_handoff", "session_identity", "schema_guard", "design_specs", "queue_discard_states", "tool_write_idempotency", "research_evidence", "review_comments", "questions", "accepted_delivery_settlement", "completions", "review_observer_input_state", "task_handoff_key", "task_handoff_seq", "peer_takeover"] as const;
24
+ export const DAEMON_FEATURES = ["model_overrides", "visuals", "project_autonomy", "conductor_recovery", "encrypted_rooms", "external_adapter_instances", "external_adapter_modules", "configured_acp", "native_hook_launch", "task_cancel", "invocation", "recovery_deferred_hub", "peer_instances", "role_stages", "task_planners", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated", "action_policy_floor", "calendar_schedules", "research_records", "research_dispatch", "research_dropped_offers", "research_experiments", "research_comparisons", "research_runners", "task_handoff", "session_identity", "schema_guard", "design_specs", "queue_discard_states", "tool_write_idempotency", "research_evidence", "review_comments", "questions", "accepted_delivery_settlement", "completions", "review_observer_input_state", "task_handoff_key", "task_handoff_seq", "peer_takeover"] as const;
25
25
  /** Protocols a current coordinator may authenticate while upgrading a running source. */
26
26
  export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, 16, PROTOCOL] as const;
27
27