@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.
- package/CHANGELOG.md +30 -0
- package/README.md +19 -7
- package/docs/agent-notes/README.md +4 -0
- package/docs/agent-notes/adapters.md +18 -4
- package/docs/agent-notes/budget.md +5 -4
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +19 -7
- package/docs/agent-notes/tasks.md +15 -7
- package/docs/agent-notes/tests.md +4 -4
- package/docs/dogfood.md +18 -10
- package/docs/events.md +21 -7
- package/docs/operations.md +131 -27
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +13 -3
- package/docs/sdlc.md +40 -2
- package/docs/security.md +45 -14
- package/docs/specs/2026-09-19-agent-hub-design.md +112 -10
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +14 -0
- package/docs/verification/2026-10-23-0.14.0.md +69 -0
- package/docs/verification/2026-10-23-0.15.0.md +80 -0
- package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
- package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
- package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
- package/docs/verified.json +34 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1078 -327
- package/src/adapters/acp.ts +43 -7
- package/src/adapters/claude-channel.ts +26 -4
- package/src/adapters/claude-implementer.ts +243 -0
- package/src/adapters/claude-print-child.ts +90 -0
- package/src/adapters/claude-task-sessions.ts +80 -0
- package/src/adapters/codex-review-isolation.ts +18 -7
- package/src/adapters/compat.ts +4 -0
- package/src/adapters/conformance/enrollment.ts +2 -1
- package/src/adapters/conformance/fakes/acp-server.ts +3 -1
- package/src/adapters/definitions/builtin-metadata.ts +15 -3
- package/src/adapters/definitions/native-channel-state.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-factories.ts +8 -0
- package/src/adapters/kimi-wire-usage.ts +303 -0
- package/src/adapters/registry-types.ts +7 -3
- package/src/adapters/registry.ts +14 -2
- package/src/adapters/reviewer-print.ts +64 -60
- package/src/cli/console-config.ts +123 -0
- package/src/cli/console-state.ts +28 -1
- package/src/cli/console.ts +25 -1
- package/src/cli/facts-hook.ts +9 -5
- package/src/cli/help.ts +6 -4
- package/src/cli/launch.ts +10 -4
- package/src/cli/main.ts +52 -9
- package/src/cli/output.ts +75 -2
- package/src/cli/reset.ts +1 -1
- package/src/cli/settings-edit.ts +89 -0
- package/src/cli/status-lines.ts +1 -1
- package/src/cli/terminal-recovery.ts +1 -6
- package/src/hub/action-enforce.ts +82 -0
- package/src/hub/action-policy.ts +22 -10
- package/src/hub/board.ts +48 -3
- package/src/hub/budget.ts +5 -0
- package/src/hub/bus.ts +5 -5
- package/src/hub/claude-launch-proof.ts +33 -0
- package/src/hub/config-apply.ts +5 -0
- package/src/hub/config-edit-host.ts +173 -0
- package/src/hub/config-edit.ts +282 -0
- package/src/hub/config-tier.ts +89 -0
- package/src/hub/control-client.ts +24 -3
- package/src/hub/daemon.ts +450 -71
- package/src/hub/deliverable-files.ts +131 -0
- package/src/hub/deliverables.ts +125 -0
- package/src/hub/delivery-journal.ts +18 -2
- package/src/hub/events.ts +3 -1
- package/src/hub/hub-tools.ts +21 -4
- package/src/hub/instances.ts +19 -6
- package/src/hub/kimi-usage.ts +144 -0
- package/src/hub/manager.ts +3 -3
- package/src/hub/peers.ts +7 -0
- package/src/hub/permission-payload.ts +15 -0
- package/src/hub/routing.ts +118 -3
- package/src/hub/sdlc-metrics.ts +260 -0
- package/src/hub/sdlc.ts +5 -3
- package/src/hub/settings.ts +1 -1
- package/src/hub/spec-read.ts +31 -0
- package/src/hub/task-code-group.ts +52 -0
- package/src/hub/tasks.ts +374 -37
- package/src/hub/token-window.ts +33 -0
- package/src/hub/ui.ts +3 -0
- package/src/hub/usage-rollup.ts +74 -6
- package/src/hub/workflow.ts +16 -0
- 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
|
|
package/src/hub/usage-rollup.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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:
|
|
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();
|
package/src/hub/workflow.ts
CHANGED
|
@@ -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"); }
|