huddle-cli 0.0.0-stage → 0.1.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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +357 -2
  3. package/dist/adapters/amp.js +40 -0
  4. package/dist/adapters/antigravity.js +52 -0
  5. package/dist/adapters/claude.js +70 -0
  6. package/dist/adapters/codex.js +59 -0
  7. package/dist/adapters/copilot.js +69 -0
  8. package/dist/adapters/cursor.js +85 -0
  9. package/dist/adapters/custom.js +340 -0
  10. package/dist/adapters/droid.js +57 -0
  11. package/dist/adapters/gemini.js +61 -0
  12. package/dist/adapters/goose.js +55 -0
  13. package/dist/adapters/index.js +135 -0
  14. package/dist/adapters/kiro.js +41 -0
  15. package/dist/adapters/opencode.js +63 -0
  16. package/dist/adapters/qwen.js +67 -0
  17. package/dist/adapters/types.js +6 -0
  18. package/dist/adapters/usage.js +141 -0
  19. package/dist/attach/connect.js +409 -0
  20. package/dist/attach/guard.js +64 -0
  21. package/dist/attach/live.js +222 -0
  22. package/dist/attach/plug.js +174 -0
  23. package/dist/attach/session.js +172 -0
  24. package/dist/cli.js +176 -0
  25. package/dist/mcp/server.js +240 -0
  26. package/dist/orchestrator/ask.js +51 -0
  27. package/dist/orchestrator/brain.js +164 -0
  28. package/dist/orchestrator/council.js +274 -0
  29. package/dist/orchestrator/handoff.js +117 -0
  30. package/dist/orchestrator/launcher.js +155 -0
  31. package/dist/orchestrator/planner.js +194 -0
  32. package/dist/orchestrator/qa.js +350 -0
  33. package/dist/orchestrator/scheduler.js +748 -0
  34. package/dist/orchestrator/takeover.js +85 -0
  35. package/dist/preflight.js +31 -0
  36. package/dist/relay/server.js +113 -0
  37. package/dist/room/db.js +617 -0
  38. package/dist/room/identity.js +122 -0
  39. package/dist/room/live.js +293 -0
  40. package/dist/room/progress.js +110 -0
  41. package/dist/room/relaylink.js +85 -0
  42. package/dist/room/server.js +752 -0
  43. package/dist/room/sqlite.js +156 -0
  44. package/dist/room/transport.js +107 -0
  45. package/dist/room/tunnel.js +85 -0
  46. package/dist/sandbox/sync.js +104 -0
  47. package/dist/sandbox/worktree.js +261 -0
  48. package/dist/types.js +4 -0
  49. package/dist/ui/app.js +1534 -0
  50. package/dist/ui/catalog.js +75 -0
  51. package/dist/ui/dashboard.js +192 -0
  52. package/dist/ui/doctor.js +54 -0
  53. package/dist/ui/logo.js +40 -0
  54. package/dist/ui/remote.js +332 -0
  55. package/dist/ui/story.js +932 -0
  56. package/dist/ui/term.js +114 -0
  57. package/dist/ui/watch.js +174 -0
  58. package/install.sh +44 -0
  59. package/package.json +60 -4
@@ -0,0 +1,135 @@
1
+ // The adapter registry + detection/auth probing. This is what `huddle doctor`
2
+ // uses to decide who's in the roster for a session. Because we only ever ask
3
+ // "installed?" and "authed?", expiring subscriptions and account swaps are a
4
+ // non-event: a failing probe just drops that agent, the session runs on.
5
+ import { execFile, execFileSync } from "node:child_process";
6
+ import path from "node:path";
7
+ import { pathToFileURL } from "node:url";
8
+ import { claudeAdapter } from "./claude.js";
9
+ import { codexAdapter } from "./codex.js";
10
+ import { geminiAdapter } from "./gemini.js";
11
+ import { cursorAdapter } from "./cursor.js";
12
+ import { antigravityAdapter } from "./antigravity.js";
13
+ import { opencodeAdapter } from "./opencode.js";
14
+ import { copilotAdapter } from "./copilot.js";
15
+ import { ampAdapter } from "./amp.js";
16
+ import { gooseAdapter } from "./goose.js";
17
+ import { qwenAdapter } from "./qwen.js";
18
+ import { droidAdapter } from "./droid.js";
19
+ import { kiroAdapter } from "./kiro.js";
20
+ import { loadAgentsFiles } from "./custom.js";
21
+ export const ADAPTERS = [
22
+ claudeAdapter,
23
+ codexAdapter,
24
+ antigravityAdapter,
25
+ cursorAdapter,
26
+ geminiAdapter,
27
+ // Opt-in connectors: invisible until their CLI is installed (`optional`).
28
+ opencodeAdapter,
29
+ copilotAdapter,
30
+ ampAdapter,
31
+ gooseAdapter,
32
+ qwenAdapter,
33
+ droidAdapter,
34
+ kiroAdapter,
35
+ ];
36
+ /**
37
+ * Only shown (and never offered for install) when already installed: legacy
38
+ * CLIs — Gemini CLI still works with an API key or a work account, but new
39
+ * personal users should use Antigravity — plus every `optional` connector, so
40
+ * a dozen CLIs you don't use never clutter the agents list.
41
+ */
42
+ export const LEGACY = new Set(["gemini", ...ADAPTERS.filter((a) => a.optional).map((a) => a.id)]);
43
+ /** Every built-in connector, before agents.json / env narrowing — for `huddle agents --all`. */
44
+ export const BUILTIN_ADAPTERS = [...ADAPTERS];
45
+ /** Problems found in ~/.huddle/agents.json or .huddle/agents.json — `huddle doctor` prints them. */
46
+ export const AGENTS_FILE_ERRORS = [];
47
+ // User-declared connectors (docs/ADAPTERS.md). HUDDLE_NO_AGENTS_FILE=1 skips
48
+ // them, so a test run is never shaped by the machine's own files.
49
+ if (!process.env.HUDDLE_NO_AGENTS_FILE) {
50
+ const r = loadAgentsFiles();
51
+ for (const a of r.adapters)
52
+ registerAdapter(a);
53
+ AGENTS_FILE_ERRORS.push(...r.errors);
54
+ }
55
+ /**
56
+ * Extra adapters, for tests and for trying out a new connector before it has
57
+ * a file here: HUDDLE_EXTRA_ADAPTERS is a comma-separated list of ES modules
58
+ * whose default export is an Adapter (or an array of them). Unset = nothing
59
+ * changes. In-process callers (tests) can use registerAdapter() instead.
60
+ */
61
+ for (const mod of (process.env.HUDDLE_EXTRA_ADAPTERS ?? "").split(",").map((s) => s.trim()).filter(Boolean)) {
62
+ const m = await import(pathToFileURL(path.resolve(mod)).href);
63
+ for (const a of [m.default].flat())
64
+ registerAdapter(a);
65
+ }
66
+ // HUDDLE_ONLY_ADAPTERS=id,id narrows the roster to those ids — so a test run
67
+ // of the real CLI can never wake (and bill) the real agents on this machine.
68
+ const only = (process.env.HUDDLE_ONLY_ADAPTERS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
69
+ if (only.length)
70
+ ADAPTERS.splice(0, ADAPTERS.length, ...ADAPTERS.filter((a) => only.includes(a.id)));
71
+ /** Add an adapter to the roster (same id = replaced). */
72
+ export function registerAdapter(adapter) {
73
+ const i = ADAPTERS.findIndex((a) => a.id === adapter.id);
74
+ if (i >= 0)
75
+ ADAPTERS[i] = adapter;
76
+ else
77
+ ADAPTERS.push(adapter);
78
+ if (adapter.optional)
79
+ LEGACY.add(adapter.id);
80
+ else if (adapter.id !== "gemini")
81
+ LEGACY.delete(adapter.id);
82
+ }
83
+ function onPath(bin) {
84
+ try {
85
+ execFileSync(process.platform === "win32" ? "where" : "which", [bin], { stdio: "pipe" });
86
+ return true;
87
+ }
88
+ catch {
89
+ return false;
90
+ }
91
+ }
92
+ function run(cmd) {
93
+ try {
94
+ const stdout = execFileSync(cmd[0], cmd.slice(1), { encoding: "utf8", stdio: "pipe", timeout: 8000 });
95
+ return { code: 0, stdout, stderr: "" };
96
+ }
97
+ catch (e) {
98
+ return { code: e?.status ?? 1, stdout: e?.stdout?.toString() ?? "", stderr: e?.stderr?.toString() ?? "" };
99
+ }
100
+ }
101
+ /** Detect + auth-probe a single adapter. */
102
+ export function probe(adapter) {
103
+ if (!onPath(adapter.bin)) {
104
+ return { adapter, state: "not_installed", detail: `'${adapter.bin}' not on PATH` };
105
+ }
106
+ const r = run(adapter.authProbe.cmd);
107
+ if (!adapter.authProbe.ok(r) || (adapter.authProbe.check && !adapter.authProbe.check())) {
108
+ return { adapter, state: "not_authed", detail: adapter.authProbe.fixHint };
109
+ }
110
+ return { adapter, state: "ready", detail: `${adapter.vendor} · ${adapter.accountNote ?? "ready"}` };
111
+ }
112
+ function runAsync(cmd) {
113
+ return new Promise((resolve) => {
114
+ execFile(cmd[0], cmd.slice(1), { encoding: "utf8", timeout: 8000 }, (err, stdout, stderr) => resolve({ code: err ? (typeof err.code === "number" ? err.code : 1) : 0, stdout, stderr }));
115
+ });
116
+ }
117
+ /** Non-blocking probe, so the welcome screen can animate while agents connect. */
118
+ export async function probeAsync(adapter) {
119
+ if (!onPath(adapter.bin)) {
120
+ return { adapter, state: "not_installed", detail: `'${adapter.bin}' not on PATH` };
121
+ }
122
+ const r = await runAsync(adapter.authProbe.cmd);
123
+ if (!adapter.authProbe.ok(r) || (adapter.authProbe.check && !adapter.authProbe.check())) {
124
+ return { adapter, state: "not_authed", detail: adapter.authProbe.fixHint };
125
+ }
126
+ return { adapter, state: "ready", detail: `${adapter.vendor} · ${adapter.accountNote ?? "ready"}` };
127
+ }
128
+ /** Probe the whole roster. */
129
+ export function probeAll() {
130
+ return ADAPTERS.map(probe);
131
+ }
132
+ export function getAdapter(id) {
133
+ return ADAPTERS.find((a) => a.id === id);
134
+ }
135
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,41 @@
1
+ // Kiro CLI (AWS) — headless via `kiro-cli chat --no-interactive`. Workspace
2
+ // MCP servers come from .kiro/settings/mcp.json in the project root, so the
3
+ // launcher writes the Plug into the worktree (kept out of commits). Auth is
4
+ // Kiro's own (`kiro-cli login`: Builder ID, Identity Center, Google, GitHub);
5
+ // `kiro-cli whoami` is the probe.
6
+ //
7
+ // Sources (checked 2026-10):
8
+ // https://kiro.dev/docs/cli/ curl -fsSL https://cli.kiro.dev/install | bash
9
+ // https://kiro.dev/docs/reference/cli-commands/
10
+ // chat --no-interactive, --trust-all-tools, --trust-tools (exact internal ids), login, whoami
11
+ // https://kiro.dev/docs/cli/mcp/configuration/ <project-root>/.kiro/settings/mcp.json, mcpServers { command, args, env }
12
+ // Approvals: a headless run can't approve anything, and --trust-tools needs
13
+ // internal tool ids the docs don't list, so every mode trusts all tools; the
14
+ // worktree is the fence. No model flag on `chat` in the reference, so none is
15
+ // passed. No `ask`: no documented read-only headless mode. Not yet run live.
16
+ export const kiroAdapter = {
17
+ id: "kiro",
18
+ vendor: "aws",
19
+ bin: "kiro-cli",
20
+ roles: ["worker"],
21
+ prefers: ["backend", "systems", "docs"],
22
+ models: { orchestrator: "", worker: "", fast: "", reviewer: "" },
23
+ accountNote: "your Kiro login",
24
+ homepage: "https://kiro.dev/docs/cli/",
25
+ optional: true,
26
+ install: { cmd: "curl -fsSL https://cli.kiro.dev/install | bash", login: ["kiro-cli", "login"] },
27
+ authProbe: {
28
+ cmd: ["kiro-cli", "whoami"],
29
+ ok: (r) => r.code === 0 && !/not logged in/i.test(r.stdout + r.stderr),
30
+ fixHint: "run `kiro-cli login`",
31
+ },
32
+ plug: {
33
+ file: { path: ".kiro/settings/mcp.json", in: "worktree" },
34
+ entry: (plug) => ({ mcpServers: { huddle: plug } }),
35
+ },
36
+ launch: ({ worktree, prompt }) => ({
37
+ cmd: ["kiro-cli", "chat", "--no-interactive", "--trust-all-tools", prompt],
38
+ cwd: worktree,
39
+ }),
40
+ };
41
+ //# sourceMappingURL=kiro.js.map
@@ -0,0 +1,63 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ // OpenCode (sst) — headless via `opencode run`. The Plug goes in a config file
5
+ // in .huddle/run/ that OPENCODE_CONFIG points at; OpenCode MERGES config files,
6
+ // so the user's own opencode.json (and their providers) still apply and nothing
7
+ // lands in the worktree. Auth is OpenCode's own (`opencode auth login`, or a
8
+ // provider API key in the environment).
9
+ //
10
+ // Sources (checked 2026-10):
11
+ // https://opencode.ai/docs/ install script, npm package opencode-ai
12
+ // https://opencode.ai/docs/cli/ run, -m provider/model, --agent, --auto, OPENCODE_PERMISSION, auth list
13
+ // https://opencode.ai/docs/config/ OPENCODE_CONFIG custom path, configs merge
14
+ // https://opencode.ai/docs/mcp-servers/ mcp.<name> { type: "local", command: [...], environment, enabled }
15
+ // https://opencode.ai/docs/permissions/ permission keys edit / bash / "*", values allow / ask / deny
16
+ // https://opencode.ai/docs/agents/ built-in "plan" agent is the restricted one
17
+ // Not yet run live on this machine.
18
+ // Headless: nobody can answer an "ask", so each mode decides up front.
19
+ const PERMISSION = {
20
+ // edits yes, shell no — like Claude's acceptEdits
21
+ safe: JSON.stringify({ edit: "allow", bash: "deny", webfetch: "deny" }),
22
+ auto: null, // --auto: approve whatever isn't explicitly denied
23
+ yolo: JSON.stringify({ "*": "allow" }),
24
+ };
25
+ const authed = () => fs.existsSync(path.join(os.homedir(), ".local", "share", "opencode", "auth.json")) ||
26
+ ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "OPENROUTER_API_KEY", "GROQ_API_KEY"].some((k) => !!process.env[k]);
27
+ export const opencodeAdapter = {
28
+ id: "opencode",
29
+ vendor: "sst",
30
+ bin: "opencode",
31
+ roles: ["worker", "reviewer"],
32
+ prefers: ["backend", "refactor", "quickfix"],
33
+ // OpenCode wants provider/model; "" = whatever its owner configured.
34
+ models: { orchestrator: "", worker: "", fast: "", reviewer: "" },
35
+ accountNote: "your OpenCode providers",
36
+ homepage: "https://opencode.ai/docs/",
37
+ optional: true,
38
+ install: { cmd: "curl -fsSL https://opencode.ai/install | bash", login: ["opencode", "auth", "login"] },
39
+ authProbe: {
40
+ cmd: ["opencode", "--version"],
41
+ ok: (r) => r.code === 0,
42
+ check: authed,
43
+ fixHint: "run `opencode auth login` (or set a provider API key)",
44
+ },
45
+ plug: {
46
+ file: { path: "opencode.json", in: "run-dir" },
47
+ entry: (plug) => ({
48
+ $schema: "https://opencode.ai/config.json",
49
+ mcp: { huddle: { type: "local", command: [plug.command, ...plug.args], environment: plug.env, enabled: true } },
50
+ }),
51
+ },
52
+ // The built-in "plan" agent is OpenCode's restricted, read-only one.
53
+ ask: ({ prompt, cwd, model }) => ({ cmd: ["opencode", "run", "--agent", "plan", ...(model ? ["-m", model] : []), prompt], cwd }),
54
+ launch: ({ worktree, prompt, model, mcpConfigPath, approvals }) => ({
55
+ cmd: ["opencode", "run", ...(model ? ["-m", model] : []), ...(approvals === "safe" ? [] : ["--auto"]), prompt],
56
+ cwd: worktree,
57
+ env: {
58
+ OPENCODE_CONFIG: mcpConfigPath,
59
+ ...(PERMISSION[approvals] ? { OPENCODE_PERMISSION: PERMISSION[approvals] } : {}),
60
+ },
61
+ }),
62
+ };
63
+ //# sourceMappingURL=opencode.js.map
@@ -0,0 +1,67 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { qwenUsage } from "./usage.js";
5
+ // Qwen Code — headless via `qwen -p`. It reads MCP servers from
6
+ // .qwen/settings.json in the project, so the launcher writes the Plug into the
7
+ // worktree (the merge engine keeps it out of the commit). `trust: true` skips
8
+ // Qwen's per-call confirmation for this one server, which a headless run
9
+ // can't answer. Auth is Qwen's own (/auth inside `qwen`, or a provider key).
10
+ //
11
+ // Sources (checked 2026-10):
12
+ // https://qwenlm.github.io/qwen-code-docs/en/users/quickstart/ npm install -g @qwen-code/qwen-code@latest, /auth
13
+ // https://qwenlm.github.io/qwen-code-docs/en/users/features/headless/ -p, -m/--model, --yolo,
14
+ // --approval-mode plan|default|auto-edit|auto|yolo, --output-format text|json|stream-json
15
+ // https://qwenlm.github.io/qwen-code-docs/en/users/features/mcp/ .qwen/settings.json mcpServers
16
+ // { command, args, env, trust }
17
+ // https://qwenlm.github.io/qwen-code-docs/en/users/configuration/trusted-folders/
18
+ // folder trust is OFF by default; if you turned it on, untrusted worktrees ignore .qwen/settings.json
19
+ // Not yet run live on this machine.
20
+ const authed = () => fs.existsSync(path.join(os.homedir(), ".qwen", "settings.json")) ||
21
+ ["DASHSCOPE_API_KEY", "OPENAI_API_KEY"].some((k) => !!process.env[k]);
22
+ export const qwenAdapter = {
23
+ id: "qwen",
24
+ vendor: "alibaba",
25
+ bin: "qwen",
26
+ roles: ["worker", "reviewer"],
27
+ prefers: ["backend", "algorithms", "data"],
28
+ models: { orchestrator: "", worker: "", fast: "", reviewer: "" },
29
+ accountNote: "your Qwen Code provider",
30
+ homepage: "https://qwenlm.github.io/qwen-code-docs/en/users/quickstart/",
31
+ optional: true,
32
+ install: {
33
+ cmd: "npm install -g @qwen-code/qwen-code@latest",
34
+ login: ["qwen"],
35
+ loginNote: "Qwen Code asks you to pick a provider on first run — once it's set, type /quit to come back to Huddle",
36
+ },
37
+ authProbe: {
38
+ cmd: ["qwen", "--version"],
39
+ ok: (r) => r.code === 0,
40
+ check: authed,
41
+ fixHint: "run `qwen` once and set up a provider with /auth",
42
+ },
43
+ plug: {
44
+ file: { path: ".qwen/settings.json", in: "worktree" },
45
+ entry: (plug) => ({ mcpServers: { huddle: { ...plug, trust: true } } }),
46
+ },
47
+ ask: ({ prompt, cwd, model }) => ({
48
+ cmd: ["qwen", "-p", prompt, "--approval-mode", "plan", ...(model ? ["-m", model] : [])],
49
+ cwd,
50
+ }),
51
+ usage: qwenUsage,
52
+ launch: ({ worktree, prompt, model, approvals }) => ({
53
+ cmd: [
54
+ "qwen",
55
+ ...(model ? ["-m", model] : []),
56
+ "-p",
57
+ prompt,
58
+ "--output-format",
59
+ "stream-json", // so Huddle can count tokens
60
+ ...(approvals === "safe" ? ["--approval-mode", "auto-edit"] : []),
61
+ ...(approvals === "auto" ? ["--approval-mode", "auto"] : []),
62
+ ...(approvals === "yolo" ? ["--yolo"] : []),
63
+ ],
64
+ cwd: worktree,
65
+ }),
66
+ };
67
+ //# sourceMappingURL=qwen.js.map
@@ -0,0 +1,6 @@
1
+ // An Adapter is the ENTIRE vendor-specific surface area of Huddle. It answers:
2
+ // how do I detect you, how do I know you're logged in, how do I hand you the
3
+ // Plug (MCP), and how do I launch you on a task. When a vendor changes a flag
4
+ // or a new CLI appears, only a file like this changes — never the engine.
5
+ export {};
6
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,141 @@
1
+ // Token accounting. Every agent CLI can stream JSON events that include token
2
+ // usage; each adapter maps one output line to a reading. Readings are either
3
+ // a piece of usage (summed, deduped by `key`) or a final total (replaces the sum).
4
+ //
5
+ // "Tokens" = everything the provider reports processing: input (including
6
+ // cached context) + output. It's what the run actually consumed.
7
+ const n = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
8
+ function json(line) {
9
+ if (!line.startsWith("{"))
10
+ return null;
11
+ try {
12
+ return JSON.parse(line);
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ /** claude --output-format stream-json --verbose */
19
+ export function claudeUsage(line) {
20
+ const e = json(line);
21
+ const u = e?.type === "result" ? e.usage : e?.type === "assistant" ? e.message?.usage : null;
22
+ if (!u)
23
+ return null;
24
+ const tokens = n(u.input_tokens) + n(u.cache_creation_input_tokens) + n(u.cache_read_input_tokens) + n(u.output_tokens);
25
+ const cached = n(u.cache_read_input_tokens);
26
+ return e.type === "result" ? { tokens, cached, total: true } : { tokens, cached, key: e.message?.id };
27
+ }
28
+ /** codex exec --json */
29
+ export function codexUsage(line) {
30
+ const e = json(line);
31
+ if (e?.type !== "turn.completed" || !e.usage)
32
+ return null;
33
+ // input already includes cached
34
+ return { tokens: n(e.usage.input_tokens) + n(e.usage.output_tokens), cached: n(e.usage.cached_input_tokens) };
35
+ }
36
+ /** cursor-agent --output-format stream-json */
37
+ export function cursorUsage(line) {
38
+ const e = json(line);
39
+ const u = e?.usage;
40
+ if (!u)
41
+ return null;
42
+ const tokens = n(u.inputTokens) + n(u.outputTokens) + n(u.cacheReadTokens) + n(u.cacheWriteTokens);
43
+ const cached = n(u.cacheReadTokens);
44
+ return e.type === "result" ? { tokens, cached, total: true } : { tokens, cached, key: e.request_id };
45
+ }
46
+ /** agy --output-format stream-json (also works for Gemini CLI's stream-json) */
47
+ export function agyUsage(line) {
48
+ const e = json(line);
49
+ if (e?.event === "result" && e.result?.usage) {
50
+ const u = e.result.usage;
51
+ return { tokens: n(u.total_tokens) || n(u.input_tokens) + n(u.output_tokens), cached: n(u.cache_read_tokens), total: true };
52
+ }
53
+ if (e?.event === "step_update" && e.step_update?.usage && e.step_update.state === "DONE") {
54
+ const u = e.step_update.usage;
55
+ return {
56
+ tokens: n(u.total_tokens) || n(u.input_tokens) + n(u.output_tokens),
57
+ cached: n(u.cache_read_tokens),
58
+ key: String(e.step_update.step_index),
59
+ };
60
+ }
61
+ return null;
62
+ }
63
+ /**
64
+ * amp -x --stream-json — Claude-compatible messages, but per the Streaming JSON
65
+ * docs assistant messages carry no id and the final result has no usage, so
66
+ * each assistant turn's usage is summed.
67
+ */
68
+ export function ampUsage(line) {
69
+ const e = json(line);
70
+ const u = e?.type === "assistant" ? e.message?.usage : null;
71
+ if (!u)
72
+ return null;
73
+ const tokens = n(u.input_tokens) + n(u.cache_creation_input_tokens) + n(u.cache_read_input_tokens) + n(u.output_tokens);
74
+ return { tokens, cached: n(u.cache_read_input_tokens) };
75
+ }
76
+ /**
77
+ * qwen -p --output-format stream-json — message shape matches Claude's
78
+ * (assistant messages with an id + usage, a final result with usage). The docs
79
+ * elide the usage fields, so both Anthropic- and OpenAI-style names are read.
80
+ * UNVERIFIED field names: no live run yet.
81
+ */
82
+ export function qwenUsage(line) {
83
+ const e = json(line);
84
+ const u = e?.type === "result" ? e.usage : e?.type === "assistant" ? e.message?.usage : null;
85
+ if (!u)
86
+ return null;
87
+ const cached = n(u.cache_read_input_tokens) || n(u.cached_tokens) || n(u.cached_input_tokens);
88
+ const tokens = n(u.total_tokens) ||
89
+ n(u.input_tokens) + n(u.cache_creation_input_tokens) + n(u.cache_read_input_tokens) + n(u.output_tokens) ||
90
+ n(u.prompt_tokens) + n(u.completion_tokens);
91
+ if (!tokens)
92
+ return null;
93
+ return e.type === "result" ? { tokens, cached, total: true } : { tokens, cached, key: e.message?.id };
94
+ }
95
+ /** Accumulates readings into running totals. */
96
+ export class UsageMeter {
97
+ byKey = new Map();
98
+ loose = { tokens: 0, cached: 0 };
99
+ final = null;
100
+ /** False until the agent has reported anything (some only report at the end). */
101
+ seen = false;
102
+ add(r) {
103
+ this.seen = true;
104
+ const p = { tokens: r.tokens, cached: r.cached ?? 0 };
105
+ if (r.total)
106
+ this.final = p;
107
+ else if (r.key)
108
+ this.byKey.set(r.key, p);
109
+ else
110
+ this.loose = { tokens: this.loose.tokens + p.tokens, cached: this.loose.cached + p.cached };
111
+ }
112
+ sum() {
113
+ if (this.final)
114
+ return this.final;
115
+ const s = { ...this.loose };
116
+ for (const v of this.byKey.values())
117
+ (s.tokens += v.tokens), (s.cached += v.cached);
118
+ return s;
119
+ }
120
+ get tokens() {
121
+ return this.sum().tokens;
122
+ }
123
+ get cached() {
124
+ return this.sum().cached;
125
+ }
126
+ }
127
+ export function fmtTokens(t) {
128
+ if (t < 1000)
129
+ return `${t}`;
130
+ if (t < 1_000_000)
131
+ return `${(t / 1000).toFixed(t < 10_000 ? 1 : 0)}k`;
132
+ return `${(t / 1_000_000).toFixed(1)}M`;
133
+ }
134
+ export function fmtDuration(ms) {
135
+ const s = Math.max(0, Math.round(ms / 1000));
136
+ if (s < 60)
137
+ return `${s}s`;
138
+ const m = Math.floor(s / 60);
139
+ return m < 60 ? `${m}m${String(s % 60).padStart(2, "0")}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
140
+ }
141
+ //# sourceMappingURL=usage.js.map