@hanamorilabs/tab 0.1.12 → 0.1.13

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
@@ -86,6 +86,8 @@ Codex shows `FlockTab - Self-hosted` as its provider.
86
86
 
87
87
  ## Commands
88
88
 
89
+ `tab help` lists every command by group. `tab help <command>` (or `tab <command> --help`) explains one in full: what it does, its options, examples. `tab help glossary` says what is what, `tab help errors` lists every refusal and what to do. The same reference, with how the system works, is in the console under Docs (`console.flocktab.com/docs`).
90
+
89
91
  ```
90
92
  tab login approve this machine in the console, once
91
93
  tab <agent> [args] claude, codex, grok, kimi, gemini, or any command, on the tab
package/dist/cli.js CHANGED
@@ -32,9 +32,11 @@ import { bold, box, cyan, dim, green, heading, line, rows, underline, yellow } f
32
32
  import { envFor, knownClients, subscriptionVendorFor } from "./clients.js";
33
33
  import { describeLogin, localLogin } from "./logins.js";
34
34
  import { prepareCodexHome } from "./codex-home.js";
35
- import { addMember, loadPool, memberEnv, memberHome, POOL_VENDORS, poolVendorFor, prepareClaudeMember, prepareSharedSessions, removeMember, savePool, setThreshold, thresholdFor, usedPct } from "./pool.js";
35
+ import { addMember, loadPool, memberEnv, memberHome, POOL_VENDORS, poolVendorFor, prepareClaudeMember, prepareSharedSessions, limitsFor, removeMember, savePool, setGuard, setThreshold, thresholdFor, usage as quotaUsage } from "./pool.js";
36
36
  import { runPooled } from "./pool-run.js";
37
37
  import { renderPool } from "./pool-view.js";
38
+ import { renderHelp } from "./help.js";
39
+ import { findTabCommand } from "./tab-docs.js";
38
40
  import { ConsoleApiError, createAgent, issueAgentKey, listAgents } from "./console-api.js";
39
41
  import { consoleUrlFor, DeviceLoginError, startDeviceLogin, waitForApproval } from "./device-login.js";
40
42
  import { clearConfig, configDir, configPath, HOSTED_PROXY, isLocalProxy, loadConfig, LOCAL_PROXY, normalizeProxyUrl, presentedKey, saveConfig, } from "./config.js";
@@ -50,52 +52,15 @@ const say = (text) => console.error(text);
50
52
  const ok = (text) => say(line("ok", text));
51
53
  const warn = (text) => say(line("warn", text));
52
54
  const fail = (text) => say(line("fail", text));
53
- function usage() {
54
- const others = knownClients().filter((n) => n !== "claude" && n !== "codex");
55
- const cmd = (name, what) => [`tab ${name}`, what];
56
- say(`${heading("tab")} ${dim("- run any AI agent on a FlockTab tab")}
57
- `);
58
- say(rows([
59
- cmd("login", "approve this machine in the console, once"),
60
- cmd("claude [args]", "Claude Code on the tab"),
61
- cmd("codex [args]", "Codex on the tab"),
62
- cmd(others.join(" | tab "), "the same, for those agents"),
63
- cmd("<command> [args]", "any other agent, both APIs pointed at the tab"),
64
- cmd("use", "pick or change the Agent this folder runs as (.flocktab)"),
65
- cmd("agent create <name>", "a new Agent: --subscription (your own logins) or --api (the flock's key, metered)"),
66
- cmd("agent kind <agent> api|subscription", "change it"),
67
- cmd("accounts", "accounts your subscription Agents were seen on: plan, price, quota used"),
68
- cmd("pool", "several logins of one vendor; tab claude runs as the one with most room"),
69
- cmd("pool add claude|codex|grok|kimi <name>", "sign another login in through the browser (--email to prefill, --dir <path> to use a folder you have)"),
70
- cmd("pool at [claude|codex|grok|kimi] <percent>", "move to another login once this much of a window is used (default 80); per vendor if named"),
71
- cmd("alias setup <name>...", "make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)"),
72
- cmd("alias remove <name>...", "undo that; `tab alias list` shows them"),
73
- cmd("status", "which flock, which Agent here, is the proxy up"),
74
- cmd("log [-f] [--all]", "everything through the tab on this machine's Agents, one line per call; -f follows"),
75
- cmd("log --proxy [-f]", "self-hosted: the local proxy's own log (cached tokens, milliseconds; no keys, logins or prompts)"),
76
- cmd("version", "tab and proxy versions"),
77
- cmd("list", "every Agent: state, spent of cap, window, project"),
78
- cmd("close | open [agent]", "the kill switch; omit the Agent for this folder's"),
79
- cmd("cap [agent] <dollars>", "set the cap (--window day|week|month|run|hour|lifetime)"),
80
- cmd("rename <agent> <name>", "rename it (the slug stays)"),
81
- cmd("policy [agent]", "show or set: --velocity N|none --models a,b|none --allow tool|none"),
82
- cmd("project [agent] <name>", "attribute to a project (--create, --repo owner/name, none)"),
83
- cmd("projects [add <name>]", "list or add projects"),
84
- cmd("key rotate [agent]", "new key, kept on this machine (--show prints it once)"),
85
- cmd("archive [agent]", "close for good and leave the bill"),
86
- cmd("ledger [agent]", "one row per call (--limit N --blocked)"),
87
- cmd("spend [agent|project|day]", "meter and outside spend (--since 7d --agent x)"),
88
- cmd("outside", "outside-spend connections and per-project totals"),
89
- cmd("live", "who is working now (--watch 4)"),
90
- cmd("web [agent]", "open the Agent's console page"),
91
- cmd("... --json", "every read command as JSON, for scripts and agents"),
92
- cmd("up", "self-hosted: start the local proxy (fetches it the first time)"),
93
- cmd("down", "self-hosted: stop the local proxy"),
94
- cmd("update", "the newest tab (and proxy) from npm; restarts a running local proxy"),
95
- cmd("logout", "forget the session and keys"),
96
- ].map(([k, v]) => [cyan(k), v])));
97
- say(`
98
- ${dim("Config")} ${configPath()} ${dim("or FLOCKTAB_PROXY_URL, FLOCKTAB_KEY, FLOCKTAB_UNLOCK")}`);
55
+ /** `tab help`, `tab help <word>`: the overview, one command, the glossary, the errors. */
56
+ function usage(word) {
57
+ const text = renderHelp(word, { configPath: configPath() });
58
+ if (text === undefined) {
59
+ fail(`No help for "${word}". ${dim("tab help lists every command.")}`);
60
+ return 2;
61
+ }
62
+ say(text);
63
+ return 0;
99
64
  }
100
65
  /**
101
66
  * Without a terminal (CI, a script, `printf ... | tab up`) every answer
@@ -537,6 +502,31 @@ const VENDOR_HARNESS = { anthropic: "claude", openai: "codex", xai: "grok", kimi
537
502
  async function flockAccounts(config) {
538
503
  return (await manage.api(config)("GET", "/api/cli/accounts")).accounts;
539
504
  }
505
+ /**
506
+ * Tell the console which logins this machine is signed in with, so a pool
507
+ * member shows there before its first call. Only what the harness's own
508
+ * files say about the account (the vendor's id, email, plan tier), never a
509
+ * login. Best effort: the pool works the same when the console is away.
510
+ */
511
+ async function registerPoolLogins(config, pool) {
512
+ const accounts = [];
513
+ for (const vendor of POOL_VENDORS) {
514
+ for (const member of pool.members[vendor] ?? []) {
515
+ const login = await memberLogin(vendor, member);
516
+ if (!login?.accountId)
517
+ continue;
518
+ accounts.push({ provider: vendor, externalId: login.accountId, ...(login.email ? { email: login.email } : {}), ...(login.plan ? { plan: login.plan } : {}) });
519
+ }
520
+ }
521
+ if (accounts.length === 0)
522
+ return undefined;
523
+ try {
524
+ return (await manage.api(config)("POST", "/api/cli/accounts", { accounts })).accounts;
525
+ }
526
+ catch {
527
+ return undefined;
528
+ }
529
+ }
540
530
  /** Each member with the login its folder holds and how used the vendor last said that account is. */
541
531
  async function poolStandings(config, vendor, members, known) {
542
532
  const accounts = known ?? (await flockAccounts(config).catch(() => []));
@@ -548,7 +538,7 @@ async function poolStandings(config, vendor, members, known) {
548
538
  const shown = email ?? (login?.accountId ? `account ${login.accountId.slice(0, 12)}` : undefined);
549
539
  // The vendor's plan name once seen on the wire; before that, what the harness's own file says.
550
540
  const plan = account?.planLabel ?? login?.plan?.replace(/^default_/, "").replaceAll("_", " ");
551
- return { member, used: account ? usedPct(account.quota) : undefined, ...(shown ? { email: shown } : {}), ...(plan ? { plan } : {}), ...(account ? { windows: account.quota } : {}) };
541
+ return { member, used: undefined, ...(account ? quotaUsage(account.quota) : {}), ...(shown ? { email: shown } : {}), ...(plan ? { plan } : {}), ...(account ? { windows: account.quota } : {}) };
552
542
  }));
553
543
  }
554
544
  /** Quiet: no call of this Agent in flight, and no reply from the vendor in the last 20 seconds. */
@@ -661,6 +651,7 @@ async function runAgent(name, argv) {
661
651
  // Codex, Grok and Kimi members share one conversations folder.
662
652
  for (const member of members)
663
653
  await prepareSharedSessions(poolVendor, member);
654
+ void registerPoolLogins(config, pool);
664
655
  if (poolVendor === "openai") {
665
656
  for (const member of members) {
666
657
  await prepareCodexHome({ baseDir: member.dir, proxyUrl: config.proxyUrl, presentedKey: key, mode: config.mode, auth, userCodexHome: path.join(member.dir, "no-personal-login") });
@@ -668,7 +659,7 @@ async function runAgent(name, argv) {
668
659
  }
669
660
  return runPooled(args, {
670
661
  harness: name,
671
- at: thresholdFor(pool, poolVendor),
662
+ limits: limitsFor(pool, poolVendor),
672
663
  swap: pool.swap,
673
664
  ...(pinned ? { pinned } : {}),
674
665
  standings: () => poolStandings(config, poolVendor, members),
@@ -696,23 +687,32 @@ async function poolCommand(argv) {
696
687
  const HARNESSES = "claude|codex|grok|kimi";
697
688
  if (sub === "at") {
698
689
  // `tab pool at 80` for everyone; `tab pool at claude 70` for one vendor; `tab pool at claude default` to clear it.
699
- const vendor = rest.length > 1 ? vendorOf(rest[0]) : undefined;
700
- const value = rest.length > 1 ? rest[1] : rest[0];
690
+ // A window word makes it the weekly guard: `tab pool at 7d 95`, `tab pool at claude weekly 90`.
691
+ const words = rest.filter((w) => !["7d", "weekly", "week", "long"].includes(w));
692
+ const guard = words.length !== rest.length;
693
+ // `5h` / `short` is the default meaning, accepted so both read the same way.
694
+ const plain = words.filter((w) => !["5h", "short"].includes(w));
695
+ const vendor = plain.length > 1 ? vendorOf(plain[0]) : undefined;
696
+ const value = plain.length > 1 ? plain[1] : plain[0];
701
697
  const clear = vendor !== undefined && (value === "default" || value === "reset");
702
- if ((rest.length > 1 && !vendor) || (!clear && !/^\d{1,3}$/.test(value ?? ""))) {
703
- fail(`tab pool at <percent>, or tab pool at claude|codex|grok|kimi <percent>|default. ${dim("A percent is 1 to 100.")}`);
698
+ if ((plain.length > 1 && !vendor) || plain.length > 2 || (!clear && !/^\d{1,3}$/.test(value ?? ""))) {
699
+ fail(`tab pool at [claude|codex|grok|kimi] [7d] <percent>|default. ${dim("A percent is 1 to 100. Without 7d it is the short (5-hour) window; with it, the weekly guard.")}`);
704
700
  return 2;
705
701
  }
706
702
  let next;
707
703
  try {
708
- next = setThreshold(pool, clear ? undefined : Number(value), vendor);
704
+ next = (guard ? setGuard : setThreshold)(pool, clear ? undefined : Number(value), vendor);
709
705
  }
710
706
  catch (err) {
711
707
  fail(err.message);
712
708
  return 2;
713
709
  }
714
710
  await savePool(next);
715
- if (!vendor) {
711
+ if (guard) {
712
+ const g = vendor ? limitsFor(next, vendor).guard : next.guard;
713
+ ok(`${vendor ? `${VENDOR_NAMES[vendor]} logins give` : "A login gives"} way once ${g}% of the long (weekly) window is used, whatever the short one says. ${dim("Below that the weekly window is ignored.")}`);
714
+ }
715
+ else if (!vendor) {
716
716
  const own = POOL_VENDORS.filter((v) => next.atByVendor[v] !== undefined).map((v) => `${VENDOR_HARNESS[v]} ${next.atByVendor[v]}%`);
717
717
  ok(`The pool moves to another login at ${next.at}% used.${own.length > 0 ? dim(` Still their own: ${own.join(", ")}.`) : ""}`);
718
718
  }
@@ -795,7 +795,9 @@ async function poolCommand(argv) {
795
795
  return 1;
796
796
  }
797
797
  await savePool(next);
798
- ok(`${bold(memberName)} is in the pool as ${describeLogin(login)}.`);
798
+ const session = await loadConfig();
799
+ const shown = session?.token ? await registerPoolLogins(session, next) : undefined;
800
+ ok(`${bold(memberName)} is in the pool as ${describeLogin(login)}.${shown ? dim(" It shows in the console under Subscriptions.") : ""}`);
799
801
  return 0;
800
802
  }
801
803
  if (sub && sub !== "list" && sub !== "ls" && sub !== "--paths") {
@@ -810,9 +812,9 @@ async function poolCommand(argv) {
810
812
  say(dim(`No logins in the pool. ${bold("tab pool add claude work")} signs one in; then ${bold("tab claude")} runs as whichever has most room.`));
811
813
  return 0;
812
814
  }
813
- // One read of the accounts for every vendor.
814
- const accounts = await flockAccounts(config).catch(() => []);
815
- const sections = await Promise.all(vendors.map(async (vendor) => ({ vendor, title: VENDOR_NAMES[vendor], harness: VENDOR_HARNESS[vendor], at: thresholdFor(pool, vendor), standings: await poolStandings(config, vendor, pool.members[vendor], accounts) })));
815
+ // One round trip: register what this machine is signed in with, and get every account back.
816
+ const accounts = (await registerPoolLogins(config, pool)) ?? (await flockAccounts(config).catch(() => []));
817
+ const sections = await 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) })));
816
818
  say(renderPool(sections, { at: pool.at, swap: pool.swap, paths: argv.includes("--paths") }));
817
819
  return 0;
818
820
  }
@@ -1042,13 +1044,20 @@ async function managed(command, rest) {
1042
1044
  }
1043
1045
  async function main(argv) {
1044
1046
  const [command, ...rest] = argv;
1047
+ if (command && (rest.includes("--help") || rest.includes("-h"))) {
1048
+ const doc = findTabCommand(command);
1049
+ // `tab claude --help` is Claude's help: everything after an agent's name is the agent's.
1050
+ if (doc && doc.group !== "run")
1051
+ return usage(command);
1052
+ }
1045
1053
  switch (command) {
1046
1054
  case undefined:
1047
1055
  case "-h":
1048
1056
  case "--help":
1049
- case "help":
1050
- usage();
1051
- return command === undefined ? 2 : 0;
1057
+ case "help": {
1058
+ const code = usage(command === "help" ? rest[0] : undefined);
1059
+ return command === undefined ? 2 : code;
1060
+ }
1052
1061
  case "login":
1053
1062
  return login(rest);
1054
1063
  case "logout":
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * The person's own `~/.codex` is never read or written.
12
12
  */
13
- import { chmod, copyFile, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
13
+ import { chmod, copyFile, lstat, mkdir, readdir, readFile, rename, rm, symlink, unlink, writeFile } from "node:fs/promises";
14
14
  import { homedir } from "node:os";
15
15
  import path from "node:path";
16
16
  import { passthroughBase } from "./clients.js";
@@ -67,13 +67,57 @@ export async function prepareCodexHome(input) {
67
67
  await writeFile(config, codexConfigToml(input.proxyUrl, input.model, input.mode ?? "hosted"), { mode: 0o600 });
68
68
  }
69
69
  await chmod(config, 0o600);
70
+ await shareConversations(home, input.userCodexHome ?? path.join(homedir(), ".codex"));
70
71
  return home;
71
72
  }
73
+ /**
74
+ * Codex keeps conversations as rollout files under `sessions`. The tab's
75
+ * home is its own folder (so its config and login never touch `~/.codex`),
76
+ * which hid every conversation the person already had: `tab codex resume
77
+ * <id>` answered "no saved session". `sessions` is therefore a link to the
78
+ * person's own, and anything already recorded under the tab is moved across
79
+ * first. With no `~/.codex` on the machine there is nothing to share.
80
+ */
81
+ export async function shareConversations(home, userCodexHome) {
82
+ if (!(await lstat(userCodexHome).then((s) => s.isDirectory(), () => false)))
83
+ return "none";
84
+ const theirs = path.join(userCodexHome, "sessions");
85
+ const mine = path.join(home, "sessions");
86
+ if (path.resolve(theirs) === path.resolve(mine))
87
+ return "already";
88
+ const state = await lstat(mine).catch(() => undefined);
89
+ if (state?.isSymbolicLink())
90
+ return "already";
91
+ await mkdir(theirs, { recursive: true, mode: 0o700 });
92
+ if (state?.isDirectory()) {
93
+ await moveInto(mine, theirs);
94
+ await rm(mine, { recursive: true, force: true });
95
+ }
96
+ await symlink(theirs, mine);
97
+ return "linked";
98
+ }
99
+ /** Move every file under `from` to the same place under `to`; a file already there is kept. */
100
+ export async function moveInto(from, to) {
101
+ for (const entry of await readdir(from, { withFileTypes: true })) {
102
+ const src = path.join(from, entry.name);
103
+ const dest = path.join(to, entry.name);
104
+ if (entry.isDirectory()) {
105
+ await mkdir(dest, { recursive: true, mode: 0o700 });
106
+ await moveInto(src, dest);
107
+ }
108
+ else if (!(await lstat(dest).then(() => true, () => false))) {
109
+ await rename(src, dest).catch(async () => {
110
+ // Another volume: copy, then the caller removes the source tree.
111
+ await copyFile(src, dest);
112
+ });
113
+ }
114
+ }
115
+ }
72
116
  /**
73
117
  * The ChatGPT login for the tab's Codex home. A login already there (from
74
118
  * `codex login` run through `tab codex`) stays. Otherwise the person's own
75
119
  * `~/.codex/auth.json` is copied, owner-only: same machine, same person,
76
- * and `~/.codex` itself is still never written. A leftover API-key auth
120
+ * and the login and config in `~/.codex` are still never written. A leftover API-key auth
77
121
  * file from metered mode is replaced; with nothing to copy, Codex asks the
78
122
  * person to sign in and keeps that login in the tab home.
79
123
  */
package/dist/help.js ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * `tab help`, drawn from the one command reference (`tab-docs.ts`, generated
3
+ * from packages/shared): the overview by group, one command in full, the
4
+ * glossary, and the refusals a call can meet.
5
+ */
6
+ import { findTabCommand, TAB_COMMAND_GROUPS, TAB_COMMANDS, TAB_ERRORS, TAB_GLOSSARY } from "./tab-docs.js";
7
+ import { bold, cyan, dim, heading, rows, width, yellow } from "./ui.js";
8
+ const DOCS_URL = "https://console.flocktab.com/docs";
9
+ /** Break a paragraph at word boundaries so it fits `cols`, each line after `indent` spaces. */
10
+ export function wrap(text, cols, indent = 0) {
11
+ const room = Math.max(24, cols - indent);
12
+ const lines = [];
13
+ let line = "";
14
+ for (const word of text.split(/\s+/).filter(Boolean)) {
15
+ if (line && width(line) + 1 + width(word) > room) {
16
+ lines.push(line);
17
+ line = word;
18
+ }
19
+ else
20
+ line = line ? `${line} ${word}` : word;
21
+ }
22
+ if (line)
23
+ lines.push(line);
24
+ return lines.map((l) => `${" ".repeat(indent)}${l}`).join("\n");
25
+ }
26
+ const columns = () => Math.min(100, Math.max(60, process.stdout.columns ?? 100));
27
+ export function renderOverview(opts) {
28
+ const cols = opts.cols ?? columns();
29
+ const out = [`${heading("tab")} ${dim("- run any AI agent on a FlockTab tab: a hard cap, a kill switch and a record of every call")}`, ""];
30
+ // One width for the usage column across every group, so the summaries line up.
31
+ const first = (c) => `tab ${c.overview ?? c.usage[0]}`;
32
+ const left = Math.min(42, Math.max(...TAB_COMMANDS.map((c) => width(first(c)))));
33
+ for (const group of TAB_COMMAND_GROUPS) {
34
+ out.push(bold(group.title));
35
+ out.push(dim(wrap(group.blurb, cols, 2)));
36
+ for (const c of TAB_COMMANDS.filter((x) => x.group === group.id)) {
37
+ const name = first(c);
38
+ const label = width(name) > left ? `${name.slice(0, left - 1)}…` : name;
39
+ const text = wrap(c.summary, cols, left + 4).trimStart();
40
+ out.push(` ${cyan(label)}${" ".repeat(left - width(label))} ${text}`);
41
+ }
42
+ out.push("");
43
+ }
44
+ out.push(`${bold("tab help <command>")} ${dim("everything about one command: what it does, its options, examples")}`);
45
+ out.push(`${bold("tab help glossary")} ${dim("what is what: Agent, tab, kind, hold, unlock, pool, ...")}`);
46
+ out.push(`${bold("tab help errors")} ${dim("every refusal a call can meet, and what to do")}`);
47
+ out.push("");
48
+ out.push(dim(`Docs ${DOCS_URL} Config ${opts.configPath} or FLOCKTAB_PROXY_URL, FLOCKTAB_KEY, FLOCKTAB_UNLOCK`));
49
+ return out.join("\n");
50
+ }
51
+ export function renderCommand(doc, cols = columns()) {
52
+ const out = [`${heading(`tab ${doc.name}`)}${doc.aliases?.length ? dim(` also: ${doc.aliases.join(", ")}`) : ""}`, wrap(doc.summary, cols), ""];
53
+ out.push(bold("Usage"));
54
+ for (const u of doc.usage)
55
+ out.push(` ${cyan(`tab ${u}`)}`);
56
+ out.push("");
57
+ for (const p of doc.details)
58
+ out.push(wrap(p, cols), "");
59
+ if (doc.options?.length) {
60
+ out.push(bold("Options"));
61
+ const w = Math.max(...doc.options.map((o) => width(o.flag)));
62
+ for (const o of doc.options)
63
+ out.push(` ${yellow(o.flag)}${" ".repeat(w - width(o.flag))} ${wrap(o.what, cols, w + 4).trimStart()}`);
64
+ out.push("");
65
+ }
66
+ if (doc.examples?.length) {
67
+ out.push(bold("Examples"));
68
+ const w = Math.max(...doc.examples.map((e) => width(e.cmd)));
69
+ for (const e of doc.examples)
70
+ out.push(` ${cyan(e.cmd)}${" ".repeat(w - width(e.cmd))} ${dim(wrap(e.what, cols, w + 4).trimStart())}`);
71
+ out.push("");
72
+ }
73
+ if (doc.see?.length)
74
+ out.push(dim(`See also: ${doc.see.map((s) => `tab help ${s}`).join(" · ")}`));
75
+ return out.join("\n").trimEnd();
76
+ }
77
+ export function renderGlossary(cols = columns()) {
78
+ const out = [heading("What is what"), ""];
79
+ for (const e of [...TAB_GLOSSARY].sort((a, b) => a.term.replace(/^[^A-Za-z]+/, "").localeCompare(b.term.replace(/^[^A-Za-z]+/, "")))) {
80
+ out.push(`${bold(e.term)}${e.also?.length ? dim(` (${e.also.join(", ")})`) : ""}`);
81
+ out.push(wrap(e.meaning, cols, 2), "");
82
+ }
83
+ return out.join("\n").trimEnd();
84
+ }
85
+ export function renderErrors(cols = columns()) {
86
+ const out = [heading("When a call is refused"), wrap("A refusal always happens before the provider or vendor is reached, and carries an x-flocktab-reason header naming the rule.", cols), ""];
87
+ for (const e of TAB_ERRORS) {
88
+ out.push(`${yellow(String(e.status))} ${bold(e.code)}`);
89
+ out.push(wrap(e.when, cols, 2));
90
+ out.push(dim(wrap(`Do: ${e.fix}`, cols, 2)), "");
91
+ }
92
+ return out.join("\n").trimEnd();
93
+ }
94
+ /** What `tab help [word]` prints, or undefined when the word names nothing. */
95
+ export function renderHelp(word, opts) {
96
+ if (!word)
97
+ return renderOverview(opts);
98
+ if (word === "glossary" || word === "terms")
99
+ return renderGlossary();
100
+ if (word === "errors" || word === "error")
101
+ return renderErrors();
102
+ const doc = findTabCommand(word);
103
+ return doc ? renderCommand(doc) : undefined;
104
+ }
105
+ export { rows };
package/dist/logins.js CHANGED
@@ -73,7 +73,9 @@ export async function localLogin(vendor, opts = {}) {
73
73
  if (!entry)
74
74
  return undefined;
75
75
  const email = str(entry.email);
76
- const accountId = str(entry.user_id);
76
+ // The proxy files Grok calls under the login token's subject, then its user id: read it the same way.
77
+ const claims = jwtClaims(entry.key);
78
+ const accountId = str(claims?.sub) ?? str(claims?.user_id) ?? str(entry.user_id);
77
79
  return email || accountId ? { ...(email ? { email } : {}), ...(accountId ? { accountId } : {}) } : undefined;
78
80
  }
79
81
  if (vendor === "kimi") {
package/dist/manage.js CHANGED
@@ -198,7 +198,7 @@ export async function accounts(config, flags) {
198
198
  };
199
199
  return table(["vendor", "account", "plan", "/month", "30d at list", "calls", "used", "agents"], list.map((a) => [
200
200
  a.vendor,
201
- a.label ?? a.email ?? dim(a.id),
201
+ a.label ?? a.email ?? dim(`account ${(a.externalId ?? a.id).slice(0, 12)}`),
202
202
  a.planLabel ?? dim("unknown"),
203
203
  a.monthlyCents ? money(a.monthlyCents) : dim("-"),
204
204
  money(a.listCents),
package/dist/pool-run.js CHANGED
@@ -4,29 +4,35 @@
4
4
  * another has room, wait for a quiet moment and relaunch into the same
5
5
  * conversation as that other login.
6
6
  */
7
- import { pickMember, relaunchArgs, shouldSwap } from "./pool.js";
8
- const label = (s) => `${s.member.name}${s.email ? ` (${s.email})` : ""}${s.used === undefined ? "" : `, ${Math.round(s.used)}% used`}`;
7
+ import { isOver, pickMember, relaunchArgs, shouldSwap } from "./pool.js";
8
+ const label = (s) => `${s.member.name}${s.email && s.email !== s.member.name ? ` (${s.email})` : ""}${s.used === undefined ? "" : `, ${Math.round(s.used)}% used`}`;
9
+ /** Which limit a login is over, in words. */
10
+ function why(s, limits) {
11
+ return (s.used ?? 0) >= limits.at ? `is over ${limits.at}%` : `has ${Math.round(s.longUsed ?? 0)}% of its long window used`;
12
+ }
9
13
  export async function runPooled(args, deps) {
10
14
  const all = await deps.standings();
11
- let current = deps.pinned ? all.find((s) => s.member.name === deps.pinned) : pickMember(all, deps.at);
15
+ let current = deps.pinned ? all.find((s) => s.member.name === deps.pinned) : pickMember(all, deps.limits);
12
16
  if (!current) {
13
17
  deps.say(deps.pinned ? `No pool member named ${deps.pinned}. See tab pool.` : "The pool is empty. Add a login with tab pool add.");
14
18
  return 2;
15
19
  }
16
- if (!deps.pinned && (current.used ?? 0) >= deps.at)
17
- deps.say(`Every login in the pool is over ${deps.at}%. Running as the least used.`);
20
+ if (!deps.pinned && isOver(current, deps.limits))
21
+ deps.say(`Every login in the pool is over its limit. Running as the least used.`);
18
22
  let launchArgs = args;
19
23
  for (;;) {
20
24
  deps.say(`Pool: running as ${label(current)}.`);
21
25
  const child = deps.start(current, launchArgs);
22
26
  let next;
27
+ let seen = current;
23
28
  if (!deps.pinned && deps.swap === "auto") {
24
29
  while (await deps.wait(deps.intervalMs ?? 60_000, child.done)) {
25
30
  // A failed look at the console is not a reason to touch a running harness.
26
31
  const now = await deps.standings().catch(() => undefined);
27
- const to = now ? shouldSwap(now, deps.at, current.member.name) : undefined;
32
+ const to = now ? shouldSwap(now, deps.limits, current.member.name) : undefined;
28
33
  if (to && (await deps.idle().catch(() => false))) {
29
34
  next = to;
35
+ seen = now?.find((s) => s.member.name === current.member.name) ?? current;
30
36
  child.stop();
31
37
  break;
32
38
  }
@@ -35,7 +41,7 @@ export async function runPooled(args, deps) {
35
41
  const code = await child.done;
36
42
  if (!next)
37
43
  return code;
38
- deps.say(`Pool: ${current.member.name} is over ${deps.at}%. Continuing as ${label(next)}; the conversation carries over, the vendor's prompt cache does not.`);
44
+ deps.say(`Pool: ${current.member.name} ${why(seen, deps.limits)}. Continuing as ${label(next)}; the conversation carries over, the vendor's prompt cache does not.`);
39
45
  current = next;
40
46
  launchArgs = relaunchArgs(deps.harness, args);
41
47
  }
package/dist/pool-view.js CHANGED
@@ -3,7 +3,7 @@
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 { pickMember } from "./pool.js";
6
+ import { isOver, pickMember, windowMinutes } from "./pool.js";
7
7
  import { bold, dim, green, red, table, width, yellow } from "./ui.js";
8
8
  const BAR = 10;
9
9
  /** "2h 10m", "3d 4h", "12m": how long until a window resets. */
@@ -30,19 +30,15 @@ export function usageBar(pct, at) {
30
30
  const filled = Math.min(BAR, Math.max(0, Math.round((pct / 100) * BAR)));
31
31
  return `${paint(pct, at)("█".repeat(filled))}${dim("░".repeat(BAR - filled))}`;
32
32
  }
33
- /** `5h` before `7d`: the window that bites soonest comes first. */
34
- function minutes(window) {
35
- const m = /^(\d+)([mhd])$/.exec(window);
36
- return m ? Number(m[1]) * (m[2] === "d" ? 1440 : m[2] === "h" ? 60 : 1) : Number.MAX_SAFE_INTEGER;
37
- }
38
33
  /** "claude max 20x" and "pro" as the harness files spell them, in the vendor's capitals. */
39
34
  export function planName(plan) {
40
35
  if (!plan)
41
36
  return undefined;
42
37
  return plan.replace(/\b[a-z]/g, (c) => c.toUpperCase()).replace(/\b(\d+)X\b/g, "$1x");
43
38
  }
44
- function windowsLine(windows, at, now) {
45
- const live = [...(windows ?? [])].sort((a, b) => minutes(a.window) - minutes(b.window)).filter((w) => !w.resetsAt || new Date(w.resetsAt).getTime() > now.getTime());
39
+ function windowsLine(windows, limits, now) {
40
+ const sorted = [...(windows ?? [])].sort((a, b) => windowMinutes(a.window) - windowMinutes(b.window));
41
+ const live = sorted.filter((w) => !w.resetsAt || new Date(w.resetsAt).getTime() > now.getTime());
46
42
  if (!windows || windows.length === 0)
47
43
  return dim("no call through FlockTab yet");
48
44
  if (live.length === 0)
@@ -50,6 +46,8 @@ function windowsLine(windows, at, now) {
50
46
  return live
51
47
  .map((w) => {
52
48
  const left = until(w.resetsAt, now);
49
+ // The short window is judged by the threshold, the long one by its guard.
50
+ const at = sorted.length > 1 && w === sorted.at(-1) ? limits.guard : limits.at;
53
51
  return `${w.window} ${paint(w.usedPct, at)(`${Math.round(w.usedPct)}%`)}${left ? dim(` resets in ${left}`) : ""}`;
54
52
  })
55
53
  .join(dim(" · "));
@@ -63,11 +61,12 @@ export function renderPool(sections, opts) {
63
61
  const loginWidth = Math.max(5, ...all.map((s) => width(s.email ? whoOf(s) : `${whoOf(s)} not signed in`)));
64
62
  const planWidth = Math.max(4, ...all.map((s) => width(planName(s.plan) ?? "-")));
65
63
  for (const section of sections) {
66
- const at = section.at ?? opts.at;
67
- const next = pickMember(section.standings, at)?.member.name;
68
- const over = section.standings.filter((s) => (s.used ?? 0) >= at).length;
64
+ const limits = section.limits ?? { at: opts.at, guard: opts.guard ?? 95 };
65
+ const at = limits.at;
66
+ const next = pickMember(section.standings, limits)?.member.name;
67
+ const over = section.standings.filter((s) => isOver(s, limits)).length;
69
68
  const count = `${section.standings.length} ${section.standings.length === 1 ? "login" : "logins"}`;
70
- out.push(`${bold(section.title)} ${dim(`${count} · moves at ${at}%${over > 0 ? ` · ${over} over` : ""} · tab ${section.harness}`)}`);
69
+ out.push(`${bold(section.title)} ${dim(`${count} · moves at ${at}% · weekly guard ${limits.guard}%${over > 0 ? ` · ${over} over` : ""} · tab ${section.harness}`)}`);
71
70
  out.push(table(["", "login".padEnd(loginWidth), "plan".padEnd(planWidth), "used", "", "windows", ...(opts.paths ? ["folder"] : [])], section.standings.map((s) => {
72
71
  const who = whoOf(s);
73
72
  return [
@@ -76,13 +75,13 @@ export function renderPool(sections, opts) {
76
75
  planName(s.plan) ?? dim("-"),
77
76
  usageBar(s.used, at),
78
77
  s.used === undefined ? dim(" -") : paint(s.used, at)(`${String(Math.round(s.used)).padStart(3)}%`),
79
- windowsLine(s.windows, at, now),
78
+ windowsLine(s.windows, limits, now),
80
79
  ...(opts.paths ? [dim(s.member.dir)] : []),
81
80
  ];
82
81
  }), { indent: 1 }));
83
82
  out.push("");
84
83
  }
85
- out.push(dim(`${green("→")} runs next. A login over its vendor's threshold gives way to the one with most room${opts.swap === "auto" ? ", also while running when idle" : ", at launch only"}.`));
86
- out.push(dim("--as <login> pins one · --no-pool uses your usual login · tab pool at [claude|codex|grok|kimi] <percent> · --paths shows folders"));
84
+ out.push(dim(`${green("→")} runs next. The short window decides; the weekly one only from its guard up. A login over either gives way to the one with most room${opts.swap === "auto" ? ", also while running when idle" : ", at launch only"}.`));
85
+ out.push(dim("--as <login> pins one · --no-pool uses your usual login · tab pool at [vendor] [7d] <percent> · --paths shows folders"));
87
86
  return out.join("\n");
88
87
  }
package/dist/pool.js CHANGED
@@ -8,12 +8,14 @@
8
8
  * threshold and another has room, the harness is relaunched into the same
9
9
  * conversation as that other login.
10
10
  */
11
- import { chmod, mkdir, readdir, readFile, symlink, writeFile, lstat } from "node:fs/promises";
11
+ import { chmod, mkdir, readdir, readFile, readlink, symlink, unlink, writeFile, lstat } from "node:fs/promises";
12
12
  import { homedir } from "node:os";
13
13
  import path from "node:path";
14
+ import { moveInto } from "./codex-home.js";
14
15
  import { configDir } from "./config.js";
15
16
  export const POOL_VENDORS = ["anthropic", "openai", "xai", "kimi"];
16
17
  export const DEFAULT_AT = 80;
18
+ export const DEFAULT_GUARD = 95;
17
19
  export function poolPath(env = process.env) {
18
20
  return path.join(configDir(env), "pool.json");
19
21
  }
@@ -29,22 +31,26 @@ export function poolVendorFor(harness) {
29
31
  return undefined;
30
32
  }
31
33
  export async function loadPool(env = process.env) {
32
- const empty = { at: DEFAULT_AT, atByVendor: {}, swap: "auto", members: {} };
34
+ const empty = { at: DEFAULT_AT, atByVendor: {}, guard: DEFAULT_GUARD, guardByVendor: {}, swap: "auto", members: {} };
33
35
  try {
34
36
  const raw = JSON.parse(await readFile(poolPath(env), "utf8"));
35
37
  const valid = (n) => typeof n === "number" && n >= 1 && n <= 100;
36
38
  const at = valid(raw.at) ? raw.at : DEFAULT_AT;
37
39
  const members = {};
38
40
  const atByVendor = {};
41
+ const guardByVendor = {};
39
42
  for (const vendor of POOL_VENDORS) {
40
43
  const own = raw.atByVendor?.[vendor];
41
44
  if (valid(own))
42
45
  atByVendor[vendor] = own;
46
+ const ownGuard = raw.guardByVendor?.[vendor];
47
+ if (valid(ownGuard))
48
+ guardByVendor[vendor] = ownGuard;
43
49
  const list = raw.members?.[vendor];
44
50
  if (Array.isArray(list))
45
51
  members[vendor] = list.filter((m) => typeof m?.name === "string" && typeof m?.dir === "string").map((m) => ({ name: m.name, dir: m.dir }));
46
52
  }
47
- return { at, atByVendor, swap: raw.swap === "launch" ? "launch" : "auto", members };
53
+ return { at, atByVendor, guard: valid(raw.guard) ? raw.guard : DEFAULT_GUARD, guardByVendor, swap: raw.swap === "launch" ? "launch" : "auto", members };
48
54
  }
49
55
  catch {
50
56
  return empty;
@@ -61,6 +67,26 @@ export async function savePool(pool, env = process.env) {
61
67
  export function thresholdFor(pool, vendor) {
62
68
  return pool.atByVendor[vendor] ?? pool.at;
63
69
  }
70
+ /** Both limits for one vendor: its own where set, else the shared ones. */
71
+ export function limitsFor(pool, vendor) {
72
+ return { at: thresholdFor(pool, vendor), guard: pool.guardByVendor[vendor] ?? pool.guard };
73
+ }
74
+ /** Set the weekly guard, shared or one vendor's; `undefined` for a vendor clears its override. */
75
+ export function setGuard(pool, pct, vendor) {
76
+ if (pct !== undefined && (!Number.isFinite(pct) || pct < 1 || pct > 100))
77
+ throw new Error("A threshold is a percent from 1 to 100.");
78
+ if (!vendor) {
79
+ if (pct === undefined)
80
+ throw new Error("A threshold is a percent from 1 to 100.");
81
+ return { ...pool, guard: Math.round(pct) };
82
+ }
83
+ const guardByVendor = { ...pool.guardByVendor };
84
+ if (pct === undefined)
85
+ delete guardByVendor[vendor];
86
+ else
87
+ guardByVendor[vendor] = Math.round(pct);
88
+ return { ...pool, guardByVendor };
89
+ }
64
90
  /** Set the shared threshold, or one vendor's; `undefined` for a vendor clears its override. */
65
91
  export function setThreshold(pool, pct, vendor) {
66
92
  if (pct !== undefined && (!Number.isFinite(pct) || pct < 1 || pct > 100))
@@ -107,46 +133,97 @@ export function memberEnv(vendor, member) {
107
133
  }
108
134
  /**
109
135
  * Codex, Grok Build and Kimi Code keep conversations in `sessions` inside
110
- * their home. Members of one vendor link that to one shared folder, so the
111
- * login that takes over finds the conversation the other was in. A home
136
+ * their home. Every member links that to one place, so the login that takes
137
+ * over finds the conversation the other was in. A home
112
138
  * that already has its own `sessions` is left as it is.
113
139
  */
114
- export async function prepareSharedSessions(vendor, member, env = process.env) {
140
+ export async function prepareSharedSessions(vendor, member, env = process.env, userHome = homedir()) {
115
141
  if (vendor === "anthropic")
116
142
  return;
117
143
  const home = memberHome(vendor, member);
118
144
  const link = path.join(home, "sessions");
119
- if (await lstat(link).then(() => true, () => false))
145
+ const poolShared = path.join(configDir(env), "pool", vendor, ".shared", "sessions");
146
+ const existing = await lstat(link).catch(() => undefined);
147
+ const pointsAt = existing?.isSymbolicLink() ? await readlink(link).catch(() => undefined) : undefined;
148
+ // A folder of its own, or a link somewhere we did not choose, is left alone.
149
+ if (existing && pointsAt !== poolShared)
150
+ return;
151
+ // The person's own conversations where that harness has a home here, so a
152
+ // pooled launch can resume what was started without the pool; else one
153
+ // folder shared by this vendor's members.
154
+ const own = { openai: [".codex"], xai: [".grok"], kimi: [".kimi-code", ".kimi"] }[vendor].map((d) => path.join(userHome, d));
155
+ let shared = poolShared;
156
+ for (const dir of own) {
157
+ if (path.resolve(dir) !== path.resolve(home) && (await lstat(dir).then((s) => s.isDirectory(), () => false))) {
158
+ shared = path.join(dir, "sessions");
159
+ break;
160
+ }
161
+ }
162
+ if (existing && shared === poolShared)
120
163
  return;
121
- const shared = path.join(configDir(env), "pool", vendor, ".shared", "sessions");
122
164
  await mkdir(shared, { recursive: true, mode: 0o700 });
123
165
  await mkdir(home, { recursive: true, mode: 0o700 });
166
+ if (existing) {
167
+ // An earlier link to the pool-only folder: carry its conversations over, then point at the person's own.
168
+ await moveInto(poolShared, shared).catch(() => undefined);
169
+ await unlink(link);
170
+ }
124
171
  await symlink(shared, link);
125
172
  }
126
- /** How used an account is: its fullest window that has not reset yet. */
127
- export function usedPct(quota, now = new Date()) {
128
- const live = quota.filter((w) => !w.resetsAt || new Date(w.resetsAt).getTime() > now.getTime());
173
+ /** `5h` is 300 minutes, `7d` 10080; a name that is not a length sorts last. */
174
+ 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;
177
+ }
178
+ /**
179
+ * How used an account is. The short window (Claude's 5 hours, Codex's
180
+ * primary) is the usage: it runs out first and comes back within hours. The
181
+ * long one (the week) is kept apart as `longUsed`, and only matters once it
182
+ * is nearly spent. A window past its reset counts as nothing used.
183
+ */
184
+ export function usage(quota, now = new Date()) {
129
185
  if (quota.length === 0)
130
- return undefined;
131
- return live.reduce((max, w) => Math.max(max, w.usedPct), 0);
186
+ return {};
187
+ const sorted = [...quota].sort((x, y) => windowMinutes(x.window) - windowMinutes(y.window));
188
+ const pct = (w) => (!w.resetsAt || new Date(w.resetsAt).getTime() > now.getTime() ? w.usedPct : 0);
189
+ const short = sorted[0];
190
+ const long = sorted.length > 1 ? sorted.at(-1) : undefined;
191
+ return { used: pct(short), ...(long ? { longUsed: pct(long) } : {}) };
132
192
  }
133
193
  const room = (s) => s.used ?? 0;
134
- /** Stay on the current login while it is under the threshold; else the one with most room. */
135
- export function pickMember(all, at, current) {
194
+ const longRoom = (s) => s.longUsed ?? 0;
195
+ export function isOver(s, limits) {
196
+ return room(s) >= limits.at || longRoom(s) >= limits.guard;
197
+ }
198
+ /** The vendor refuses every call: a window is fully spent. */
199
+ const spent = (s) => room(s) >= 100 || longRoom(s) >= 100;
200
+ /** Least short-window usage first, in steps of five points; within a step, the emptier week first. */
201
+ function byRoom(a, b) {
202
+ return Math.floor(room(a) / 5) - Math.floor(room(b) / 5) || longRoom(a) - longRoom(b) || room(a) - room(b);
203
+ }
204
+ /**
205
+ * Stay on the current login while it is fine; else the login with most
206
+ * room among those that are fine; and when none is, the least used that the
207
+ * vendor would still answer, rather than refusing to run.
208
+ */
209
+ export function pickMember(all, limits, current) {
136
210
  if (all.length === 0)
137
211
  return undefined;
138
212
  const now = all.find((s) => s.member.name === current);
139
- if (now && room(now) < at)
213
+ if (now && !isOver(now, limits))
140
214
  return now;
141
- return [...all].sort((a, b) => room(a) - room(b))[0];
215
+ const fine = all.filter((s) => !isOver(s, limits)).sort(byRoom);
216
+ if (fine.length > 0)
217
+ return fine[0];
218
+ return [...all].sort((a, b) => Number(spent(a)) - Number(spent(b)) || byRoom(a, b))[0];
142
219
  }
143
- /** The login to move to, when the current one is over and another has room. */
144
- export function shouldSwap(all, at, current) {
220
+ /** The login to move to, when the current one is over and another is not. */
221
+ export function shouldSwap(all, limits, current) {
145
222
  const now = all.find((s) => s.member.name === current);
146
- if (!now || room(now) < at)
223
+ if (!now || !isOver(now, limits))
147
224
  return undefined;
148
- const best = pickMember(all, at, current);
149
- return best && best.member.name !== current && room(best) < at ? best : undefined;
225
+ const best = pickMember(all, limits, current);
226
+ return best && best.member.name !== current && !isOver(best, limits) ? best : undefined;
150
227
  }
151
228
  /** Arguments that reopen the conversation the previous login was in. */
152
229
  export function relaunchArgs(harness, args) {
@@ -0,0 +1,429 @@
1
+ // GENERATED from packages/shared/src/tab-docs.ts by scripts/sync-docs.mjs. Edit that file, not this one.
2
+ /**
3
+ * The `tab` CLI, described once. `tab help`, `tab help <command>` and the
4
+ * console's Docs pages all read this file, so what the terminal says and
5
+ * what the docs say cannot drift apart.
6
+ *
7
+ * Pure data: no imports, nothing from Node or the browser. `apps/tab` ships
8
+ * to npm on its own, so it carries a generated copy of this file
9
+ * (`apps/tab/src/tab-docs.ts`, written by `apps/tab/scripts/sync-docs.mjs`);
10
+ * a test there fails when the copy is stale.
11
+ */
12
+ export const TAB_COMMAND_GROUPS = [
13
+ { id: "start", title: "Get started", blurb: "Once per machine, then once per project folder." },
14
+ { id: "run", title: "Run an agent on the tab", blurb: "Prefix the agent you already use. Its calls are gated, recorded and, on an API-key Agent, charged to the cap." },
15
+ { id: "agents", title: "Agents, caps and policy", blurb: "Everything the console's Agent page does, from the terminal. Leave the Agent out and the command acts on this folder's." },
16
+ { id: "subscriptions", title: "Subscriptions and the pool", blurb: "Agents that run on your own Claude, ChatGPT, SuperGrok or Kimi plan, and choosing between several logins of one vendor." },
17
+ { id: "watch", title: "See what happened", blurb: "Read commands. Add --json to any of them for scripts and agents." },
18
+ { id: "selfhost", title: "Self-hosted proxy", blurb: "Provider keys stay on your machine; the ledger stays on flocktab.com." },
19
+ { id: "machine", title: "This machine", blurb: "The CLI itself." },
20
+ ];
21
+ export const TAB_COMMANDS = [
22
+ {
23
+ name: "login",
24
+ group: "start",
25
+ usage: ["login"],
26
+ summary: "Approve this machine in the console, once.",
27
+ details: [
28
+ "Asks whether this machine uses the hosted proxy (proxy.flocktab.com) or a self-hosted one on this machine, then prints a short code and opens the console. Approve the code there and the machine receives its session. Nothing is typed into the terminal except that choice, and no Agent is chosen here.",
29
+ "Login is per machine. Which Agent a project runs as is per folder: see tab use.",
30
+ "On a hosted flock with a saved provider key, login also asks for the flock's unlock, checks that it opens the key, and keeps it in the machine's config (owner-only). The unlock is never stored on FlockTab.",
31
+ ],
32
+ examples: [{ cmd: "tab login", what: "approve this machine" }],
33
+ see: ["use", "logout", "status"],
34
+ },
35
+ {
36
+ name: "use",
37
+ group: "start",
38
+ usage: ["use"],
39
+ summary: "Pick or change the Agent this folder runs as.",
40
+ details: [
41
+ "Lists the flock's Agents (state, kind, cap) and lets you choose one or make a new one. A new Agent asks for its kind: API key or Subscription. The choice is written to a one-line .flocktab file at the git root (or this folder), and a key for that Agent is minted and kept on this machine.",
42
+ "Running tab claude or tab codex in a folder with no .flocktab asks the same question, so tab use is only needed to change the answer.",
43
+ "An Agent that already holds a key on another machine can be used here too, but minting a key here replaces the one there. tab asks before doing it.",
44
+ ],
45
+ see: ["agent", "key", "list"],
46
+ },
47
+ {
48
+ name: "claude",
49
+ overview: "claude | codex | grok | kimi | <cmd>",
50
+ aliases: ["codex", "grok", "kimi", "gemini", "aider", "cursor", "<command>"],
51
+ group: "run",
52
+ usage: ["claude [args]", "codex [args]", "grok [args]", "kimi [args]", "<any command> [args]"],
53
+ summary: "Run an agent on this folder's tab. Everything after the name goes to the agent untouched.",
54
+ details: [
55
+ "tab sets the environment that agent reads (its base URL and key, or for Codex a home folder of its own) and starts it. It does not wrap or parse the agent's traffic; the proxy does the gating.",
56
+ "On an API-key Agent the agent presents the tab's key, the flock's provider key pays, and every call is held, settled and charged to the cap. A call that would pass the cap is refused before the provider with 402 tab_closed.",
57
+ "On a Subscription Agent the agent keeps its own login (Claude Max, ChatGPT, SuperGrok, Kimi). The tab key rides in the base URL, the same kill switch and policies apply before the vendor is reached, the call is recorded at list price under the account the vendor names, and nothing is charged to the cap.",
58
+ "Any other command works too: tab points both the OpenAI and the Anthropic variables at the proxy and runs it. An agent with no consumer plan on a Subscription Agent runs metered on the flock's key, and tab says so.",
59
+ "With logins in the pool for that vendor, a Subscription Agent runs as the login with most room. See tab pool.",
60
+ ],
61
+ options: [
62
+ { flag: "--as <login>", what: "run as one pool login and never move off it" },
63
+ { flag: "--no-pool", what: "ignore the pool and use the agent's usual login" },
64
+ { flag: "--pool", what: "require the pool; fail rather than fall back when it cannot be used" },
65
+ ],
66
+ examples: [
67
+ { cmd: "tab claude", what: "Claude Code on this folder's tab" },
68
+ { cmd: "tab codex resume <id>", what: "arguments pass straight through; Codex sees the conversations in ~/.codex" },
69
+ { cmd: "tab claude --as work", what: "pin the pool login named work" },
70
+ { cmd: "tab python agent.py", what: "any script, with OPENAI_* and ANTHROPIC_* pointed at the tab" },
71
+ ],
72
+ see: ["use", "pool", "alias", "log"],
73
+ },
74
+ {
75
+ name: "alias",
76
+ overview: "alias setup | remove | list",
77
+ aliases: ["unalias"],
78
+ group: "run",
79
+ usage: ["alias setup <name>...", "alias remove <name>...", "alias list"],
80
+ summary: "Make the plain command (codex) run through the tab (tab codex).",
81
+ details: [
82
+ "Writes a small shim named like the agent into ~/.flocktab/bin. With that folder first on your PATH, typing codex runs tab codex. tab removes that folder from the PATH of the agent it starts, so the shim never calls itself.",
83
+ "tab alias setup prints the PATH line to add to your shell if it is not there yet.",
84
+ ],
85
+ examples: [
86
+ { cmd: "tab alias setup claude codex", what: "plain claude and codex now run on the tab" },
87
+ { cmd: "tab alias remove codex", what: "undo one" },
88
+ ],
89
+ },
90
+ {
91
+ name: "agent",
92
+ overview: "agent create | kind",
93
+ group: "agents",
94
+ usage: ["agent create <name> --subscription|--api [--cap <dollars>]", "agent kind <agent> api|subscription"],
95
+ summary: "Make an Agent of a given kind, or change an Agent's kind.",
96
+ details: [
97
+ "An Agent is one named worker with one tab. Its kind is the only thing anyone declares: api means the flock's provider key pays and the cap is the meter; subscription means its agents use your own plan logins and nothing is charged.",
98
+ "Changing the kind applies to the next launch. tab claude reads it fresh every time.",
99
+ ],
100
+ options: [
101
+ { flag: "--subscription | --api", what: "the kind of the new Agent (api when neither is given)" },
102
+ { flag: "--cap <dollars>", what: "the new Agent's cap; 50 when left out" },
103
+ ],
104
+ examples: [
105
+ { cmd: "tab agent create billing-api --api --cap 20", what: "a metered Agent with a $20 cap" },
106
+ { cmd: "tab agent create my-laptop --subscription", what: "an Agent for your own plan logins" },
107
+ { cmd: "tab agent kind billing-api subscription", what: "switch an existing Agent" },
108
+ ],
109
+ see: ["use", "cap", "accounts"],
110
+ },
111
+ {
112
+ name: "list",
113
+ aliases: ["ls"],
114
+ group: "agents",
115
+ usage: ["list"],
116
+ summary: "Every Agent: kind, state, spent of cap, window, project.",
117
+ details: ["One row per Agent in the flock. The same list tab use chooses from."],
118
+ options: [{ flag: "--json", what: "the rows as JSON" }],
119
+ see: ["status", "live"],
120
+ },
121
+ {
122
+ name: "close",
123
+ overview: "close | open [agent]",
124
+ aliases: ["open"],
125
+ group: "agents",
126
+ usage: ["close [agent]", "open [agent]"],
127
+ summary: "The kill switch. Close stops the next call; open lets calls through again.",
128
+ details: [
129
+ "Closing a tab takes effect on the next call: it is refused before the provider or vendor with 402 tab_closed. Calls already in flight finish. This works the same on API-key and Subscription Agents.",
130
+ "A tab that closed itself by reaching its cap reopens by itself when its window rolls over (unless its reopen rule says manual or never), or now with tab open.",
131
+ ],
132
+ examples: [
133
+ { cmd: "tab close", what: "stop this folder's Agent" },
134
+ { cmd: "tab open billing-api", what: "let a named Agent run again" },
135
+ ],
136
+ see: ["cap", "policy"],
137
+ },
138
+ {
139
+ name: "cap",
140
+ group: "agents",
141
+ usage: ["cap [agent] <dollars>"],
142
+ summary: "Set the hard cap and, optionally, its window.",
143
+ details: [
144
+ "The cap is a hard dollar limit on one Agent for one window. Before every call the proxy holds an estimate against it; a call whose estimate would pass the cap is refused before the provider. There is no overage.",
145
+ "The window is how often the spent amount starts again: run, hour, day, week, month or lifetime.",
146
+ ],
147
+ options: [{ flag: "--window run|hour|day|week|month|lifetime", what: "when spent starts again" }],
148
+ examples: [{ cmd: "tab cap 25 --window week", what: "$25 a week for this folder's Agent" }],
149
+ see: ["close", "policy", "spend"],
150
+ },
151
+ {
152
+ name: "policy",
153
+ group: "agents",
154
+ usage: ["policy [agent]", "policy [agent] --velocity N|none --models a,b|none --allow tool,tool|none"],
155
+ summary: "Show or set the rules checked before every call.",
156
+ details: [
157
+ "Checked in this order, first refusal wins: tab state, cap, velocity (calls per minute, 429), model allowlist (403 model_blocked), irreversible tools (403 risk_blocked), per-tool caps (402 tool_cap).",
158
+ "A tool FlockTab does not know is treated as a write, not a read. Irreversible tools (payments, sending email, deletes, production deploys) are denied unless named in --allow, and even then only while at least $25.00 of the cap remains.",
159
+ ],
160
+ options: [
161
+ { flag: "--velocity N|none", what: "most calls per minute (a new Agent starts at 120)" },
162
+ { flag: "--models a,b|none", what: "the only models allowed; none allows any" },
163
+ { flag: "--allow tool,tool|none", what: "irreversible tools this Agent may call" },
164
+ ],
165
+ examples: [{ cmd: "tab policy --velocity 60 --models claude-sonnet-5,gpt-5", what: "slow it down and pin its models" }],
166
+ see: ["cap", "ledger"],
167
+ },
168
+ {
169
+ name: "rename",
170
+ group: "agents",
171
+ usage: ["rename <agent> <name>"],
172
+ summary: "Rename an Agent. Its slug, and so its console URL and .flocktab files, stay.",
173
+ details: ["The display name changes everywhere. The slug is permanent so folders and links keep working."],
174
+ },
175
+ {
176
+ name: "key",
177
+ overview: "key rotate [agent]",
178
+ group: "agents",
179
+ usage: ["key rotate [agent]"],
180
+ summary: "Mint a new key for an Agent, kept on this machine. The old one stops working.",
181
+ details: [
182
+ "Keys look like ft_live_... and are stored hashed; FlockTab cannot show one again. The old key is refused (401 key_revoked) from the next call, on every machine that still holds it.",
183
+ ],
184
+ options: [{ flag: "--show", what: "print the new key once, for a machine without tab" }],
185
+ see: ["use"],
186
+ },
187
+ {
188
+ name: "project",
189
+ overview: "project | projects",
190
+ aliases: ["projects"],
191
+ group: "agents",
192
+ usage: ["projects", "projects add <name>", "project [agent] <name>|none"],
193
+ summary: "Group Agents under a project for chargeback (Team plan and up).",
194
+ details: [
195
+ "A project is a label on FlockTab's own meter: what these Agents spent, together. --repo links a GitHub repository so outside spend (Actions, storage) can be attributed to the same project.",
196
+ ],
197
+ options: [
198
+ { flag: "--create", what: "make the project if it does not exist" },
199
+ { flag: "--repo owner/name", what: "link a GitHub repository" },
200
+ ],
201
+ see: ["spend", "outside"],
202
+ },
203
+ {
204
+ name: "archive",
205
+ group: "agents",
206
+ usage: ["archive [agent]"],
207
+ summary: "Close an Agent for good and take it off the bill. Its history stays.",
208
+ details: ["The tab closes for good, the Agent leaves the lists and stops counting towards the plan's Agents. Its ledger rows stay."],
209
+ options: [{ flag: "--yes", what: "do not ask" }],
210
+ },
211
+ {
212
+ name: "accounts",
213
+ aliases: ["quota"],
214
+ group: "subscriptions",
215
+ usage: ["accounts"],
216
+ summary: "Every plan login your Subscription Agents were seen on, and how used it is.",
217
+ details: [
218
+ "Accounts are discovered, never typed in: the vendor names the login on every reply, and FlockTab files the call under it. Each row shows the plan, the seat price taken from the plan tier, what the last 30 days would have cost at list price, the number of calls, and the usage the vendor last reported per window.",
219
+ "The console shows the same under Spend, Subscriptions; a label or a seat price of your own is set under Control, Accounts.",
220
+ ],
221
+ see: ["pool", "agent"],
222
+ },
223
+ {
224
+ name: "pool",
225
+ overview: "pool [add | remove | at | swap]",
226
+ group: "subscriptions",
227
+ usage: [
228
+ "pool",
229
+ "pool add claude|codex|grok|kimi <name> [--email <address>] [--dir <folder>]",
230
+ "pool remove claude|codex|grok|kimi <name>",
231
+ "pool at [claude|codex|grok|kimi] [7d] <percent>|default",
232
+ "pool swap auto|launch",
233
+ ],
234
+ summary: "Several logins of one vendor on this machine; the agent runs as the one with most room.",
235
+ details: [
236
+ "A pool member is a folder the agent signs in to itself (CLAUDE_CONFIG_DIR, CODEX_HOME, GROK_HOME, KIMI_CODE_HOME). tab never reads, copies or forwards a login, and neither proxy holds one: tab only chooses which folder a launch uses. pool add opens the vendor's own browser sign-in and finishes by itself. The login then shows in the console under Subscriptions at once, before its first call: tab tells the console the account's id, email and plan tier as the agent's own files state them, never the login itself.",
237
+ "Usage is what the vendor last reported for that account. The short window (Claude's 5 hours, Codex's primary) decides: at or over the threshold, the login gives way. The long window (the week) is ignored until it reaches its guard, 95% unless set, and then the login gives way whatever the short window says. Between logins within five points of each other, the one with more of its week left goes first. A login never seen through FlockTab counts as unused.",
238
+ "At launch tab takes the login with most room. While running (swap auto) it looks once a minute; when the login in use is over a limit and another is not, it waits for a quiet moment, stops the agent and starts it again as the other login in the same conversation. The vendor's prompt cache does not carry over, and anything typed but not sent is lost. When every login is over, nothing moves, and at launch the least used still runs.",
239
+ "Members share your own conversations (~/.claude, ~/.codex, ~/.grok, ~/.kimi-code), so a conversation continues under another login and can be resumed with or without the pool. The pool lives in ~/.flocktab/pool.json on this machine only.",
240
+ ],
241
+ options: [
242
+ { flag: "--email <address>", what: "pool add: prefill the sign-in page (a name that is an email does this by itself)" },
243
+ { flag: "--dir <folder>", what: "pool add: use a config folder you already have instead of making one" },
244
+ { flag: "--paths", what: "pool: also show each login's folder" },
245
+ { flag: "at <percent>", what: "the short-window threshold for every vendor (default 80)" },
246
+ { flag: "at <vendor> <percent>", what: "one vendor's own threshold; default clears it" },
247
+ { flag: "at [vendor] 7d <percent>", what: "the weekly guard (default 95)" },
248
+ { flag: "swap auto|launch", what: "auto also moves a running agent when idle; launch only chooses at start" },
249
+ ],
250
+ examples: [
251
+ { cmd: "tab pool add claude me@example.com", what: "sign a Claude login in; the email is prefilled" },
252
+ { cmd: "tab pool", what: "every login: plan, usage bar, each window and its reset, and which runs next" },
253
+ { cmd: "tab pool at claude 70", what: "Claude logins give way at 70% of the 5-hour window" },
254
+ { cmd: "tab pool at 7d 90", what: "any login gives way once 90% of its week is used" },
255
+ { cmd: "tab claude --as me@example.com", what: "pin one login for this run" },
256
+ ],
257
+ see: ["accounts", "claude"],
258
+ },
259
+ {
260
+ name: "status",
261
+ group: "watch",
262
+ usage: ["status"],
263
+ summary: "Which flock, which Agent this folder runs as, its kind, and whether the proxy answers.",
264
+ 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."],
265
+ see: ["login", "use", "up"],
266
+ },
267
+ {
268
+ name: "log",
269
+ overview: "log [-f] | log --proxy",
270
+ aliases: ["logs"],
271
+ group: "watch",
272
+ usage: ["log [-f] [--all] [--lines N]", "log --proxy [-f]"],
273
+ summary: "Everything that went through the tab, one line per call.",
274
+ details: [
275
+ "tab log reads the flock's ledger and shows the calls of the Agents keyed on this machine: time, Agent, what was called, tokens in and out, the amount, and the outcome (settled, subscription, refunded, blocked and why). It works the same on hosted and self-hosted.",
276
+ "tab log --proxy (also tab logs) is the self-hosted proxy's own log on this machine: one line per call with the vendor, model, status, cached tokens and milliseconds. Neither log ever contains a key, a login, a prompt, an email or an IP address.",
277
+ ],
278
+ options: [
279
+ { flag: "-f", what: "follow: keep printing new calls" },
280
+ { flag: "--all", what: "every Agent in the flock, not only this machine's" },
281
+ { flag: "--lines N", what: "how many to start with (30)" },
282
+ { flag: "--proxy", what: "the local proxy's log instead" },
283
+ ],
284
+ see: ["ledger", "live"],
285
+ },
286
+ {
287
+ name: "ledger",
288
+ group: "watch",
289
+ usage: ["ledger [agent]"],
290
+ summary: "One row per call, with the hold and what it settled at.",
291
+ details: [
292
+ "SETTLED $0.08 of $0.14 held means $0.14 was held before the call and $0.08 was the real cost; the rest went back. REFUNDED means the provider failed and nothing was charged. SUBSCRIPTION is a call on your own plan, priced at list and not charged. BLOCKED names the rule that refused it.",
293
+ ],
294
+ options: [
295
+ { flag: "--limit N", what: "how many rows" },
296
+ { flag: "--blocked", what: "only refused calls" },
297
+ ],
298
+ see: ["log", "spend"],
299
+ },
300
+ {
301
+ name: "spend",
302
+ group: "watch",
303
+ usage: ["spend [agent|project|day]"],
304
+ summary: "What was spent through the tab, and outside it, grouped.",
305
+ details: ["Meter spend is what went through FlockTab. Outside spend is what the connected billing sources report (Team plan and up), so the two can be told apart."],
306
+ options: [
307
+ { flag: "--by agent|project|day", what: "how to group" },
308
+ { flag: "--since 7d", what: "how far back" },
309
+ { flag: "--agent <agent>", what: "only one Agent" },
310
+ ],
311
+ see: ["outside", "ledger"],
312
+ },
313
+ {
314
+ name: "outside",
315
+ group: "watch",
316
+ usage: ["outside"],
317
+ summary: "The outside-spend connections and their totals per project.",
318
+ details: ["Connections are made in the console (Control, Outside spend): provider admin APIs and cloud billing. They answer whether a dollar went through a tab or around it."],
319
+ see: ["spend", "project"],
320
+ },
321
+ {
322
+ name: "live",
323
+ group: "watch",
324
+ usage: ["live"],
325
+ summary: "Who is working right now.",
326
+ details: ["Each Agent's pulse (working, recent, idle, stopped), calls per minute and last decision."],
327
+ options: [{ flag: "--watch N", what: "refresh every N seconds" }],
328
+ },
329
+ {
330
+ name: "web",
331
+ group: "watch",
332
+ usage: ["web [agent]"],
333
+ summary: "Open the Agent's page in the console.",
334
+ details: ["Opens this folder's Agent when none is named."],
335
+ },
336
+ {
337
+ name: "up",
338
+ overview: "up | down",
339
+ aliases: ["down"],
340
+ group: "selfhost",
341
+ usage: ["up", "down"],
342
+ summary: "Start or stop the self-hosted proxy on this machine.",
343
+ details: [
344
+ "The self-hosted proxy is one binary (flocktab-proxy). Your provider keys live in ~/.flocktab/proxy.env on this machine, owner-only, and never reach FlockTab. The proxy asks flocktab.com to hold, settle or refund each call with the Agent's own key, so caps, policies, the kill switch and the ledger work exactly as on hosted.",
345
+ "tab up asks for provider keys the first time (typed without echo), starts the proxy on 127.0.0.1:8787 and waits until it answers. tab claude on a self-hosted machine starts it by itself when it is down.",
346
+ "tab down stops the proxy that tab started. Anything running through it is cut, so stop your agents first.",
347
+ ],
348
+ see: ["log", "update", "status"],
349
+ },
350
+ {
351
+ name: "update",
352
+ aliases: ["upgrade"],
353
+ group: "machine",
354
+ usage: ["update"],
355
+ summary: "Install the newest tab from npm, and with it the newest proxy.",
356
+ details: ["Restarts a self-hosted proxy that was running, so it runs the new version. Stop your agents first on a self-hosted machine."],
357
+ see: ["version"],
358
+ },
359
+ {
360
+ name: "version",
361
+ group: "machine",
362
+ usage: ["version"],
363
+ summary: "The versions of tab and of the proxy it carries.",
364
+ details: ["A proxy that is older than tab can miss features; tab says so when it notices."],
365
+ },
366
+ {
367
+ name: "logout",
368
+ group: "machine",
369
+ usage: ["logout"],
370
+ summary: "Forget this machine's session and Agent keys.",
371
+ details: ["Removes ~/.flocktab/config.json. The pool, the proxy's provider keys and each folder's .flocktab are left alone. The Agents and their history are untouched on FlockTab."],
372
+ see: ["login"],
373
+ },
374
+ ];
375
+ /** What is what. Alphabetical in the docs; grouped by subject here. */
376
+ export const TAB_GLOSSARY = [
377
+ { term: "Flock", meaning: "Your workspace: many Agents, one plan, one ledger. Each console login has its own." },
378
+ { term: "Agent", meaning: "One named worker, such as billing-api or my-laptop. It has exactly one tab, one kind, and its own keys, policies and history. Plans count Agents." },
379
+ { term: "Tab", meaning: "An Agent's spend account: a hard dollar cap, a window, and a state, open or closed. Closing the tab is the kill switch." },
380
+ { term: "Kind", also: ["api", "subscription"], meaning: "What pays for an Agent's tokens. api: the flock's provider key, every call charged to the cap. subscription: your own plan login, gated and recorded but never charged. Chosen when the Agent is made; it can be changed." },
381
+ { term: "Cap", meaning: "The most an Agent may spend in one window, in dollars. Hard: a call that would pass it is refused before the provider. There is no overage." },
382
+ { term: "Window", meaning: "How often a tab's spent amount starts again: run, hour, day, week, month or lifetime." },
383
+ { term: "Hold", also: ["reserve"], meaning: "Before each call the proxy estimates its cost and holds that against the cap. If the hold does not fit, the call is refused and the provider is never reached." },
384
+ { term: "Settle", also: ["commit"], meaning: "After the provider answers, the hold is replaced by the real cost from the provider's own token counts, and the rest goes back to the cap." },
385
+ { term: "Refund", meaning: "The provider failed (any non-2xx): the hold is released and nothing is charged. Only a successful call is ever charged." },
386
+ { term: "Shadow row", also: ["subscription call"], meaning: "The record of a call made on your own plan: the same row as a metered call, priced at list from the vendor's token counts, filed under the account, and never added to the tab's spent amount." },
387
+ { term: "List price", meaning: "What a call would have cost through the API at the vendor's published rates, with cached input priced at its discount. It is how a plan's worth is stated; nobody is charged it." },
388
+ { term: "Kill switch", meaning: "Closing a tab. It applies to the next call, on API-key and Subscription Agents alike, before the provider or vendor is reached. Close all tabs does it for the whole flock." },
389
+ { term: "Policy", meaning: "The rules checked before every call, first refusal wins: tab state, cap, velocity, model allowlist, irreversible tools, per-tool caps." },
390
+ { term: "Velocity", meaning: "The most calls per minute an Agent may make. Over it, calls are refused with 429 until the minute passes. A new Agent starts at 120." },
391
+ { term: "Irreversible tool", meaning: "A tool whose effect cannot be undone: payments, sending email, deletes, production deploys. Denied unless the Agent's policy names it, and then only while at least $25.00 of the cap remains. A tool FlockTab does not know counts as a write." },
392
+ { term: "Virtual key", also: ["tab key", "ft_live_"], meaning: "The key an agent presents to the proxy: ft_live_... (or ft_test_...). It names the Agent. It is stored hashed and shown once. It is not a provider key and is worth nothing at a provider." },
393
+ { term: "Provider key", also: ["BYOK"], meaning: "Your own OpenAI, Anthropic or other API key. You pay the provider directly; FlockTab takes no cut. Hosted: saved encrypted, opened only by your unlock. Self-hosted: it never leaves your machine." },
394
+ { term: "Unlock", meaning: "A secret of yours that opens the flock's saved provider key for the length of one call. It is never stored on FlockTab. Agents send it inside the key (ft_live_<secret>.<unlock>) or as the x-flocktab-unlock header. Without it a hosted call fails closed." },
395
+ { term: "Proxy", meaning: "What agents talk to instead of the provider. It checks the tab and the policies, holds, forwards, and settles. Hosted at proxy.flocktab.com, or self-hosted on your machine." },
396
+ { term: "Hosted", meaning: "Agents talk to proxy.flocktab.com, which opens your saved provider key with your unlock for each call. Nothing to run." },
397
+ { term: "Self-hosted", meaning: "Agents talk to a proxy on your own machine (tab up). Provider keys stay in a file there; only the hold, settle and refund requests, with token counts, go to flocktab.com." },
398
+ { 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." },
399
+ { 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." },
400
+ { 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." },
401
+ { 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." },
402
+ { 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." },
403
+ { 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." },
404
+ { 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." },
405
+ { term: "Account switch", meaning: "An Agent's call landing on a different account of the same vendor than its previous one: a new login mid-session, another machine, or the pool moving. Shown on the Agent's page and under Accounts." },
406
+ { term: "Project", meaning: "A label grouping Agents for chargeback on FlockTab's own meter, optionally linked to a GitHub repository. Team plan and up." },
407
+ { term: "Outside spend", meaning: "Money that did not go through a tab, read from provider admin APIs and cloud billing, so it can be told apart from metered spend. Team plan and up." },
408
+ { term: "Ledger", meaning: "Every decision, one row per call: allowed, settled, refunded, blocked and why. Money is whole cents, never a fraction. If the ledger cannot be reached, no call goes out." },
409
+ { term: "Fail closed", meaning: "When FlockTab cannot be sure a call fits (the ledger is down, the key is unknown, the unlock is missing) the call is refused rather than let through." },
410
+ { term: "Harness", meaning: "The coding agent you run: Claude Code, Codex, Grok Build, Kimi Code, or any command. tab calls them agents in its output." },
411
+ { term: ".flocktab", meaning: "A one-line file at a project's root naming the Agent that folder runs as. Safe to commit: it holds no key." },
412
+ { term: "~/.flocktab", meaning: "This machine's tab folder: the session and Agent keys (config.json), the pool (pool.json), the self-hosted proxy's keys, log and binary, and the alias shims. Owner-only." },
413
+ ];
414
+ /** Every refusal the proxy can answer with, for the docs and for `tab help errors`. */
415
+ export const TAB_ERRORS = [
416
+ { status: 402, code: "tab_closed", when: "The tab is closed, or the call's estimate would pass the cap.", fix: "tab open, raise the cap with tab cap, or wait for the window to roll over." },
417
+ { status: 402, code: "tool_cap", when: "A per-tool cap is reached.", fix: "Raise that tool's cap in the console." },
418
+ { status: 403, code: "risk_blocked", when: "An irreversible tool that the Agent's policy does not allow, or less than $25.00 of the cap remains.", fix: "tab policy --allow <tool>, and leave room under the cap." },
419
+ { status: 403, code: "model_blocked", when: "The model is not on the Agent's allowlist.", fix: "tab policy --models ..., or none to allow any." },
420
+ { status: 403, code: "agent_frozen", when: "The Agent was frozen from the console.", fix: "Frozen by FlockTab support or an admin rule; write to support." },
421
+ { status: 403, code: "not_subscription", when: "A passthrough (/t/...) call for an API-key Agent.", fix: "tab agent kind <agent> subscription, or run it metered." },
422
+ { status: 429, code: "velocity", when: "More calls this minute than the policy allows.", fix: "Wait, or tab policy --velocity N." },
423
+ { status: 401, code: "key_revoked", when: "The key is unknown, rotated or missing.", fix: "tab key rotate mints a fresh one on this machine." },
424
+ { status: 503, code: "ledger_unavailable", when: "FlockTab could not reach its ledger. Nothing was sent to the provider.", fix: "Retry. This is fail closed on purpose." },
425
+ ];
426
+ export function findTabCommand(word) {
427
+ const w = word.trim().toLowerCase();
428
+ return TAB_COMMANDS.find((c) => c.name === w || c.aliases?.includes(w));
429
+ }
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.12";
2
+ export const TAB_VERSION = "0.1.13";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanamorilabs/tab",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
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",
@@ -15,7 +15,7 @@
15
15
  "node": ">=22"
16
16
  },
17
17
  "scripts": {
18
- "build": "node scripts/write-version.mjs && tsc -p tsconfig.build.json && chmod +x dist/cli.js",
18
+ "build": "node scripts/write-version.mjs && node scripts/sync-docs.mjs && tsc -p tsconfig.build.json && chmod +x dist/cli.js",
19
19
  "dev": "tsx src/cli.ts",
20
20
  "test": "vitest run",
21
21
  "typecheck": "tsc --noEmit",