huddle-cli 0.0.0-stage → 0.1.1
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 +31 -0
- package/LICENSE +21 -0
- package/QUICKSTART.md +102 -0
- package/README.md +376 -2
- package/dist/adapters/amp.js +40 -0
- package/dist/adapters/antigravity.js +54 -0
- package/dist/adapters/claude.js +86 -0
- package/dist/adapters/codex.js +64 -0
- package/dist/adapters/copilot.js +69 -0
- package/dist/adapters/cursor.js +92 -0
- package/dist/adapters/custom.js +342 -0
- package/dist/adapters/droid.js +58 -0
- package/dist/adapters/gemini.js +61 -0
- package/dist/adapters/goose.js +57 -0
- package/dist/adapters/index.js +161 -0
- package/dist/adapters/kiro.js +41 -0
- package/dist/adapters/opencode.js +63 -0
- package/dist/adapters/qwen.js +67 -0
- package/dist/adapters/types.js +6 -0
- package/dist/adapters/usage.js +141 -0
- package/dist/attach/connect.js +412 -0
- package/dist/attach/guard.js +83 -0
- package/dist/attach/live.js +226 -0
- package/dist/attach/plug.js +185 -0
- package/dist/attach/screen.js +150 -0
- package/dist/attach/session.js +177 -0
- package/dist/cli.js +244 -0
- package/dist/mcp/context-tools.js +79 -0
- package/dist/mcp/server.js +267 -0
- package/dist/orchestrator/ask.js +54 -0
- package/dist/orchestrator/brain.js +174 -0
- package/dist/orchestrator/council.js +615 -0
- package/dist/orchestrator/goal.js +196 -0
- package/dist/orchestrator/handoff.js +117 -0
- package/dist/orchestrator/launcher.js +171 -0
- package/dist/orchestrator/learn.js +105 -0
- package/dist/orchestrator/planner.js +212 -0
- package/dist/orchestrator/qa.js +398 -0
- package/dist/orchestrator/radar.js +719 -0
- package/dist/orchestrator/scheduler.js +1207 -0
- package/dist/orchestrator/takeover.js +240 -0
- package/dist/platform.js +273 -0
- package/dist/preflight.js +31 -0
- package/dist/relay/server.js +113 -0
- package/dist/room/context.js +1285 -0
- package/dist/room/contract.js +132 -0
- package/dist/room/db.js +680 -0
- package/dist/room/followup.js +233 -0
- package/dist/room/identity.js +122 -0
- package/dist/room/lessons.js +275 -0
- package/dist/room/live.js +348 -0
- package/dist/room/mail.js +212 -0
- package/dist/room/progress.js +149 -0
- package/dist/room/relaylink.js +85 -0
- package/dist/room/server.js +882 -0
- package/dist/room/sqlite.js +156 -0
- package/dist/room/transport.js +107 -0
- package/dist/room/tunnel.js +86 -0
- package/dist/sandbox/sync.js +104 -0
- package/dist/sandbox/worktree.js +392 -0
- package/dist/types.js +4 -0
- package/dist/ui/app.js +1955 -0
- package/dist/ui/catalog.js +113 -0
- package/dist/ui/dashboard.js +335 -0
- package/dist/ui/doctor.js +58 -0
- package/dist/ui/lessons.js +81 -0
- package/dist/ui/logo.js +40 -0
- package/dist/ui/paste.js +515 -0
- package/dist/ui/remote.js +338 -0
- package/dist/ui/setup.js +152 -0
- package/dist/ui/story.js +1529 -0
- package/dist/ui/term.js +249 -0
- package/dist/ui/watch.js +271 -0
- package/install.sh +44 -0
- package/package.json +63 -4
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
// Factory Droid — headless via `droid exec`. It reads project MCP servers
|
|
5
|
+
// from .factory/mcp.json in the project root, so the launcher writes the Plug
|
|
6
|
+
// into the worktree (kept out of commits). `droid exec` is read-only unless
|
|
7
|
+
// given an --auto level, which makes it a natural reviewer too. Auth is
|
|
8
|
+
// Factory's own (sign in inside `droid`, or FACTORY_API_KEY).
|
|
9
|
+
//
|
|
10
|
+
// Sources (checked 2026-10):
|
|
11
|
+
// https://docs.factory.ai/cli/getting-started/quickstart curl -fsSL https://app.factory.ai/cli | sh, brew, npm
|
|
12
|
+
// https://docs.factory.ai/cli/droid-exec/overview
|
|
13
|
+
// droid exec [prompt], -m/--model, --auto low|medium|high, --skip-permissions-unsafe,
|
|
14
|
+
// --cwd, default = read-only spec mode, FACTORY_API_KEY
|
|
15
|
+
// https://docs.factory.ai/cli/configuration/mcp
|
|
16
|
+
// project .factory/mcp.json, mcpServers { type: "stdio", command, args, env }
|
|
17
|
+
// UNVERIFIED: which --auto level is needed for project MCP tools in exec
|
|
18
|
+
// mode. Not yet run live on this machine.
|
|
19
|
+
const AUTONOMY = {
|
|
20
|
+
safe: ["--auto", "low"], // file edits
|
|
21
|
+
auto: ["--auto", "medium"], // + normal local dev commands (installs, tests)
|
|
22
|
+
yolo: ["--skip-permissions-unsafe"],
|
|
23
|
+
};
|
|
24
|
+
const authed = () => !!process.env.FACTORY_API_KEY || fs.existsSync(path.join(os.homedir(), ".factory"));
|
|
25
|
+
export const droidAdapter = {
|
|
26
|
+
id: "droid",
|
|
27
|
+
vendor: "factory",
|
|
28
|
+
bin: "droid",
|
|
29
|
+
roles: ["worker", "reviewer"],
|
|
30
|
+
prefers: ["backend", "refactor", "systems"],
|
|
31
|
+
models: { orchestrator: "", worker: "", fast: "", reviewer: "" },
|
|
32
|
+
accountNote: "your Factory account",
|
|
33
|
+
homepage: "https://docs.factory.ai/cli/getting-started/quickstart",
|
|
34
|
+
optional: true,
|
|
35
|
+
install: {
|
|
36
|
+
cmd: "curl -fsSL https://app.factory.ai/cli | sh",
|
|
37
|
+
win: "irm https://app.factory.ai/cli/windows | iex",
|
|
38
|
+
login: ["droid"],
|
|
39
|
+
loginNote: "Droid signs you in from its own screen — once you're in, press ctrl-c to come back to Huddle",
|
|
40
|
+
},
|
|
41
|
+
authProbe: {
|
|
42
|
+
cmd: ["droid", "--version"],
|
|
43
|
+
ok: (r) => r.code === 0,
|
|
44
|
+
check: authed,
|
|
45
|
+
fixHint: "run `droid` once and sign in (or set FACTORY_API_KEY)",
|
|
46
|
+
},
|
|
47
|
+
plug: {
|
|
48
|
+
file: { path: ".factory/mcp.json", in: "worktree" },
|
|
49
|
+
entry: (plug) => ({ mcpServers: { huddle: { type: "stdio", ...plug } } }),
|
|
50
|
+
},
|
|
51
|
+
// No --auto = Droid's read-only spec mode.
|
|
52
|
+
ask: ({ prompt, cwd, model }) => ({ cmd: ["droid", "exec", ...(model ? ["-m", model] : []), prompt], cwd }),
|
|
53
|
+
launch: ({ worktree, prompt, model, approvals }) => ({
|
|
54
|
+
cmd: ["droid", "exec", ...(model ? ["-m", model] : []), ...AUTONOMY[approvals], "--cwd", worktree, prompt],
|
|
55
|
+
cwd: worktree,
|
|
56
|
+
}),
|
|
57
|
+
};
|
|
58
|
+
//# sourceMappingURL=droid.js.map
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
// Gemini has no `login status` command. Since June 2026 Gemini CLI no longer
|
|
5
|
+
// serves personal Google accounts (sign-in fails with "migrate to Antigravity"),
|
|
6
|
+
// so personal OAuth doesn't count — an API key, Vertex, or a work account does.
|
|
7
|
+
// Personal accounts should use the Antigravity adapter instead.
|
|
8
|
+
function geminiAuthed() {
|
|
9
|
+
if (process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || process.env.GOOGLE_GENAI_USE_VERTEXAI)
|
|
10
|
+
return true;
|
|
11
|
+
if (!fs.existsSync(path.join(os.homedir(), ".gemini", "oauth_creds.json")))
|
|
12
|
+
return false;
|
|
13
|
+
try {
|
|
14
|
+
const settings = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".gemini", "settings.json"), "utf8"));
|
|
15
|
+
return settings?.security?.auth?.selectedType !== "oauth-personal";
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// Gemini CLI — headless via `-p`. It reads MCP servers from .gemini/settings.json
|
|
22
|
+
// in its cwd, so the launcher writes that into the worktree (and the merge
|
|
23
|
+
// engine keeps it out of the commit). Auth is Google OAuth or GEMINI_API_KEY.
|
|
24
|
+
export const geminiAdapter = {
|
|
25
|
+
id: "gemini",
|
|
26
|
+
vendor: "google",
|
|
27
|
+
bin: "gemini",
|
|
28
|
+
roles: ["worker", "fast"],
|
|
29
|
+
prefers: ["research", "breadth", "docs", "data"],
|
|
30
|
+
models: { orchestrator: "", worker: "", fast: "", reviewer: "" },
|
|
31
|
+
accountNote: "personal Google",
|
|
32
|
+
install: { cmd: "npm install -g @google/gemini-cli" },
|
|
33
|
+
authProbe: {
|
|
34
|
+
cmd: ["gemini", "--version"],
|
|
35
|
+
ok: (r) => r.code === 0,
|
|
36
|
+
check: geminiAuthed,
|
|
37
|
+
fixHint: "personal Google accounts moved to Antigravity (June 2026) — use antigravity, or set GEMINI_API_KEY",
|
|
38
|
+
},
|
|
39
|
+
plug: {
|
|
40
|
+
file: { path: ".gemini/settings.json", in: "worktree" },
|
|
41
|
+
entry: (plug) => ({ mcpServers: { huddle: plug } }),
|
|
42
|
+
},
|
|
43
|
+
ask: ({ prompt, cwd, model }) => ({
|
|
44
|
+
cmd: ["gemini", "-p", prompt, "--approval-mode", "plan", "--skip-trust", ...(model ? ["-m", model] : [])],
|
|
45
|
+
cwd,
|
|
46
|
+
}),
|
|
47
|
+
launch: ({ worktree, prompt, model, approvals }) => ({
|
|
48
|
+
cmd: [
|
|
49
|
+
"gemini",
|
|
50
|
+
...(model ? ["-m", model] : []),
|
|
51
|
+
"-p",
|
|
52
|
+
prompt,
|
|
53
|
+
"--skip-trust", // worktrees are new folders; without trust the Plug config is ignored
|
|
54
|
+
...(approvals === "safe" ? ["--approval-mode", "auto_edit"] : []),
|
|
55
|
+
...(approvals === "auto" ? ["--approval-mode", "yolo", "--sandbox"] : []),
|
|
56
|
+
...(approvals === "yolo" ? ["--approval-mode", "yolo"] : []),
|
|
57
|
+
],
|
|
58
|
+
cwd: worktree,
|
|
59
|
+
}),
|
|
60
|
+
};
|
|
61
|
+
//# sourceMappingURL=gemini.js.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
// goose (Block / AAIF) — headless via `goose run -t`. The Plug is added for
|
|
5
|
+
// this run only with --with-extension "huddle:ENV=… command args…", so the
|
|
6
|
+
// user's ~/.config/goose/config.yaml is never touched. Auth: goose uses the
|
|
7
|
+
// provider its owner picked in `goose configure` (keys live in goose's own
|
|
8
|
+
// config/keyring) — Huddle only checks that a config exists.
|
|
9
|
+
//
|
|
10
|
+
// Sources (checked 2026-10):
|
|
11
|
+
// https://goose-docs.ai/docs/getting-started/installation download_cli.sh (CONFIGURE=false), brew
|
|
12
|
+
// https://goose-docs.ai/docs/guides/goose-cli-commands
|
|
13
|
+
// run -t/--text, --with-extension "[name:]ENV1=val1 command args...", --model, --provider,
|
|
14
|
+
// --output-format text|json|stream-json, goose configure, goose info
|
|
15
|
+
// https://goose-docs.ai/docs/guides/goose-permissions Autonomous mode is the default
|
|
16
|
+
// Approvals: headless goose runs in its default (autonomous) mode in all three
|
|
17
|
+
// Huddle modes; the worktree is the fence. --model needs a matching provider,
|
|
18
|
+
// so Huddle passes neither. UNVERIFIED: how goose splits the extension string
|
|
19
|
+
// when a path contains spaces (tokens with spaces are double-quoted here).
|
|
20
|
+
// No `ask`: goose has no documented read-only flag. Not yet run live.
|
|
21
|
+
const quote = (s) => (/[\s"'\\]/.test(s) ? JSON.stringify(s) : s);
|
|
22
|
+
export function gooseExtension(plug) {
|
|
23
|
+
const env = Object.entries(plug.env).map(([k, v]) => `${k}=${quote(v)}`);
|
|
24
|
+
return `huddle:${[...env, quote(plug.command), ...plug.args.map(quote)].join(" ")}`;
|
|
25
|
+
}
|
|
26
|
+
const configured = () => !!process.env.GOOSE_PROVIDER ||
|
|
27
|
+
fs.existsSync(path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "goose", "config.yaml"));
|
|
28
|
+
export const gooseAdapter = {
|
|
29
|
+
id: "goose",
|
|
30
|
+
vendor: "block",
|
|
31
|
+
bin: "goose",
|
|
32
|
+
roles: ["worker"],
|
|
33
|
+
prefers: ["backend", "data", "docs"],
|
|
34
|
+
models: { orchestrator: "", worker: "", fast: "", reviewer: "" },
|
|
35
|
+
accountNote: "the provider in your goose config",
|
|
36
|
+
homepage: "https://goose-docs.ai/docs/getting-started/installation",
|
|
37
|
+
optional: true,
|
|
38
|
+
install: {
|
|
39
|
+
cmd: "curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash",
|
|
40
|
+
// Windows has a download_cli.ps1 that saves itself into the current folder: point at the docs instead.
|
|
41
|
+
win: null,
|
|
42
|
+
login: ["goose", "configure"],
|
|
43
|
+
loginNote: "pick a provider and paste its key in goose's own setup — Huddle never sees it",
|
|
44
|
+
},
|
|
45
|
+
authProbe: {
|
|
46
|
+
cmd: ["goose", "--version"],
|
|
47
|
+
ok: (r) => r.code === 0,
|
|
48
|
+
check: configured,
|
|
49
|
+
fixHint: "run `goose configure` and pick a provider",
|
|
50
|
+
},
|
|
51
|
+
plug: {},
|
|
52
|
+
launch: ({ worktree, prompt, plug }) => ({
|
|
53
|
+
cmd: ["goose", "run", "--with-extension", gooseExtension(plug), "-t", prompt],
|
|
54
|
+
cwd: worktree,
|
|
55
|
+
}),
|
|
56
|
+
};
|
|
57
|
+
//# sourceMappingURL=goose.js.map
|
|
@@ -0,0 +1,161 @@
|
|
|
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 { spawn } 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
|
+
import { onPath, spawnToolSync, toolCommand } from "../platform.js";
|
|
22
|
+
export const ADAPTERS = [
|
|
23
|
+
claudeAdapter,
|
|
24
|
+
codexAdapter,
|
|
25
|
+
antigravityAdapter,
|
|
26
|
+
cursorAdapter,
|
|
27
|
+
geminiAdapter,
|
|
28
|
+
// Opt-in connectors: invisible until their CLI is installed (`optional`).
|
|
29
|
+
opencodeAdapter,
|
|
30
|
+
copilotAdapter,
|
|
31
|
+
ampAdapter,
|
|
32
|
+
gooseAdapter,
|
|
33
|
+
qwenAdapter,
|
|
34
|
+
droidAdapter,
|
|
35
|
+
kiroAdapter,
|
|
36
|
+
];
|
|
37
|
+
/**
|
|
38
|
+
* Only shown (and never offered for install) when already installed: legacy
|
|
39
|
+
* CLIs — Gemini CLI still works with an API key or a work account, but new
|
|
40
|
+
* personal users should use Antigravity — plus every `optional` connector, so
|
|
41
|
+
* a dozen CLIs you don't use never clutter the agents list.
|
|
42
|
+
*/
|
|
43
|
+
export const LEGACY = new Set(["gemini", ...ADAPTERS.filter((a) => a.optional).map((a) => a.id)]);
|
|
44
|
+
/** Every built-in connector, before agents.json / env narrowing — for `huddle agents --all`. */
|
|
45
|
+
export const BUILTIN_ADAPTERS = [...ADAPTERS];
|
|
46
|
+
/** Problems found in ~/.huddle/agents.json or .huddle/agents.json — `huddle doctor` prints them. */
|
|
47
|
+
export const AGENTS_FILE_ERRORS = [];
|
|
48
|
+
// User-declared connectors (docs/ADAPTERS.md). HUDDLE_NO_AGENTS_FILE=1 skips
|
|
49
|
+
// them, so a test run is never shaped by the machine's own files.
|
|
50
|
+
if (!process.env.HUDDLE_NO_AGENTS_FILE) {
|
|
51
|
+
const r = loadAgentsFiles();
|
|
52
|
+
for (const a of r.adapters)
|
|
53
|
+
registerAdapter(a);
|
|
54
|
+
AGENTS_FILE_ERRORS.push(...r.errors);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Extra adapters, for tests and for trying out a new connector before it has
|
|
58
|
+
* a file here: HUDDLE_EXTRA_ADAPTERS is a comma-separated list of ES modules
|
|
59
|
+
* whose default export is an Adapter (or an array of them). Unset = nothing
|
|
60
|
+
* changes. In-process callers (tests) can use registerAdapter() instead.
|
|
61
|
+
*/
|
|
62
|
+
for (const mod of (process.env.HUDDLE_EXTRA_ADAPTERS ?? "").split(",").map((s) => s.trim()).filter(Boolean)) {
|
|
63
|
+
const m = await import(pathToFileURL(path.resolve(mod)).href);
|
|
64
|
+
for (const a of [m.default].flat())
|
|
65
|
+
registerAdapter(a);
|
|
66
|
+
}
|
|
67
|
+
// HUDDLE_ONLY_ADAPTERS=id,id narrows the roster to those ids — so a test run
|
|
68
|
+
// of the real CLI can never wake (and bill) the real agents on this machine.
|
|
69
|
+
const only = (process.env.HUDDLE_ONLY_ADAPTERS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
70
|
+
if (only.length)
|
|
71
|
+
ADAPTERS.splice(0, ADAPTERS.length, ...ADAPTERS.filter((a) => only.includes(a.id)));
|
|
72
|
+
/** Add an adapter to the roster (same id = replaced). */
|
|
73
|
+
export function registerAdapter(adapter) {
|
|
74
|
+
const i = ADAPTERS.findIndex((a) => a.id === adapter.id);
|
|
75
|
+
if (i >= 0)
|
|
76
|
+
ADAPTERS[i] = adapter;
|
|
77
|
+
else
|
|
78
|
+
ADAPTERS.push(adapter);
|
|
79
|
+
if (adapter.optional)
|
|
80
|
+
LEGACY.add(adapter.id);
|
|
81
|
+
else if (adapter.id !== "gemini")
|
|
82
|
+
LEGACY.delete(adapter.id);
|
|
83
|
+
}
|
|
84
|
+
/** Mark the agents you skipped, keeping what the probe found in `was`. */
|
|
85
|
+
export function applySkips(results, skip) {
|
|
86
|
+
const s = new Set(skip);
|
|
87
|
+
return results.map((r) => s.has(r.adapter.id) && r.state !== "skipped" ? { ...r, state: "skipped", was: r.state, detail: `skipped by you — huddle agents use ${r.adapter.id}` } : r);
|
|
88
|
+
}
|
|
89
|
+
/** Undo applySkips for `ids` — `--agents=` on a run overrides a skip for that run. */
|
|
90
|
+
export function unskip(results, ids) {
|
|
91
|
+
return results.map((r) => (r.state === "skipped" && r.was && ids.includes(r.adapter.id) ? { ...r, state: r.was, was: undefined } : r));
|
|
92
|
+
}
|
|
93
|
+
function run(cmd) {
|
|
94
|
+
const r = spawnToolSync(cmd, { encoding: "utf8", stdio: "pipe", timeout: 8000 });
|
|
95
|
+
return { code: r.error ? 1 : (r.status ?? 1), stdout: String(r.stdout ?? ""), stderr: String(r.stderr ?? "") };
|
|
96
|
+
}
|
|
97
|
+
/** Detect + auth-probe a single adapter. */
|
|
98
|
+
export function probe(adapter) {
|
|
99
|
+
if (!onPath(adapter.bin)) {
|
|
100
|
+
return { adapter, state: "not_installed", detail: `'${adapter.bin}' not on PATH` };
|
|
101
|
+
}
|
|
102
|
+
const r = run(adapter.authProbe.cmd);
|
|
103
|
+
if (!adapter.authProbe.ok(r) || (adapter.authProbe.check && !adapter.authProbe.check())) {
|
|
104
|
+
return { adapter, state: "not_authed", detail: adapter.authProbe.fixHint };
|
|
105
|
+
}
|
|
106
|
+
return { adapter, state: "ready", detail: `${adapter.vendor} · ${adapter.accountNote ?? "ready"}` };
|
|
107
|
+
}
|
|
108
|
+
function runAsync(cmd, timeoutMs = 8000) {
|
|
109
|
+
return new Promise((resolve) => {
|
|
110
|
+
const t = toolCommand(cmd);
|
|
111
|
+
const child = spawn(t.file, t.args, { stdio: ["ignore", "pipe", "pipe"], windowsVerbatimArguments: t.verbatim });
|
|
112
|
+
let stdout = "";
|
|
113
|
+
let stderr = "";
|
|
114
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
115
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
116
|
+
const timer = setTimeout(() => child.kill(), timeoutMs);
|
|
117
|
+
const done = (code) => {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
resolve({ code, stdout, stderr });
|
|
120
|
+
};
|
|
121
|
+
child.on("error", () => done(1));
|
|
122
|
+
child.on("close", (code) => done(code ?? 1));
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/** Non-blocking probe, so the welcome screen can animate while agents connect. */
|
|
126
|
+
export async function probeAsync(adapter) {
|
|
127
|
+
if (!onPath(adapter.bin)) {
|
|
128
|
+
return { adapter, state: "not_installed", detail: `'${adapter.bin}' not on PATH` };
|
|
129
|
+
}
|
|
130
|
+
const r = await runAsync(adapter.authProbe.cmd);
|
|
131
|
+
if (!adapter.authProbe.ok(r) || (adapter.authProbe.check && !adapter.authProbe.check())) {
|
|
132
|
+
return { adapter, state: "not_authed", detail: adapter.authProbe.fixHint };
|
|
133
|
+
}
|
|
134
|
+
return { adapter, state: "ready", detail: `${adapter.vendor} · ${adapter.accountNote ?? "ready"}` };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* The install command for this platform, or null when there's no native one.
|
|
138
|
+
* An `npm install -g …` works everywhere; curl-pipe-bash doesn't on Windows.
|
|
139
|
+
*/
|
|
140
|
+
export function installCommand(a, platform = process.platform) {
|
|
141
|
+
if (platform !== "win32")
|
|
142
|
+
return a.install.cmd || null;
|
|
143
|
+
if (a.install.win !== undefined)
|
|
144
|
+
return a.install.win;
|
|
145
|
+
return /^npm (install|i) /.test(a.install.cmd) ? a.install.cmd : null;
|
|
146
|
+
}
|
|
147
|
+
/** What to say when installCommand() is null. */
|
|
148
|
+
export function noInstallHint(a, platform = process.platform) {
|
|
149
|
+
const docs = a.homepage ? `see ${a.homepage}` : `install ${a.bin} the way its docs say`;
|
|
150
|
+
return platform === "win32" && a.install.cmd ? `no Windows installer — ${docs}, or use it from WSL` : docs;
|
|
151
|
+
}
|
|
152
|
+
/** The sign-in command as one line, for messages ("codex login"). */
|
|
153
|
+
export const loginLine = (a) => (a.install.login ? a.install.login.join(" ") : null);
|
|
154
|
+
/** Probe the whole roster. */
|
|
155
|
+
export function probeAll() {
|
|
156
|
+
return ADAPTERS.map(probe);
|
|
157
|
+
}
|
|
158
|
+
export function getAdapter(id) {
|
|
159
|
+
return ADAPTERS.find((a) => a.id === id);
|
|
160
|
+
}
|
|
161
|
+
//# 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", win: null, 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", win: "npm install -g opencode-ai", 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
|