@staix/agent-hub 0.27.0 → 0.29.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 (106) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +11 -3
  3. package/docs/agent-notes/adapters.md +4 -2
  4. package/docs/agent-notes/bus.md +4 -4
  5. package/docs/agent-notes/daemon.md +9 -3
  6. package/docs/agent-notes/local-worker.md +3 -1
  7. package/docs/agent-notes/tasks.md +7 -0
  8. package/docs/agent-notes/tests.md +7 -5
  9. package/docs/design/adapters.md +76 -0
  10. package/docs/design/agent-hub-architecture.md +67 -0
  11. package/docs/design/agent-hub.md +52 -0
  12. package/docs/design/bus.md +48 -0
  13. package/docs/design/task-board.md +86 -0
  14. package/docs/dogfood.md +21 -3
  15. package/docs/operations.md +370 -12
  16. package/docs/profiles.md +1 -0
  17. package/docs/quickstart.md +19 -3
  18. package/docs/sdlc.md +2 -2
  19. package/docs/security.md +24 -4
  20. package/docs/smoke.md +2 -0
  21. package/docs/specs/2026-09-19-agent-hub-design.md +2 -0
  22. package/docs/verification/2026-10-31-codex-0166-reviewer-isolation.md +2 -0
  23. package/docs/verification/2026-11-02-0.28.0.md +54 -0
  24. package/docs/verification/2026-11-02-autonomy-t4.md +39 -0
  25. package/docs/verification/2026-11-02-codex-0166-reviewer-tool-call.md +53 -0
  26. package/docs/verification/2026-11-02-control-write-after.json +139 -0
  27. package/docs/verification/2026-11-02-control-write-baseline.json +127 -0
  28. package/docs/verification/2026-11-02-control-write-guarded.json +127 -0
  29. package/docs/verification/2026-11-02-control-write-outcomes.md +65 -0
  30. package/docs/verification/2026-11-02-orca-1.4.245-cli-contract.md +51 -0
  31. package/docs/verification/2026-11-03-0.29.0.md +59 -0
  32. package/docs/verification/2026-11-03-control-write-stall-windows.json +241 -0
  33. package/docs/verification/2026-11-03-control-write-stall-windows.md +20 -0
  34. package/docs/verification/2026-11-03-live-board-latency.json +556 -0
  35. package/docs/verification/2026-11-03-live-board-latency.md +64 -0
  36. package/docs/verified.json +14 -14
  37. package/package.json +2 -2
  38. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  39. package/plugins/agent-hub/server.js +857 -125
  40. package/src/adapters/claude-channel.ts +12 -2
  41. package/src/adapters/compat.ts +1 -1
  42. package/src/adapters/definitions/native-cli-claude-setup.ts +31 -9
  43. package/src/adapters/definitions/native-cli-entry-jcode.ts +33 -0
  44. package/src/adapters/definitions/native-cli-entry-qwen.ts +2 -2
  45. package/src/adapters/jcode.ts +37 -4
  46. package/src/adapters/qwen-dual.ts +3 -2
  47. package/src/cli/agents.ts +4 -2
  48. package/src/cli/bench.ts +4 -2
  49. package/src/cli/board-read.ts +25 -0
  50. package/src/cli/claude-homes.ts +2 -2
  51. package/src/cli/console-state.ts +8 -3
  52. package/src/cli/help.ts +6 -5
  53. package/src/cli/identity.ts +9 -1
  54. package/src/cli/init-prompt.ts +179 -0
  55. package/src/cli/init-settings.ts +229 -0
  56. package/src/cli/init.ts +102 -9
  57. package/src/cli/launch.ts +3 -1
  58. package/src/cli/main.ts +106 -35
  59. package/src/cli/output.ts +19 -0
  60. package/src/cli/recovery-package.ts +9 -0
  61. package/src/cli/setup-homes.ts +18 -3
  62. package/src/cli/specs.ts +150 -0
  63. package/src/cli/terminal-recovery.ts +35 -11
  64. package/src/hub/board.ts +59 -5
  65. package/src/hub/bus.ts +54 -7
  66. package/src/hub/child-process.ts +6 -4
  67. package/src/hub/config-apply.ts +4 -1
  68. package/src/hub/config-edit.ts +1 -1
  69. package/src/hub/config-tier.ts +1 -0
  70. package/src/hub/control-client.ts +43 -4
  71. package/src/hub/control-write-trace.ts +409 -0
  72. package/src/hub/cost-governance.ts +22 -6
  73. package/src/hub/crash.ts +20 -0
  74. package/src/hub/daemon.ts +470 -98
  75. package/src/hub/delivery-hints.ts +6 -3
  76. package/src/hub/delivery-journal.ts +14 -3
  77. package/src/hub/hub-tools.ts +10 -6
  78. package/src/hub/peers.ts +1 -1
  79. package/src/hub/review-brief.ts +18 -0
  80. package/src/hub/routing.ts +14 -3
  81. package/src/hub/runtime-capture-prune.ts +4 -1
  82. package/src/hub/sdlc-release-authorization.ts +13 -4
  83. package/src/hub/sdlc-release.ts +23 -10
  84. package/src/hub/sdlc.ts +15 -2
  85. package/src/hub/settings.ts +2 -0
  86. package/src/hub/specs-config.ts +43 -0
  87. package/src/hub/specs-task.ts +108 -0
  88. package/src/hub/specs.ts +1008 -0
  89. package/src/hub/task-list-page.ts +75 -0
  90. package/src/hub/task-sweep.ts +10 -1
  91. package/src/hub/task-worktree.ts +331 -0
  92. package/src/hub/tasks.ts +606 -53
  93. package/src/hub/terminal-open.ts +10 -5
  94. package/src/hub/tool-request.ts +131 -0
  95. package/src/local/deny.ts +49 -0
  96. package/src/local/tools.ts +4 -2
  97. package/src/memory/brief.ts +5 -0
  98. package/src/memory/client.ts +5 -2
  99. package/src/ui/office-model.ts +17 -1
  100. package/src/ui/office-space.ts +1 -1
  101. package/templates/config.json +1 -0
  102. package/templates/profiles/dev.json +1 -0
  103. package/templates/profiles/general.json +1 -0
  104. package/templates/profiles/research.json +1 -0
  105. package/templates/profiles/writing.json +1 -0
  106. package/templates/specs.block.md +5 -0
@@ -0,0 +1,409 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ export const CONTROL_WRITE_KINDS = [
4
+ "hub_task_propose", "hub_review", "hub_task_accept", "hub_task_done", "hub_task_decline", "hub_task_cancel",
5
+ "hub_task_assign", "task_assign", "task_assign_checked", "task_unassign", "hub_delivery_done", "background",
6
+ ] as const;
7
+ export type ControlWriteKind = typeof CONTROL_WRITE_KINDS[number];
8
+
9
+ export const CONTROL_WRITE_PHASES = [
10
+ "client-send", "client-wait", "daemon-dispatch", "daemon-reply",
11
+ "board-write", "queue-hook", "status-hook", "memory", "external", "snapshot",
12
+ ] as const;
13
+ export type ControlWritePhase = typeof CONTROL_WRITE_PHASES[number];
14
+
15
+ export const CONTROL_WRITE_SLOW_MS = 1_000;
16
+ export const CONTROL_WRITE_LOOP_DELAY_MS = 250;
17
+ export const CONTROL_WRITE_SAMPLE_MS = 100;
18
+ export const CONTROL_WRITE_MAX_LINE_BYTES = 2_048;
19
+ export const CONTROL_WRITE_MAX_DURATION_MS = 86_400_000;
20
+ export const CONTROL_WRITE_REQUEST_MAX_AGE_MS = 5 * 60_000;
21
+
22
+ const MAX_ACTIVE_REQUESTS = 128;
23
+ const MAX_ACTIVE_SPANS = 256;
24
+ const MAX_RECENT_SPANS = 512;
25
+ const MAX_LOOP_SPANS = 8;
26
+ const MIN_SYNC_PREFIX_MS = 10;
27
+ const MAX_EMITS_PER_MINUTE = 60;
28
+ const RATE_LIMIT_MS = 1_000;
29
+ const RATE_KEYS = ["slow", "loop"] as const;
30
+
31
+ export interface ControlWriteMetadata { kind: ControlWriteKind; task?: number }
32
+ export type ControlWritePrivacy = () => boolean;
33
+ export type ControlWriteSink = (line: string) => void;
34
+ export interface ControlWriteTelemetryOptions {
35
+ emit: ControlWriteSink;
36
+ now?: () => number;
37
+ maxActiveRequests?: number;
38
+ maxActiveSpans?: number;
39
+ maxRecentSpans?: number;
40
+ rateLimitMs?: number;
41
+ }
42
+
43
+ interface PhaseTotal { phase: ControlWritePhase; durationMs: number }
44
+ interface RequestTrace {
45
+ telemetry: ControlWriteTelemetry;
46
+ metadata: ControlWriteMetadata;
47
+ privacyCurrent: ControlWritePrivacy;
48
+ logPrivacy: ControlWritePrivacy;
49
+ startedAt: number;
50
+ phases: Map<ControlWritePhase, number>;
51
+ phaseOrder: ControlWritePhase[];
52
+ settled: boolean;
53
+ privacySuppressed: boolean;
54
+ }
55
+ interface SpanTrace {
56
+ id?: number;
57
+ request: RequestTrace;
58
+ phase: ControlWritePhase;
59
+ startedAt: number;
60
+ async: boolean;
61
+ finished: boolean;
62
+ prefixRecorded: boolean;
63
+ }
64
+ interface SpanInterval {
65
+ kind: ControlWriteKind;
66
+ task?: number;
67
+ phase: ControlWritePhase;
68
+ startedAt: number;
69
+ endedAt?: number;
70
+ async: boolean;
71
+ privacyCurrent: ControlWritePrivacy;
72
+ }
73
+
74
+ const currentTrace = new AsyncLocalStorage<RequestTrace>();
75
+
76
+ function isObject(value: unknown): value is Record<string, unknown> {
77
+ return !!value && typeof value === "object" && !Array.isArray(value);
78
+ }
79
+
80
+ function validTaskId(value: unknown): value is number {
81
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
82
+ }
83
+
84
+ function numericTaskId(value: unknown): number | undefined {
85
+ return validTaskId(value) ? value : undefined;
86
+ }
87
+
88
+ function ownValue(value: Record<string, unknown>, key: string): unknown {
89
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
90
+ return descriptor && Object.hasOwn(descriptor, "value") ? descriptor.value : undefined;
91
+ }
92
+
93
+ const OPERATIONS: Readonly<Record<string, ControlWriteKind>> = {
94
+ hub_task_propose: "hub_task_propose", hub_review: "hub_review", hub_task_accept: "hub_task_accept",
95
+ hub_task_done: "hub_task_done", hub_task_decline: "hub_task_decline", hub_task_cancel: "hub_task_cancel",
96
+ hub_task_assign: "hub_task_assign", task_assign: "task_assign", task_assign_checked: "task_assign_checked", task_unassign: "task_unassign",
97
+ };
98
+
99
+ /** The control protocol contributes only a closed operation name and, when present, a positive task id. */
100
+ export function controlWriteMetadata(message: unknown): ControlWriteMetadata | undefined {
101
+ if (!isObject(message)) return undefined;
102
+ try {
103
+ let kind: ControlWriteKind | undefined;
104
+ let args: Record<string, unknown> | undefined;
105
+ const type = ownValue(message, "t"), operation = ownValue(message, "op");
106
+ if (type === "delivery_complete") kind = "hub_delivery_done";
107
+ else if (type === "task" && typeof operation === "string") {
108
+ kind = Object.hasOwn(OPERATIONS, operation) ? OPERATIONS[operation] : undefined;
109
+ const rawArgs = ownValue(message, "args");
110
+ args = isObject(rawArgs) ? rawArgs : undefined;
111
+ }
112
+ if (!kind) return undefined;
113
+ const task = numericTaskId(args ? ownValue(args, "id") : undefined);
114
+ return task === undefined ? { kind } : { kind, task };
115
+ } catch { return undefined; }
116
+ }
117
+
118
+ function isPhase(value: string): value is ControlWritePhase {
119
+ return (CONTROL_WRITE_PHASES as readonly string[]).includes(value);
120
+ }
121
+
122
+ function isThenable(value: unknown): value is PromiseLike<unknown> {
123
+ try { return !!value && (typeof value === "object" || typeof value === "function") && typeof (value as { then?: unknown }).then === "function"; }
124
+ catch { return false; }
125
+ }
126
+
127
+ /** Add a bounded phase to the current request. With no request context, this is a no-op. */
128
+ export function controlWriteSpan<T>(phase: ControlWritePhase, fn: () => T): T {
129
+ const request = currentTrace.getStore();
130
+ if (!request || request.settled || request.privacySuppressed || !isPhase(phase)) return fn();
131
+ const span = request.telemetry.beginSpan(request, phase);
132
+ let result: T;
133
+ try { result = fn(); }
134
+ catch (error) { request.telemetry.finishSpan(span); throw error; }
135
+ if (!isThenable(result)) { request.telemetry.finishSpan(span); return result; }
136
+ span.async = true;
137
+ request.telemetry.recordSyncPrefix(span);
138
+ return Promise.resolve(result).then(
139
+ value => { request.telemetry.finishSpan(span); return value; },
140
+ error => { request.telemetry.finishSpan(span); throw error; },
141
+ ) as T;
142
+ }
143
+
144
+ /** Bounded per-daemon control write and event-loop timing. Logs contain metadata, never request payloads. */
145
+ export class ControlWriteTelemetry {
146
+ private readonly nowFn: () => number;
147
+ private readonly maxActiveRequests: number;
148
+ private readonly maxActiveSpans: number;
149
+ private readonly maxRecentSpans: number;
150
+ private readonly rateLimitMs: number;
151
+ private readonly requests = new Map<number, RequestTrace>();
152
+ private readonly spans = new Map<number, SpanTrace>();
153
+ private readonly recent: SpanInterval[] = [];
154
+ private readonly emitted: number[] = [];
155
+ private readonly lastEmit = new Map<typeof RATE_KEYS[number], number>();
156
+ private nextRequestId = 1;
157
+ private nextSpanId = 1;
158
+ private timer: ReturnType<typeof setInterval> | undefined;
159
+ private nextSampleAt: number | undefined;
160
+ private lastNow = 0;
161
+ private stopped = false;
162
+ private completedRequests = 0;
163
+ private expiredRequests = 0;
164
+ private slowRequests = 0;
165
+ private blockedLoopSamples = 0;
166
+ private droppedActive = 0;
167
+ private droppedSpans = 0;
168
+ private droppedRecent = 0;
169
+ private droppedLogs = 0;
170
+ private droppedMetadata = 0;
171
+ private clockFailures = 0;
172
+
173
+ constructor(private readonly options: ControlWriteTelemetryOptions) {
174
+ this.nowFn = options.now ?? (() => performance.now());
175
+ this.maxActiveRequests = options.maxActiveRequests ?? MAX_ACTIVE_REQUESTS;
176
+ this.maxActiveSpans = options.maxActiveSpans ?? MAX_ACTIVE_SPANS;
177
+ this.maxRecentSpans = options.maxRecentSpans ?? MAX_RECENT_SPANS;
178
+ this.rateLimitMs = options.rateLimitMs ?? RATE_LIMIT_MS;
179
+ if (![this.maxActiveRequests, this.maxActiveSpans, this.maxRecentSpans].every(value => Number.isSafeInteger(value) && value > 0)
180
+ || !Number.isSafeInteger(this.rateLimitMs) || this.rateLimitMs < 0 || this.rateLimitMs > RATE_LIMIT_MS
181
+ || this.maxActiveRequests > MAX_ACTIVE_REQUESTS || this.maxActiveSpans > MAX_ACTIVE_SPANS || this.maxRecentSpans > MAX_RECENT_SPANS) throw new Error("invalid control write trace bounds");
182
+ }
183
+
184
+ /** Wrap a synchronous or asynchronous operation without changing its result or exception. */
185
+ run<T>(metadata: ControlWriteMetadata, privacyCurrent: ControlWritePrivacy, fn: () => T, inputPublic?: ControlWritePrivacy): T {
186
+ if (this.stopped) return fn();
187
+ const safeMetadata = this.safeMetadata(metadata);
188
+ if (!safeMetadata || typeof privacyCurrent !== "function") {
189
+ this.droppedMetadata++;
190
+ return fn();
191
+ }
192
+ const now = this.readNow();
193
+ this.expireRequests(now);
194
+ if (this.requests.size >= this.maxActiveRequests) { this.droppedActive++; return fn(); }
195
+ if (!this.privateNow(privacyCurrent)) return fn();
196
+ const inputVisible = !inputPublic || this.privateNow(inputPublic);
197
+ inputPublic = undefined; // The admission-only callback may carry raw input; never retain it in a trace.
198
+ if (!inputVisible) return fn();
199
+ const request: RequestTrace = {
200
+ telemetry: this, metadata: safeMetadata, privacyCurrent,
201
+ startedAt: now, phases: new Map(), phaseOrder: [], settled: false, privacySuppressed: false,
202
+ logPrivacy: () => false,
203
+ };
204
+ request.logPrivacy = () => !request.privacySuppressed && this.privateNow(request.privacyCurrent);
205
+ const id = this.nextRequestId++;
206
+ this.requests.set(id, request);
207
+ let result: T;
208
+ try { result = currentTrace.run(request, fn); }
209
+ catch (error) { this.finishRequest(id, request, "threw"); throw error; }
210
+ if (!isThenable(result)) { this.finishRequest(id, request, "returned"); return result; }
211
+ return Promise.resolve(result).then(
212
+ value => { this.finishRequest(id, request, "returned"); return value; },
213
+ error => { this.finishRequest(id, request, "threw"); throw error; },
214
+ ) as T;
215
+ }
216
+
217
+ /** Observe a daemon boundary; work outside a control request is labelled as background. */
218
+ observe<T>(phase: ControlWritePhase, privacyCurrent: ControlWritePrivacy, fn: () => T, inputPublic?: ControlWritePrivacy): T {
219
+ if (this.stopped) return fn();
220
+ const current = currentTrace.getStore();
221
+ if (current?.telemetry === this && !current.settled) {
222
+ if (current.privacySuppressed) return fn();
223
+ const inputVisible = this.privateNow(privacyCurrent) && (!inputPublic || this.privateNow(inputPublic));
224
+ inputPublic = undefined;
225
+ if (!inputVisible) current.privacySuppressed = true;
226
+ return controlWriteSpan(phase, fn);
227
+ }
228
+ return this.run({ kind: "background" }, privacyCurrent, () => controlWriteSpan(phase, fn), inputPublic);
229
+ }
230
+
231
+ /** Start one unref'd event-loop delay probe for the daemon. */
232
+ start(): void {
233
+ if (this.stopped || this.timer) return;
234
+ this.nextSampleAt = this.readNow() + CONTROL_WRITE_SAMPLE_MS;
235
+ this.timer = setInterval(() => this.sample(), CONTROL_WRITE_SAMPLE_MS);
236
+ this.timer.unref?.();
237
+ }
238
+
239
+ /** Stop the probe and suppress later diagnostics from this daemon instance. */
240
+ stop(): void {
241
+ this.stopped = true;
242
+ if (this.timer) clearInterval(this.timer);
243
+ this.timer = undefined;
244
+ this.nextSampleAt = undefined;
245
+ }
246
+
247
+ /** Deterministic probe seam used by tests; a missing cause remains explicitly unknown. */
248
+ sample(): void {
249
+ if (this.stopped) return;
250
+ const now = this.readNow(), expected = this.nextSampleAt ?? now;
251
+ this.expireRequests(now);
252
+ this.nextSampleAt = now + CONTROL_WRITE_SAMPLE_MS;
253
+ const delayMs = Math.max(0, now - expected);
254
+ if (delayMs < CONTROL_WRITE_LOOP_DELAY_MS) return;
255
+ this.blockedLoopSamples++;
256
+ const from = expected;
257
+ const observed: { kind: ControlWriteKind; task?: number; phase: ControlWritePhase; durationMs: number; open?: true; privacyCurrent: ControlWritePrivacy }[] = [];
258
+ for (const interval of this.recent) {
259
+ if (interval.async || interval.endedAt === undefined || interval.startedAt >= now || interval.endedAt < from) continue;
260
+ observed.push({ kind: interval.kind, ...(interval.task === undefined ? {} : { task: interval.task }), phase: interval.phase, durationMs: this.roundMs(interval.endedAt - interval.startedAt), privacyCurrent: interval.privacyCurrent });
261
+ }
262
+ for (const span of this.spans.values()) {
263
+ if (!span.async || span.startedAt >= now) continue;
264
+ observed.push({ kind: span.request.metadata.kind, ...(span.request.metadata.task === undefined ? {} : { task: span.request.metadata.task }), phase: span.phase, durationMs: this.roundMs(now - span.startedAt), open: true,
265
+ privacyCurrent: span.request.logPrivacy });
266
+ }
267
+ const unique = new Map<string, (typeof observed)[number]>();
268
+ for (const row of observed.sort((a, b) => b.durationMs - a.durationMs)) {
269
+ const key = `${row.kind}:${row.task ?? ""}:${row.phase}:${row.open === true}`;
270
+ if (!unique.has(key)) unique.set(key, row);
271
+ }
272
+ const selected = [...unique.values()].slice(0, MAX_LOOP_SPANS);
273
+ const spans = selected.map(({ kind, task, phase, durationMs, open }) => ({ kind, ...(task === undefined ? {} : { task }), phase, durationMs, ...(open ? { open: true as const } : {}) }));
274
+ const guards = [...new Set(selected.map(row => row.privacyCurrent))];
275
+ const attribution = selected.some(row => row.open !== true) ? "sync_overlap_observed" : selected.length ? "open_async_wait" : "unknown";
276
+ this.emit("loop", {
277
+ type: "control_write_loop_delay", delayMs: this.roundMs(delayMs), attribution, spans,
278
+ }, () => guards.every(guard => this.privateNow(guard)));
279
+ }
280
+
281
+ /** Flat numeric counters for the authenticated full-status projection. */
282
+ status(): Record<string, number> {
283
+ return {
284
+ active: this.requests.size,
285
+ activeSpans: this.spans.size,
286
+ completedRequests: this.completedRequests,
287
+ expiredRequests: this.expiredRequests,
288
+ slowRequests: this.slowRequests,
289
+ blockedLoopSamples: this.blockedLoopSamples,
290
+ recentSpans: this.recent.length,
291
+ droppedActive: this.droppedActive,
292
+ droppedSpans: this.droppedSpans,
293
+ droppedRecent: this.droppedRecent,
294
+ droppedLogs: this.droppedLogs,
295
+ droppedMetadata: this.droppedMetadata,
296
+ clockFailures: this.clockFailures,
297
+ };
298
+ }
299
+
300
+ beginSpan(request: RequestTrace, phase: ControlWritePhase): SpanTrace {
301
+ const span: SpanTrace = { request, phase, startedAt: this.readNow(), async: false, finished: false, prefixRecorded: false };
302
+ if (this.spans.size >= this.maxActiveSpans) { this.droppedSpans++; return span; }
303
+ span.id = this.nextSpanId++;
304
+ this.spans.set(span.id, span);
305
+ return span;
306
+ }
307
+
308
+ finishSpan(span: SpanTrace): void {
309
+ if (span.finished) return;
310
+ span.finished = true;
311
+ if (span.request.settled) return;
312
+ const endedAt = this.readNow();
313
+ if (span.id !== undefined && this.spans.get(span.id) === span) this.spans.delete(span.id);
314
+ const durationMs = Math.max(0, endedAt - span.startedAt);
315
+ const request = span.request;
316
+ const prior = request.phases.get(span.phase) ?? 0;
317
+ if (!request.phaseOrder.includes(span.phase)) request.phaseOrder.push(span.phase);
318
+ request.phases.set(span.phase, prior + durationMs);
319
+ if (!span.async && !span.prefixRecorded) this.rememberSyncInterval(span, endedAt);
320
+ }
321
+
322
+ /** Preserve a long synchronous prelude inside an async function for the delayed loop probe. */
323
+ recordSyncPrefix(span: SpanTrace): void {
324
+ if (span.finished || span.prefixRecorded || span.request.settled) return;
325
+ const endedAt = this.readNow();
326
+ if (endedAt - span.startedAt < MIN_SYNC_PREFIX_MS) return;
327
+ span.prefixRecorded = true;
328
+ this.rememberSyncInterval(span, endedAt);
329
+ }
330
+
331
+ private rememberSyncInterval(span: SpanTrace, endedAt: number): void {
332
+ const request = span.request;
333
+ this.recent.push({ kind: request.metadata.kind, ...(request.metadata.task === undefined ? {} : { task: request.metadata.task }), phase: span.phase, startedAt: span.startedAt, endedAt, async: false, privacyCurrent: request.logPrivacy });
334
+ while (this.recent.length > this.maxRecentSpans) { this.recent.shift(); this.droppedRecent++; }
335
+ }
336
+
337
+ private finishRequest(id: number, request: RequestTrace, outcome: "returned" | "threw"): void {
338
+ if (!this.requests.delete(id)) return;
339
+ request.settled = true;
340
+ for (const [spanId, span] of this.spans) if (span.request === request) this.spans.delete(spanId);
341
+ this.completedRequests++;
342
+ const durationMs = this.roundMs(this.readNow() - request.startedAt);
343
+ if (durationMs < CONTROL_WRITE_SLOW_MS) return;
344
+ this.slowRequests++;
345
+ const phases: PhaseTotal[] = request.phaseOrder.map(phase => ({ phase, durationMs: this.roundMs(request.phases.get(phase) ?? 0) }));
346
+ this.emit("slow", {
347
+ type: "control_write_slow", kind: request.metadata.kind, ...(request.metadata.task === undefined ? {} : { task: request.metadata.task }),
348
+ durationMs, outcome, phases: phases.slice(0, MAX_LOOP_SPANS), ...(phases.length > MAX_LOOP_SPANS ? { truncated: true } : {}),
349
+ }, request.logPrivacy);
350
+ }
351
+
352
+ /** Retire diagnostics only; the operation and its eventual result remain owned by the caller. */
353
+ private expireRequests(now: number): void {
354
+ for (const [id, request] of this.requests) {
355
+ if (now - request.startedAt < CONTROL_WRITE_REQUEST_MAX_AGE_MS) continue;
356
+ request.settled = true;
357
+ request.privacySuppressed = true; // Recent intervals must not attribute an expired request either.
358
+ this.requests.delete(id);
359
+ this.expiredRequests++;
360
+ }
361
+ for (const [id, span] of this.spans) if (span.request.settled) this.spans.delete(id);
362
+ }
363
+
364
+ private emit(key: typeof RATE_KEYS[number], value: Record<string, unknown>, privacyCurrent: ControlWritePrivacy): void {
365
+ if (this.stopped || !this.privateNow(privacyCurrent)) return;
366
+ const now = this.readNow(), last = this.lastEmit.get(key);
367
+ if (last !== undefined && now - last < this.rateLimitMs) { this.droppedLogs++; return; }
368
+ while (this.emitted.length && now - this.emitted[0]! >= 60_000) this.emitted.shift();
369
+ if (this.emitted.length >= MAX_EMITS_PER_MINUTE) { this.droppedLogs++; return; }
370
+ let line: string;
371
+ try { line = JSON.stringify(value); } catch { this.droppedLogs++; return; }
372
+ if (Buffer.byteLength(line) > CONTROL_WRITE_MAX_LINE_BYTES) {
373
+ const minimal = value.type === "control_write_loop_delay"
374
+ ? { type: "control_write_loop_delay", delayMs: typeof value.delayMs === "number" ? value.delayMs : 0, attribution: "unknown", spans: [], truncated: true }
375
+ : { type: "control_write_slow", kind: value.kind, ...(value.task === undefined ? {} : { task: value.task }), durationMs: value.durationMs, outcome: value.outcome, phases: [], truncated: true };
376
+ line = JSON.stringify(minimal);
377
+ if (Buffer.byteLength(line) > CONTROL_WRITE_MAX_LINE_BYTES) { this.droppedLogs++; return; }
378
+ }
379
+ this.lastEmit.set(key, now);
380
+ this.emitted.push(now);
381
+ try { this.options.emit(line); } catch { this.droppedLogs++; }
382
+ }
383
+
384
+ private privateNow(guard: ControlWritePrivacy): boolean {
385
+ try { return guard() === true; } catch { return false; }
386
+ }
387
+
388
+ private safeMetadata(metadata: unknown): ControlWriteMetadata | undefined {
389
+ if (!isObject(metadata)) return undefined;
390
+ try {
391
+ const kind = ownValue(metadata, "kind"), task = ownValue(metadata, "task");
392
+ if (typeof kind !== "string" || !(CONTROL_WRITE_KINDS as readonly string[]).includes(kind) || task !== undefined && !validTaskId(task)) return undefined;
393
+ return { kind: kind as ControlWriteKind, ...(task === undefined ? {} : { task }) };
394
+ } catch { return undefined; }
395
+ }
396
+
397
+ private readNow(): number {
398
+ let value: number;
399
+ try { value = this.nowFn(); } catch { this.clockFailures++; return this.lastNow; }
400
+ if (!Number.isFinite(value) || value < this.lastNow) { this.clockFailures++; return this.lastNow; }
401
+ this.lastNow = value;
402
+ return value;
403
+ }
404
+
405
+ private roundMs(value: number): number {
406
+ const bounded = Number.isFinite(value) ? Math.min(CONTROL_WRITE_MAX_DURATION_MS, Math.max(0, value)) : CONTROL_WRITE_MAX_DURATION_MS;
407
+ return Math.round(bounded * 10) / 10;
408
+ }
409
+ }
@@ -6,19 +6,35 @@ export interface CostTask { repo: string; pr: number; private?: boolean; history
6
6
  export interface CostPr { repo: string; pr: number; state: "OPEN" | "CLOSED" | "MERGED"; mergedAt?: number; complete: boolean; ciRuns?: { id: number; attempt: number }[]; reviews?: number }
7
7
  export interface CostRow { repo: string; pr: number; reviewRounds: number; ciRuns: number | null; ciReruns: number | null; fullReReviews: number; deltaReviews: number; reviewMs: number | null; unknownReviewScopes: number; githubReviews: number | null; complete: boolean }
8
8
  export interface CostReport { rows: CostRow[]; truncated: boolean }
9
- export function cleanCostPlan(input: unknown): CostPlan {
10
- if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("invalid cost scope");
9
+ /**
10
+ * #1087: why a cost scope was refused. The message is fixed text that names the field and its allowed values and never the value given, so a call site may show it
11
+ * as it is. Only this class is passed through; any other error keeps the generic refusal.
12
+ */
13
+ export class CostScopeError extends Error {
14
+ constructor(message: string) { super(message); this.name = "CostScopeError"; }
15
+ }
16
+ /**
17
+ * `field` says how the caller's own argument is named, so the refusal points at what the caller actually wrote: `plan` for a task plan (`plan.review.scope`), empty for the
18
+ * plan-review tool's own `review` and `ci` arguments (`review.scope`). One string covers both keys; an object names each key's source separately, for a call that mixes them.
19
+ */
20
+ export function cleanCostPlan(input: unknown, field: string | { review: string; ci: string } = "plan"): CostPlan {
21
+ const at = (name: string) => { const prefix = typeof field === "string" ? field : field[name.split(".")[0] as "review" | "ci"]; return prefix ? `${prefix}.${name}` : name; };
22
+ const refuse = (message: string): never => { throw new CostScopeError(message); };
23
+ if (!input || typeof input !== "object" || Array.isArray(input)) refuse(`${typeof field === "string" && field ? field : "the request"} must be an object; review and ci are its cost scope fields`);
11
24
  const value = input as Record<string, unknown>;
12
25
  const result: CostPlan = {};
13
26
  if (value.review !== undefined) {
14
27
  const review = value.review as Record<string, unknown>;
15
- if (!review || typeof review !== "object" || Array.isArray(review) || Object.keys(review).some(key => !["scope", "reviewers"].includes(key))
16
- || !["none", "delta", "full"].includes(review.scope as string) || !Number.isSafeInteger(review.reviewers)
17
- || (review.scope === "none" ? review.reviewers !== 0 : (review.reviewers as number) < 1 || (review.reviewers as number) > 8)) throw new Error("invalid review cost scope");
28
+ if (!review || typeof review !== "object" || Array.isArray(review)) refuse(`${at("review")} must be an object with scope (none, delta or full) and reviewers (a whole number)`);
29
+ if (Object.keys(review).some(key => !["scope", "reviewers"].includes(key))) refuse(`${at("review")} takes only scope and reviewers`);
30
+ if (!["none", "delta", "full"].includes(review.scope as string)) refuse(`${at("review.scope")} must be none, delta or full`);
31
+ if (!Number.isSafeInteger(review.reviewers)) refuse(`${at("review.reviewers")} must be a whole number: 0 when scope is none, 1 to 8 when scope is delta or full`);
32
+ if (review.scope === "none" && review.reviewers !== 0) refuse(`${at("review.reviewers")} must be 0 when scope is none`);
33
+ if (review.scope !== "none" && ((review.reviewers as number) < 1 || (review.reviewers as number) > 8)) refuse(`${at("review.reviewers")} must be 1 to 8 when scope is delta or full`);
18
34
  result.review = { scope: review.scope as ReviewScope["scope"], reviewers: review.reviewers as number };
19
35
  }
20
36
  if (value.ci !== undefined) {
21
- if (!["focused", "full", "reuse"].includes(value.ci as string)) throw new Error("invalid CI cost scope");
37
+ if (!["focused", "full", "reuse"].includes(value.ci as string)) refuse(`${at("ci")} must be focused, full or reuse`);
22
38
  result.ci = value.ci as CostPlan["ci"];
23
39
  }
24
40
  return result;
package/src/hub/crash.ts CHANGED
@@ -97,3 +97,23 @@ export function lossNotice(lost: JournalDelivery[], taskTitle: (id: number) => s
97
97
  "Check your work against them before you go on.",
98
98
  ].join("\n");
99
99
  }
100
+
101
+ /**
102
+ * One line of the crash-recovery report in `ahub status` (#1082). The report is a list of these, kept for the whole run, and what `ahub status` shows is `openCrashNotices` of it, computed when
103
+ * status is asked, so a line whose condition is gone is gone.
104
+ * - `peer` set: the line asks something of that peer's owner ("run ahub codex again", "reconnects by itself if its client is still running"). It stands while that peer is not attached, and clears
105
+ * when it is, whoever started it.
106
+ * - `inFlight` set: the headline. Its count of deliveries the crash left in needs_review is read live, and the clause disappears at zero.
107
+ * - neither: what recovery found or did ("kimi resumed", "pi not resumed (...)"). It is history, not an open action, and stays.
108
+ */
109
+ export interface CrashNotice { text: string; peer?: string; inFlight?: true }
110
+
111
+ /** The lines `ahub status` shows now: `attached` are the peers that are not offline, `inFlight` is how many of the crash's own deliveries are still in needs_review. */
112
+ export function openCrashNotices(notices: readonly CrashNotice[], live: { attached: ReadonlySet<string>; inFlight: number }): string[] {
113
+ const lines: string[] = [];
114
+ for (const notice of notices) {
115
+ if (notice.peer !== undefined && live.attached.has(notice.peer)) continue;
116
+ lines.push(notice.inFlight ? `${notice.text}${live.inFlight ? `; ${live.inFlight} deliveries it had in flight are in needs_review (ahub queue list)` : ""}` : notice.text);
117
+ }
118
+ return lines;
119
+ }