@staix/agent-hub 0.12.35 → 0.12.36

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 (49) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +8 -6
  3. package/docs/agent-notes/adapters.md +4 -1
  4. package/docs/agent-notes/budget.md +1 -1
  5. package/docs/agent-notes/bus.md +3 -0
  6. package/docs/agent-notes/daemon.md +6 -2
  7. package/docs/agent-notes/tasks.md +11 -1
  8. package/docs/agent-notes/tests.md +5 -1
  9. package/docs/operations.md +40 -16
  10. package/docs/quickstart.md +6 -8
  11. package/docs/sdlc.md +46 -5
  12. package/docs/security.md +5 -1
  13. package/docs/specs/2026-09-19-agent-hub-design.md +76 -1
  14. package/docs/verification/2026-10-19-0.12.36.md +63 -0
  15. package/docs/verified.json +18 -14
  16. package/package.json +1 -1
  17. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  18. package/plugins/agent-hub/server.js +385 -29
  19. package/src/adapters/claude-print.ts +230 -0
  20. package/src/adapters/definitions/builtin-metadata.ts +17 -1
  21. package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
  22. package/src/adapters/definitions/native-cli-entry-jcode.ts +3 -2
  23. package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -2
  24. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  25. package/src/adapters/definitions/native-factories.ts +7 -0
  26. package/src/adapters/registry-types.ts +8 -2
  27. package/src/adapters/registry.ts +13 -1
  28. package/src/cli/help.ts +5 -0
  29. package/src/cli/identity.ts +2 -1
  30. package/src/cli/launch.ts +19 -6
  31. package/src/cli/main.ts +38 -4
  32. package/src/cli/reset.ts +4 -2
  33. package/src/cli/statusline-tee.ts +5 -2
  34. package/src/cli/terminal-recovery.ts +7 -2
  35. package/src/cli/upgrade-runtime.ts +18 -15
  36. package/src/hub/board.ts +29 -7
  37. package/src/hub/bus.ts +11 -5
  38. package/src/hub/claude-state.ts +37 -0
  39. package/src/hub/cost-governance.ts +76 -0
  40. package/src/hub/daemon.ts +181 -73
  41. package/src/hub/hub-tools.ts +20 -1
  42. package/src/hub/instances.ts +17 -3
  43. package/src/hub/review-brief.ts +175 -0
  44. package/src/hub/routing.ts +17 -2
  45. package/src/hub/sdlc.ts +205 -4
  46. package/src/hub/status-roster.ts +10 -0
  47. package/src/hub/task-sweep.ts +143 -0
  48. package/src/hub/tasks.ts +544 -26
  49. package/src/hub/usage.ts +57 -20
package/src/hub/usage.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { readFileSync, statSync } from "node:fs";
2
+ import { closeSync, fstatSync, openSync, readFileSync, readSync, statSync } from "node:fs";
3
3
  import { normalizeUsage, safeModelLabel, type NormalizedUsage } from "../omniroute/usage.ts";
4
4
 
5
5
  export interface UsageRecord {
@@ -65,26 +65,63 @@ export function readClaudeTranscriptUsage(sessionId: string, transcriptPath: str
65
65
  // Signal unavailable coverage with a stable opaque id; a repeated poll dedupes in the report.
66
66
  return [{ id: opaqueId(sessionId, "transcript-too-large") }];
67
67
  }
68
- const latest = new Map<string, { usage?: NormalizedUsage; at?: string; servedModel?: string; completedTurn?: boolean }>();
69
- for (const line of readFileSync(transcriptPath, "utf8").split("\n")) {
70
- if (!line.trim()) continue;
71
- try {
72
- const row = JSON.parse(line) as { type?: unknown; timestamp?: unknown; sessionId?: unknown; message?: unknown };
73
- if (typeof row.sessionId === "string" && row.sessionId !== sessionId) continue;
74
- if (row.type !== "assistant" || !row.message || typeof row.message !== "object") continue;
75
- const message = row.message as { id?: unknown; stop_reason?: unknown; usage?: unknown; model?: unknown };
76
- if (typeof message.id !== "string" || !message.id || typeof message.stop_reason !== "string" || !message.stop_reason) continue;
77
- const supportedStopReason = COMPLETED_STOP_REASONS.has(message.stop_reason);
78
- const usage = supportedStopReason ? normalizeUsage(message.usage) : undefined;
79
- const at = typeof row.timestamp === "string" && Number.isFinite(Date.parse(row.timestamp)) ? new Date(row.timestamp).toISOString() : undefined;
80
- const servedModel = safeModelLabel(message.model);
81
- latest.set(message.id, { ...(usage ? { usage } : {}), ...(at ? { at } : {}), ...(servedModel ? { servedModel } : {}), ...(message.stop_reason === "end_turn" ? { completedTurn: true } : {}) });
82
- } catch {
83
- // A streaming or crash-truncated line is not a record.
84
- }
85
- }
86
- return [...latest].map(([messageId, value]) => ({ id: opaqueId(sessionId, messageId), ...value }));
68
+ return transcriptRecords(sessionId, readFileSync(transcriptPath, "utf8"));
87
69
  } catch {
88
70
  return [];
89
71
  }
90
72
  }
73
+
74
+ /**
75
+ * The tail read for turn completion: the newest end_turn row is always near the end.
76
+ * ponytail: a fixed 4 MiB window; a completion written more than 4 MiB before EOF stays invisible (Claude appends the final
77
+ * row last, so this needs >4 MiB written after the turn ended). Upgrade: scan backwards in chunks until an assistant row.
78
+ */
79
+ export const COMPLETION_TAIL_BYTES = 4 * 1024 * 1024;
80
+
81
+ /**
82
+ * The newest completed assistant turn (stop_reason end_turn) in a transcript of any size, from its last
83
+ * COMPLETION_TAIL_BYTES only (#517: a whole-file cap made every Stop unverifiable on a long session). The first,
84
+ * possibly partial, line of a cut tail is dropped. Undefined when there is none or the file cannot be read.
85
+ */
86
+ export function readClaudeTranscriptCompletion(sessionId: string, transcriptPath: string, tailBytes = COMPLETION_TAIL_BYTES): UsageRecord | undefined {
87
+ let fd: number | undefined;
88
+ try {
89
+ fd = openSync(transcriptPath, "r");
90
+ const size = fstatSync(fd).size, start = Math.max(0, size - tailBytes);
91
+ // One byte before the window shows whether the cut falls on a line boundary.
92
+ const from = start > 0 ? start - 1 : 0, buffer = Buffer.alloc(size - from);
93
+ let read = 0;
94
+ while (read < buffer.length) { const n = readSync(fd, buffer, read, buffer.length - read, from + read); if (n <= 0) break; read += n; }
95
+ let text = buffer.subarray(0, read).toString("utf8");
96
+ // A cut inside a line drops up to the first newline, or all of it when it holds none; a cut right after a newline
97
+ // keeps the complete row that starts the window.
98
+ if (start > 0) { const nl = text.indexOf("\n"); text = nl < 0 ? "" : text.slice(nl + 1); }
99
+ return transcriptRecords(sessionId, text).filter(record => record.completedTurn).at(-1);
100
+ } catch {
101
+ return undefined;
102
+ } finally {
103
+ if (fd !== undefined) try { closeSync(fd); } catch { /* already closed */ }
104
+ }
105
+ }
106
+
107
+ function transcriptRecords(sessionId: string, text: string): UsageRecord[] {
108
+ const latest = new Map<string, { usage?: NormalizedUsage; at?: string; servedModel?: string; completedTurn?: boolean }>();
109
+ for (const line of text.split("\n")) {
110
+ if (!line.trim()) continue;
111
+ try {
112
+ const row = JSON.parse(line) as { type?: unknown; timestamp?: unknown; sessionId?: unknown; message?: unknown };
113
+ if (typeof row.sessionId === "string" && row.sessionId !== sessionId) continue;
114
+ if (row.type !== "assistant" || !row.message || typeof row.message !== "object") continue;
115
+ const message = row.message as { id?: unknown; stop_reason?: unknown; usage?: unknown; model?: unknown };
116
+ if (typeof message.id !== "string" || !message.id || typeof message.stop_reason !== "string" || !message.stop_reason) continue;
117
+ const supportedStopReason = COMPLETED_STOP_REASONS.has(message.stop_reason);
118
+ const usage = supportedStopReason ? normalizeUsage(message.usage) : undefined;
119
+ const at = typeof row.timestamp === "string" && Number.isFinite(Date.parse(row.timestamp)) ? new Date(row.timestamp).toISOString() : undefined;
120
+ const servedModel = safeModelLabel(message.model);
121
+ latest.set(message.id, { ...(usage ? { usage } : {}), ...(at ? { at } : {}), ...(servedModel ? { servedModel } : {}), ...(message.stop_reason === "end_turn" ? { completedTurn: true } : {}) });
122
+ } catch {
123
+ // A streaming or crash-truncated line is not a record.
124
+ }
125
+ }
126
+ return [...latest].map(([messageId, value]) => ({ id: opaqueId(sessionId, messageId), ...value }));
127
+ }