@hanamorilabs/tab 0.1.2 → 0.1.4

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/README.md CHANGED
@@ -59,8 +59,8 @@ Codex shows `FlockTab - Self-hosted` as its provider.
59
59
  tab login approve this machine in the console, once
60
60
  tab <agent> [args] claude, codex, grok, kimi, gemini, or any command, on the tab
61
61
  tab use pick or change the Agent this folder runs as (.flocktab)
62
- tab alias <name>... make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)
63
- tab unalias <name> remove such a shim
62
+ tab alias setup <name>... make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)
63
+ tab alias remove <name>... undo that; `tab alias list` shows them
64
64
  tab status flock, folder Agent, proxy health, provider key state
65
65
  tab version tab and proxy versions
66
66
  tab up | down self-hosted: start or stop the local proxy
@@ -69,19 +69,44 @@ tab logout forget the session and keys on this machine
69
69
  tab help this list
70
70
  ```
71
71
 
72
+ ## The console from the terminal
73
+
74
+ Everything the console does to a tab, `tab` does too, with the machine
75
+ session from `tab login`. An Agent is named by slug or name; left out, it is
76
+ this folder's Agent. Every read takes `--json`.
77
+
78
+ ```
79
+ tab list every Agent: state, spent of cap, window, project
80
+ tab close crawler tab open crawler the kill switch (402 tab_closed on the next call)
81
+ tab cap crawler 25 --window week cap in dollars, and the window
82
+ tab rename crawler "Nightly crawler" the slug stays
83
+ tab policy crawler --velocity 60 --models gpt-5,claude-sonnet-5 --allow email.send
84
+ tab project crawler Foley --create attribute to a project (--repo owner/name; none clears)
85
+ tab projects add Foley projects
86
+ tab key rotate crawler [--show] new key, kept on this machine; --show prints it once
87
+ tab archive crawler closes for good, leaves the bill (asks first; -y skips)
88
+ tab ledger [crawler] --limit 20 --blocked one row per call: settled of held, status, reason
89
+ tab spend [agent|project|day] --since 7d meter and outside spend, the console's numbers
90
+ tab outside connections, per-project outside spend, unattributed
91
+ tab live --watch 4 who is working now, refreshed every 4 seconds
92
+ tab web [crawler] the Agent's console page in the browser
93
+ ```
94
+
72
95
  ## Aliases: plain `codex` on the tab
73
96
 
74
97
  ```
75
- tab alias codex claude
98
+ tab alias setup codex claude
76
99
  export PATH="$HOME/.flocktab/bin:$PATH" # once, in your shell rc
77
100
  codex # now runs: tab codex
101
+ tab alias remove codex # back to the plain codex
78
102
  ```
79
103
 
80
- `tab alias` writes a shim named after the command into `~/.flocktab/bin`
81
- (`codex.cmd` on Windows). With that folder first on `PATH`, the plain
82
- command runs on the tab. When `tab` spawns the real agent it removes its
83
- own shim folder from the child's `PATH`, so nothing loops. `tab alias` with
84
- no name lists them; `tab unalias codex` removes one. Prefer a shell alias?
104
+ `tab alias setup` writes a shim named after the command into
105
+ `~/.flocktab/bin` (`codex.cmd` on Windows). With that folder first on
106
+ `PATH`, the plain command runs on the tab. When `tab` spawns the real agent
107
+ it removes its own shim folder from the child's `PATH`, so nothing loops.
108
+ `tab alias list` shows them; `tab alias remove <name>` deletes the shim, and
109
+ the plain command is the real one again. Prefer a shell alias?
85
110
  `alias codex='tab codex'` in your rc does the same for that shell only.
86
111
 
87
112
  ## Secrets and colour
package/dist/cli.js CHANGED
@@ -7,7 +7,8 @@
7
7
  * tab codex [...args] Codex on the tab
8
8
  * tab <cmd> [...args] anything else, both APIs pointed at the tab
9
9
  * tab use pick (or change) the Agent this folder runs as
10
- * tab alias codex ... make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)
10
+ * tab alias setup codex make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)
11
+ * tab alias remove codex undo that; `tab alias list` shows them
11
12
  * tab status which flock, which Agent here, is the proxy up
12
13
  * tab version tab and proxy versions
13
14
  * tab up | down | update self-hosted: start, stop, or fetch the newest proxy
@@ -30,6 +31,7 @@ import { consoleUrlFor, DeviceLoginError, startDeviceLogin, waitForApproval } fr
30
31
  import { clearConfig, configDir, configPath, HOSTED_PROXY, isLocalProxy, loadConfig, LOCAL_PROXY, normalizeProxyUrl, presentedKey, saveConfig, } from "./config.js";
31
32
  import { reportFolder } from "./folder.js";
32
33
  import { listAliases, pathLine, pathWithoutShims, removeAlias, shimDir, shimDirOnPath, validAliasName, writeAlias } from "./alias.js";
34
+ import * as manage from "./manage.js";
33
35
  import { agentNameFor, projectRoot, readProject, writeProject } from "./project.js";
34
36
  import { downloadProxy, hasProxyEnv, packagedBinPath, proxyBinPath, proxyEnvPath, proxyInstalled, proxyLogPath, PROXY_VERSION, startProxy, stopProxy, tailProxyLog, writeProxyEnv, } from "./proxy-bin.js";
35
37
  import { proxyHealth, waitHealthy } from "./selfhost.js";
@@ -50,10 +52,25 @@ function usage() {
50
52
  cmd(others.join(" | tab "), "the same, for those agents"),
51
53
  cmd("<command> [args]", "any other agent, both APIs pointed at the tab"),
52
54
  cmd("use", "pick or change the Agent this folder runs as (.flocktab)"),
53
- cmd("alias <name>...", "make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)"),
54
- cmd("unalias <name>", "remove such a shim"),
55
+ cmd("alias setup <name>...", "make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)"),
56
+ cmd("alias remove <name>...", "undo that; `tab alias list` shows them"),
55
57
  cmd("status", "which flock, which Agent here, is the proxy up"),
56
58
  cmd("version", "tab and proxy versions"),
59
+ cmd("list", "every Agent: state, spent of cap, window, project"),
60
+ cmd("close | open [agent]", "the kill switch; omit the Agent for this folder's"),
61
+ cmd("cap [agent] <dollars>", "set the cap (--window day|week|month|run|hour|lifetime)"),
62
+ cmd("rename <agent> <name>", "rename it (the slug stays)"),
63
+ cmd("policy [agent]", "show or set: --velocity N|none --models a,b|none --allow tool|none"),
64
+ cmd("project [agent] <name>", "attribute to a project (--create, --repo owner/name, none)"),
65
+ cmd("projects [add <name>]", "list or add projects"),
66
+ cmd("key rotate [agent]", "new key, kept on this machine (--show prints it once)"),
67
+ cmd("archive [agent]", "close for good and leave the bill"),
68
+ cmd("ledger [agent]", "one row per call (--limit N --blocked)"),
69
+ cmd("spend [agent|project|day]", "meter and outside spend (--since 7d --agent x)"),
70
+ cmd("outside", "outside-spend connections and per-project totals"),
71
+ cmd("live", "who is working now (--watch 4)"),
72
+ cmd("web [agent]", "open the Agent's console page"),
73
+ cmd("... --json", "every read command as JSON, for scripts and agents"),
57
74
  cmd("up", "self-hosted: start the local proxy (fetches it the first time)"),
58
75
  cmd("down", "self-hosted: stop the local proxy"),
59
76
  cmd("update", "self-hosted: fetch the newest proxy and restart it"),
@@ -521,7 +538,20 @@ async function runAgent(name, args) {
521
538
  * `tab alias codex claude`: shims so the plain commands run on the tab.
522
539
  * `tab alias` alone lists them; `tab unalias codex` removes one.
523
540
  */
524
- async function alias(names, remove = false) {
541
+ async function alias(args, removeFlag = false) {
542
+ // `tab alias setup codex`, `tab alias remove codex`, `tab alias list`;
543
+ // the bare `tab alias codex` and `tab unalias codex` mean the same.
544
+ const [first, ...rest] = args;
545
+ let names = args;
546
+ let remove = removeFlag;
547
+ if (first === "setup" || first === "add")
548
+ names = rest;
549
+ else if (first === "remove" || first === "rm") {
550
+ names = rest;
551
+ remove = true;
552
+ }
553
+ else if (first === "list" || first === "ls")
554
+ names = [];
525
555
  if (names.length === 0 && !remove) {
526
556
  const existing = await listAliases();
527
557
  if (existing.length === 0)
@@ -531,7 +561,7 @@ async function alias(names, remove = false) {
531
561
  return pathHint();
532
562
  }
533
563
  if (names.length === 0) {
534
- fail("tab unalias needs a name.");
564
+ fail("tab alias remove needs a name, e.g. tab alias remove codex");
535
565
  return 2;
536
566
  }
537
567
  for (const name of names) {
@@ -634,6 +664,52 @@ async function selfHosted(action) {
634
664
  say(`Next: ${bold("tab login")} and pick 2 Self-hosted to approve this machine.`);
635
665
  return 0;
636
666
  }
667
+ /** Console management from the terminal; every command needs the machine session. */
668
+ async function managed(command, rest) {
669
+ const config = await ensureLogin();
670
+ if (!config)
671
+ return 2;
672
+ const flags = manage.parseFlags(rest);
673
+ try {
674
+ switch (command) {
675
+ case "list":
676
+ case "ls":
677
+ return await manage.list(config, flags);
678
+ case "close":
679
+ return await manage.setState(config, flags, "closed");
680
+ case "open":
681
+ return await manage.setState(config, flags, "open");
682
+ case "cap":
683
+ return await manage.cap(config, flags);
684
+ case "rename":
685
+ return await manage.rename(config, flags);
686
+ case "policy":
687
+ return await manage.policy(config, flags);
688
+ case "project":
689
+ return await manage.project(config, flags);
690
+ case "projects":
691
+ return await manage.projects(config, flags);
692
+ case "key":
693
+ return await manage.key(config, flags);
694
+ case "archive":
695
+ return await manage.archive(config, flags, ask);
696
+ case "ledger":
697
+ return await manage.ledger(config, flags);
698
+ case "spend":
699
+ return await manage.spend(config, flags);
700
+ case "outside":
701
+ return await manage.outside(config, flags);
702
+ case "live":
703
+ return await manage.live(config, flags);
704
+ default:
705
+ return await manage.web(config, flags);
706
+ }
707
+ }
708
+ catch (err) {
709
+ fail(err instanceof Error ? err.message : String(err));
710
+ return 1;
711
+ }
712
+ }
637
713
  async function main(argv) {
638
714
  const [command, ...rest] = argv;
639
715
  switch (command) {
@@ -659,6 +735,23 @@ async function main(argv) {
659
735
  case "--version":
660
736
  case "-v":
661
737
  return version();
738
+ case "list":
739
+ case "ls":
740
+ case "close":
741
+ case "open":
742
+ case "cap":
743
+ case "rename":
744
+ case "policy":
745
+ case "project":
746
+ case "projects":
747
+ case "key":
748
+ case "archive":
749
+ case "ledger":
750
+ case "spend":
751
+ case "outside":
752
+ case "live":
753
+ case "web":
754
+ return managed(command, rest);
662
755
  case "status":
663
756
  return status();
664
757
  case "up":
package/dist/manage.js ADDED
@@ -0,0 +1,329 @@
1
+ /**
2
+ * The console from the terminal: tabs, caps, policies, keys, projects, the
3
+ * ledger and spend, all through the machine session from `tab login`. Every
4
+ * read takes `--json` for scripts and other agents. An Agent argument is a
5
+ * slug or a name; omitted, it is this folder's Agent from `.flocktab`.
6
+ */
7
+ import { spawn } from "node:child_process";
8
+ import { readProject } from "./project.js";
9
+ import { saveConfig } from "./config.js";
10
+ import { ConsoleApiError } from "./console-api.js";
11
+ import { bold, dim, green, money, red, rows, table, yellow } from "./ui.js";
12
+ export class ManageError extends Error {
13
+ }
14
+ /** One place to talk to `/api/cli/*` with the session. */
15
+ export function api(config, fetchImpl = fetch) {
16
+ const base = config.consoleUrl;
17
+ const token = config.token;
18
+ if (!base || !token)
19
+ throw new ManageError("This machine is not logged in. Run tab login.");
20
+ return async (method, path, body) => {
21
+ const res = await fetchImpl(`${base}${path}`, {
22
+ method,
23
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
24
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
25
+ });
26
+ const json = (await res.json().catch(() => ({})));
27
+ if (!res.ok) {
28
+ const detail = json.error ?? `console answered ${res.status}`;
29
+ throw new ConsoleApiError(res.status === 401 ? "Session expired. Run tab login." : json.hint ? `${detail} (${json.hint})` : detail, res.status);
30
+ }
31
+ return json;
32
+ };
33
+ }
34
+ /** `--json`, `--yes`, `--name value` or `--name=value`; the rest are positional. */
35
+ export function parseFlags(argv) {
36
+ const flags = { json: false, yes: false, opts: {}, args: [] };
37
+ for (let i = 0; i < argv.length; i += 1) {
38
+ const a = argv[i];
39
+ if (a === "--json")
40
+ flags.json = true;
41
+ else if (a === "--yes" || a === "-y")
42
+ flags.yes = true;
43
+ else if (a.startsWith("--")) {
44
+ const eq = a.indexOf("=");
45
+ if (eq > 0)
46
+ flags.opts[a.slice(2, eq)] = a.slice(eq + 1);
47
+ else {
48
+ const next = argv[i + 1];
49
+ if (next !== undefined && !next.startsWith("--")) {
50
+ flags.opts[a.slice(2)] = next;
51
+ i += 1;
52
+ }
53
+ else
54
+ flags.opts[a.slice(2)] = "";
55
+ }
56
+ }
57
+ else
58
+ flags.args.push(a);
59
+ }
60
+ return flags;
61
+ }
62
+ async function tabsOf(call) {
63
+ return (await call("GET", "/api/cli/tabs")).tabs;
64
+ }
65
+ /** Slug or name (case-insensitive); omitted means this folder's Agent. */
66
+ export async function resolveSlug(call, given, cwd = process.cwd()) {
67
+ const tabs = await tabsOf(call);
68
+ if (!given) {
69
+ const project = await readProject(cwd);
70
+ if (!project)
71
+ throw new ManageError("No Agent named and this folder has none yet (tab use), so say which: tab <command> <agent>.");
72
+ return project.agent;
73
+ }
74
+ const want = given.trim().toLowerCase();
75
+ const hit = tabs.find((t) => t.slug.toLowerCase() === want) ?? tabs.find((t) => t.name.toLowerCase() === want);
76
+ if (!hit)
77
+ throw new ManageError(`No Agent called ${given}. tab list shows them.`);
78
+ return hit.slug;
79
+ }
80
+ const state = (s) => (s === "open" ? green("open") : red(s));
81
+ const pct = (spent, cap) => {
82
+ const c = BigInt(cap || "0");
83
+ return c === 0n ? "" : `${Number((BigInt(spent) * 100n) / c)}%`;
84
+ };
85
+ function out(text) {
86
+ console.log(text);
87
+ }
88
+ function emit(json, value, text) {
89
+ out(json ? JSON.stringify(value, null, 2) : text());
90
+ }
91
+ export async function list(config, flags) {
92
+ const tabs = await tabsOf(api(config));
93
+ emit(flags.json, { tabs }, () => tabs.length === 0
94
+ ? dim("No Agents yet. Run tab claude or tab codex in a project folder.")
95
+ : table(["agent", "state", "spent", "cap", "used", "window", "project"], tabs.map((t) => [t.name === t.slug ? t.slug : `${t.name} ${dim(t.slug)}`, state(t.state), money(t.spentCents), money(t.capCents), pct(t.spentCents, t.capCents), t.window, t.projectName ?? dim("-")]), { right: [2, 3, 4] }));
96
+ return 0;
97
+ }
98
+ export async function setState(config, flags, next) {
99
+ const call = api(config);
100
+ const slug = await resolveSlug(call, flags.args[0]);
101
+ const { tab } = await call("POST", `/api/cli/tabs/${encodeURIComponent(slug)}/state`, { state: next });
102
+ emit(flags.json, { tab }, () => `${bold(tab.name)} is ${state(tab.state)}${next === "closed" ? dim(" (the next call answers 402 tab_closed)") : ""}`);
103
+ return 0;
104
+ }
105
+ export async function cap(config, flags) {
106
+ const call = api(config);
107
+ const [given, dollars] = flags.args.length >= 2 ? [flags.args[0], flags.args[1]] : [undefined, flags.args[0]];
108
+ if (!dollars && !flags.opts.window)
109
+ throw new ManageError("tab cap [agent] <dollars> [--window day|week|month|run|hour|lifetime]");
110
+ const slug = await resolveSlug(call, given);
111
+ const body = {};
112
+ if (dollars)
113
+ body.capDollars = dollars.replace(/^\$/, "");
114
+ if (flags.opts.window)
115
+ body.window = flags.opts.window;
116
+ const { tab } = await call("PATCH", `/api/cli/tabs/${encodeURIComponent(slug)}`, body);
117
+ emit(flags.json, { tab }, () => `${bold(tab.name)}: cap ${money(tab.capCents)} per ${tab.window}, ${money(tab.spentCents)} spent`);
118
+ return 0;
119
+ }
120
+ export async function rename(config, flags) {
121
+ const call = api(config);
122
+ if (flags.args.length < 2)
123
+ throw new ManageError('tab rename <agent> "new name"');
124
+ const slug = await resolveSlug(call, flags.args[0]);
125
+ const name = flags.args.slice(1).join(" ");
126
+ const { tab } = await call("PATCH", `/api/cli/tabs/${encodeURIComponent(slug)}`, { name });
127
+ emit(flags.json, { tab }, () => `Renamed to ${bold(tab.name)} ${dim(`(slug stays ${tab.slug})`)}`);
128
+ return 0;
129
+ }
130
+ export async function archive(config, flags, ask) {
131
+ const call = api(config);
132
+ const slug = await resolveSlug(call, flags.args[0]);
133
+ if (!flags.yes) {
134
+ const go = await ask(`Archive ${bold(slug)}? Its tab closes for good and it leaves the bill. [y/N]: `);
135
+ if (!/^y(es)?$/i.test(go))
136
+ return 1;
137
+ }
138
+ const { tab } = await call("DELETE", `/api/cli/tabs/${encodeURIComponent(slug)}`);
139
+ emit(flags.json, { tab, archived: true }, () => `${bold(tab.name)} archived.`);
140
+ return 0;
141
+ }
142
+ function listOpt(raw) {
143
+ if (raw === undefined)
144
+ return undefined;
145
+ if (raw.trim().toLowerCase() === "none" || raw.trim() === "")
146
+ return [];
147
+ return raw.split(",").map((s) => s.trim()).filter(Boolean);
148
+ }
149
+ export async function policy(config, flags) {
150
+ const call = api(config);
151
+ const slug = await resolveSlug(call, flags.args[0]);
152
+ const patch = {};
153
+ if (flags.opts.velocity !== undefined) {
154
+ patch.velocityMaxCalls = flags.opts.velocity.toLowerCase() === "none" ? null : Number(flags.opts.velocity);
155
+ }
156
+ const models = listOpt(flags.opts.models);
157
+ if (models)
158
+ patch.models = models;
159
+ const allow = listOpt(flags.opts.allow);
160
+ if (allow)
161
+ patch.irreversibleTools = allow;
162
+ const path = `/api/cli/tabs/${encodeURIComponent(slug)}`;
163
+ const { tab, policy } = Object.keys(patch).length > 0
164
+ ? await call("PATCH", path, { policy: patch })
165
+ : await call("GET", path);
166
+ emit(flags.json, { tab, policy }, () => `${bold(tab.name)}\n` +
167
+ rows([
168
+ ["cap", `${money(tab.capCents)} per ${tab.window}`],
169
+ ["velocity", policy?.velocityMaxCalls ? `${policy.velocityMaxCalls} calls/min` : dim("ledger default")],
170
+ ["models", policy?.models.length ? policy.models.join(", ") : dim("any")],
171
+ ["irreversible", policy?.irreversibleTools.length ? policy.irreversibleTools.join(", ") : dim("none allowed")],
172
+ ]) +
173
+ `\n${dim("Change with --velocity N|none --models a,b|none --allow tool,tool|none")}`);
174
+ return 0;
175
+ }
176
+ export async function projects(config, flags) {
177
+ const call = api(config);
178
+ if (flags.args[0] === "add") {
179
+ const name = flags.args.slice(1).join(" ");
180
+ if (!name)
181
+ throw new ManageError("tab projects add <name>");
182
+ const { project } = await call("POST", "/api/cli/projects", { name });
183
+ emit(flags.json, { project }, () => `Created project ${bold(project.name)}`);
184
+ return 0;
185
+ }
186
+ const { projects } = await call("GET", "/api/cli/projects");
187
+ emit(flags.json, { projects }, () => (projects.length === 0 ? dim("No projects. tab projects add <name>") : projects.map((p) => ` ${p.name}`).join("\n")));
188
+ return 0;
189
+ }
190
+ /** `tab project [agent] <project name|none> [--repo owner/name]` */
191
+ export async function project(config, flags) {
192
+ const call = api(config);
193
+ const [given, ...rest] = flags.args.length >= 2 ? [flags.args[0], ...flags.args.slice(1)] : [undefined, ...flags.args];
194
+ const target = rest.join(" ").trim();
195
+ if (!target && flags.opts.repo === undefined)
196
+ throw new ManageError("tab project [agent] <project name|none> [--repo owner/name]");
197
+ const slug = await resolveSlug(call, given);
198
+ const body = {};
199
+ if (target) {
200
+ if (target.toLowerCase() === "none")
201
+ body.projectId = null;
202
+ else {
203
+ const { projects } = await call("GET", "/api/cli/projects");
204
+ let hit = projects.find((p) => p.name.toLowerCase() === target.toLowerCase());
205
+ if (!hit) {
206
+ if (!flags.opts.create && flags.opts.create !== "")
207
+ throw new ManageError(`No project called ${target}. Add --create to make it.`);
208
+ hit = (await call("POST", "/api/cli/projects", { name: target })).project;
209
+ }
210
+ body.projectId = hit.id;
211
+ }
212
+ }
213
+ if (flags.opts.repo !== undefined)
214
+ body.githubRepo = flags.opts.repo || null;
215
+ const { tab } = await call("PATCH", `/api/cli/tabs/${encodeURIComponent(slug)}`, body);
216
+ emit(flags.json, { tab }, () => `${bold(tab.name)} → project ${tab.projectName ?? dim("none")}${tab.githubRepo ? dim(` repo ${tab.githubRepo}`) : ""}`);
217
+ return 0;
218
+ }
219
+ /** `tab key rotate [agent]`: a fresh key; this machine keeps using the Agent without a new login. */
220
+ export async function key(config, flags) {
221
+ const call = api(config);
222
+ if (flags.args[0] !== "rotate")
223
+ throw new ManageError("tab key rotate [agent] [--show]");
224
+ const slug = await resolveSlug(call, flags.args[1]);
225
+ const issued = await call("POST", `/api/cli/tabs/${encodeURIComponent(slug)}/key`);
226
+ const agents = { ...(config.agents ?? {}), [issued.agent.slug]: { key: issued.key, agentId: issued.agent.id, agentName: issued.agent.name } };
227
+ await saveConfig({ ...config, agents });
228
+ emit(flags.json, { agent: issued.agent, rotated: issued.rotated, ...(flags.opts.show !== undefined ? { key: issued.key } : {}) }, () => `${bold(issued.agent.name)}: new key saved on this machine${issued.rotated ? "; anything using the old one is refused from now on" : ""}.` +
229
+ (flags.opts.show !== undefined ? `\n${issued.key}` : `\n${dim("Add --show to print it once.")}`));
230
+ return 0;
231
+ }
232
+ export async function ledger(config, flags) {
233
+ const call = api(config);
234
+ const params = new URLSearchParams();
235
+ if (flags.args[0])
236
+ params.set("agent", await resolveSlug(call, flags.args[0]));
237
+ if (flags.opts.limit)
238
+ params.set("limit", flags.opts.limit);
239
+ if (flags.opts.blocked !== undefined)
240
+ params.set("blocked", "1");
241
+ const { rows: list, timezone } = await call("GET", `/api/cli/ledger?${params}`);
242
+ emit(flags.json, { timezone, rows: list }, () => list.length === 0
243
+ ? dim("No decisions yet.")
244
+ : table(["time", "agent", "action", "amount", "status", "reason"], list.map((r) => {
245
+ const status = (r.status ?? r.decision).toUpperCase();
246
+ const paint = status === "BLOCKED" || status === "CLOSED" ? red : status === "PENDING" ? yellow : green;
247
+ const held = r.reservedCents && r.reservedCents !== r.cents ? dim(` of ${money(r.reservedCents)}`) : "";
248
+ return [r.at, r.agent, r.action.length > 28 ? `${r.action.slice(0, 27)}…` : r.action, `${money(r.cents)}${held}`, paint(status), r.reason];
249
+ }), { right: [3] }) + `\n${dim(`times in ${timezone}`)}`);
250
+ return 0;
251
+ }
252
+ export async function spend(config, flags) {
253
+ const call = api(config);
254
+ const by = flags.opts.by ?? (flags.args[0] === "day" || flags.args[0] === "project" || flags.args[0] === "agent" ? flags.args[0] : "agent");
255
+ const params = new URLSearchParams({ by });
256
+ if (flags.opts.since)
257
+ params.set("since", flags.opts.since);
258
+ if (flags.opts.agent)
259
+ params.set("agent", await resolveSlug(call, flags.opts.agent));
260
+ const data = await call("GET", `/api/cli/spend?${params}`);
261
+ emit(flags.json, data, () => {
262
+ if (by === "day") {
263
+ const days = data.days;
264
+ return days.length === 0
265
+ ? dim("Nothing settled in the window.")
266
+ : table(["day", "calls", "settled", "in", "out"], days.map((d) => [d.day, String(d.calls), money(d.settledCents), d.promptTokens, d.completionTokens]), { right: [1, 2, 3, 4] });
267
+ }
268
+ if (by === "project") {
269
+ const ps = data.projects;
270
+ const lines = table(["project", "agents", "meter", "outside", "total"], ps.map((p) => [p.name, String(p.agents), money(p.meterCents), money(p.outsideCents), money(BigInt(p.meterCents) + BigInt(p.outsideCents))]), { right: [1, 2, 3, 4] });
271
+ const un = data.unattributedOutsideCents;
272
+ return `${lines}\n${dim(`unattributed outside spend ${money(un)}`)}`;
273
+ }
274
+ const as = data.agents;
275
+ return table(["agent", "state", "spent", "cap", "window", "in", "out", "project"], as.map((a) => [a.slug, state(a.state), money(a.spentCents), money(a.capCents), a.window, a.promptTokens, a.completionTokens, a.project ?? dim("-")]), { right: [2, 3, 5, 6] });
276
+ });
277
+ return 0;
278
+ }
279
+ export async function outside(config, flags) {
280
+ const call = api(config);
281
+ const data = await call("GET", "/api/cli/outside");
282
+ emit(flags.json, data, () => {
283
+ const conns = data.connections.length === 0
284
+ ? dim("No connections. Console → Outside spend → Connect.")
285
+ : table(["connection", "30 days", "status", "note"], data.connections.map((c) => [c.label ? `${c.provider} (${c.label})` : c.provider, money(c.windowCents), c.lastError ? red("error") : c.status, c.notice ?? c.lastError ?? ""]), { right: [1] });
286
+ const projects = data.byProject.length === 0 ? "" : `\n\n${table(["project", "outside", "events"], data.byProject.map((p) => [p.projectName, money(p.cents), String(p.events)]), { right: [1, 2] })}`;
287
+ return `${conns}${projects}\n${dim(`unattributed ${money(data.unattributedCents)}`)}`;
288
+ });
289
+ return 0;
290
+ }
291
+ export async function live(config, flags) {
292
+ const call = api(config);
293
+ const once = async () => {
294
+ const feed = await call("GET", "/api/cli/live");
295
+ emit(flags.json, feed, () => `${dim("active")} ${feed.totals.active} ${dim("calls/min")} ${feed.totals.callsPerMinute} ${dim("blocked 5m")} ${feed.totals.blocked} ${dim("spend 5m")} ${money(feed.totals.windowCents)}\n` +
296
+ table(["agent", "pulse", "calls/min", "blocked", "5m", "last"], feed.agents.map((a) => [a.slug, a.pulse, String(a.callsPerMinute), String(a.blocked), money(a.windowCents), a.lastDecision ? `${a.lastDecision.toLowerCase()} ${dim(a.lastReason ?? "")}` : dim("-")]), { right: [2, 3, 4] }));
297
+ };
298
+ await once();
299
+ if (flags.opts.watch === undefined)
300
+ return 0;
301
+ const every = Math.max(2, Number(flags.opts.watch) || 4) * 1000;
302
+ await new Promise((resolve) => {
303
+ const timer = setInterval(() => {
304
+ out("");
305
+ once().catch((err) => out(red(String(err instanceof Error ? err.message : err))));
306
+ }, every);
307
+ process.on("SIGINT", () => {
308
+ clearInterval(timer);
309
+ resolve();
310
+ });
311
+ });
312
+ return 0;
313
+ }
314
+ /** `tab web [agent]`: this folder's Agent in the console. */
315
+ export async function web(config, flags) {
316
+ const call = api(config);
317
+ const slug = flags.args[0] || (await readProject(process.cwd()))?.agent;
318
+ const url = slug ? `${config.consoleUrl}/console/tabs/${encodeURIComponent(await resolveSlug(call, slug))}` : `${config.consoleUrl}/console`;
319
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
320
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
321
+ try {
322
+ spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
323
+ }
324
+ catch {
325
+ // The URL is printed either way.
326
+ }
327
+ out(url);
328
+ return 0;
329
+ }
package/dist/ui.js CHANGED
@@ -97,3 +97,26 @@ export function rows(pairs, indent = 2) {
97
97
  export function heading(text) {
98
98
  return bold(text);
99
99
  }
100
+ /**
101
+ * A plain table: header dimmed, columns padded to their widest cell, numbers
102
+ * right-aligned when the column says so. Visible width, so painted cells
103
+ * line up.
104
+ */
105
+ export function table(header, body, options = {}) {
106
+ const right = new Set(options.right ?? []);
107
+ const cols = header.length;
108
+ const widths = header.map((h, i) => Math.max(width(h), ...body.map((r) => width(r[i] ?? ""))));
109
+ const cell = (text, i) => {
110
+ const pad = " ".repeat(Math.max(0, widths[i] - width(text)));
111
+ return right.has(i) ? `${pad}${text}` : `${text}${pad}`;
112
+ };
113
+ const line = (r) => `${" ".repeat(options.indent ?? 2)}${r.map((c, i) => cell(c ?? "", i)).join(" ").trimEnd()}`;
114
+ return [line(header.map((h) => dim(h))), ...body.map((r) => line(r.slice(0, cols)))].join("\n");
115
+ }
116
+ /** "$0.14" from a cents string or bigint. */
117
+ export function money(cents) {
118
+ const n = typeof cents === "bigint" ? cents : BigInt(String(cents).replace(/[^0-9-]/g, "") || "0");
119
+ const negative = n < 0n;
120
+ const abs = negative ? -n : n;
121
+ return `${negative ? "-" : ""}$${abs / 100n}.${(abs % 100n).toString().padStart(2, "0")}`;
122
+ }
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Written by scripts/write-version.mjs from package.json at build; `tab version` prints it. */
2
- export const TAB_VERSION = "0.1.2";
2
+ export const TAB_VERSION = "0.1.4";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanamorilabs/tab",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Run any AI agent on a FlockTab tab: tab claude, tab codex, tab <command>.",
5
5
  "license": "MIT",
6
6
  "type": "module",