@staix/agent-hub 0.34.0 → 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 (161) hide show
  1. package/CHANGELOG.md +51 -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 +18 -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-0.34.1.md +54 -0
  33. package/docs/verification/2026-11-07-compat-python-startup-1248.md +51 -0
  34. package/docs/verification/2026-11-07-compat-receipt-harness-1248.md +105 -0
  35. package/docs/verification/2026-11-07-compat-receipt-producers-1248.md +96 -0
  36. package/docs/verification/2026-11-07-compat-workflow-1248.md +94 -0
  37. package/docs/verification/2026-11-07-daemon-startup-1232.md +169 -0
  38. package/docs/verification/2026-11-07-jcode-receipt-reconstruction-1248.md +118 -0
  39. package/docs/verification/2026-11-07-kimi-delayed-usage.md +34 -0
  40. package/docs/verification/2026-11-07-kimi-review-watch-1284.md +115 -0
  41. package/docs/verification/2026-11-08-agy-1.5.3.md +96 -0
  42. package/docs/verification/2026-11-08-agy-acp-t0.md +56 -0
  43. package/docs/verification/2026-11-08-hub-visual-1058.md +161 -0
  44. package/docs/verification/2026-11-08-jcode-0991-model-setter-1317.md +143 -0
  45. package/docs/verification/2026-11-08-kimi-252-model-setter-1317.md +137 -0
  46. package/docs/verification/2026-11-08-kimi-tui-completion-1282.md +45 -0
  47. package/docs/verification/2026-11-08-kimi-wire-yield-1283.md +31 -0
  48. package/docs/verification/2026-11-08-live-model-override-1317.md +250 -0
  49. package/docs/verification/2026-11-08-qwen-0269-acp.md +138 -0
  50. package/docs/verification/2026-11-08-qwen-0269-model-setter-1317.md +91 -0
  51. package/docs/verification/2026-11-09-0.35.0.md +129 -0
  52. package/docs/verification/2026-11-09-headless-records-1320.md +157 -0
  53. package/docs/verification/claude-permission-transport-native.py +10 -3
  54. package/docs/verification/claude-shell-wrapper-native.py +8 -8
  55. package/docs/verified.json +245 -15
  56. package/package.json +3 -3
  57. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  58. package/plugins/agent-hub/kimi-hook.js +20397 -0
  59. package/plugins/agent-hub/kimi-review-policy-worker.js +217 -0
  60. package/plugins/agent-hub/server.js +2701 -664
  61. package/scripts/check-docs.mjs +12 -1
  62. package/src/adapters/acp-model-policy.ts +56 -0
  63. package/src/adapters/acp.ts +175 -5
  64. package/src/adapters/agy/entry.ts +8 -5
  65. package/src/adapters/agy/hooks.ts +65 -15
  66. package/src/adapters/channel-peer.ts +33 -1
  67. package/src/adapters/claude-implementer.ts +16 -3
  68. package/src/adapters/codex-appserver.ts +118 -8
  69. package/src/adapters/codex-review-isolation.ts +3 -1
  70. package/src/adapters/compat.ts +11 -6
  71. package/src/adapters/definitions/builtin-metadata.ts +8 -1
  72. package/src/adapters/definitions/native-cli-entry-kimi.ts +76 -7
  73. package/src/adapters/definitions/native-cli-preview-common.ts +2 -2
  74. package/src/adapters/definitions/native-cli-preview-kimi.ts +9 -2
  75. package/src/adapters/definitions/reviewer-variants.ts +3 -1
  76. package/src/adapters/kimi-review-policy-worker.js +2 -0
  77. package/src/adapters/kimi-review-policy-worker.ts +85 -0
  78. package/src/adapters/kimi-review-policy.ts +68 -0
  79. package/src/adapters/kimi-review.ts +41 -41
  80. package/src/adapters/kimi-tui-facts.ts +153 -0
  81. package/src/adapters/kimi-wire-usage.ts +33 -8
  82. package/src/adapters/local-worker.ts +59 -20
  83. package/src/adapters/model-token.ts +3 -0
  84. package/src/adapters/pi.ts +27 -5
  85. package/src/adapters/registry-types.ts +34 -3
  86. package/src/adapters/registry.ts +1 -0
  87. package/src/adapters/reviewer-policy-watch.ts +86 -0
  88. package/src/adapters/reviewer-print.ts +80 -18
  89. package/src/cli/agents.ts +1 -1
  90. package/src/cli/facts-hook.ts +4 -2
  91. package/src/cli/help.ts +3 -2
  92. package/src/cli/identity.ts +3 -0
  93. package/src/cli/kimi-facts-hook.ts +11 -0
  94. package/src/cli/kimi-plugin.ts +157 -0
  95. package/src/cli/main.ts +48 -9
  96. package/src/cli/model.ts +52 -0
  97. package/src/cli/output.ts +23 -1
  98. package/src/cli/recovery-package.ts +15 -7
  99. package/src/cli/reset.ts +1 -1
  100. package/src/cli/status-lines.ts +8 -4
  101. package/src/hub/budget.ts +1 -1
  102. package/src/hub/bus.ts +40 -2
  103. package/src/hub/code-facts.ts +10 -3
  104. package/src/hub/completions.ts +3 -1
  105. package/src/hub/config-apply.ts +2 -1
  106. package/src/hub/control-client.ts +1 -1
  107. package/src/hub/daemon.ts +1050 -106
  108. package/src/hub/deliverable-files.ts +38 -5
  109. package/src/hub/dogfood-profile.ts +1 -1
  110. package/src/hub/envelope.ts +1 -1
  111. package/src/hub/events.ts +28 -18
  112. package/src/hub/execution-lineage.ts +120 -0
  113. package/src/hub/execution-reference.ts +36 -0
  114. package/src/hub/hub-tools.ts +21 -2
  115. package/src/hub/instances.ts +4 -3
  116. package/src/hub/kimi-events.ts +46 -0
  117. package/src/hub/kimi-usage.ts +132 -29
  118. package/src/hub/lifecycle.ts +31 -15
  119. package/src/hub/limits.ts +13 -4
  120. package/src/hub/manager.ts +15 -2
  121. package/src/hub/memory-policy.ts +2 -1
  122. package/src/hub/memory-sync-attestation.ts +54 -0
  123. package/src/hub/memory-sync-state.ts +20 -5
  124. package/src/hub/model-overrides.ts +115 -0
  125. package/src/hub/native-hook-launch.ts +12 -3
  126. package/src/hub/operational-view.ts +2 -2
  127. package/src/hub/peers.ts +27 -0
  128. package/src/hub/permission-mode.ts +1 -1
  129. package/src/hub/person-figures.ts +88 -0
  130. package/src/hub/report.ts +38 -4
  131. package/src/hub/research-dataset.ts +158 -0
  132. package/src/hub/research-memory.ts +68 -0
  133. package/src/hub/research-quality.ts +118 -0
  134. package/src/hub/research-record.ts +279 -0
  135. package/src/hub/research.ts +126 -27
  136. package/src/hub/restart.ts +14 -0
  137. package/src/hub/settings.ts +3 -0
  138. package/src/hub/startup-event-seeds.ts +51 -0
  139. package/src/hub/startup-readiness.ts +65 -0
  140. package/src/hub/startup-timing.ts +38 -0
  141. package/src/hub/tasks.ts +29 -9
  142. package/src/hub/ui.ts +9 -5
  143. package/src/hub/visual-assets.ts +50 -0
  144. package/src/hub/visual.ts +257 -0
  145. package/src/memory/brief.ts +169 -14
  146. package/src/memory/capture.ts +2 -1
  147. package/src/memory/recall.ts +46 -5
  148. package/src/memory/record-provenance.ts +82 -0
  149. package/src/memory/records.ts +116 -0
  150. package/src/models/route/adec.ts +190 -0
  151. package/src/models/route/config.ts +37 -3
  152. package/src/models/route/decision-evidence.ts +83 -0
  153. package/src/models/route/decision.ts +143 -0
  154. package/src/models/route/runtime.ts +13 -6
  155. package/src/ui/index.html +134 -6
  156. package/src/ui/plugin.ts +65 -4
  157. package/templates/AGENTS.block.md +1 -1
  158. package/templates/config.json +1 -1
  159. package/templates/profiles/general.json +1 -1
  160. package/templates/profiles/research.json +1 -1
  161. package/templates/profiles/writing.json +1 -1
@@ -1,30 +1,43 @@
1
1
  import { KimiWireReader, kimiCountedTokens, type KimiWireReason, type KimiWireRow } from "../adapters/kimi-wire-usage.ts";
2
+ import type { TaskAttribution } from "./attribution.ts";
2
3
  import { TokenWindow } from "./token-window.ts";
3
4
 
4
5
  /**
5
6
  * The hub's own count of Kimi's consumption (#285 phase 2): the wire-log rows of the session the adapter owns become `usage` and
6
- * `tokens` events and a rolling 5 h window against `budget.kimi_tokens_5h`. Read at a turn end and when a `usage_update` arrives:
7
- * no watcher, no timer. The ports keep the event log, the budget and the clock in the daemon.
7
+ * `tokens` events and a rolling 5 h window against `budget.kimi_tokens_5h`. Read at a turn end, when a `usage_update` arrives and for
8
+ * a bounded backoff after each cue. Each scheduled slice returns to the event loop before the next one. The ports keep the event log,
9
+ * the budget, the clock and delayed scheduling in the daemon.
8
10
  */
9
11
  export const KIMI_WINDOW_MS = 5 * 3_600_000;
10
12
  /** A turn's rows are written after it starts: a row older than the hub's first sight of the session (less this slack) is history. */
11
13
  const FLOOR_SLACK_MS = 1000;
12
14
  const PASSES = 16;
15
+ const FOLLOW_UP_DELAYS_MS = [250, 1000, 2000] as const;
16
+ const TURN_INTERVAL_LIMIT = 32;
13
17
  /** Failures a retry can cure: the log was not there yet, or was cut mid-write. Anything else says the count no longer matches the log. */
14
18
  const TRANSIENT: ReadonlySet<KimiWireReason> = new Set(["no-index", "io-error", "partial-line"]);
15
19
 
20
+ export type KimiUsageScheduler = (delayMs: number, run: () => void) => () => void;
21
+ export interface KimiTurnStart { id: string; startedAt: number; attribution: TaskAttribution }
22
+ export interface KimiRowAttribution { attribution: TaskAttribution; turn?: string }
23
+ interface KimiTurnInterval extends KimiTurnStart { endedAt?: number }
24
+
16
25
  export interface KimiUsagePorts {
17
26
  /** Kimi's data home. */
18
27
  home: string;
19
28
  /** Per-call byte bound of the reader (its default otherwise); a test shrinks it to force several passes. */
20
29
  maxBytes?: number;
30
+ /** Numeric I/O observations for deterministic benchmarks; no path, row or native text. */
31
+ observe?(stats: { bytes: number; discovery: number }): void;
21
32
  now(): number;
22
33
  /** `budget.kimi_tokens_5h`, read live: 0 turns the window off, never the events. */
23
34
  ceiling(): number;
24
35
  /** The adapter's own session id, undefined when none is bound. */
25
36
  session(peer: string): string | undefined;
26
- /** One usage event and its counted tokens (`counted` is the one rule in `kimiCountedTokens`). */
27
- record(peer: string, row: KimiWireRow, counted: number): void;
37
+ /** One delayed drain at a time. Its cancellation leaves no live timer. Production uses an unreferenced timer. */
38
+ schedule?: KimiUsageScheduler;
39
+ /** One usage event and its counted tokens (`counted` is the one rule in `kimiCountedTokens`), with its captured turn attribution. */
40
+ record(peer: string, row: KimiWireRow, counted: number, attributed: KimiRowAttribution): void;
28
41
  /** The window as a reading: `used` is not clamped here. */
29
42
  report(peer: string, window: { used: number; resetsAt: number; ceiling: number }): void;
30
43
  /** The count cannot be trusted now: the peer's `tokens` reading is dropped, so it shows unknown rather than a short number. */
@@ -33,7 +46,20 @@ export interface KimiUsagePorts {
33
46
  notice(line: string): void;
34
47
  }
35
48
 
36
- interface Bound { session: string; reader: KimiWireReader; floor: number; unknown?: KimiWireReason; noticed?: KimiWireReason }
49
+ interface Bound {
50
+ session: string;
51
+ reader: KimiWireReader;
52
+ floor: number;
53
+ turns: KimiTurnInterval[];
54
+ followUp: number;
55
+ passes: number;
56
+ backlog: boolean;
57
+ burst: boolean;
58
+ reportPending: boolean;
59
+ pending?: () => void;
60
+ unknown?: KimiWireReason;
61
+ noticed?: KimiWireReason;
62
+ }
37
63
 
38
64
  export class KimiUsage {
39
65
  private readonly bound = new Map<string, Bound>();
@@ -41,6 +67,7 @@ export class KimiUsage {
41
67
  private readonly seen = new Set<string>();
42
68
  /** A log that stopped matching the count leaves the window short of the truth until its rows age out. */
43
69
  private readonly taintedUntil = new Map<string, number>();
70
+ private stopped = false;
44
71
 
45
72
  constructor(private readonly ports: KimiUsagePorts) {}
46
73
 
@@ -65,42 +92,110 @@ export class KimiUsage {
65
92
  }
66
93
 
67
94
  /** The peer went offline: its log is no longer read, so an unreadable state of it must not hold the other peers' figure unknown. */
68
- drop(peer: string): void { this.bound.delete(peer); }
95
+ drop(peer: string): void {
96
+ const held = this.bound.get(peer);
97
+ if (held) this.cancel(held);
98
+ this.bound.delete(peer);
99
+ }
100
+
101
+ /** Stop only delayed work. A final synchronous offline read can still use the bound readers. */
102
+ stop(): void {
103
+ this.stopped = true;
104
+ for (const state of this.bound.values()) this.cancel(state);
105
+ }
69
106
 
70
107
  /** A new window: the old counts would pause the peer again at once (after a budget resume). */
71
108
  reset(): void { this.window.reset(); this.taintedUntil.clear(); }
72
109
 
73
- /** Called when a turn starts. It only binds the session and fixes the history floor: nothing is read here, and what the log already holds is not this hub's to count. */
74
- turnStarted(peer: string): void { this.bind(peer); }
110
+ /** Called when a turn starts. It binds the session, fixes the history floor and captures this turn's immutable attribution. */
111
+ turnStarted(peer: string, turn?: KimiTurnStart): void {
112
+ const bound = this.bind(peer);
113
+ if (!bound || !turn || bound.turns.some(interval => interval.id === turn.id)) return;
114
+ bound.turns.push({ ...turn, attribution: { ...turn.attribution } });
115
+ if (bound.turns.length > TURN_INTERVAL_LIMIT) bound.turns.splice(0, bound.turns.length - TURN_INTERVAL_LIMIT);
116
+ }
117
+
118
+ turnEnded(peer: string, turn: string, endedAt: number): void {
119
+ const bound = this.bound.get(peer);
120
+ const index = bound?.turns.findIndex(interval => interval.id === turn) ?? -1;
121
+ if (!bound || index < 0) return;
122
+ bound.turns[index] = { ...bound.turns[index]!, endedAt };
123
+ }
124
+
125
+ private cancel(bound: Bound): void {
126
+ bound.pending?.(); bound.pending = undefined; bound.burst = false; bound.followUp = 0;
127
+ }
75
128
 
76
129
  private bind(peer: string): Bound | undefined {
77
- const session = this.ports.session(peer);
78
- if (!session) return undefined;
79
- const held = this.bound.get(peer);
130
+ const session = this.ports.session(peer), held = this.bound.get(peer);
131
+ if (!session) {
132
+ if (held) this.cancel(held);
133
+ this.bound.delete(peer);
134
+ return undefined;
135
+ }
80
136
  if (held?.session === session) return held;
81
- const next: Bound = { session, reader: new KimiWireReader({ home: this.ports.home, sessionId: session, ...(this.ports.maxBytes ? { maxBytes: this.ports.maxBytes } : {}) }), floor: this.ports.now() - FLOOR_SLACK_MS };
137
+ if (held) this.cancel(held);
138
+ const next: Bound = {
139
+ session,
140
+ reader: new KimiWireReader({ home: this.ports.home, sessionId: session, ...(this.ports.maxBytes ? { maxBytes: this.ports.maxBytes } : {}) }),
141
+ floor: this.ports.now() - FLOOR_SLACK_MS,
142
+ turns: [], followUp: 0, passes: 0, backlog: false, burst: false, reportPending: false,
143
+ };
82
144
  this.bound.set(peer, next);
83
145
  return next;
84
146
  }
85
147
 
86
- collect(peer: string): void {
148
+ collect(peer: string, scheduled = false, terminal = false): void {
87
149
  const bound = this.bind(peer);
88
150
  if (!bound) return;
89
- let added = 0, more = false;
90
- for (let pass = 0; pass < PASSES; pass++) {
91
- const read = bound.reader.read();
92
- // Each successful pass has already moved the reader's offsets: its rows are recorded now, before a later pass can fail, or they would
93
- // never be read again and the count would be short.
94
- if (read.status === "unknown") { this.unknown(peer, bound, read.reason); return; }
95
- added += this.record(peer, bound, read.rows);
96
- more = read.more;
97
- if (!more) break;
151
+ try {
152
+ if (bound.passes >= PASSES) bound.passes = 0;
153
+ let added = 0, more = false;
154
+ // Without a scheduler or for the terminal offline read, retain the bounded synchronous drain. Other scheduled work uses one slice per invocation.
155
+ for (let pass = 0, slices = terminal || !this.ports.schedule ? PASSES : 1; pass < slices; pass++) {
156
+ const read = bound.reader.readSlice();
157
+ // A successful slice records and commits every row before a later slice can fail. An event failure leaves the slice uncommitted,
158
+ // so the next drain reads it again and the seen set removes only rows whose events already succeeded.
159
+ if (read.status === "unknown") { bound.backlog = false; bound.passes = 0; this.unknown(peer, bound, read.reason); return; }
160
+ this.ports.observe?.(bound.reader.stats());
161
+ try { added += this.record(peer, bound, read.rows); }
162
+ catch (error) { bound.backlog = false; bound.passes = 0; throw error; }
163
+ read.commit();
164
+ more = read.more;
165
+ bound.backlog = more;
166
+ bound.passes = more ? bound.passes + 1 : 0;
167
+ if (!more) break;
168
+ }
169
+ const recovered = bound.unknown !== undefined;
170
+ bound.unknown = undefined;
171
+ // ponytail: PASSES slices per cue burst. A longer backlog continues in the next burst, and no figure is reported until it is
172
+ // caught up: a window that is still catching up would be short. Upgrade: seek to the end at bind.
173
+ if (!more && (added > 0 || recovered || bound.reportPending)) { this.report(peer); bound.reportPending = false; }
174
+ } finally {
175
+ if (this.bound.get(peer) === bound) this.schedule(peer, bound, scheduled);
176
+ }
177
+ }
178
+
179
+ private schedule(peer: string, bound: Bound, scheduled: boolean): void {
180
+ const schedule = this.ports.schedule;
181
+ if (!schedule || this.stopped) return;
182
+ const run = () => {
183
+ bound.pending = undefined;
184
+ if (this.bound.get(peer) === bound) try { this.collect(peer, true); } catch { /* scheduled retries stay bounded */ }
185
+ };
186
+ if (bound.backlog && bound.passes < PASSES) {
187
+ if (!bound.pending) bound.pending = schedule(0, run);
188
+ return;
189
+ }
190
+ if (bound.pending) {
191
+ // A fresh cue during the last pending follow-up still gets a complete bounded retry sequence, without adding another timer.
192
+ if (!scheduled && bound.followUp >= FOLLOW_UP_DELAYS_MS.length - 1) { bound.burst = true; bound.followUp = 0; }
193
+ return;
98
194
  }
99
- const recovered = bound.unknown !== undefined;
100
- bound.unknown = undefined;
101
- // ponytail: PASSES x the reader's per-call bound. A longer unread backlog (a resumed session with a huge log) is finished by the next
102
- // call, and no figure is reported until it is: a window that is still catching up would be short. Upgrade: seek to the end at bind.
103
- if (!more && (added > 0 || recovered)) this.report(peer);
195
+ if (!bound.burst) { bound.burst = true; bound.followUp = 0; }
196
+ if (bound.followUp >= FOLLOW_UP_DELAYS_MS.length) { bound.burst = false; bound.followUp = 0; return; }
197
+ const delay = FOLLOW_UP_DELAYS_MS[bound.followUp++]!;
198
+ bound.pending = schedule(delay, run);
104
199
  }
105
200
 
106
201
  private record(peer: string, bound: Bound, rows: KimiWireRow[]): number {
@@ -108,15 +203,23 @@ export class KimiUsage {
108
203
  const now = this.ports.now();
109
204
  for (const row of rows) {
110
205
  if (this.seen.has(row.id) || row.at < bound.floor) continue;
206
+ const counted = kimiCountedTokens(row), attributed = this.attribute(bound, row.at);
207
+ this.ports.record(peer, row, counted, attributed);
111
208
  this.seen.add(row.id);
112
- const counted = kimiCountedTokens(row);
113
- this.ports.record(peer, row, counted);
114
209
  this.window.add(Math.min(row.at, now), counted);
210
+ if (counted > 0) bound.reportPending = true;
115
211
  added += counted;
116
212
  }
117
213
  return added;
118
214
  }
119
215
 
216
+ private attribute(bound: Bound, at: number): KimiRowAttribution {
217
+ const matches = bound.turns.filter(interval => at >= interval.startedAt && (interval.endedAt === undefined || at <= interval.endedAt));
218
+ if (matches.length !== 1) return { attribution: { attribution: "unattributed" } };
219
+ const turn = matches[0]!;
220
+ return { attribution: { ...turn.attribution }, turn: turn.id };
221
+ }
222
+
120
223
  private unknown(peer: string, bound: Bound, reason: KimiWireReason): void {
121
224
  bound.unknown = reason;
122
225
  try { this.ports.forget(peer); } catch { /* the reading ages out on its own */ }
@@ -1,8 +1,9 @@
1
+ import { waitForStartupReadiness, type StartupOwner } from "./startup-readiness.ts";
1
2
  import { resolveProjectRuntime, recordProjectRuntime, confirmProjectRuntime, readProjectRuntimeForCaller, projectRuntimeRefusal, type ProjectRuntimeSelection } from "./project-runtime.ts";
2
3
  import { scheduleRuntimeCapturePrune } from "./runtime-capture-prune.ts";
3
4
  import { createProjectRuntimeAccess, type ProjectRuntimeAccess } from "./project-runtime-state.ts";
4
5
  import { isDatabaseBusyReply, RecoveryDatabaseBusy } from "./recovery-error.ts";
5
- import { spawn } from "node:child_process";
6
+ import { spawn, type ChildProcess } from "node:child_process";
6
7
  import { randomUUID } from "node:crypto";
7
8
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
8
9
  import { join, resolve } from "node:path";
@@ -176,7 +177,7 @@ export async function runProjectDaemon(project: Project, unattended = false): Pr
176
177
  }
177
178
  }
178
179
 
179
- export async function startProject(project: Project, options: { unattended?: boolean; env?: NodeJS.ProcessEnv; liveTest?: boolean; current?: () => void } = {}): Promise<any> {
180
+ export async function startProject(project: Project, options: { unattended?: boolean; env?: NodeJS.ProcessEnv; liveTest?: boolean; current?: () => void; starting?: (pid: number) => void } = {}): Promise<any> {
180
181
  options.current?.();
181
182
  assertLifecycleAvailable(project.root);
182
183
  const registry = new Registry();
@@ -195,6 +196,7 @@ export async function startProject(project: Project, options: { unattended?: boo
195
196
  if (before.state !== "stopped" && before.state !== "starting") throw new Error(before.error ?? before.state);
196
197
  if (!existsSync(join(project.root, ".agenthub", "config.json"))) throw new Error("project is not initialized; run ahub init there first");
197
198
  let launchedRuntime: ProjectRuntimeSelection | undefined;
199
+ let startupChild: ChildProcess | undefined, startupSignature: string | undefined, launchFailed = false;
198
200
  if (before.state === "stopped") {
199
201
  const launchEnv = { ...(options.env ?? process.env) };
200
202
  // Expired operation ids must not make an ordinary startup load a retired snapshot.
@@ -225,26 +227,40 @@ export async function startProject(project: Project, options: { unattended?: boo
225
227
  env: { ...launchEnv, AGENTHUB_STATE_DIR: project.stateDir, AGENTHUB_PROJECT_DIR: project.root,
226
228
  AGENTHUB_UNATTENDED: options.unattended ? "1" : "0" },
227
229
  });
228
- child.on("error", () => {}); // bounded readiness below reports failure with the project's log path
230
+ startupChild = child;
231
+ child.on("error", () => { launchFailed = true; }); // The bound child is observed by the readiness wait, never stopped on timeout.
229
232
  child.unref();
230
233
  } finally { closeSync(log); }
231
234
  });
232
235
  }
233
- const deadline = Date.now() + 12_000;
234
- while (Date.now() < deadline) {
235
- const observed = await inspectProject(registry.get(project.id) ?? project);
236
- options.current?.();
237
- if (observed.state === "running") {
236
+ const startupOwner = (): StartupOwner => {
237
+ if (!startupChild) return undefined; // A competing starter owns this claim; join without claiming its child.
238
+ const claim = registry.get(project.id);
239
+ if (!claim || claim.root !== project.root || claim.stateDir !== project.stateDir) throw new Error("project registration changed; refresh before starting");
240
+ const claimed = claim.instanceId !== null;
241
+ if (claimed && claim.pid !== startupChild.pid) return undefined;
242
+ if (launchFailed || startupChild.exitCode !== null || startupChild.signalCode !== null || startupChild.pid === undefined) return { state: "gone" };
243
+ // A still-unreaped owned child cannot have its PID reused. Capture the full identity once it is observable.
244
+ startupSignature ??= processSignature(startupChild.pid);
245
+ if (!startupSignature || claimed && claim.pidSignature !== startupSignature) return { state: "unknown" };
246
+ const state = processLiveness(startupChild.pid, startupSignature);
247
+ const fresh = registry.get(project.id);
248
+ if (!fresh || fresh.root !== project.root || fresh.stateDir !== project.stateDir || fresh.instanceId !== claim.instanceId || fresh.pid !== claim.pid || fresh.pidSignature !== claim.pidSignature) return undefined;
249
+ if (startupChild.exitCode !== null || startupChild.signalCode !== null || state === "gone") return { state: "gone" };
250
+ return state === "live" ? { state: "live", pid: startupChild.pid } : { state: "unknown" };
251
+ };
252
+ return await waitForStartupReadiness<any>({
253
+ now: () => performance.now(), sleep: ms => Bun.sleep(ms), current: () => { options.current?.(); }, owner: startupOwner,
254
+ inspect: timeoutMs => inspectProject(registry.get(project.id) ?? project, timeoutMs),
255
+ starting: options.starting,
256
+ confirm: status => {
257
+ options.current?.();
238
258
  admitRunningRuntime();
239
- if (launchedRuntime) confirmProjectRuntime(project, launchedRuntime, observed.status, registry, runtimeAccess);
259
+ if (launchedRuntime) confirmProjectRuntime(project, launchedRuntime, status, registry, runtimeAccess);
240
260
  // Unknown references defer maintenance; cache cleanup never changes a confirmed startup outcome.
241
261
  try { scheduleRuntimeCapturePrune(); } catch { /* retain captures on unreadable inventory or cleanup failure */ }
242
- return observed.status;
243
- }
244
- if (observed.state === "incompatible" || observed.state === "missing") throw new Error(observed.error ?? observed.state);
245
- await Bun.sleep(100);
246
- }
247
- throw new Error(`hub readiness not confirmed; inspect ${join(project.stateDir, "hub.log")} before retrying`);
262
+ },
263
+ });
248
264
  } finally { registry.close(); }
249
265
  }
250
266
 
package/src/hub/limits.ts CHANGED
@@ -39,6 +39,15 @@ export class Limiter {
39
39
  * answers: "Yes." to two different questions is two messages, not a repeat.
40
40
  */
41
41
  admit(from: PeerId, to: PeerId[] | undefined, priority: Priority, body: string, parent = "", group?: string): string | undefined {
42
+ return this.decide(true, from, to, priority, body, parent, group);
43
+ }
44
+
45
+ /** Same decision without spending buckets, recording repeat keys, or pruning history. */
46
+ peek(from: PeerId, to: PeerId[] | undefined, priority: Priority, body: string, parent = "", group?: string): string | undefined {
47
+ return this.decide(false, from, to, priority, body, parent, group);
48
+ }
49
+
50
+ private decide(consume: boolean, from: PeerId, to: PeerId[] | undefined, priority: Priority, body: string, parent: string, group?: string): string | undefined {
42
51
  const now = this.now();
43
52
  const audience = to?.length ? [...new Set(to)].sort() : ["*"];
44
53
  const key = `${from}\0${audience.join(",")}\0${parent}\0${body.trim().replace(/\s+/g, " ")}`;
@@ -48,8 +57,8 @@ export class Limiter {
48
57
  if (last !== undefined && now - last < this.cfg.repeat_window_s * 1000) {
49
58
  return `the same message went to ${to?.length ? audience.join(", ") : "everyone"} ${Math.round((now - last) / 1000)} s ago`;
50
59
  }
51
- for (const key of keys) this.recent.set(key, now);
52
- if (this.recent.size > 1000) for (const [k, at] of this.recent) if (now - at >= this.cfg.repeat_window_s * 1000) this.recent.delete(k);
60
+ if (consume) for (const key of keys) this.recent.set(key, now);
61
+ if (consume && this.recent.size > 1000) for (const [k, at] of this.recent) if (now - at >= this.cfg.repeat_window_s * 1000) this.recent.delete(k);
53
62
  }
54
63
  const wanted: [string, number, number][] = []; // key, capacity, refill per ms
55
64
  if (this.cfg.sender_per_min > 0) wanted.push([`s\0${from}`, this.cfg.sender_per_min, this.cfg.sender_per_min / 60_000]);
@@ -68,11 +77,11 @@ export class Limiter {
68
77
  const short = filled.filter((f) => f.b.tokens < 1);
69
78
  if (short.length) {
70
79
  const wait = Math.max(...short.map((f) => Math.ceil(Math.round((1 - f.b.tokens) / f.rate) / 1000))); // whole ms first: 1/(1/x) is not always x
71
- if (this.cfg.repeat_window_s > 0) for (const key of keys) this.recent.delete(key); // refused, so not sent
80
+ if (consume && this.cfg.repeat_window_s > 0) for (const key of keys) this.recent.delete(key); // refused, so not sent
72
81
  const what = short.some((f) => f.key.startsWith("i\0") || f.key.startsWith("gi\0")) ? "important messages" : "messages";
73
82
  return `rate limited: too many ${what} from ${from}; retry after ${wait} s${what === "important messages" ? ", or send it without [IMPORTANT]" : ""}`;
74
83
  }
75
- for (const f of filled) this.buckets.set(f.key, { tokens: f.b.tokens - 1, at: now });
84
+ if (consume) for (const f of filled) this.buckets.set(f.key, { tokens: f.b.tokens - 1, at: now });
76
85
  return undefined;
77
86
  }
78
87
  }
@@ -132,7 +132,8 @@ export function projectConsole(options: { registry: Registry; lifecycle: Lifecyc
132
132
  // #1057: the worktree shelf's person actions. The refresh is the one that reads GitHub, so it gets the delivery reads' deadline in its own branch below.
133
133
  const worktreeAction = ["worktree_override_set", "worktree_override_clear", "worktrees_refresh_prs"].includes(String(input.action));
134
134
  // The settings actions (#269) reach the project hub as an ordinary session's: this dashboard opens no settings session.
135
- if (!["snapshot", "send", "pause", "resume", "permit", "propose", "intake", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "setting_reload", "config_edit_preview", "config_edit_apply", "start_peer", "sdlc_queue", "sdlc_release", "result_seen", "results_read_all", "results_unread_last"].includes(String(input.action)) && !worktreeAction) return { ok: false, error: "invalid dashboard action" };
135
+ if (!["snapshot", "send", "pause", "resume", "permit", "propose", "intake", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "setting_reload", "model_preview", "model_confirm", "config_edit_preview", "config_edit_apply", "start_peer", "sdlc_queue", "sdlc_release", "result_seen", "results_read_all", "results_unread_last"].includes(String(input.action)) && !worktreeAction) return { ok: false, error: "invalid dashboard action" };
136
+ const visualSelector = input.action === "snapshot" ? visualSnapshotSelector(input) : {};
136
137
  client = await ControlClient.connect(project.stateDir, { role: "console", projectId: project.id, projectRoot: project.root, instanceId: input.instanceId });
137
138
  const deliveryRead = input.action === "sdlc_queue" || input.action === "sdlc_release";
138
139
  let nominalMs = 10_000;
@@ -143,7 +144,7 @@ export function projectConsole(options: { registry: Registry; lifecycle: Lifecyc
143
144
  else if (input.action === "worktrees_refresh_prs") nominalMs = 40_000; // up to 25 s of bounded gh reads plus two git passes
144
145
  const timeoutMs = options.forwardTimeoutMs?.(String(input.action), nominalMs) ?? nominalMs;
145
146
  const result = await client.request(input.action === "snapshot"
146
- ? { t: "ui_snapshot", after: input.after ?? 0, ...(input.reviewRefresh === true && input.reviewTask !== undefined ? { reviewTask: input.reviewTask, reviewRefresh: true } : {}), ...(input.memory !== undefined ? { memory: input.memory } : {}), ...(dashboardView(input.view) ? { view: input.view } : {}) }
147
+ ? { t: "ui_snapshot", after: input.after ?? 0, ...visualSelector, ...(input.reviewRefresh === true && input.reviewTask !== undefined ? { reviewTask: input.reviewTask, reviewRefresh: true } : {}), ...(input.memory !== undefined ? { memory: input.memory } : {}), ...(dashboardView(input.view) ? { view: input.view } : {}) }
147
148
  : { t: "ui_action", action: input, instanceId: input.instanceId }, timeoutMs);
148
149
  // ControlClient resolves its deadline as this exact sentinel; a hub refusal stays a known failure.
149
150
  if (input.action === "intake" && result.ok === false && result.error === `no answer from the hub within ${Math.round(timeoutMs / 1000)} s`) {
@@ -379,3 +380,15 @@ export async function stopManager(options: ManagerOptions = {}): Promise<void> {
379
380
  }
380
381
  throw new Error("manager shutdown is still pending; project hubs were not changed");
381
382
  }
383
+
384
+ /** Closed read selectors only; no caller path, policy or arbitrary control fields are forwarded. */
385
+ export function visualSnapshotSelector(input: Record<string, unknown>): Record<string, string | number> {
386
+ const keys = ["visual", "visualTask", "visualAsset"].filter(key => Object.hasOwn(input, key));
387
+ if (!keys.length) return {};
388
+ if (keys.length !== 1) throw new Error("visual selector is ambiguous; select one visual or task and retry");
389
+ const key = keys[0]!, value = input[key];
390
+ const id = "[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}";
391
+ if (key === "visualTask" ? Number.isSafeInteger(value) && Number(value) > 0
392
+ : typeof value === "string" && new RegExp(key === "visualAsset" ? `^${id}:(before|after)$` : `^${id}$`).test(value)) return { [key]: value as string | number };
393
+ throw new Error("visual selector is invalid; select a current visual or task and retry");
394
+ }
@@ -65,9 +65,10 @@ export function isTrustedReviewPackage(env: Envelope): boolean {
65
65
  && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(env.refs.reviewPackage);
66
66
  }
67
67
 
68
- /** Only structured review carriers are excluded; ordinary review tool capture stays unchanged. */
68
+ /** Structured review and visual tool bodies never enter observer capture, including refused inputs. */
69
69
  export function reviewCaptureExcluded(tool: string, args: string): boolean {
70
70
  const name = tool.replace(/^mcp__[^ ]+__/, "");
71
+ if (name === "hub_visual" || name === "hub_visual_show") return true;
71
72
  if (["review_comment_put", "review_comment_delete", "review_comment_send", "review_blob"].includes(name)) return true;
72
73
  if (name !== "hub_review") return false;
73
74
  try {
@@ -0,0 +1,54 @@
1
+ import { types } from "node:util";
2
+ import { createPlacementAttestation, PlacementWriterContextError, type SyncOptions } from "@staix/agent-mem";
3
+
4
+ /** The closed vocabulary of `PlacementWriterContextError.category` and `.causeClass` (agent-mem 0.23.0). Anything else projects as unknown. */
5
+ const CATEGORIES = ["running CLI", "host package", "setup receipt", "declared writer", "active locator", "home floor", "writer renewal", "unknown"] as const;
6
+ const CAUSE_CLASSES = ["missing", "changed", "unavailable"] as const;
7
+ export type WriterRefusalCategory = typeof CATEGORIES[number];
8
+ export type WriterRefusalCause = typeof CAUSE_CLASSES[number];
9
+ /** Public-safe projection of a writer-context refusal: two closed words, never the package's message, a path or a version. */
10
+ export interface WriterRefusal { category: WriterRefusalCategory; causeClass: WriterRefusalCause }
11
+
12
+ /** Reads a refusal without trusting its prototype chain or getters, and without keeping its message. */
13
+ export function writerRefusalOf(error: unknown): WriterRefusal | undefined {
14
+ if (!error || typeof error !== "object" || types.isProxy(error)) return undefined;
15
+ // The SDK throws this concrete class. Exact prototype identity avoids walking an arbitrary prototype chain.
16
+ if (Object.getPrototypeOf(error) !== PlacementWriterContextError.prototype) return undefined;
17
+ const category = Object.getOwnPropertyDescriptor(error, "category")?.value, causeClass = Object.getOwnPropertyDescriptor(error, "causeClass")?.value;
18
+ return {
19
+ category: (CATEGORIES as readonly unknown[]).includes(category) ? category as WriterRefusalCategory : "unknown",
20
+ causeClass: (CAUSE_CLASSES as readonly unknown[]).includes(causeClass) ? causeClass as WriterRefusalCause : "unavailable",
21
+ };
22
+ }
23
+
24
+ /** Legacy quiet classification reads only an ordinary error's own string, never a getter or proxy. */
25
+ export function memorySyncErrorText(error: unknown): string | undefined {
26
+ if (typeof error === "string") return error;
27
+ if (!error || typeof error !== "object" || types.isProxy(error) || !types.isNativeError(error)) return undefined;
28
+ const descriptor = Object.getOwnPropertyDescriptor(error, "message");
29
+ return descriptor && "value" in descriptor && typeof descriptor.value === "string" ? descriptor.value : undefined;
30
+ }
31
+
32
+ /**
33
+ * The attestation the hub hands one `syncMemory` pass (#1173). It is agent-mem's own `createPlacementAttestation`, bound to this
34
+ * installation and this memory home; the hub supplies no writer facts of its own and never caches a result past the pass.
35
+ * The transport turns any thrown refusal into a generic "unavailable" result, so the wrapper keeps the first typed refusal for the
36
+ * caller to project; the error is rethrown unchanged so the transport still fails closed.
37
+ */
38
+ export function hostPlacementAttestation(home: string, create: typeof createPlacementAttestation = createPlacementAttestation): {
39
+ attest: NonNullable<SyncOptions["placementAttestation"]>;
40
+ refusal: () => WriterRefusal | undefined;
41
+ } {
42
+ let inner: ReturnType<typeof createPlacementAttestation> | undefined;
43
+ let refused: WriterRefusal | undefined;
44
+ const attest: NonNullable<SyncOptions["placementAttestation"]> = () => {
45
+ try {
46
+ inner ??= create(home);
47
+ return inner();
48
+ } catch (error) {
49
+ refused ??= writerRefusalOf(error);
50
+ throw error;
51
+ }
52
+ };
53
+ return { attest, refusal: () => refused };
54
+ }
@@ -1,14 +1,19 @@
1
1
  import { createHash } from "node:crypto";
2
2
 
3
3
  /** Safe, process-local view of the most recent machine memory sync. */
4
- export type MemorySyncCause = "declined" | "remote" | "binding" | "stopped" | "transport" | "unknown";
4
+ export type MemorySyncCause = "declined" | "remote" | "binding" | "stopped" | "transport" | "writer" | "unknown";
5
+ /** Closed words of a refused writer attestation (agent-mem `PlacementWriterContextError`); never its message, a path or a version. */
6
+ export interface MemorySyncWriterRefusal { writerCategory?: string; writerCauseClass?: string }
7
+ export const WRITER_CATEGORIES: readonly string[] = ["running CLI", "host package", "setup receipt", "declared writer", "active locator", "home floor", "writer renewal", "unknown"];
8
+ export const WRITER_CAUSE_CLASSES: readonly string[] = ["missing", "changed", "unavailable"];
9
+ const closedWriter = (value: unknown, allowed: readonly string[]): string | undefined => typeof value === "string" && allowed.includes(value) ? value : undefined;
5
10
  export interface MemorySyncPlacementCounts { withheldCount: number; exportedCount: number }
6
11
  export type MemorySyncOutcome =
7
12
  | { state: "notrun" }
8
13
  | { state: "not-configured"; at: number }
9
14
  | { state: "stopped"; at: number }
10
15
  | { state: "ok"; at: number; withheldCount?: number; exportedCount?: number }
11
- | { state: "failed"; at: number; cause: MemorySyncCause; declinedCount?: number; declinedIdsKnown?: false; placementActive?: false };
16
+ | { state: "failed"; at: number; cause: MemorySyncCause; declinedCount?: number; declinedIdsKnown?: false; placementActive?: false } & MemorySyncWriterRefusal;
12
17
 
13
18
  export interface MemorySyncState {
14
19
  outcome: MemorySyncOutcome;
@@ -19,7 +24,7 @@ export interface MemorySyncState {
19
24
  export type MemorySyncEvent =
20
25
  | { type: "success"; at: number; withheldCount?: number; exportedCount?: number }
21
26
  | { type: "quiet"; at: number; state: "not-configured" | "stopped" }
22
- | { type: "failure"; at: number; cause: MemorySyncCause; declinedCount?: number; declinedIdsFingerprint?: string; declinedIdsKnown?: false; failureDetailHash?: string; placementActive?: false };
27
+ | { type: "failure"; at: number; cause: MemorySyncCause; declinedCount?: number; declinedIdsFingerprint?: string; declinedIdsKnown?: false; failureDetailHash?: string; placementActive?: false } & MemorySyncWriterRefusal;
23
28
 
24
29
  export const initialMemorySyncState = (): MemorySyncState => ({ outcome: { state: "notrun" } });
25
30
  const MAX_DECLINED_ROWS = 20_000;
@@ -50,14 +55,20 @@ export function memorySyncPlacementCounts(value: unknown): MemorySyncPlacementCo
50
55
  export function reduceMemorySync(state: MemorySyncState, event: MemorySyncEvent): { state: MemorySyncState; logFailure: boolean } {
51
56
  if (event.type === "success") return { state: { outcome: { state: "ok", at: event.at, ...placementCounts(event.withheldCount, event.exportedCount) } }, logFailure: false };
52
57
  if (event.type === "quiet") return { state: { outcome: { state: event.state, at: event.at } }, logFailure: false };
53
- const signature = [event.cause, event.declinedCount ?? "", event.declinedIdsFingerprint ?? (event.declinedIdsKnown === false ? "ids-unknown" : ""), event.failureDetailHash ?? ""].join("\0");
58
+ const signature = [event.cause, event.declinedCount ?? "", event.declinedIdsFingerprint ?? (event.declinedIdsKnown === false ? "ids-unknown" : ""), event.failureDetailHash ?? "", event.cause === "writer" ? closedWriter(event.writerCategory, WRITER_CATEGORIES) ?? "" : "", event.cause === "writer" ? closedWriter(event.writerCauseClass, WRITER_CAUSE_CLASSES) ?? "" : ""].join("\0");
54
59
  const changed = state.failureSignature !== signature;
55
60
  return {
56
- state: { outcome: { state: "failed", at: event.at, cause: event.cause, ...(event.declinedCount === undefined ? {} : { declinedCount: event.declinedCount }), ...(event.declinedIdsKnown === false ? { declinedIdsKnown: false as const } : {}), ...(event.cause === "declined" && event.placementActive === false ? { placementActive: false as const } : {}) }, failureSignature: signature },
61
+ state: { outcome: { state: "failed", at: event.at, cause: event.cause, ...(event.declinedCount === undefined ? {} : { declinedCount: event.declinedCount }), ...(event.declinedIdsKnown === false ? { declinedIdsKnown: false as const } : {}), ...(event.cause === "declined" && event.placementActive === false ? { placementActive: false as const } : {}), ...writerFields(event) }, failureSignature: signature },
57
62
  logFailure: changed,
58
63
  };
59
64
  }
60
65
 
66
+ function writerFields(value: { cause?: string } & MemorySyncWriterRefusal): MemorySyncWriterRefusal {
67
+ if (value.cause !== "writer") return {};
68
+ const writerCategory = closedWriter(value.writerCategory, WRITER_CATEGORIES), writerCauseClass = closedWriter(value.writerCauseClass, WRITER_CAUSE_CLASSES);
69
+ return { ...(writerCategory ? { writerCategory } : {}), ...(writerCauseClass ? { writerCauseClass } : {}) };
70
+ }
71
+
61
72
  export interface MemorySyncView {
62
73
  state: "disabled" | "unavailable" | "notrun" | "not-configured" | "stopped" | "ok" | "failed" | "unknown";
63
74
  at?: number;
@@ -67,6 +78,8 @@ export interface MemorySyncView {
67
78
  withheldCount?: number;
68
79
  exportedCount?: number;
69
80
  placementActive?: false;
81
+ writerCategory?: string;
82
+ writerCauseClass?: string;
70
83
  nextStep?: string;
71
84
  }
72
85
 
@@ -75,6 +88,7 @@ export function memorySyncNextStep(cause: MemorySyncCause): string {
75
88
  : cause === "remote" ? "configure a private sync remote"
76
89
  : cause === "binding" ? "ahub memory profile-check"
77
90
  : cause === "stopped" ? "retry after hub restart"
91
+ : cause === "writer" ? "repair the amem install and its host pin, then a person runs amem migrate --sync-placement --dry-run"
78
92
  : "run ahub memory sync as a person";
79
93
  }
80
94
 
@@ -91,6 +105,7 @@ export function memorySyncView(state: MemorySyncState, availability: "enabled" |
91
105
  ...(outcome.declinedCount === undefined ? {} : { declinedCount: outcome.declinedCount }),
92
106
  ...(outcome.declinedIdsKnown === false ? { declinedIdsKnown: false } : {}),
93
107
  ...(outcome.cause === "declined" && outcome.placementActive === false ? { placementActive: false } : {}),
108
+ ...writerFields(outcome),
94
109
  nextStep: memorySyncNextStep(outcome.cause),
95
110
  };
96
111
  }