@staix/agent-hub 0.13.0 → 0.15.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 (92) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +19 -7
  3. package/docs/agent-notes/README.md +4 -0
  4. package/docs/agent-notes/adapters.md +18 -4
  5. package/docs/agent-notes/budget.md +5 -4
  6. package/docs/agent-notes/bus.md +1 -0
  7. package/docs/agent-notes/daemon.md +19 -7
  8. package/docs/agent-notes/tasks.md +15 -7
  9. package/docs/agent-notes/tests.md +4 -4
  10. package/docs/dogfood.md +18 -10
  11. package/docs/events.md +21 -7
  12. package/docs/operations.md +131 -27
  13. package/docs/profiles.md +1 -0
  14. package/docs/quickstart.md +13 -3
  15. package/docs/sdlc.md +40 -2
  16. package/docs/security.md +45 -14
  17. package/docs/specs/2026-09-19-agent-hub-design.md +112 -10
  18. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +14 -0
  19. package/docs/verification/2026-10-23-0.14.0.md +69 -0
  20. package/docs/verification/2026-10-23-0.15.0.md +80 -0
  21. package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
  22. package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
  23. package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
  24. package/docs/verified.json +34 -17
  25. package/package.json +1 -1
  26. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  27. package/plugins/agent-hub/server.js +1078 -327
  28. package/src/adapters/acp.ts +43 -7
  29. package/src/adapters/claude-channel.ts +26 -4
  30. package/src/adapters/claude-implementer.ts +243 -0
  31. package/src/adapters/claude-print-child.ts +90 -0
  32. package/src/adapters/claude-task-sessions.ts +80 -0
  33. package/src/adapters/codex-review-isolation.ts +18 -7
  34. package/src/adapters/compat.ts +4 -0
  35. package/src/adapters/conformance/enrollment.ts +2 -1
  36. package/src/adapters/conformance/fakes/acp-server.ts +3 -1
  37. package/src/adapters/definitions/builtin-metadata.ts +15 -3
  38. package/src/adapters/definitions/native-channel-state.ts +1 -1
  39. package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
  40. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
  41. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  42. package/src/adapters/definitions/native-factories.ts +8 -0
  43. package/src/adapters/kimi-wire-usage.ts +303 -0
  44. package/src/adapters/registry-types.ts +7 -3
  45. package/src/adapters/registry.ts +14 -2
  46. package/src/adapters/reviewer-print.ts +64 -60
  47. package/src/cli/console-config.ts +123 -0
  48. package/src/cli/console-state.ts +28 -1
  49. package/src/cli/console.ts +25 -1
  50. package/src/cli/facts-hook.ts +9 -5
  51. package/src/cli/help.ts +6 -4
  52. package/src/cli/launch.ts +10 -4
  53. package/src/cli/main.ts +52 -9
  54. package/src/cli/output.ts +75 -2
  55. package/src/cli/reset.ts +1 -1
  56. package/src/cli/settings-edit.ts +89 -0
  57. package/src/cli/status-lines.ts +1 -1
  58. package/src/cli/terminal-recovery.ts +1 -6
  59. package/src/hub/action-enforce.ts +82 -0
  60. package/src/hub/action-policy.ts +22 -10
  61. package/src/hub/board.ts +48 -3
  62. package/src/hub/budget.ts +5 -0
  63. package/src/hub/bus.ts +5 -5
  64. package/src/hub/claude-launch-proof.ts +33 -0
  65. package/src/hub/config-apply.ts +5 -0
  66. package/src/hub/config-edit-host.ts +173 -0
  67. package/src/hub/config-edit.ts +282 -0
  68. package/src/hub/config-tier.ts +89 -0
  69. package/src/hub/control-client.ts +24 -3
  70. package/src/hub/daemon.ts +450 -71
  71. package/src/hub/deliverable-files.ts +131 -0
  72. package/src/hub/deliverables.ts +125 -0
  73. package/src/hub/delivery-journal.ts +18 -2
  74. package/src/hub/events.ts +3 -1
  75. package/src/hub/hub-tools.ts +21 -4
  76. package/src/hub/instances.ts +19 -6
  77. package/src/hub/kimi-usage.ts +144 -0
  78. package/src/hub/manager.ts +3 -3
  79. package/src/hub/peers.ts +7 -0
  80. package/src/hub/permission-payload.ts +15 -0
  81. package/src/hub/routing.ts +118 -3
  82. package/src/hub/sdlc-metrics.ts +260 -0
  83. package/src/hub/sdlc.ts +5 -3
  84. package/src/hub/settings.ts +1 -1
  85. package/src/hub/spec-read.ts +31 -0
  86. package/src/hub/task-code-group.ts +52 -0
  87. package/src/hub/tasks.ts +374 -37
  88. package/src/hub/token-window.ts +33 -0
  89. package/src/hub/ui.ts +3 -0
  90. package/src/hub/usage-rollup.ts +74 -6
  91. package/src/hub/workflow.ts +16 -0
  92. package/src/ui/index.html +388 -3
@@ -0,0 +1,33 @@
1
+ /**
2
+ * A rolling window of counted tokens, by the time each was used (not the time it was read), so a row read late still ages out
3
+ * on schedule. The hub's own count against a ceiling a person configured, never the provider's quota (#285).
4
+ */
5
+ export class TokenWindow {
6
+ private rows: { at: number; n: number }[] = [];
7
+ constructor(private readonly spanMs: number) {}
8
+
9
+ add(at: number, n: number): void {
10
+ if (!Number.isFinite(at) || !Number.isSafeInteger(n) || n <= 0) return;
11
+ const index = this.rows.findIndex(row => row.at > at);
12
+ if (index < 0) this.rows.push({ at, n }); else this.rows.splice(index, 0, { at, n });
13
+ }
14
+
15
+ private prune(now: number): void {
16
+ let drop = 0;
17
+ while (drop < this.rows.length && now - this.rows[drop]!.at > this.spanMs) drop++;
18
+ if (drop) this.rows.splice(0, drop);
19
+ }
20
+
21
+ sum(now: number): number {
22
+ this.prune(now);
23
+ return this.rows.reduce((total, row) => total + row.n, 0);
24
+ }
25
+
26
+ /** When the oldest counted token was used: the window frees its first share at this time plus its span. */
27
+ oldest(now: number): number | undefined {
28
+ this.prune(now);
29
+ return this.rows[0]?.at;
30
+ }
31
+
32
+ reset(): void { this.rows = []; }
33
+ }
package/src/hub/ui.ts CHANGED
@@ -25,6 +25,9 @@ const TICKET_MS = 60_000;
25
25
  const SESSION_MS = 60 * 60_000;
26
26
  /** How long a session opened by `ahub ui --settings` may change settings that raise authority; it stays an ordinary session after. */
27
27
  export const SETTINGS_MS = 15 * 60_000;
28
+ /** The page's view names (index.html DASHBOARD_VIEWS): the only values a snapshot request's `view` may carry to a project hub. */
29
+ export const DASHBOARD_VIEWS = ["board", "office", "stream", "agents", "message", "memory", "settings"] as const;
30
+ export const dashboardView = (value: unknown): typeof DASHBOARD_VIEWS[number] | undefined => (DASHBOARD_VIEWS as readonly unknown[]).includes(value) ? value as typeof DASHBOARD_VIEWS[number] : undefined;
28
31
  const LIMIT = 64;
29
32
  const secret = () => randomBytes(32).toString("hex");
30
33
 
@@ -65,6 +65,10 @@ export interface TotalRow { peer: string; tokens: Counter; withoutCache: Counter
65
65
  /** `unknownPeers`: the peers whose figure is unknown, which the overall figure then leaves out (so it is `partial`, a lower bound, never a sum that treats them as 0). */
66
66
  export interface TotalResult { since: string | null; peers: TotalRow[]; overall: { tokens: Counter; withoutCache: Counter; cacheRead: Counter }; unknownPeers: { tokens: string[]; withoutCache: string[]; cacheRead: string[] } }
67
67
 
68
+ /** `empty`: no record at all for that UTC day (a gap), shown as an empty column and never as a zero. */
69
+ export interface DailyRow { day: string; peers: Record<string, Counter>; total: Counter; empty?: true }
70
+ export interface DailyResult { days: DailyRow[]; peers: string[] }
71
+
68
72
  const sum = (a: number | null, b: number | null): number | null => a === null ? b : b === null ? a : a + b;
69
73
  const counter = (value: number | null, n: number, source: UsageSource = "native"): Counter => n > 0 && value !== null ? { value, source } : { value: null, source: "unknown" };
70
74
 
@@ -97,7 +101,8 @@ export class UsageRollup {
97
101
  * Fold the next slice of the log. Whole lines only: a trailing partial line (a crash, or a write in flight) waits for the next pass.
98
102
  * A log that is shorter than the stored offset was replaced or reset, so the roll-up starts over from byte 0.
99
103
  */
100
- poll(): { bytes: number; done: boolean } {
104
+ poll(maxMs = 20): { bytes: number; done: boolean } {
105
+ const deadline = performance.now() + Math.max(0, Math.min(20, maxMs));
101
106
  if (this.closed) return { bytes: 0, done: true };
102
107
  if (!existsSync(this.eventsFile)) return { bytes: 0, done: true };
103
108
  const fd = openSync(this.eventsFile, "r");
@@ -123,18 +128,27 @@ export class UsageRollup {
123
128
  want = Math.min(size - offset, want * 2);
124
129
  }
125
130
  if (end < 0) return { bytes: 0, done: true }; // the last line is still being written: it waits for the next pass
126
- const text = buffer.subarray(0, end + 1).toString("utf8");
131
+ let consumed = 0;
127
132
  // The offset was read before the slice was; another roll-up on this hub.db (a restart that overlaps the old process) may have folded the same
128
133
  // bytes since. The transaction takes the write lock first and checks the stored offset again: a pass whose start is stale is abandoned whole,
129
134
  // so a byte range is folded exactly once however many writers there are (found by a forced interleave: the counts doubled).
130
135
  const applied = this.db.transaction(() => {
131
136
  if (this.state().offset !== offset) return false;
132
- for (const line of text.split("\n")) this.fold(line);
133
- this.db.query("UPDATE rollup_state SET byte_offset = ? WHERE id = 1").run(offset + end + 1);
137
+ // Commit only complete processed records with their offset. Yield between records;
138
+ // ponytail: one whole record and the existing corrupt-line skip are indivisible;
139
+ // a streaming parser would be needed to preempt within either operation.
140
+ for (let start = 0; start <= end;) {
141
+ const newline = buffer.indexOf(0x0a, start);
142
+ if (newline < 0 || newline > end) break;
143
+ this.fold(buffer.subarray(start, newline).toString("utf8"));
144
+ consumed = newline + 1; start = consumed;
145
+ if (performance.now() >= deadline) break;
146
+ }
147
+ this.db.query("UPDATE rollup_state SET byte_offset = ? WHERE id = 1").run(offset + consumed);
134
148
  return true;
135
149
  }).immediate();
136
150
  if (!applied) return { bytes: 0, done: false }; // stale: the next pass starts from the stored offset
137
- return { bytes: end + 1, done: offset + end + 1 >= size };
151
+ return { bytes: consumed, done: offset + consumed >= size };
138
152
  } finally { closeSync(fd); }
139
153
  }
140
154
 
@@ -156,7 +170,7 @@ export class UsageRollup {
156
170
  */
157
171
  catchUp(maxMs?: number): boolean {
158
172
  const until = maxMs === undefined ? Infinity : Date.now() + maxMs;
159
- for (let i = 0; i < 100_000; i++) { if (this.poll().done) return true; if (Date.now() >= until) return false; }
173
+ for (let i = 0; i < 100_000; i++) { if (this.poll(Math.min(20, until - Date.now())).done) return true; if (Date.now() >= until) return false; }
160
174
  return true;
161
175
  }
162
176
 
@@ -350,6 +364,60 @@ export class UsageRollup {
350
364
  unknownPeers: { tokens: unknownPeers(r => r.tokens), withoutCache: unknownPeers(r => r.withoutCache), cacheRead: unknownPeers(r => r.cacheRead) } };
351
365
  }
352
366
 
367
+ /**
368
+ * Daily tokens per peer for the last `days` calendar UTC days up to `now`, stacked for the dashboard Usage view (#683 U4).
369
+ * For each day and peer, tokens is native when reported, counted increments when available, otherwise unknown. A day inside
370
+ * the window with no record is an `empty` row (a gap the chart shows), and the window never starts before the first record.
371
+ */
372
+ daily(days = 14, now: number = Date.now()): DailyResult {
373
+ const raw = this.db.query(`SELECT day, peer, SUM(total) AS total, SUM(total_n) AS total_n FROM usage_rollup GROUP BY day, peer ORDER BY day ASC`).all() as any[];
374
+ const counted = this.db.query(`SELECT day, peer, SUM(tokens) AS tokens, SUM(increments) AS increments FROM token_rollup GROUP BY day, peer ORDER BY day ASC`).all() as any[];
375
+ const usageMap = new Map<string, { total: number; total_n: number }>();
376
+ for (const r of raw) usageMap.set(`${r.day}\0${r.peer}`, { total: r.total, total_n: r.total_n });
377
+ const countMap = new Map<string, { tokens: number; increments: number }>();
378
+ for (const r of counted) countMap.set(`${r.day}\0${r.peer}`, { tokens: r.tokens, increments: r.increments });
379
+
380
+ const allDays = [...new Set([...raw.map(r => r.day as string), ...counted.map(r => r.day as string)])].sort();
381
+ const allPeers = [...new Set([...raw.map(r => r.peer as string), ...counted.map(r => r.peer as string)])].sort();
382
+ // The last `days` calendar days ending today, clipped to the first day that has any record.
383
+ const dayMs = 86_400_000, today = new Date(now).toISOString().slice(0, 10);
384
+ const sliceDays: string[] = [];
385
+ if (allDays.length) for (let i = Math.max(1, Math.min(days, 366)) - 1; i >= 0; i--) {
386
+ const day = new Date(Date.parse(today + "T00:00:00Z") - i * dayMs).toISOString().slice(0, 10);
387
+ if (day >= allDays[0]!) sliceDays.push(day);
388
+ }
389
+
390
+ const resultDays: DailyRow[] = [];
391
+ for (const d of sliceDays) {
392
+ const peerCounters: Record<string, Counter> = {};
393
+ const knownCounters: Counter[] = [];
394
+ for (const p of allPeers) {
395
+ const u = usageMap.get(`${d}\0${p}`);
396
+ const c = countMap.get(`${d}\0${p}`);
397
+ let counterVal: Counter = { value: null, source: "unknown" };
398
+ if (u && u.total_n > 0) {
399
+ counterVal = counter(u.total, u.total_n);
400
+ } else if (c && c.increments > 0) {
401
+ counterVal = { value: c.tokens, source: "count" };
402
+ }
403
+ peerCounters[p] = counterVal;
404
+ if (counterVal.value !== null) knownCounters.push(counterVal);
405
+ }
406
+ let dayTotal: Counter = { value: null, source: "unknown" };
407
+ if (knownCounters.length > 0) {
408
+ const sumVal = knownCounters.reduce((acc, cur) => acc + cur.value!, 0);
409
+ const source: UsageSource = knownCounters.length !== allPeers.length
410
+ ? "partial"
411
+ : knownCounters.every(cur => cur.source === "native")
412
+ ? "native"
413
+ : "count";
414
+ dayTotal = { value: sumVal, source };
415
+ }
416
+ resultDays.push({ day: d, peers: peerCounters, total: dayTotal, ...(!allDays.includes(d) ? { empty: true as const } : {}) });
417
+ }
418
+ return { days: resultDays, peers: allPeers };
419
+ }
420
+
353
421
  /** Every roll-up row, in key order, for the rebuild-equals-incremental test. */
354
422
  dump(): unknown {
355
423
  const all = (table: string, order: string) => this.db.query(`SELECT * FROM ${table} ORDER BY ${order}`).all();
@@ -46,4 +46,20 @@ export function criteriaView(config: SdlcConfig, task: Task, isPrivate: (t: Task
46
46
  for (const criterion of checklist.criteria) if (criterion.required && criterion.result !== "verified") blockers.push(`criterion ${criterion.id}: ${criterion.result}`);
47
47
  return { id: checklist.id, ready: blockers.length === 0, blockers, criteria: checklist.criteria.map(c => ({ ...c, refs: [...c.refs] })), ...("tasks" in checklist && (checklist as LegacyPhaseReceipt).tasks.length ? { note: "stored checklist task list ignored: task import was retired (#481)" } : {}) };
48
48
  }
49
+ /**
50
+ * #507b: which of a task's recorded acceptance criteria (the #481 S8 checklist, the only criteria model) a reviewer's unmet items name.
51
+ * An item names a criterion when it STARTS with that criterion's id, exactly (case and all), followed by the end of the item or a delimiter:
52
+ * `AC3: the overwrite case has no test`, or just `AC3`. An id the checklist does not hold is plain text, never a criterion, and an id in the
53
+ * middle of a sentence is not a claim. Pure: items in, ids out (in the order first named, each once).
54
+ */
55
+ export function unmetCriteriaIds(items: readonly string[], checklist: Pick<CriteriaReceipt, "criteria"> | undefined): string[] {
56
+ if (!checklist) return [];
57
+ const known = new Set(checklist.criteria.map(c => c.id));
58
+ const named: string[] = [];
59
+ for (const item of items) {
60
+ const id = /^\s*([A-Za-z][A-Za-z0-9-]{0,47})(?=$|[\s:.,;)\]])/.exec(item)?.[1];
61
+ if (id !== undefined && known.has(id) && !named.includes(id)) named.push(id);
62
+ }
63
+ return named;
64
+ }
49
65
  export function workflowHash(value: unknown): string { return createHash("sha256").update(JSON.stringify(value)).digest("hex"); }