@hanamorilabs/tab 0.1.21 → 0.1.22

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/dist/cli.js CHANGED
@@ -36,6 +36,7 @@ import { addMember, loadPool, memberEnv, memberHome, POOL_VENDORS, poolVendorFor
36
36
  import { runPooled } from "./pool-run.js";
37
37
  import { claudeSettingsArg, composedStatusline, installStatusLine } from "./statusline.js";
38
38
  import { renderPool } from "./pool-view.js";
39
+ import { runTop } from "./top.js";
39
40
  import { renderHelp } from "./help.js";
40
41
  import { findTabCommand } from "./tab-docs.js";
41
42
  import { ConsoleApiError, createAgent, issueAgentKey, listAgents, setAgentHarness } from "./console-api.js";
@@ -468,6 +469,35 @@ async function version() {
468
469
  say(rows([["tab", tabVersion], ["proxy", `${PROXY_VERSION} ${dim(`(${where})`)}`]], 0));
469
470
  return 0;
470
471
  }
472
+ /** The pool as `tab pool` sees it, for the dashboard's Subscriptions panel: nothing when no login is pooled. */
473
+ async function poolSections(config) {
474
+ const pool = await loadPool();
475
+ const vendors = POOL_VENDORS.filter((v) => (pool.members[v] ?? []).length > 0);
476
+ if (vendors.length === 0)
477
+ return [];
478
+ const accounts = await flockAccounts(config).catch(() => []);
479
+ return Promise.all(vendors.map(async (vendor) => ({ vendor, title: VENDOR_NAMES[vendor], harness: VENDOR_HARNESS[vendor], limits: limitsFor(pool, vendor), standings: await poolStandings(config, vendor, pool.members[vendor], accounts) })));
480
+ }
481
+ /** `tab top [--every <seconds>] [--once]`: the flock on one screen, redrawn every few seconds. */
482
+ async function topCommand(argv) {
483
+ const config = await ensureLogin();
484
+ if (!config)
485
+ return 2;
486
+ const flags = manage.parseFlags(argv);
487
+ const every = Number(flags.opts.every ?? "2");
488
+ if (!Number.isFinite(every) || every < 1 || every > 3600) {
489
+ fail(`tab top [--every <seconds>] [--once]. ${dim("Refresh every 1 to 3600 seconds; the default is 2.")}`);
490
+ return 2;
491
+ }
492
+ const once = flags.args.includes("--once") || "once" in flags.opts;
493
+ try {
494
+ return await runTop({ call: manage.api(config), config, health: () => proxyHealth(config.proxyUrl), pool: () => poolSections(config) }, { every, once });
495
+ }
496
+ catch (err) {
497
+ fail(err instanceof Error ? err.message : String(err));
498
+ return 1;
499
+ }
500
+ }
471
501
  async function status() {
472
502
  const config = await loadConfig();
473
503
  if (!config) {
@@ -1194,6 +1224,8 @@ async function main(argv) {
1194
1224
  return poolCommand(rest);
1195
1225
  case "status":
1196
1226
  return status();
1227
+ case "top":
1228
+ return topCommand(rest);
1197
1229
  case "statusline": {
1198
1230
  if (rest[0] === "install")
1199
1231
  return statuslineInstall(rest.slice(1));
package/dist/manage.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * slug or a name; omitted, it is this folder's Agent from `.flocktab`.
6
6
  */
7
7
  import { spawn } from "node:child_process";
8
+ import { quotaWindowLabel } from "./quota-windows.js";
8
9
  import { readProject } from "./project.js";
9
10
  import { saveConfig } from "./config.js";
10
11
  import { ConsoleApiError } from "./console-api.js";
@@ -219,7 +220,7 @@ export async function accounts(config, flags) {
219
220
  a.monthlyCents ? money(a.monthlyCents) : dim("-"),
220
221
  money(a.listCents),
221
222
  String(a.calls),
222
- a.quota.length === 0 ? dim("-") : a.quota.map((q) => `${q.window} ${left(q)}`).join(" "),
223
+ a.quota.length === 0 ? dim("-") : a.quota.map((q) => `${quotaWindowLabel(q.window)} ${left(q)}`).join(" "),
223
224
  a.agents.join(", "),
224
225
  ]), { right: [3, 4, 5] }) + `\n${dim("Set a price or label in the console, Control, Subscriptions.")}`;
225
226
  });
package/dist/pool-view.js CHANGED
@@ -3,23 +3,22 @@
3
3
  * used the vendor last said it is (a bar, then each window with when it
4
4
  * resets), and an arrow on the login the next launch would run as.
5
5
  */
6
- import { isOver, pickMember, windowMinutes } from "./pool.js";
6
+ import { isOver, pickMember } from "./pool.js";
7
+ import { compareQuotaWindows, quotaWindowLabel } from "./quota-windows.js";
7
8
  import { bold, dim, green, red, table, width, yellow } from "./ui.js";
8
9
  const BAR = 10;
9
- /** "2h 10m", "3d 4h", "12m": how long until a window resets. */
10
+ /** "0h 12m", "2h 10m", "3d 04h": how long until a window resets, always six characters so columns hold. */
10
11
  export function until(resetsAt, now) {
11
12
  if (!resetsAt)
12
13
  return undefined;
13
14
  const ms = new Date(resetsAt).getTime() - now.getTime();
14
15
  if (!Number.isFinite(ms) || ms <= 0)
15
16
  return undefined;
16
- const minutes = Math.round(ms / 60_000);
17
- if (minutes < 60)
18
- return `${Math.max(1, minutes)}m`;
17
+ const minutes = Math.max(1, Math.round(ms / 60_000));
19
18
  const hours = Math.floor(minutes / 60);
20
19
  if (hours < 24)
21
20
  return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
22
- return `${Math.floor(hours / 24)}d ${hours % 24}h`;
21
+ return `${Math.floor(hours / 24)}d ${String(hours % 24).padStart(2, "0")}h`;
23
22
  }
24
23
  function paint(pct, at) {
25
24
  return pct >= at ? red : pct >= at * 0.75 ? yellow : green;
@@ -37,7 +36,7 @@ export function planName(plan) {
37
36
  return plan.replace(/\b[a-z]/g, (c) => c.toUpperCase()).replace(/\b(\d+)X\b/g, "$1x");
38
37
  }
39
38
  function windowsLine(windows, limits, now) {
40
- const sorted = [...(windows ?? [])].sort((a, b) => windowMinutes(a.window) - windowMinutes(b.window));
39
+ const sorted = [...(windows ?? [])].sort((a, b) => compareQuotaWindows(a.window, b.window));
41
40
  const live = sorted.filter((w) => !w.resetsAt || new Date(w.resetsAt).getTime() > now.getTime());
42
41
  if (!windows || windows.length === 0)
43
42
  return dim("no call through FlockTab yet");
@@ -48,7 +47,7 @@ function windowsLine(windows, limits, now) {
48
47
  const left = until(w.resetsAt, now);
49
48
  // The short window is judged by the threshold, the long one by its guard.
50
49
  const at = sorted.length > 1 && w === sorted.at(-1) ? limits.guard : limits.at;
51
- return `${w.window} ${paint(w.usedPct, at)(`${Math.round(w.usedPct)}%`)}${left ? dim(` resets in ${left}`) : ""}`;
50
+ return `${quotaWindowLabel(w.window)} ${paint(w.usedPct, at)(`${Math.round(w.usedPct)}%`)}${left ? dim(` resets in ${left}`) : ""}`;
52
51
  })
53
52
  .join(dim(" · "));
54
53
  }
package/dist/pool.js CHANGED
@@ -9,6 +9,7 @@
9
9
  * conversation as that other login.
10
10
  */
11
11
  import { chmod, mkdir, readdir, readFile, readlink, symlink, unlink, writeFile, lstat } from "node:fs/promises";
12
+ import { compareQuotaWindows, quotaWindowIsLong, quotaWindowMinutes } from "./quota-windows.js";
12
13
  import { homedir } from "node:os";
13
14
  import path from "node:path";
14
15
  import { moveInto } from "./codex-home.js";
@@ -172,8 +173,7 @@ export async function prepareSharedSessions(vendor, member, env = process.env, u
172
173
  }
173
174
  /** `5h` is 300 minutes, `7d` 10080; a name that is not a length sorts last. */
174
175
  export function windowMinutes(window) {
175
- const m = /^(\d+)([mhd])$/.exec(window);
176
- return m ? Number(m[1]) * (m[2] === "d" ? 1440 : m[2] === "h" ? 60 : 1) : Number.MAX_SAFE_INTEGER;
176
+ return quotaWindowMinutes(window);
177
177
  }
178
178
  /**
179
179
  * How used an account is. The short window (Claude's 5 hours, Codex's
@@ -184,11 +184,14 @@ export function windowMinutes(window) {
184
184
  export function usage(quota, now = new Date()) {
185
185
  if (quota.length === 0)
186
186
  return {};
187
- const sorted = [...quota].sort((x, y) => windowMinutes(x.window) - windowMinutes(y.window));
187
+ const sorted = [...quota].sort((x, y) => compareQuotaWindows(x.window, y.window));
188
188
  const pct = (w) => (!w.resetsAt || new Date(w.resetsAt).getTime() > now.getTime() ? w.usedPct : 0);
189
189
  const short = sorted[0];
190
- const long = sorted.length > 1 ? sorted.at(-1) : undefined;
191
- return { used: pct(short), ...(long ? { longUsed: pct(long) } : {}) };
190
+ // The week is whichever weekly window is fullest: Claude Max meters Fable on its own (7d_oi)
191
+ // beside the general 7d, and a login out of Fable is out for a Fable run.
192
+ const longs = sorted.filter((w) => quotaWindowIsLong(w.window));
193
+ const longUsed = longs.length > 0 ? Math.max(...longs.map(pct)) : undefined;
194
+ return { used: pct(short), ...(longUsed !== undefined ? { longUsed } : {}) };
192
195
  }
193
196
  const room = (s) => s.used ?? 0;
194
197
  const longRoom = (s) => s.longUsed ?? 0;
package/dist/proxy-bin.js CHANGED
@@ -13,7 +13,7 @@ import { createRequire } from "node:module";
13
13
  import path from "node:path";
14
14
  import { configDir } from "./config.js";
15
15
  /** The proxy release `tab up` fetches. Bump with each proxy tag. */
16
- export const PROXY_VERSION = "0.1.16";
16
+ export const PROXY_VERSION = "0.1.17";
17
17
  export const RELEASES = "https://github.com/joseairosa/flocktab/releases/download";
18
18
  export function targetFor(platform = process.platform, arch = process.arch) {
19
19
  if (platform === "darwin")
@@ -0,0 +1,37 @@
1
+ // GENERATED from packages/shared/src/quota-windows.ts by scripts/sync-docs.mjs. Edit that file, not this one.
2
+ /**
3
+ * A vendor's quota windows, as the wire names them: "5h", "7d" (Claude Max,
4
+ * both models), "7d_oi" (Claude Max, the weekly window Fable has of its own;
5
+ * "oi" is the vendor's tag for it), "5h"/"7d" (ChatGPT). A window's name is
6
+ * its length, then an optional `_<tag>` for a model-specific one.
7
+ */
8
+ /** Which models a tagged window belongs to, in the person's words. */
9
+ const TAGS = {
10
+ oi: "Fable",
11
+ };
12
+ export function quotaWindowMinutes(window) {
13
+ const m = /^(\d+)([mhd])(?:_[a-z0-9]+)?$/.exec(window);
14
+ return m ? Number(m[1]) * (m[2] === "d" ? 1440 : m[2] === "h" ? 60 : 1) : Number.MAX_SAFE_INTEGER;
15
+ }
16
+ /** "Fable 7d" for "7d_oi"; a plain window is its own label. */
17
+ export function quotaWindowLabel(window) {
18
+ const m = /^(\d+[mhd])_([a-z0-9]+)$/.exec(window);
19
+ if (!m)
20
+ return window;
21
+ const tag = TAGS[m[2]] ?? m[2];
22
+ return `${tag} ${m[1]}`;
23
+ }
24
+ /** The model a tagged window is for ("Fable"), or undefined for a window every model shares. */
25
+ export function quotaWindowModel(window) {
26
+ const m = /^\d+[mhd]_([a-z0-9]+)$/.exec(window);
27
+ return m ? (TAGS[m[1]] ?? m[1]) : undefined;
28
+ }
29
+ /** A week-long window (or longer): the one that only matters once it is nearly spent. */
30
+ export function quotaWindowIsLong(window) {
31
+ const minutes = quotaWindowMinutes(window);
32
+ return minutes !== Number.MAX_SAFE_INTEGER && minutes > 1440;
33
+ }
34
+ /** Sort order for windows: shortest first; a model's own window after the general one of the same length. */
35
+ export function compareQuotaWindows(a, b) {
36
+ return quotaWindowMinutes(a) - quotaWindowMinutes(b) || (quotaWindowModel(a) ? 1 : 0) - (quotaWindowModel(b) ? 1 : 0);
37
+ }
@@ -6,6 +6,7 @@
6
6
  * few seconds costs one request a minute.
7
7
  */
8
8
  import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
9
+ import { compareQuotaWindows, quotaWindowLabel } from "./quota-windows.js";
9
10
  import { homedir } from "node:os";
10
11
  import { spawn } from "node:child_process";
11
12
  import path from "node:path";
@@ -13,10 +14,6 @@ import { configDir } from "./config.js";
13
14
  import { readProject } from "./project.js";
14
15
  import { api } from "./manage.js";
15
16
  const CACHE_MS = 60_000;
16
- function windowMinutes(window) {
17
- const m = /^(\d+)([mhd])$/.exec(window);
18
- return m ? Number(m[1]) * (m[2] === "d" ? 1440 : m[2] === "h" ? 60 : 1) : Number.MAX_SAFE_INTEGER;
19
- }
20
17
  function money(cents) {
21
18
  const n = BigInt(cents);
22
19
  return `$${n / 100n}.${(n % 100n).toString().padStart(2, "0")}`;
@@ -35,9 +32,9 @@ export function planLine(accounts, now = new Date()) {
35
32
  return `${account.email ?? `account ${account.externalId.slice(0, 8)}`} · usage not reported by the vendor`;
36
33
  const windows = [...account.quota]
37
34
  .filter((w) => !w.resetsAt || new Date(w.resetsAt).getTime() > now.getTime())
38
- .sort((a, b) => windowMinutes(a.window) - windowMinutes(b.window));
35
+ .sort((a, b) => compareQuotaWindows(a.window, b.window));
39
36
  const who = account.email ?? `account ${account.externalId.slice(0, 8)}`;
40
- return `${who} · ${windows.length === 0 ? "windows reset" : windows.map((w) => `${w.window} ${Math.round(w.usedPct)}%`).join(" · ")}`;
37
+ return `${who} · ${windows.length === 0 ? "windows reset" : windows.map((w) => `${quotaWindowLabel(w.window)} ${Math.round(w.usedPct)}%`).join(" · ")}`;
41
38
  }
42
39
  /** The line itself, from what the console said, and the pool login this run is on when there is one. */
43
40
  export function statusText(info, now = new Date(), pool) {
@@ -167,6 +164,9 @@ export function runTheirs(command, stdin, env = process.env) {
167
164
  child.stdout.on("data", (d) => { out += d.toString(); });
168
165
  child.on("error", () => { clearTimeout(timer); resolve(undefined); });
169
166
  child.on("close", () => { clearTimeout(timer); const text = out.replace(/\s+$/, ""); resolve(text.trim() ? text : undefined); });
167
+ // A command that exits before reading stdin (echo, true) closes the pipe.
168
+ // That EPIPE is on stdin, not the child, and must not escape the status line.
169
+ child.stdin.on("error", () => { });
170
170
  child.stdin.end(stdin);
171
171
  });
172
172
  }
package/dist/tab-docs.js CHANGED
@@ -259,7 +259,7 @@ export const TAB_COMMANDS = [
259
259
  { cmd: "tab pool add claude me@example.com", what: "sign a Claude login in; the email is prefilled" },
260
260
  { cmd: "tab pool", what: "every login: plan, usage bar, each window and its reset, and which runs next" },
261
261
  { cmd: "tab pool at claude 70", what: "Claude logins give way at 70% of the 5-hour window" },
262
- { cmd: "tab pool at 7d 90", what: "any login gives way once 90% of its week is used" },
262
+ { cmd: "tab pool at 7d 90", what: "any login gives way once 90% of its week is used (for Claude, the general week or Fable's own, whichever is fuller)" },
263
263
  { cmd: "tab claude --as me@example.com", what: "pin one login for this run" },
264
264
  ],
265
265
  see: ["accounts", "claude"],
@@ -272,6 +272,22 @@ export const TAB_COMMANDS = [
272
272
  details: ["The first thing to run when something is off. Exit code 0 only when the machine is logged in, the folder has an Agent with a key here, and the proxy is healthy."],
273
273
  see: ["login", "use", "up"],
274
274
  },
275
+ {
276
+ name: "top",
277
+ overview: "top [--every <seconds>] [--once]",
278
+ group: "watch",
279
+ usage: ["top", "top --every 5", "top --once"],
280
+ summary: "The flock on one screen, live: Agents, subscriptions and their windows, the pool, outside spend, the ledger.",
281
+ details: [
282
+ "An always-on dashboard in the terminal, redrawn every 2 seconds (--every sets it). At the top: the flock, its plan, Agents used of the plan's limit, whether the proxy answers. Then who is working right now, calls per minute and this window's spend with their trend; every Agent with its harness, project, spent of cap, calls per minute, calls blocked and its last decision; every subscription login with each of its windows (for Claude, the general 5h and 7d windows and Fable 7d, Fable's own week) and how long until each resets, with the pool's next pick marked; outside spend by project over the last 30 days with the unattributed share; and the ledger's latest decisions as they happen.",
283
+ "q, Escape or Ctrl-C leaves and the terminal is as it was; r redraws at once. --once prints one screen and returns, for a pipe or a quick look. A panel the console cannot answer on a tick is a line at the bottom, and the last good read stays on screen. Everything comes from the console the other commands read, so nothing here disagrees with tab list, tab pool, tab accounts or the Console.",
284
+ ],
285
+ options: [
286
+ { flag: "--every <seconds>", what: "how often to redraw (default 2, 1 to 3600)" },
287
+ { flag: "--once", what: "one screen, then return" },
288
+ ],
289
+ see: ["list", "pool", "accounts", "live", "status"],
290
+ },
275
291
  {
276
292
  name: "statusline",
277
293
  overview: "statusline [harness]",
@@ -430,7 +446,7 @@ export const TAB_GLOSSARY = [
430
446
  { term: "Control plane", meaning: "The part of flocktab.com a self-hosted proxy settles through: identity, reserve, commit, refund, and for subscriptions the account and its quota." },
431
447
  { term: "Passthrough", meaning: "How a Subscription Agent's calls travel: /t/<tab key>/<vendor>/... on either proxy. The agent's own login is forwarded untouched; FlockTab adds nothing to the request and keeps no copy of the login." },
432
448
  { term: "Account", also: ["vendor account", "subscription"], meaning: "One plan login (a Claude Max, ChatGPT, SuperGrok or Kimi account) as the vendor named it on the wire. Discovered on first sight, with its plan tier, seat price and reported usage. Nobody types one in." },
433
- { term: "Quota window", also: ["5h", "7d"], meaning: "A vendor's own usage limit over a period, reported on every reply: Claude's 5-hour and 7-day windows, Codex's primary and secondary. FlockTab keeps the latest figure and its reset time per account." },
449
+ { term: "Quota window", also: ["5h", "7d"], meaning: "A vendor's own usage limit over a period, reported on every reply: Claude's 5-hour and 7-day windows plus Fable's own 7-day window (shown as Fable 7d), Codex's primary and secondary. FlockTab keeps the latest figure and its reset time per account." },
434
450
  { term: "Pool", meaning: "Several logins of one vendor on one machine, and tab choosing between them by reported usage. Each member is a folder the agent signs in to itself." },
435
451
  { term: "Threshold", meaning: "In the pool: the share of the short window at which a login gives way to one with more room. 80% unless set, per vendor if you like." },
436
452
  { term: "Weekly guard", meaning: "In the pool: the share of the long window from which a login gives way whatever its short window says. 95% unless set. Below it the weekly window is ignored." },
package/dist/top.js ADDED
@@ -0,0 +1,413 @@
1
+ import { isOver, pickMember } from "./pool.js";
2
+ import { planName, until, usageBar } from "./pool-view.js";
3
+ import { compareQuotaWindows, quotaWindowIsLong, quotaWindowLabel } from "./quota-windows.js";
4
+ import { bold, cyan, dim, green, magenta, money, red, stripAnsi, width, yellow } from "./ui.js";
5
+ export const HISTORY = 40;
6
+ const SPARK = "▁▂▃▄▅▆▇█";
7
+ /** A sparkline of the last `n` values, scaled to their own maximum. */
8
+ export function sparkline(values, n = 24) {
9
+ const tail = values.slice(-n);
10
+ if (tail.length === 0)
11
+ return "";
12
+ const max = Math.max(...tail);
13
+ if (max <= 0)
14
+ return dim(SPARK[0].repeat(tail.length));
15
+ return tail.map((v) => SPARK[Math.min(SPARK.length - 1, Math.round((v / max) * (SPARK.length - 1)))]).join("");
16
+ }
17
+ /** "3s", "2m", "1h 05m", "3d": how long ago. */
18
+ export function ago(iso, now) {
19
+ if (!iso)
20
+ return "-";
21
+ const ms = now.getTime() - new Date(iso).getTime();
22
+ if (!Number.isFinite(ms) || ms < 0)
23
+ return "now";
24
+ const s = Math.round(ms / 1000);
25
+ if (s < 60)
26
+ return `${s}s`;
27
+ const m = Math.floor(s / 60);
28
+ if (m < 60)
29
+ return `${m}m`;
30
+ const h = Math.floor(m / 60);
31
+ if (h < 24)
32
+ return `${h}h ${String(m % 60).padStart(2, "0")}m`;
33
+ return `${Math.floor(h / 24)}d`;
34
+ }
35
+ /** Cut a coloured line to `cols` visible characters, keeping escape sequences intact and closing them. */
36
+ export function clip(line, cols) {
37
+ if (width(line) <= cols)
38
+ return line;
39
+ let out = "";
40
+ let seen = 0;
41
+ for (let i = 0; i < line.length;) {
42
+ if (line[i] === "\u001b") {
43
+ const end = line.indexOf("m", i);
44
+ if (end === -1)
45
+ break;
46
+ out += line.slice(i, end + 1);
47
+ i = end + 1;
48
+ continue;
49
+ }
50
+ const ch = line.codePointAt(i);
51
+ const chStr = String.fromCodePoint(ch);
52
+ const w = width(chStr);
53
+ if (seen + w > cols - 1)
54
+ break;
55
+ out += chStr;
56
+ seen += w;
57
+ i += chStr.length;
58
+ }
59
+ // Close an open colour only when one was opened; with colour off there is nothing to close.
60
+ return `${out}${out.includes("\u001b[") ? "\u001b[0m" : ""}${dim("…")}`;
61
+ }
62
+ function pad(text, cols, right = false) {
63
+ const gap = Math.max(0, cols - width(text));
64
+ return right ? `${" ".repeat(gap)}${text}` : `${text}${" ".repeat(gap)}`;
65
+ }
66
+ function pulseMark(pulse) {
67
+ switch (pulse) {
68
+ case "working":
69
+ return green("●");
70
+ case "recent":
71
+ return cyan("◐");
72
+ case "idle":
73
+ return dim("○");
74
+ default:
75
+ return red("■");
76
+ }
77
+ }
78
+ /** ALLOWED and SETTLED green, SHADOW (a subscription call priced at list) cyan, PENDING yellow, the rest red. */
79
+ function decisionMark(decision) {
80
+ const d = decision.toUpperCase();
81
+ if (d.startsWith("ALLOW") || d === "OK" || d === "SETTLED")
82
+ return green(d);
83
+ if (d === "SHADOW")
84
+ return cyan(d);
85
+ if (d.startsWith("PEND"))
86
+ return yellow(d);
87
+ if (d.startsWith("FLAG"))
88
+ return magenta(d);
89
+ if (d === "REFUNDED")
90
+ return dim(d);
91
+ return red(d);
92
+ }
93
+ const failingConnector = (c) => c.status !== "active";
94
+ function pctOf(spent, cap) {
95
+ const c = Number(cap);
96
+ if (!Number.isFinite(c) || c <= 0)
97
+ return undefined;
98
+ return Math.min(999, (Number(spent) / c) * 100);
99
+ }
100
+ function tone(pct) {
101
+ if (pct === undefined)
102
+ return dim;
103
+ return pct >= 100 ? red : pct >= 75 ? yellow : green;
104
+ }
105
+ function section(title, note, cols) {
106
+ const head = `${bold(title)} ${dim(note)}`;
107
+ const rule = Math.max(0, cols - width(head) - 1);
108
+ return `${head} ${dim("─".repeat(rule))}`;
109
+ }
110
+ function header(snap, cols, now) {
111
+ const proxy = snap.proxy.health.ok ? green("● proxy") : red(`■ proxy ${snap.proxy.health.reason}`);
112
+ const agents = snap.flock.agentLimit === null ? `${snap.flock.agents} Agents` : `${snap.flock.agents}/${snap.flock.agentLimit} Agents`;
113
+ const agentsTone = snap.flock.agentLimit !== null && snap.flock.agents >= snap.flock.agentLimit ? yellow : (t) => t;
114
+ const clock = now.toLocaleTimeString("en-GB", { hour12: false });
115
+ const left = `${bold(magenta("FlockTab"))} ${bold("top")} ${bold(snap.flock.name)} ${dim("·")} ${planName(snap.flock.plan) ?? snap.flock.plan} ${dim("·")} ${agentsTone(agents)} ${dim("·")} ${proxy} ${dim(snap.proxy.mode)}`;
116
+ const right = `${dim("q quit · r refresh")} ${clock}`;
117
+ const gap = Math.max(1, cols - width(left) - width(right));
118
+ return [`${left}${" ".repeat(gap)}${right}`];
119
+ }
120
+ function totalsLine(snap) {
121
+ const t = snap.live?.totals;
122
+ if (!t)
123
+ return dim("live totals unavailable");
124
+ const cpm = snap.history.map((h) => h.callsPerMinute);
125
+ const cents = snap.history.map((h) => h.windowCents);
126
+ const parts = [
127
+ `${green(String(t.active))} working`,
128
+ `${bold(t.callsPerMinute.toFixed(1))} calls/min ${cyan(sparkline(cpm))}`,
129
+ `${(t.blocked > 0 ? red : dim)(String(t.blocked))} blocked`,
130
+ `${bold(money(t.windowCents))} this window ${cyan(sparkline(cents))}`,
131
+ ];
132
+ if (snap.outside)
133
+ parts.push(`outside 30d ${bold(money(sumCents(snap.outside.byProject.map((p) => p.cents))))}`);
134
+ return parts.join(dim(" · "));
135
+ }
136
+ function sumCents(list) {
137
+ return list.reduce((sum, c) => sum + BigInt(String(c).replace(/[^0-9-]/g, "") || "0"), 0n);
138
+ }
139
+ function agentsPanel(snap, cols, max, now) {
140
+ const out = [];
141
+ const agents = snap.live?.agents ?? [];
142
+ const shown = [...agents].sort((a, b) => rank(a) - rank(b) || b.callsPerMinute - a.callsPerMinute || a.name.localeCompare(b.name));
143
+ out.push(section("Agents", `${agents.filter((a) => a.pulse === "working").length} working · ${agents.filter((a) => a.tabState !== "open").length} closed`, cols));
144
+ if (shown.length === 0) {
145
+ out.push(dim(" no Agents yet · tab claude in a project folder makes one"));
146
+ return out;
147
+ }
148
+ const nameW = Math.min(24, Math.max(5, ...shown.map((a) => width(a.name))));
149
+ const projW = Math.min(18, Math.max(7, ...shown.map((a) => width(a.projectName ?? "-"))));
150
+ out.push(dim(` ${pad("", 1)} ${pad("agent", nameW)} ${pad("harness", 7)} ${pad("project", projW)} ${pad("spent / cap · login", 32)} ${pad("c/min", 5, true)} ${pad("blk", 3, true)} ${pad("last", 8)} ${pad("ago", 6)} reason`));
151
+ for (const a of shown.slice(0, max)) {
152
+ const pct = a.kind === "subscription" ? undefined : pctOf(a.spentCents, a.capCents);
153
+ // A subscription Agent shows the login its latest call landed on instead of a cap it does not have.
154
+ const spent = a.kind === "subscription" ? (a.lastAccount ? cyan(clip(a.lastAccount, 32)) : dim("subscription · no call yet")) : `${tone(pct)(money(a.spentCents))} ${dim(`/ ${money(a.capCents)}`)}`;
155
+ const bar = a.kind === "subscription" ? "" : usageBar(pct, 100);
156
+ const last = a.lastDecision ? decisionMark(a.lastDecision) : dim("-");
157
+ const reason = a.lastReason ?? "";
158
+ out.push(` ${pulseMark(a.pulse)} ${pad(clip(a.name, nameW), nameW)} ${pad(a.harness === "any" ? dim("any") : a.harness, 7)} ${pad(clip(a.projectName ?? dim("-"), projW), projW)} ${pad(`${spent}${bar ? ` ${bar}` : ""}`, 32)} ${pad((a.callsPerMinute > 0 ? bold : dim)(a.callsPerMinute.toFixed(1)), 5, true)} ${pad((a.blocked > 0 ? red : dim)(String(a.blocked)), 3, true)} ${pad(last, 8)} ${pad(dim(ago(a.lastAt, now)), 6)} ${dim(reason)}`);
159
+ }
160
+ if (shown.length > max)
161
+ out.push(dim(` … ${shown.length - max} more`));
162
+ return out;
163
+ }
164
+ function rank(a) {
165
+ return a.pulse === "working" ? 0 : a.pulse === "recent" ? 1 : a.pulse === "idle" ? 2 : 3;
166
+ }
167
+ const VENDOR_TITLES = { anthropic: "Claude", openai: "ChatGPT", xai: "Grok", kimi: "Kimi" };
168
+ function standingFor(pool, account) {
169
+ for (const s of pool ?? []) {
170
+ if (s.vendor !== account.provider)
171
+ continue;
172
+ const limits = s.limits ?? { at: 80, guard: 95 };
173
+ const next = pickMember(s.standings, limits)?.member.name;
174
+ for (const st of s.standings) {
175
+ if (account.email && st.email && st.email.toLowerCase() === account.email.toLowerCase())
176
+ return { standing: st, next: st.member.name === next, over: isOver(st, limits) };
177
+ }
178
+ }
179
+ return undefined;
180
+ }
181
+ function subscriptionsPanel(snap, cols, now) {
182
+ const out = [];
183
+ const accounts = snap.accounts ?? [];
184
+ const pooled = (snap.pool ?? []).reduce((n, s) => n + s.standings.length, 0);
185
+ out.push(section("Subscriptions", `${accounts.length} ${accounts.length === 1 ? "login" : "logins"}${pooled > 0 ? ` · ${pooled} in the pool` : ""}`, cols));
186
+ if (accounts.length === 0) {
187
+ out.push(dim(" no subscription login seen yet · tab claude with a subscription Agent"));
188
+ return out;
189
+ }
190
+ const whoW = Math.min(30, Math.max(5, ...accounts.map((a) => width(a.email ?? "account"))));
191
+ const planW = Math.max(4, ...accounts.map((a) => width(planName(a.planLabel ?? undefined) ?? "-")));
192
+ const live = (a) => a.quota.filter((w) => !w.resetsAt || new Date(w.resetsAt).getTime() > now.getTime());
193
+ // One slot per window the vendor has on any of its logins, so the columns hold across rows:
194
+ // a login that has not reported a window yet (Fable before its first Fable call) leaves the slot blank.
195
+ const slots = new Map();
196
+ for (const a of accounts) {
197
+ const names = slots.get(a.provider) ?? [];
198
+ for (const w of live(a))
199
+ if (!names.includes(w.window))
200
+ names.push(w.window);
201
+ slots.set(a.provider, names.sort(compareQuotaWindows));
202
+ }
203
+ // Label width per vendor: Claude's "Fable 7d" must not widen ChatGPT's "7d".
204
+ const labelWidth = (provider) => Math.max(2, ...(slots.get(provider) ?? []).map((w) => width(quotaWindowLabel(w))));
205
+ for (const a of [...accounts].sort((x, y) => x.provider.localeCompare(y.provider) || (x.email ?? "").localeCompare(y.email ?? ""))) {
206
+ const pool = standingFor(snap.pool, a);
207
+ const mark = pool?.next ? green("→") : pool?.over ? red("×") : pool ? dim("·") : " ";
208
+ const mine = new Map(live(a).map((w) => [w.window, w]));
209
+ const labelW = labelWidth(a.provider);
210
+ const cellW = labelW + 1 + 10 + 1 + 4 + 1 + 7; // label, bar, "100%", reset ("11h 00m")
211
+ const cells = (slots.get(a.provider) ?? []).map((name) => {
212
+ const w = mine.get(name);
213
+ if (!w)
214
+ return pad("", cellW);
215
+ const at = quotaWindowIsLong(name) ? 95 : 80;
216
+ const label = quotaWindowLabel(name);
217
+ const paint = w.usedPct >= at ? red : w.usedPct >= at * 0.75 ? yellow : green;
218
+ const left = until(w.resetsAt, now);
219
+ return `${pad((label.startsWith("Fable") ? magenta : dim)(label), labelW)} ${usageBar(w.usedPct, at)} ${paint(`${String(Math.round(w.usedPct)).padStart(3)}%`)} ${pad(left ? dim(left) : "", 7)}`;
220
+ });
221
+ const line = ` ${mark} ${pad(VENDOR_TITLES[a.provider] ?? a.vendor, 7)} ${pad(clip(a.email ?? dim("account"), whoW), whoW)} ${pad(planName(a.planLabel ?? undefined) ?? dim("-"), planW)} ${cells.length > 0 ? cells.join(dim(" ")) : dim("no window reported yet")} ${dim(`${a.calls} calls · seen ${ago(a.lastSeenAt, now)}`)}`;
222
+ out.push(line);
223
+ }
224
+ if (pooled > 0)
225
+ out.push(dim(` ${green("→")} the pool's next pick · ${red("×")} over its limit · Fable's own week beside Claude's general one`));
226
+ return out;
227
+ }
228
+ function outsidePanel(snap, cols, max, now) {
229
+ const out = [];
230
+ const o = snap.outside;
231
+ if (!o)
232
+ return out;
233
+ const total = sumCents(o.byProject.map((p) => p.cents));
234
+ const failing = o.connections.filter(failingConnector);
235
+ out.push(section("Outside spend", `last 30 days · ${money(total)} · ${o.connections.length} ${o.connections.length === 1 ? "connector" : "connectors"}${failing.length > 0 ? ` · ${failing.length} failing` : ""}`, cols));
236
+ if (o.byProject.length === 0 && o.connections.length === 0) {
237
+ out.push(dim(" no connector yet · Console → Outside spend"));
238
+ return out;
239
+ }
240
+ const rows = [...o.byProject].sort((x, y) => Number(BigInt(y.cents) - BigInt(x.cents)));
241
+ const nameW = Math.min(28, Math.max(7, ...rows.map((p) => width(p.projectName))));
242
+ const barW = 20;
243
+ for (const p of rows.slice(0, max)) {
244
+ const cents = BigInt(p.cents);
245
+ const share = total > 0n ? Number((cents * 1000n) / total) / 10 : 0;
246
+ const filled = Math.round((share / 100) * barW);
247
+ const unattributed = p.projectId === null;
248
+ out.push(` ${pad(clip(unattributed ? yellow(p.projectName) : p.projectName, nameW), nameW)} ${(unattributed ? yellow : cyan)("█".repeat(filled))}${dim("░".repeat(barW - filled))} ${pad(money(cents), 10, true)} ${dim(`${share.toFixed(0)}%`)}`);
249
+ }
250
+ if (rows.length > max)
251
+ out.push(dim(` … ${rows.length - max} more`));
252
+ for (const c of failing.slice(0, 2))
253
+ out.push(` ${red("■")} ${c.label} ${dim(c.lastError ?? c.status)}${c.lastSyncAt ? dim(` · synced ${ago(c.lastSyncAt, now)} ago`) : ""}`);
254
+ return out;
255
+ }
256
+ function tapePanel(snap, cols, max) {
257
+ const out = [];
258
+ const tape = snap.live?.tape ?? [];
259
+ out.push(section("Ledger", "latest decisions", cols));
260
+ if (tape.length === 0) {
261
+ out.push(dim(" no call yet"));
262
+ return out;
263
+ }
264
+ const agentW = Math.min(20, Math.max(5, ...tape.map((t) => width(t.agent))));
265
+ for (const t of tape.slice(0, max)) {
266
+ const time = t.at.length > 8 ? t.at.slice(-8) : t.at;
267
+ const bad = !["ALLOWED", "SETTLED", "SHADOW", "PENDING", "REFUNDED"].includes(t.decision.toUpperCase());
268
+ out.push(` ${dim(time)} ${pad(clip(t.agent, agentW), agentW)} ${pad(decisionMark(t.decision), 8)} ${pad(t.amount, 8, true)} ${pad(clip(t.action, 30), 30)} ${pad(t.model ? dim(clip(t.model, 34)) : "", 34)} ${bad ? red(t.reason) : dim(t.reason)}`);
269
+ }
270
+ return out;
271
+ }
272
+ /** The whole screen as lines, each cut to the terminal's width; never more lines than `rows`. */
273
+ export function renderTop(snap, size, now = snap.at) {
274
+ const cols = Math.max(60, size.columns);
275
+ const rows = Math.max(12, size.rows);
276
+ const fixed = 1 + 1 + 1; // header, totals, a blank line
277
+ const subs = subscriptionsPanel(snap, cols, now);
278
+ const errors = snap.errors.map((e) => red(` ! ${e}`));
279
+ // Whatever is left after the header, the logins and any errors is split
280
+ // between the Agents, the outside projects and the tape, Agents first.
281
+ const agentCount = snap.live?.agents.length ?? 0;
282
+ const outsideCount = snap.outside ? Math.min(6, snap.outside.byProject.length + Math.min(2, snap.outside.connections.filter(failingConnector).length)) : 0;
283
+ let budget = rows - fixed - subs.length - errors.length - 1;
284
+ const agentRows = Math.max(3, Math.min(agentCount + 2, Math.floor(budget * 0.45)));
285
+ budget -= agentRows + 1;
286
+ const outsideRows = snap.outside ? Math.max(0, Math.min(outsideCount + 1, Math.floor(budget * 0.35))) : 0;
287
+ budget -= outsideRows > 0 ? outsideRows + 1 : 0;
288
+ const tapeRows = Math.max(2, budget);
289
+ const lines = [
290
+ ...header(snap, cols, now),
291
+ totalsLine(snap),
292
+ "",
293
+ ...agentsPanel(snap, cols, Math.max(1, agentRows - 2), now),
294
+ "",
295
+ ...subs,
296
+ "",
297
+ ...(outsideRows > 0 ? [...outsidePanel(snap, cols, Math.max(1, outsideRows - 1), now), ""] : []),
298
+ ...tapePanel(snap, cols, Math.max(1, tapeRows - 1)),
299
+ ...errors,
300
+ ];
301
+ return lines.slice(0, rows).map((l) => clip(l, cols));
302
+ }
303
+ /** One read of every panel; a panel that fails is a line at the bottom, never a blank screen. */
304
+ export async function fetchSnapshot(deps, previous) {
305
+ const errors = [];
306
+ const note = (what) => (err) => {
307
+ errors.push(`${what}: ${err instanceof Error ? err.message : String(err)}`);
308
+ return undefined;
309
+ };
310
+ const [flock, live, accounts, outside, health, pool] = await Promise.all([
311
+ deps.call("GET", "/api/cli/agents").catch(note("agents")),
312
+ deps.call("GET", "/api/cli/live").catch(note("live")),
313
+ deps.call("GET", "/api/cli/accounts").catch(note("accounts")),
314
+ deps.call("GET", "/api/cli/outside").catch(note("outside")),
315
+ deps.health().catch((err) => ({ ok: false, reason: err instanceof Error ? err.message : String(err) })),
316
+ deps.pool ? deps.pool().catch(note("pool")) : Promise.resolve(undefined),
317
+ ]);
318
+ const history = [...(previous?.history ?? [])];
319
+ if (live) {
320
+ history.push({ callsPerMinute: live.totals.callsPerMinute, windowCents: Number(live.totals.windowCents) });
321
+ while (history.length > HISTORY)
322
+ history.shift();
323
+ }
324
+ return {
325
+ at: new Date(),
326
+ flock: flock
327
+ ? { name: flock.flock.name, plan: flock.flock.plan, agents: flock.agents.length, agentLimit: flock.flock.agentLimit ?? null }
328
+ : (previous?.flock ?? { name: deps.config.flockName ?? "flock", plan: "solo", agents: 0, agentLimit: null }),
329
+ proxy: { url: deps.config.proxyUrl, mode: deps.config.mode, health },
330
+ ...(live ? { live } : previous?.live ? { live: previous.live } : {}),
331
+ ...(accounts ? { accounts: accounts.accounts } : previous?.accounts ? { accounts: previous.accounts } : {}),
332
+ ...(outside ? { outside } : previous?.outside ? { outside: previous.outside } : {}),
333
+ ...(pool ? { pool } : previous?.pool ? { pool: previous.pool } : {}),
334
+ errors,
335
+ history,
336
+ };
337
+ }
338
+ const ALT_ON = "\u001b[?1049h\u001b[?25l";
339
+ const ALT_OFF = "\u001b[?25h\u001b[?1049l";
340
+ /** Draw in place: home, each line cleared to its end, then everything below. No flicker from a full clear. */
341
+ function paint(lines, out) {
342
+ out.write(`\u001b[H${lines.map((l) => `${l}\u001b[K`).join("\n")}\u001b[J`);
343
+ }
344
+ /**
345
+ * Runs until `q`, Escape or Ctrl-C. `--every <seconds>` sets the refresh (2 by
346
+ * default, never under 1); `--once` prints one screen and returns, for a pipe
347
+ * or a quick look.
348
+ */
349
+ export async function runTop(deps, opts, io = {}) {
350
+ const stdout = io.stdout ?? process.stdout;
351
+ const stdin = io.stdin ?? process.stdin;
352
+ // A pipe has no size: COLUMNS if the shell exports it, else wide enough that nothing useful is cut.
353
+ const size = () => ({ columns: stdout.columns || Number(process.env.COLUMNS) || 160, rows: stdout.rows || Number(process.env.LINES) || 40 });
354
+ let snap = await fetchSnapshot(deps);
355
+ if (opts.once) {
356
+ stdout.write(`${renderTop(snap, { columns: size().columns, rows: 200 }).join("\n")}\n`);
357
+ return 0;
358
+ }
359
+ const every = Math.max(1, opts.every) * 1000;
360
+ let stopped = false;
361
+ let wake;
362
+ const stop = () => {
363
+ stopped = true;
364
+ wake?.();
365
+ };
366
+ const onKey = (chunk) => {
367
+ const key = chunk.toString();
368
+ if (key === "q" || key === "Q" || key === "\u0003" || key === "\u001b")
369
+ stop();
370
+ else if (key === "r" || key === "R")
371
+ wake?.();
372
+ };
373
+ const onResize = () => paint(renderTop(snap, size(), new Date()), stdout);
374
+ const interactive = Boolean(stdin.isTTY && typeof stdin.setRawMode === "function");
375
+ stdout.write(ALT_ON);
376
+ if (interactive) {
377
+ stdin.setRawMode(true);
378
+ stdin.resume();
379
+ stdin.on("data", onKey);
380
+ }
381
+ stdout.on("resize", onResize);
382
+ process.once("SIGINT", stop);
383
+ process.once("SIGTERM", stop);
384
+ try {
385
+ while (!stopped) {
386
+ paint(renderTop(snap, size(), new Date()), stdout);
387
+ await new Promise((resolve) => {
388
+ wake = resolve;
389
+ setTimeout(resolve, every).unref();
390
+ });
391
+ wake = undefined;
392
+ if (stopped)
393
+ break;
394
+ snap = await fetchSnapshot(deps, snap);
395
+ }
396
+ }
397
+ finally {
398
+ stdout.off("resize", onResize);
399
+ process.off("SIGINT", stop);
400
+ process.off("SIGTERM", stop);
401
+ if (interactive) {
402
+ stdin.off("data", onKey);
403
+ stdin.setRawMode(false);
404
+ stdin.pause();
405
+ }
406
+ stdout.write(ALT_OFF);
407
+ }
408
+ return 0;
409
+ }
410
+ /** For tests: the visible text of a screen. */
411
+ export function plain(lines) {
412
+ return lines.map(stripAnsi).join("\n");
413
+ }
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.21";
2
+ export const TAB_VERSION = "0.1.22";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanamorilabs/tab",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
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",
@@ -22,11 +22,11 @@
22
22
  "prepublishOnly": "pnpm build"
23
23
  },
24
24
  "optionalDependencies": {
25
- "@hanamorilabs/flocktab-proxy-darwin-arm64": "0.1.16",
26
- "@hanamorilabs/flocktab-proxy-darwin-x64": "0.1.16",
27
- "@hanamorilabs/flocktab-proxy-linux-x64": "0.1.16",
28
- "@hanamorilabs/flocktab-proxy-linux-arm64": "0.1.16",
29
- "@hanamorilabs/flocktab-proxy-win-x64": "0.1.16"
25
+ "@hanamorilabs/flocktab-proxy-darwin-arm64": "0.1.17",
26
+ "@hanamorilabs/flocktab-proxy-darwin-x64": "0.1.17",
27
+ "@hanamorilabs/flocktab-proxy-linux-x64": "0.1.17",
28
+ "@hanamorilabs/flocktab-proxy-linux-arm64": "0.1.17",
29
+ "@hanamorilabs/flocktab-proxy-win-x64": "0.1.17"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^22.18.6",