@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,131 @@
1
+ import { constants, lstatSync, type Stats } from "node:fs";
2
+ import { open } from "node:fs/promises";
3
+ import { createHash } from "node:crypto";
4
+ import { relative } from "node:path";
5
+ import { guardPath } from "../local/tools.ts";
6
+ import { isHubWritePath } from "./permission-mode.ts";
7
+ import { realPath } from "./project.ts";
8
+ import { DELIVERABLE_LIMITS, deliverableId, type DeliverableClaim, type DeliverableRecord, type DeliverableState } from "./deliverables.ts";
9
+
10
+ /**
11
+ * The only code that opens a deliverable (#509a). The hub hashes the bytes itself; nothing an agent says about a file is used.
12
+ *
13
+ * - Containment: `guardPath` (read mode) resolves the path with `realPath`, refuses anything outside the project, on the secrets denylist or
14
+ * through a dangling link, and returns the resolved path. The file is then opened without following a final link and its identity
15
+ * (device, inode, size and mtime: Linux reuses an inode number at once) is compared with what was checked, so a path swapped between the check and the open is refused, not read.
16
+ * - Plain folders: nothing here needs git, a PR, a snapshot or a lifecycle receipt.
17
+ * - Bounded and non-blocking: a regular file only, at most `fileBytes` each and `totalBytes` per call, read in 1 MiB chunks with an await
18
+ * between them so a large file never stalls the daemon. A file that changes while it is read is refused, never recorded half-read.
19
+ */
20
+
21
+ export interface DeliverablePorts {
22
+ cwd: string;
23
+ /** The project's secrets denylist (`local.deny`). */
24
+ deny: readonly string[];
25
+ /** Whether a file belongs to a PII task or matches `pii.paths`: its record is private. */
26
+ private(path: string): boolean;
27
+ }
28
+
29
+ const CHUNK = 1024 * 1024;
30
+ const NOFOLLOW = constants.O_NOFOLLOW ?? 0;
31
+
32
+ export class DeliverableRefusal extends Error {}
33
+
34
+ /**
35
+ * Test seam: runs between vetting a path and opening it, the window a swapped file would use. Never set outside tests; no config or tool reaches it.
36
+ * ponytail: a hook, not a lock. The open uses O_NOFOLLOW and a device/inode comparison, which is what this seam lets a test attack.
37
+ */
38
+ export const deliverableHooks: { beforeOpen?: (path: string) => void } = {};
39
+ const refuse = (path: string, why: string): never => { throw new DeliverableRefusal(`deliverable ${path}: ${why}`); };
40
+
41
+ interface Located { real: string; stat: Stats }
42
+
43
+ /** Resolve and vet one path. Throws `DeliverableRefusal` with a reason that names the given relative path, never a resolved one. */
44
+ function locate(path: string, ports: DeliverablePorts): Located {
45
+ let real: string;
46
+ try { real = guardPath({ cwd: ports.cwd, deny: [...ports.deny], readOnly: [] }, path, "read"); }
47
+ catch (error) { return refuse(path, (error as Error).message.replace(path, "the path").slice(0, 160)); }
48
+ // guardPath's read mode keeps secrets out but not the hub's own state: `.agenthub` and `.git` are refused here, so no hash of a config, token or
49
+ // git file ever reaches a record (a hash of a low-entropy file confirms guesses). Compared on the resolved path, folded like the write guard.
50
+ let root: string;
51
+ try { root = realPath(ports.cwd); } catch { return refuse(path, "the project root cannot be resolved"); }
52
+ if (isHubWritePath(relative(root, real))) return refuse(path, "the hub's own state is not a deliverable");
53
+ let stat: Stats;
54
+ try { stat = lstatSync(real); }
55
+ catch (error) { return refuse(path, (error as NodeJS.ErrnoException).code === "ENOENT" ? "the file does not exist" : "the file cannot be read"); }
56
+ if (!stat.isFile()) return refuse(path, "not a regular file");
57
+ if (stat.size > DELIVERABLE_LIMITS.fileBytes) return refuse(path, `larger than ${DELIVERABLE_LIMITS.fileBytes / (1024 * 1024)} MiB`);
58
+ return { real, stat };
59
+ }
60
+
61
+ const sameFile = (a: Pick<Stats, "dev" | "ino">, b: Pick<Stats, "dev" | "ino">) => a.dev === b.dev && a.ino === b.ino;
62
+
63
+ /** sha256 and size of one vetted file, or a refusal. */
64
+ async function digest(path: string, found: Located): Promise<{ sha256: string; bytes: number }> {
65
+ let handle;
66
+ deliverableHooks.beforeOpen?.(path);
67
+ try { handle = await open(found.real, constants.O_RDONLY | NOFOLLOW); }
68
+ catch { return refuse(path, "the file cannot be opened"); }
69
+ try {
70
+ const before = await handle.stat();
71
+ if (!before.isFile() || !sameFile(before, found.stat) || before.size !== found.stat.size || before.mtimeMs !== found.stat.mtimeMs) return refuse(path, "the file changed while it was being checked");
72
+ const hash = createHash("sha256"), buffer = Buffer.allocUnsafe(CHUNK);
73
+ let bytes = 0;
74
+ for (;;) {
75
+ const { bytesRead } = await handle.read(buffer, 0, CHUNK, bytes);
76
+ if (!bytesRead) break;
77
+ bytes += bytesRead;
78
+ if (bytes > DELIVERABLE_LIMITS.fileBytes) return refuse(path, "larger than the size limit");
79
+ hash.update(buffer.subarray(0, bytesRead));
80
+ }
81
+ const after = await handle.stat();
82
+ if (after.size !== bytes || after.size !== before.size || after.mtimeMs !== before.mtimeMs) return refuse(path, "the file changed while it was being read");
83
+ return { sha256: hash.digest("hex"), bytes };
84
+ } finally { await handle.close().catch(() => {}); }
85
+ }
86
+
87
+ /** Refuse a bad claim before any effect, without hashing: the path must be inside the project, allowed, and a regular file of a usable size. */
88
+ export function vetDeliverables(claims: readonly DeliverableClaim[], ports: DeliverablePorts): void {
89
+ let total = 0;
90
+ for (const claim of claims) total += locate(claim.path, ports).stat.size;
91
+ if (total > DELIVERABLE_LIMITS.totalBytes) throw new DeliverableRefusal(`deliverables: together larger than ${DELIVERABLE_LIMITS.totalBytes / (1024 * 1024)} MiB`);
92
+ }
93
+
94
+ /** Hash the claimed files now. Any refusal refuses the whole call: there is no partial record. */
95
+ export async function hashDeliverables(claims: readonly DeliverableClaim[], ports: DeliverablePorts): Promise<DeliverableRecord[]> {
96
+ const found = claims.map(claim => ({ claim, at: locate(claim.path, ports) }));
97
+ if (found.reduce((sum, row) => sum + row.at.stat.size, 0) > DELIVERABLE_LIMITS.totalBytes) throw new DeliverableRefusal(`deliverables: together larger than ${DELIVERABLE_LIMITS.totalBytes / (1024 * 1024)} MiB`);
98
+ const records: DeliverableRecord[] = [];
99
+ for (const { claim, at } of found) {
100
+ const { sha256, bytes } = await digest(claim.path, at);
101
+ records.push({ id: deliverableId(claim.path, claim.format), path: claim.path, format: claim.format, ...(claim.version !== undefined ? { version: claim.version } : {}), sha256, bytes, ...(ports.private(claim.path) ? { private: true as const } : {}) });
102
+ }
103
+ return records;
104
+ }
105
+
106
+ export interface DeliverableCheck { record: DeliverableRecord; state: DeliverableState; why?: string }
107
+
108
+ /**
109
+ * Compare recorded deliverables with the files as they are now. `unchanged` and `changed` are measurements; `missing` is a file that is gone;
110
+ * `unknown` is a file the hub cannot vouch for (outside the project, denied, too large, unreadable, or changing while read): never `unchanged`.
111
+ */
112
+ export async function checkDeliverables(records: readonly DeliverableRecord[], ports: DeliverablePorts): Promise<DeliverableCheck[]> {
113
+ const out: DeliverableCheck[] = [];
114
+ // The files may have grown since delivery: the aggregate bound holds for a read as for a delivery, so an over-large set is read as unknown, not hashed.
115
+ let total = 0;
116
+ for (const record of records) { try { if (record.path) total += locate(record.path, ports).stat.size; } catch { /* reported per file below */ } }
117
+ const tooLarge = total > DELIVERABLE_LIMITS.totalBytes;
118
+ for (const record of records) {
119
+ if (!record.path) { out.push({ record, state: "unknown", why: "no path recorded" }); continue; }
120
+ if (tooLarge) { out.push({ record, state: "unknown", why: `the files together are now larger than ${DELIVERABLE_LIMITS.totalBytes / (1024 * 1024)} MiB` }); continue; }
121
+ try {
122
+ const found = locate(record.path, ports);
123
+ const now = await digest(record.path, found);
124
+ out.push({ record, state: now.sha256 === record.sha256 ? "unchanged" : "changed" });
125
+ } catch (error) {
126
+ const why = error instanceof DeliverableRefusal ? error.message : "unreadable";
127
+ out.push({ record, state: /does not exist/.test(why) ? "missing" : "unknown", why });
128
+ }
129
+ }
130
+ return out;
131
+ }
@@ -0,0 +1,125 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ /**
4
+ * Deliverable records (#509a): which bytes a task delivered, bound to content hashes the hub computed itself. A claim names a file and its
5
+ * format; the hub opens the file (`deliverable-files.ts`), hashes what it finds and records that. A hash an agent supplies is never read:
6
+ * `parseDeliverableClaims` refuses any field it does not own, so a claimed `sha256` is a refusal, not an input.
7
+ *
8
+ * Pure: no filesystem, clock or config. Plain folders work because nothing here (or in the file reader) needs git, a PR or a snapshot.
9
+ */
10
+
11
+ export const DELIVERABLE_LIMITS = Object.freeze({
12
+ /** Records on one task. */
13
+ count: 16,
14
+ /** One file the hub will hash: larger is refused, never hashed in part. */
15
+ fileBytes: 256 * 1024 * 1024,
16
+ /** All files of one call. */
17
+ totalBytes: 512 * 1024 * 1024,
18
+ pathChars: 512,
19
+ formatChars: 32,
20
+ versionChars: 64,
21
+ });
22
+
23
+ /** What an agent may say about a deliverable. The hash, the size and the id are the hub's. */
24
+ export interface DeliverableClaim { path: string; format: string; version?: string }
25
+
26
+ /**
27
+ * What the board stores: the claim plus what the hub measured. The board keeps the path of every record, because "changed since review" needs
28
+ * it to find the file again (a PII task's row holds its full text there too). `private` marks a record whose path no other peer, log, event or
29
+ * memory may see: every view goes through `publicDeliverables`, which shows a private record by id, hash and size only.
30
+ */
31
+ export interface DeliverableRecord {
32
+ id: string;
33
+ /** Project-relative, forward slashes. Absent only in a public view of a private record. */
34
+ path?: string;
35
+ format: string;
36
+ version?: string;
37
+ sha256: string;
38
+ bytes: number;
39
+ private?: true;
40
+ }
41
+
42
+ export type DeliverableState = "unchanged" | "changed" | "missing" | "unknown";
43
+ export interface DeliverableStatus { id: string; sha256: string; state: DeliverableState; path?: string; format: string; version?: string; private?: true }
44
+
45
+ const FORMAT = /^[a-z0-9][a-z0-9.+-]{0,31}$/;
46
+ const VERSION = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/;
47
+ const SHA256 = /^[0-9a-f]{64}$/;
48
+ const ID = /^d-[0-9a-f]{12}$/;
49
+
50
+ const own = (value: unknown, keys: readonly string[], label: string): Record<string, unknown> => {
51
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label}: expected an object`);
52
+ const extra = Object.keys(value).filter(key => !keys.includes(key));
53
+ if (extra.length) throw new Error(`${label}: unsupported field ${extra[0]!.slice(0, 32)}${extra.includes("sha256") || extra.includes("hash") || extra.includes("bytes") ? " (the hub hashes the file itself; a claimed hash is not used)" : ""}`);
54
+ return value as Record<string, unknown>;
55
+ };
56
+
57
+ /** A project-relative spelling: forward slashes, no `.` or `..` segments, no NUL or control characters, not empty, not absolute. */
58
+ export function deliverablePath(value: unknown): string {
59
+ if (typeof value !== "string" || !value || value.length > DELIVERABLE_LIMITS.pathChars) throw new Error("deliverable path: expected 1 to 512 characters");
60
+ if (/[\u0000-\u001f\u007f\\]/.test(value) || value.startsWith("/") || /^[A-Za-z]:/.test(value)) throw new Error("deliverable path: expected a relative path with forward slashes");
61
+ const parts = value.split("/");
62
+ if (parts.some(part => !part || part === "." || part === "..")) throw new Error("deliverable path: no empty, . or .. segments");
63
+ return parts.join("/");
64
+ }
65
+
66
+ /** The claims of a `hub_task_done` or `hub_sdlc_record` call. A bad claim is refused whole: no partial record. */
67
+ export function parseDeliverableClaims(value: unknown): DeliverableClaim[] {
68
+ if (!Array.isArray(value)) throw new Error("deliverables: expected a list");
69
+ if (!value.length || value.length > DELIVERABLE_LIMITS.count) throw new Error(`deliverables: 1 to ${DELIVERABLE_LIMITS.count} entries`);
70
+ const seen = new Set<string>();
71
+ return value.map((raw, index) => {
72
+ const claim = own(raw, ["path", "format", "version"], `deliverables[${index}]`);
73
+ const path = deliverablePath(claim.path);
74
+ if (typeof claim.format !== "string" || !FORMAT.test(claim.format)) throw new Error(`deliverables[${index}].format: expected a lowercase label such as md, pdf, docx or csv`);
75
+ if (claim.version !== undefined && (typeof claim.version !== "string" || !VERSION.test(claim.version))) throw new Error(`deliverables[${index}].version: expected a short label such as v2 or 2026-10`);
76
+ if (seen.has(path)) throw new Error(`deliverables[${index}]: ${path} is named twice`);
77
+ seen.add(path);
78
+ return { path, format: claim.format, ...(claim.version !== undefined ? { version: claim.version } : {}) };
79
+ });
80
+ }
81
+
82
+ /** An opaque stable id for a deliverable: its path and format, so a changed file keeps its id and an unrelated one never shares it. */
83
+ export function deliverableId(path: string, format: string): string {
84
+ return `d-${createHash("sha256").update(`${format}\0${path}`).digest("hex").slice(0, 12)}`;
85
+ }
86
+
87
+ /** Records read back from the board: the same bounds, so a damaged row is refused rather than trusted. */
88
+ export function parseDeliverableRecords(value: unknown): DeliverableRecord[] {
89
+ if (!Array.isArray(value) || !value.length || value.length > DELIVERABLE_LIMITS.count) throw new Error("deliverable records: 1 to 16 entries");
90
+ const ids = new Set<string>();
91
+ return value.map((raw, index) => {
92
+ const row = own(raw, ["id", "path", "format", "version", "sha256", "bytes", "private"], `deliverable record ${index}`);
93
+ if (typeof row.id !== "string" || !ID.test(row.id) || ids.has(row.id)) throw new Error(`deliverable record ${index}: expected a distinct hub id`);
94
+ ids.add(row.id);
95
+ if (typeof row.sha256 !== "string" || !SHA256.test(row.sha256)) throw new Error(`deliverable record ${index}: expected a sha256`);
96
+ if (typeof row.bytes !== "number" || !Number.isSafeInteger(row.bytes) || row.bytes < 0 || row.bytes > DELIVERABLE_LIMITS.fileBytes) throw new Error(`deliverable record ${index}: expected a bounded size`);
97
+ if (typeof row.format !== "string" || !FORMAT.test(row.format)) throw new Error(`deliverable record ${index}: expected a format label`);
98
+ if (row.version !== undefined && (typeof row.version !== "string" || !VERSION.test(row.version))) throw new Error(`deliverable record ${index}: expected a version label`);
99
+ if (row.private !== undefined && row.private !== true) throw new Error(`deliverable record ${index}: private is true or absent`);
100
+ const path = deliverablePath(row.path);
101
+ if (row.id !== deliverableId(path, row.format)) throw new Error(`deliverable record ${index}: the id does not match its path and format`);
102
+ return { id: row.id, path, format: row.format, ...(row.version !== undefined ? { version: row.version } : {}), sha256: row.sha256, bytes: row.bytes, ...(row.private === true ? { private: true as const } : {}) };
103
+ });
104
+ }
105
+
106
+ /** The records as another peer may see them: a private record, or one whose path matches a PII pattern, shows no path, whatever else is shown. */
107
+ export function publicDeliverables(records: readonly DeliverableRecord[] | undefined, screen: (text: string) => boolean): DeliverableRecord[] | undefined {
108
+ if (!records?.length) return undefined;
109
+ return records.map(record => {
110
+ const { path, ...rest } = record;
111
+ return path !== undefined && !record.private && screen(path) ? { ...rest, path } : { ...rest, private: true as const };
112
+ });
113
+ }
114
+
115
+ type Entry = { event: string; deliverables?: DeliverableRecord[] };
116
+ /** What the newest `done`, `done (checking)` or lifecycle record said was delivered. An approval's own copy is not a delivery. */
117
+ export function latestDeliverables(history: readonly Entry[]): DeliverableRecord[] | undefined {
118
+ for (let i = history.length - 1; i >= 0; i--) if (history[i]!.event !== "approved" && history[i]!.deliverables?.length) return history[i]!.deliverables;
119
+ return undefined;
120
+ }
121
+ /** What the newest approval bound: the bytes that were reviewed and approved. */
122
+ export function approvedDeliverables(history: readonly Entry[]): DeliverableRecord[] | undefined {
123
+ for (let i = history.length - 1; i >= 0; i--) if (history[i]!.event === "approved" && history[i]!.deliverables?.length) return history[i]!.deliverables;
124
+ return undefined;
125
+ }
@@ -335,6 +335,7 @@ export class DeliveryJournal {
335
335
  if (!resolutionColumns.some((column) => column.name === "original_ids")) this.db.run("ALTER TABLE resolution_history ADD COLUMN original_ids TEXT NOT NULL DEFAULT '[]'");
336
336
  this.db.run("CREATE INDEX IF NOT EXISTS resolution_retry_peer ON resolution_history(project_id, peer, action)");
337
337
  this.db.run("CREATE INDEX IF NOT EXISTS deliveries_peer ON deliveries(project_id, peer, state)");
338
+ this.db.run("CREATE INDEX IF NOT EXISTS deliveries_pending ON deliveries(project_id, created_at, id, peer) WHERE state IN ('queued', 'dispatching', 'accepted', 'needs_review')");
338
339
  })();
339
340
  }
340
341
  private recoverRetryHistory(meta: Record<string, unknown>): void {
@@ -740,6 +741,19 @@ export class DeliveryJournal {
740
741
  .get(id, this.projectId) as Record<string, unknown> | null;
741
742
  return row ? parseDelivery(row) : undefined;
742
743
  }
744
+ /** Hot roster reads never materialize completed envelope history. */
745
+ pendingPeers(): string[] {
746
+ this.ensureOpen();
747
+ const rows = this.db.query("SELECT DISTINCT peer FROM deliveries WHERE project_id = ? AND state IN ('queued', 'dispatching', 'accepted', 'needs_review') ORDER BY peer").all(this.projectId) as { peer: unknown }[];
748
+ return rows.map(row => text(row.peer, "delivery peer", 256));
749
+ }
750
+ /** The queue needs a current revision, not the recovery snapshot's payloads. */
751
+ revision(): number {
752
+ this.ensureOpen();
753
+ const row = this.db.query("SELECT revision FROM delivery_meta WHERE project_id = ?").get(this.projectId) as { revision: unknown } | null;
754
+ if (!row) throw new Error("delivery journal metadata missing");
755
+ return integer(row.revision, "revision");
756
+ }
743
757
  list(peer?: string): JournalDelivery[] {
744
758
  this.ensureOpen();
745
759
  const rows = (
@@ -757,9 +771,11 @@ export class DeliveryJournal {
757
771
  ) as Record<string, unknown>[];
758
772
  return rows.map(parseDelivery);
759
773
  }
760
- unresolved(peer: string): JournalDelivery[] {
774
+ unresolved(peer?: string): JournalDelivery[] {
761
775
  this.ensureOpen();
762
- const rows = this.db.query("SELECT * FROM deliveries WHERE project_id = ? AND peer = ? AND state IN ('queued', 'dispatching', 'accepted', 'needs_review') ORDER BY created_at, id").all(this.projectId, peer) as Record<string, unknown>[];
776
+ const rows = (peer === undefined
777
+ ? this.db.query("SELECT * FROM deliveries WHERE project_id = ? AND state IN ('queued', 'dispatching', 'accepted', 'needs_review') ORDER BY created_at, id").all(this.projectId)
778
+ : this.db.query("SELECT * FROM deliveries WHERE project_id = ? AND peer = ? AND state IN ('queued', 'dispatching', 'accepted', 'needs_review') ORDER BY created_at, id").all(this.projectId, peer)) as Record<string, unknown>[];
763
779
  return rows.map(parseDelivery);
764
780
  }
765
781
  heldDeliveryHints(peer: string) {
package/src/hub/events.ts CHANGED
@@ -18,6 +18,8 @@ export type HubEvent =
18
18
  | { type: "role"; peer?: string; task?: number; actor: string; stage: "prepared" | "assigned"; roles: { id: string; layer: "shipped" | "project"; hash: string }[] }
19
19
  /** A settings change (#269): the registry key, the bounded registry values before/after and their source; an optional person reason is sanitized and capped at 400 UTF-8 bytes. */
20
20
  | { type: "settings"; key: string; from: import("./settings.ts").SettingValue; to: import("./settings.ts").SettingValue; source: "dashboard" | "terminal"; undo?: boolean; reason?: string }
21
+ /** A structural config edit (#689): the file, key paths with short hashes of their values before and after (never a value), the file revisions and the person who made it. */
22
+ | { type: "config_edit"; file: "config.local.json" | "sdlc.toml"; paths: { path: string; before: string | null; after: string | null }[]; fileBefore: string; fileAfter: string; tier: 2 | 3; by: "person"; source: "dashboard" | "terminal"; backup?: string; reason?: string }
21
23
  | { type: "envelope"; id: string; from: string; to?: string[]; priority: string; hop: number; kind?: string; task?: string; bytes?: number; private?: boolean; dropped?: string }
22
24
  | { type: "overflow" | "undeliverable"; id: string; from: string; peer: string }
23
25
  | { type: "stale"; id: string; from: string; peer: string; task?: string }
@@ -51,7 +53,7 @@ export type HubEvent =
51
53
  | { type: "hook_stats"; peer: string; n: number; startupMs: number; hubMs: number; maxStartupMs: number }
52
54
  | { type: "cohort"; id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: string[] }
53
55
  | ({ type: "tokens"; peer: string; n: number } & Partial<TaskAttribution>)
54
- | ({ type: "usage"; peer: string; source: "omniroute" | "claude_transcript" | "codex_appserver"; id: string; reasoningTokens?: number; measuredAt?: string; requestedModel?: string; servedModel?: string; provider?: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; totalTokens?: number } & Partial<TaskAttribution>)
56
+ | ({ type: "usage"; peer: string; source: "omniroute" | "claude_transcript" | "claude_result" | "codex_appserver" | "kimi_wire"; id: string; reasoningTokens?: number; measuredAt?: string; requestedModel?: string; servedModel?: string; provider?: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; totalTokens?: number } & Partial<TaskAttribution>)
55
57
  | { type: "planning"; by: string; task?: number; release?: number; fields: string[]; revision: number }
56
58
  | { type: "task"; id: number; event: string; by: string; state: string; owner: string | null; reviewer: string | null; class: string; profile?: string; profileClass?: string; role?: string; roleIdentity?: { id: string; layer: "shipped" | "project"; hash: string }; pii: boolean; reason?: string; release?: number | null; priority?: number | null; plannedStart?: number | null; due?: number | null }
57
59
  | { type: "overlap"; task: number; owner: string; others: { task: number; owner: string; paths: string[]; symbols?: string[] }[] }
@@ -6,6 +6,16 @@ import { CONDUCTOR_START_PEERS, defaultPeerRoles } from "../adapters/registry.ts
6
6
  // The hub's task tools, defined once: the MCP server (Claude plugin, Kimi, Codex) and the local worker both read this.
7
7
  import { NOTE_KINDS } from "./envelope.ts";
8
8
 
9
+ /** Restricted to the fixed current-child tools server, never an ordinary peer tool. */
10
+ /** The fixed MCP surface of a current owned implementer child; independent of broader project roles. */
11
+ export const IMPLEMENTER_HUB_TOOL_NAMES: readonly string[] = Object.freeze(["hub_task_accept", "hub_task_decline", "hub_task_done", "hub_checkpoint", "hub_send", "hub_task_show", "hub_task_propose", "hub_remember", "hub_inbox", "hub_permission_request"]);
12
+
13
+ export const IMPLEMENTER_PERMISSION_TOOL = {
14
+ name: "hub_permission_request",
15
+ description: "Native permission prompt for this current owned child. The person allows once or denies; failure is deny.",
16
+ inputSchema: { type: "object", properties: { tool_name: { type: "string", maxLength: 128 }, input: { type: "object" }, tool_use_id: { type: "string", maxLength: 256 } }, required: ["tool_name", "input", "tool_use_id"], additionalProperties: false },
17
+ };
18
+
9
19
  /** Shared communication rule for channel and tool-only sessions. */
10
20
  export const ACTIONABLE_MESSAGE_INSTRUCTION = "Do not use [FYI] for PR ready, done, submitted, ruling needed, blocker, or changed contract or path messages: each requires action from the recipient. Use [FYI] only for progress or information that needs no action. hub_task_done and hub_review already notify the assigned recipients; no duplicate hub_send chat is needed.";
11
21
 
@@ -18,6 +28,12 @@ const refs = {
18
28
  additionalProperties: false,
19
29
  } as const;
20
30
  const list = (description: string) => ({ type: "array", items: str, description }) as const;
31
+ /** #509a: files a task delivered. The hub hashes them itself; there is no hash field, and one is refused. */
32
+ const deliverablesSchema = {
33
+ type: "array", minItems: 1, maxItems: 16,
34
+ description: "files you delivered, as paths inside the project: the hub reads and hashes each file itself when you call this, a claimed hash is refused, and approval binds those hashes so a later edit reads as \"deliverable changed since review\". Works in a plain folder without git or a PR.",
35
+ items: { type: "object", properties: { path: { type: "string", description: "project-relative path, forward slashes" }, format: { type: "string", description: "lowercase label such as md, pdf, docx, csv" }, version: { type: "string", description: "optional label such as v2" } }, required: ["path", "format"], additionalProperties: false },
36
+ } as const;
21
37
  const reviewFindings = {
22
38
  type: "array", maxItems: 64,
23
39
  description: "Head-bound findings; open Important findings carry across heads until explicitly fixed, false_positive or person-accepted; omission never settles them. Nits stay on their head. accepted is person-only and requires rationale. Nit count is capped by sdlc.max_nits (default 5).",
@@ -89,10 +105,10 @@ export const TASK_TOOLS: HubTool[] = [
89
105
  tool("hub_task_accept", "Take a task that was assigned to you, with your plan for it. Set refs.repo for work in another repository; acceptance may correct only that repository metadata.", { id, plan: acceptPlan, refs: { ...refs, properties: { repo: refs.properties.repo } } }, ["id"]),
90
106
  tool("hub_task_cancel", "Cancel unaccepted proposed/backlog work you proposed as its configured planner; accepted work is person-only. Keeps history and does not stop a native turn or delete files.", { id, reason: str, superseded_by: str, revision: { type: "integer", minimum: 0 } }, ["id", "reason"]),
91
107
  tool("hub_task_decline", "Pass on a task assigned to you; the hub offers it to the next peer. A plain decline is a lasting refusal: you are never offered this task again. Pass capacity: true when you only have no room right now: you are skipped for this move only, and a later reroute or planner pull may offer it to you again.", { id, reason: str, capacity: { type: "boolean" } }, ["id"]),
92
- tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs; no duplicate hub_send chat is needed. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
93
- tool("hub_sdlc_record", "Record artifact ids or claimed local checks on your task. This grants no authority or external-check evidence. Only its owner/reviewer or the person may record; only its reviewer/person may name a reviewed head or phase checklist. Phase evidence refs are content hashes, not external verification.", { id, receipt: { type: "object", properties: { stage: { type: "string", enum: ["spec", "plan", "implement", "review", "verify", "merge", "release"] }, repo: str, issue: id, pr: id, localHead: str, localResult: { type: "string", enum: ["passed", "failed", "unknown"] }, reviewedHead: str, phase: { type: "object", properties: { id: str, head: str, tasks: { type: "array", maxItems: 64, items: { type: "object", properties: { id, after: { type: "array", maxItems: 16, items: id } }, required: ["id", "after"], additionalProperties: false } }, criteria: { type: "array", maxItems: 32, items: { type: "object", properties: { id: str, required: { type: "boolean" }, result: { type: "string", enum: ["verified", "failed", "unverified"] }, refs: { type: "array", maxItems: 8, items: str } }, required: ["id", "required", "result", "refs"], additionalProperties: false } } }, required: ["id", "tasks", "criteria"], additionalProperties: false } }, additionalProperties: false } }, ["id", "receipt"]),
108
+ tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs; no duplicate hub_send chat is needed. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs, deliverables: deliverablesSchema }, ["id", "summary"]),
109
+ tool("hub_sdlc_record", "Record artifact ids or claimed local checks on your task. This grants no authority or external-check evidence. Only its owner/reviewer or the person may record; only its reviewer/person may name a reviewed head or phase checklist. Phase evidence refs are content hashes, not external verification.", { id, receipt: { type: "object", properties: { deliverables: deliverablesSchema, stage: { type: "string", enum: ["spec", "plan", "implement", "review", "verify", "merge", "release"] }, repo: str, issue: id, pr: id, localHead: str, localResult: { type: "string", enum: ["passed", "failed", "unknown"] }, reviewedHead: str, phase: { type: "object", properties: { id: str, head: str, tasks: { type: "array", maxItems: 64, items: { type: "object", properties: { id, after: { type: "array", maxItems: 16, items: id } }, required: ["id", "after"], additionalProperties: false } }, criteria: { type: "array", maxItems: 32, items: { type: "object", properties: { id: str, required: { type: "boolean" }, result: { type: "string", enum: ["verified", "failed", "unverified"] }, refs: { type: "array", maxItems: 8, items: str } }, required: ["id", "required", "result", "refs"], additionalProperties: false } } }, required: ["id", "tasks", "criteria"], additionalProperties: false } }, additionalProperties: false } }, ["id", "receipt"]),
94
110
  tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested", "cancelled"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
95
- tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result (hub_task_show has it, with the done summary), and list what is unmet. The verdict notifies the task owner; no duplicate hub_send chat is needed. Consecutive changes_requested verdicts (three by default, `tasks.escalate_after`) move the task to another peer.", { id, head: { type: "string", description: "full SHA actually reviewed; enforce binds approval to the current PR head" }, findings: reviewFindings, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" }, coverage: { type: "object", description: "the review brief's reviewable paths you read (reviewed) and any you did not (skipped, each with a reason); in enforce an approval must account for every one", properties: { reviewed: { type: "array", items: str }, skipped: { type: "array", items: { type: "object", properties: { path: str, reason: str }, required: ["path", "reason"] } } } } }, ["id", "verdict"]),
111
+ tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result (hub_task_show has it, with the done summary), and list what is unmet. The verdict notifies the task owner; no duplicate hub_send chat is needed. Consecutive changes_requested verdicts (three by default, `tasks.escalate_after`) move the task to another peer.", { id, head: { type: "string", description: "full SHA actually reviewed; enforce binds approval to the current PR head" }, findings: reviewFindings, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet; when the review request lists acceptance criteria, start an item with its id (\"AC1: what is missing\") so the verdict names it" }, coverage: { type: "object", description: "the review brief's reviewable paths you read (reviewed) and any you did not (skipped, each with a reason); in enforce an approval must account for every one", properties: { reviewed: { type: "array", items: str }, skipped: { type: "array", items: { type: "object", properties: { path: str, reason: str }, required: ["path", "reason"] } } } } }, ["id", "verdict"]),
96
112
  tool("hub_checkpoint", "Answer a checkpoint request from the hub (quota or context window pressure): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str, request_id: { type: "string", description: "The request id supplied by a context checkpoint request; required for context checkpoints" } }, ["summary"]),
97
113
  tool("hub_memory_search", "Search daemon-scoped visible memory, fails first. Query <=200 UTF-8 bytes; default 8, max 20 lines; whole output <=4 KB. Incomplete excerpts are marked. No project argument.", { query: { type: "string", maxLength: 200, description: "At most 200 UTF-8 bytes, enforced by the daemon" }, limit: { type: "integer", minimum: 1, maximum: 20 } }, ["query"]),
98
114
  tool("hub_memory_show", "Read up to 8 visible memory ids or unique prefixes (6..64 UTF-8 bytes each). Original ids redirect; unavailable ids share one refusal. Whole output <=8 KB.", { ids: { type: "array", minItems: 1, maxItems: 8, items: { type: "string", minLength: 6, maxLength: 64, description: "6 to 64 UTF-8 bytes" } } }, ["ids"]),
@@ -104,7 +120,8 @@ export const TASK_TOOLS: HubTool[] = [
104
120
  export const PLANNER_REVIEW_TOOLS: HubTool[] = [
105
121
  tool("hub_planner_groom", "Read a bounded planner grooming digest of dependencies, repository-scoped overlaps, WIP and blockers. No board change; unavailable evidence stays unknown.", {}),
106
122
  tool("hub_plan_review", "Record a planner review against the task planning revision and host-read issue edit time. Configured planner or person only; never accepts caller issue timestamps.", { id, revision: { type: "integer", minimum: 0 }, reason: { ...str, maxLength: 1000 }, ...costPlanProperties }, ["id", "revision", "reason"]),
107
- tool("hub_sdlc_costs", "Read bounded review and CI cost metrics for linked merged PRs. Planner/person only; metadata only, no board writes. S7 owns automatic band proposals.", { repo: str }),
123
+ tool("hub_sdlc_costs", "Read bounded review and CI cost metrics for linked merged PRs. Planner/person only; metadata only, no board writes.", { repo: str }),
124
+ tool("hub_sdlc_metrics", "Read SDLC metrics from board history: time from acceptance to plan, first review and merge, first-pass approval and rework rounds, first-pass CI and same-head reruns, stale-head re-reviews, stalls, and the control band tiers when sdlc.toml sets [bands]. Planner/person only; read-only, no model and no GitHub issue.", { since: str }),
108
125
  tool("hub_backlog_return_untouched", "Return accepted but untouched work to the backlog with its history and planning retained. Refuses execution/branch/commit evidence; planner or person only.", { id, revision: { type: "integer", minimum: 0 }, reason: { ...str, maxLength: 1000 } }, ["id", "revision", "reason"]),
109
126
  ];
110
127
  TASK_TOOLS.push(...PLANNER_REVIEW_TOOLS);
@@ -146,19 +146,30 @@ export function validateInstances(raw: unknown, catalog: ProjectAdapterCatalog):
146
146
  if (freshReviewer && entry.permission_mode) return fail(`${path}.permission_mode`, "a fresh reviewer has a fixed read-only policy");
147
147
  // An external instance runs only in the person's terminal; its definition bounds the declared roles.
148
148
  const external = definition.instances?.mode === "external";
149
- const runtime = freshReviewer ? definition.reviewerVariant ?? definition : definition;
149
+ const headlessWriter = entry.start_mode?.value === "headless" && !!definition.headlessVariant && declaredRoles.includes("implementer") && declaredRoles.every(role => role === "implementer" || role === "verifier");
150
+ if (headlessWriter && !entry.worktree) return fail(`${path}.worktree`, "a headless implementer needs its own worktree");
151
+ if (headlessWriter && entry.permission_mode && entry.permission_mode.value !== "ask" && entry.permission_mode.value !== "ask-when-needed") return fail(`${path}.permission_mode`, "a headless implementer permits only ask or ask-when-needed");
152
+ const runtime = freshReviewer ? definition.reviewerVariant ?? definition : headlessWriter ? definition.headlessVariant! : definition;
150
153
  const unavailableReviewer = freshReviewer && !!(definition.reviewerVariant ?? definition).reviewer?.unavailable;
151
154
  if (!unavailableReviewer && runtime.instances?.mode !== "external" && !supportsHeadlessInstances(runtime)) return fail(`${path}.kind`, definition.reviewerVariant ? 'unsupported; this kind only declares headless reviewer instances with roles exactly ["reviewer"]' : "unsupported; explicit external instance policy or a headless instance policy with a headless factory is required");
155
+ if (declaredRoles.includes("planner") && definition.projection?.plannerEligible !== true) return fail(`${path}.roles`, "this kind cannot plan");
152
156
  if (external) {
153
157
  const declared = entry.roles ? roles(entry.roles.value, `${path}.roles`) : [];
154
158
  // #223: a headless one is the kind's reviewer variant, one fresh hub-run session per review request.
155
- if (entry.start_mode && definition.reviewerVariant) {
159
+ if (headlessWriter) {
160
+ // Runtime policy and worktree/permission checks above govern this builtin-only writer variant.
161
+ } else if (entry.start_mode && definition.reviewerVariant) {
162
+ const reviewerRoles = declared.length === 1 && declared[0] === "reviewer";
163
+ if (!reviewerRoles) return fail(`${path}.roles`, "a headless channel-kind instance is a reviewer: roles must be exactly [\"reviewer\"]");
156
164
  if (entry.worktree) return fail(path, "a headless reviewer reads the project checkout: worktree is not supported");
157
- if (declared.length !== 1 || declared[0] !== "reviewer") return fail(`${path}.roles`, "a headless channel-kind instance is a reviewer: roles must be exactly [\"reviewer\"]");
158
165
  } else {
159
- if (entry.start_mode || entry.worktree) return fail(path, "a channel instance starts only from the person's terminal (ahub <kind> --as <id>): start_mode and worktree are not supported; a headless reviewer declares roles [\"reviewer\"] and start_mode \"headless\"");
166
+ if (entry.start_mode) return fail(`${path}.start_mode`, "a channel instance starts only from the person's terminal (ahub <kind> --as <id>); a headless reviewer declares roles [\"reviewer\"] and start_mode \"headless\"");
160
167
  const allowed = definition.instances?.roles ?? definition.roles;
161
- if (!declared.length || declared.some(role => !allowed.includes(role))) return fail(`${path}.roles`, "a channel instance must declare its roles: reviewer and/or planner");
168
+ if (!declared.length || declared.some(role => !allowed.includes(role))) return fail(`${path}.roles`, `a channel instance must declare its roles within: ${allowed.join(", ")}`);
169
+ // #525 R1: an implementer or verifier writes, so it needs its own worktree; a planner or reviewer reads the project checkout.
170
+ const writes = declared.includes("implementer") || declared.includes("verifier");
171
+ if (writes && !entry.worktree) return fail(`${path}.worktree`, "an implementer or verifier channel instance needs its own worktree");
172
+ if (!writes && entry.worktree) return fail(`${path}.worktree`, "a planner or reviewer channel instance reads the project checkout: worktree is not supported");
162
173
  }
163
174
  } else {
164
175
  const allowed = runtime.instances?.roles;
@@ -217,5 +228,7 @@ export function effectiveInstanceRoles(id: string, declarations: InstancesConfig
217
228
  if (!definition) throw new Error(`${id}: no declared native instance`);
218
229
  const declaration = declarations && Object.hasOwn(declarations, id) ? declarations[id] : undefined;
219
230
  const ownRoles = (key: string): readonly string[] | undefined => Object.hasOwn(policyRoles, key) ? policyRoles[key] : undefined;
220
- return [...(declaration?.roles ?? ownRoles(id) ?? ownRoles(definition.kind) ?? definition.roles)];
231
+ const roles = declaration?.roles ?? ownRoles(id) ?? ownRoles(definition.kind) ?? definition.roles;
232
+ // #525 R1: a roles entry keyed by an instance id skips loadConductorPolicy's kind check, so planner is gated here too.
233
+ return roles.filter(role => role !== "planner" || definition.projection?.plannerEligible === true);
221
234
  }
@@ -0,0 +1,144 @@
1
+ import { KimiWireReader, kimiCountedTokens, type KimiWireReason, type KimiWireRow } from "../adapters/kimi-wire-usage.ts";
2
+ import { TokenWindow } from "./token-window.ts";
3
+
4
+ /**
5
+ * 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.
8
+ */
9
+ export const KIMI_WINDOW_MS = 5 * 3_600_000;
10
+ /** 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
+ const FLOOR_SLACK_MS = 1000;
12
+ const PASSES = 16;
13
+ /** 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
+ const TRANSIENT: ReadonlySet<KimiWireReason> = new Set(["no-index", "io-error", "partial-line"]);
15
+
16
+ export interface KimiUsagePorts {
17
+ /** Kimi's data home. */
18
+ home: string;
19
+ /** Per-call byte bound of the reader (its default otherwise); a test shrinks it to force several passes. */
20
+ maxBytes?: number;
21
+ now(): number;
22
+ /** `budget.kimi_tokens_5h`, read live: 0 turns the window off, never the events. */
23
+ ceiling(): number;
24
+ /** The adapter's own session id, undefined when none is bound. */
25
+ 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;
28
+ /** The window as a reading: `used` is not clamped here. */
29
+ report(peer: string, window: { used: number; resetsAt: number; ceiling: number }): void;
30
+ /** The count cannot be trusted now: the peer's `tokens` reading is dropped, so it shows unknown rather than a short number. */
31
+ forget(peer: string): void;
32
+ /** One line per peer and reason; carries no path, id or text from the log. */
33
+ notice(line: string): void;
34
+ }
35
+
36
+ interface Bound { session: string; reader: KimiWireReader; floor: number; unknown?: KimiWireReason; noticed?: KimiWireReason }
37
+
38
+ export class KimiUsage {
39
+ private readonly bound = new Map<string, Bound>();
40
+ private readonly window = new TokenWindow(KIMI_WINDOW_MS);
41
+ private readonly seen = new Set<string>();
42
+ /** A log that stopped matching the count leaves the window short of the truth until its rows age out. */
43
+ private readonly taintedUntil = new Map<string, number>();
44
+
45
+ constructor(private readonly ports: KimiUsagePorts) {}
46
+
47
+ /** Rows an earlier hub run already recorded: never counted twice, and still inside the window. */
48
+ seed(rows: { id: string; at: number; counted: number }[]): void {
49
+ for (const row of rows) {
50
+ this.seen.add(row.id);
51
+ if (row.counted > 0 && this.ports.now() - row.at <= KIMI_WINDOW_MS) this.window.add(row.at, row.counted);
52
+ }
53
+ }
54
+
55
+ /** The peer went offline: its log is no longer read, so an unreadable state of it must not hold the other peers' figure unknown. */
56
+ drop(peer: string): void { this.bound.delete(peer); }
57
+
58
+ /** A new window: the old counts would pause the peer again at once (after a budget resume). */
59
+ reset(): void { this.window.reset(); this.taintedUntil.clear(); }
60
+
61
+ /** 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. */
62
+ turnStarted(peer: string): void { this.bind(peer); }
63
+
64
+ private bind(peer: string): Bound | undefined {
65
+ const session = this.ports.session(peer);
66
+ if (!session) return undefined;
67
+ const held = this.bound.get(peer);
68
+ if (held?.session === session) return held;
69
+ 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 };
70
+ this.bound.set(peer, next);
71
+ return next;
72
+ }
73
+
74
+ collect(peer: string): void {
75
+ const bound = this.bind(peer);
76
+ if (!bound) return;
77
+ let added = 0, more = false;
78
+ for (let pass = 0; pass < PASSES; pass++) {
79
+ const read = bound.reader.read();
80
+ // Each successful pass has already moved the reader's offsets: its rows are recorded now, before a later pass can fail, or they would
81
+ // never be read again and the count would be short.
82
+ if (read.status === "unknown") { this.unknown(peer, bound, read.reason); return; }
83
+ added += this.record(peer, bound, read.rows);
84
+ more = read.more;
85
+ if (!more) break;
86
+ }
87
+ const recovered = bound.unknown !== undefined;
88
+ bound.unknown = undefined;
89
+ // 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
90
+ // 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.
91
+ if (!more && (added > 0 || recovered)) this.report(peer);
92
+ }
93
+
94
+ private record(peer: string, bound: Bound, rows: KimiWireRow[]): number {
95
+ let added = 0;
96
+ const now = this.ports.now();
97
+ for (const row of rows) {
98
+ if (this.seen.has(row.id) || row.at < bound.floor) continue;
99
+ this.seen.add(row.id);
100
+ const counted = kimiCountedTokens(row);
101
+ this.ports.record(peer, row, counted);
102
+ this.window.add(Math.min(row.at, now), counted);
103
+ added += counted;
104
+ }
105
+ return added;
106
+ }
107
+
108
+ private unknown(peer: string, bound: Bound, reason: KimiWireReason): void {
109
+ bound.unknown = reason;
110
+ try { this.ports.forget(peer); } catch { /* the reading ages out on its own */ }
111
+ if (!TRANSIENT.has(reason)) this.taintedUntil.set(peer, this.ports.now() + KIMI_WINDOW_MS);
112
+ if (bound.noticed === reason) return;
113
+ bound.noticed = reason;
114
+ try { this.ports.notice(`kimi usage unknown (${reason}): the hub counts no Kimi tokens it cannot read cleanly`); } catch { /* a notice never costs a turn */ }
115
+ }
116
+
117
+ /** The window as a reading, only while every Kimi peer's log reads cleanly: otherwise the figure is unknown, never a short count. */
118
+ private report(peer: string): void {
119
+ const ceiling = this.ports.ceiling(), now = this.ports.now();
120
+ if (!ceiling) return;
121
+ for (const state of this.bound.values()) if (state.unknown) return;
122
+ for (const until of this.taintedUntil.values()) if (now < until) return;
123
+ const oldest = this.window.oldest(now);
124
+ this.ports.report(peer, { used: this.window.sum(now) / ceiling, resetsAt: (oldest ?? now) + KIMI_WINDOW_MS, ceiling });
125
+ }
126
+ }
127
+
128
+ /**
129
+ * The rows an earlier run recorded, from its `usage` events: their ids are never counted again, and the recent ones refill the window.
130
+ * Only rows recorded after the last `resumedAt` (the newest `budget_resume` of a soft-token peer) refill it: a resume starts a new window
131
+ * (`reset`), and that is not persisted anywhere else, so a restart would otherwise bring the pre-resume rows back and pause the peer again.
132
+ */
133
+ export function kimiSeedRows(events: readonly { type: string; source?: unknown; id?: unknown; measuredAt?: unknown; at?: unknown; inputTokens?: unknown; cacheWriteTokens?: unknown; outputTokens?: unknown }[], resumedAt = 0): { id: string; at: number; counted: number }[] {
134
+ const n = (value: unknown): number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
135
+ return events.flatMap(e => {
136
+ if (e.type !== "usage" || e.source !== "kimi_wire" || typeof e.id !== "string") return [];
137
+ const measured = Date.parse(typeof e.measuredAt === "string" ? e.measuredAt : typeof e.at === "string" ? e.at : "");
138
+ const recorded = Date.parse(typeof e.at === "string" ? e.at : "");
139
+ if (!Number.isFinite(measured)) return [];
140
+ // A row before the resume keeps its id (never recounted) and counts for nothing: `counted: 0` leaves the window alone.
141
+ const counted = Number.isFinite(recorded) && recorded <= resumedAt ? 0 : kimiCountedTokens({ inputOther: n(e.inputTokens), inputCacheCreation: n(e.cacheWriteTokens), output: n(e.outputTokens) });
142
+ return [{ id: e.id, at: measured, counted }];
143
+ });
144
+ }
@@ -8,7 +8,7 @@ import { spawn } from "node:child_process";
8
8
  import { ControlClient, PROTOCOL, RECOVERY_SOURCE_PROTOCOLS } from "./control-client.ts";
9
9
  import { Registry, type Project } from "./registry.ts";
10
10
  import { hubHome } from "./project.ts";
11
- import { startDashboard } from "./ui.ts";
11
+ import { startDashboard, dashboardView } from "./ui.ts";
12
12
  import { projectChain } from "@staix/agent-mem";
13
13
  import type { ProjectInspection } from "./lifecycle.ts";
14
14
  import { MAX_COMMAND_MS } from "../cli/terminal-recovery.ts";
@@ -155,7 +155,7 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
155
155
  return { ok: true };
156
156
  }
157
157
  // The settings actions (#269) reach the project hub as an ordinary session's: this dashboard opens no settings session.
158
- if (!["snapshot", "send", "pause", "resume", "permit", "propose", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "setting_reload", "start_peer", "sdlc_queue", "sdlc_release"].includes(String(input.action))) return { ok: false, error: "invalid dashboard action" };
158
+ if (!["snapshot", "send", "pause", "resume", "permit", "propose", "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"].includes(String(input.action))) return { ok: false, error: "invalid dashboard action" };
159
159
  client = await ControlClient.connect(project.stateDir, { role: "console", projectId: project.id, projectRoot: project.root, instanceId: input.instanceId });
160
160
  const deliveryRead = input.action === "sdlc_queue" || input.action === "sdlc_release";
161
161
  let nominalMs = 10_000;
@@ -164,7 +164,7 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
164
164
  else if (deliveryRead) nominalMs = 30_000;
165
165
  const timeoutMs = options.forwardTimeoutMs?.(String(input.action), nominalMs) ?? nominalMs;
166
166
  const result = await client.request(input.action === "snapshot"
167
- ? { t: "ui_snapshot", after: input.after ?? 0, ...(input.memory !== undefined ? { memory: input.memory } : {}) }
167
+ ? { t: "ui_snapshot", after: input.after ?? 0, ...(input.memory !== undefined ? { memory: input.memory } : {}), ...(dashboardView(input.view) ? { view: input.view } : {}) }
168
168
  : { t: "ui_action", action: input, instanceId: input.instanceId }, timeoutMs);
169
169
  // ControlClient resolves its deadline as this exact sentinel; a hub refusal stays a known failure.
170
170
  if (["start_peer", "backlog_split"].includes(String(input.action)) && result.ok === false && result.error === `no answer from the hub within ${Math.round(timeoutMs / 1000)} s`) {