@hanamorilabs/tab 0.1.8 → 0.1.10

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
@@ -51,7 +51,9 @@ tab claude # your own Claude login through the tab
51
51
  tab codex # your own ChatGPT login (codex login once, inside tab codex)
52
52
  tab grok # your SuperGrok login
53
53
  tab kimi # your Kimi Code login
54
- tab accounts # accounts seen: plan, seat price, 30 days at list, what is left
54
+ tab accounts # accounts seen: plan, seat price, 30 days at list, how much is used
55
+ tab pool add claude work # another Claude login in its own folder; tab claude then runs as the one with most room
56
+ tab pool # the logins, and how used each is; tab pool at 80 sets when to move
55
57
  tab agent kind me@joseairosa.com api # back to the meter
56
58
  ```
57
59
 
@@ -90,10 +92,13 @@ tab <agent> [args] claude, codex, grok, kimi, gemini, or any command, on the t
90
92
  tab use pick or change the Agent this folder runs as (.flocktab)
91
93
  tab agent create <name> --subscription|--api a new Agent of that kind (--cap 50)
92
94
  tab agent kind <agent> api|subscription change it
93
- tab accounts accounts your subscription Agents were seen on: plan, price, quota left
95
+ tab accounts accounts your subscription Agents were seen on: plan, price, quota used
96
+ tab pool several logins of one vendor: add claude|codex <name>, at <percent>, swap auto|launch
94
97
  tab alias setup <name>... make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)
95
98
  tab alias remove <name>... undo that; `tab alias list` shows them
96
99
  tab status flock, folder Agent, proxy health, provider key state
100
+ tab log [-f] [--all] everything through the tab for this machine's Agents, one line per call
101
+ tab log --proxy [-f] self-hosted: the local proxy's own log (cached tokens, milliseconds)
97
102
  tab version tab and proxy versions
98
103
  tab up | down self-hosted: start or stop the local proxy
99
104
  tab update the newest tab and proxy from npm; restarts a running local proxy
package/dist/cli.js CHANGED
@@ -23,13 +23,17 @@
23
23
  * terminal, so an interactive agent behaves exactly as it does without `tab`.
24
24
  */
25
25
  import { spawn } from "node:child_process";
26
+ import { mkdir } from "node:fs/promises";
26
27
  import { hostname } from "node:os";
28
+ import path from "node:path";
27
29
  import { createInterface } from "node:readline/promises";
28
30
  import { stdin, stdout } from "node:process";
29
31
  import { bold, box, cyan, dim, green, heading, line, rows, underline, yellow } from "./ui.js";
30
32
  import { envFor, knownClients, subscriptionVendorFor } from "./clients.js";
31
33
  import { describeLogin, localLogin } from "./logins.js";
32
34
  import { prepareCodexHome } from "./codex-home.js";
35
+ import { addMember, loadPool, memberEnv, poolVendorFor, prepareClaudeMember, removeMember, savePool, usedPct } from "./pool.js";
36
+ import { runPooled } from "./pool-run.js";
33
37
  import { ConsoleApiError, createAgent, issueAgentKey, listAgents } from "./console-api.js";
34
38
  import { consoleUrlFor, DeviceLoginError, startDeviceLogin, waitForApproval } from "./device-login.js";
35
39
  import { clearConfig, configDir, configPath, HOSTED_PROXY, isLocalProxy, loadConfig, LOCAL_PROXY, normalizeProxyUrl, presentedKey, saveConfig, } from "./config.js";
@@ -37,7 +41,7 @@ import { reportFolder } from "./folder.js";
37
41
  import { listAliases, pathLine, pathWithoutShims, removeAlias, shimDir, shimDirOnPath, validAliasName, writeAlias } from "./alias.js";
38
42
  import * as manage from "./manage.js";
39
43
  import { agentNameFor, projectRoot, readProject, writeProject } from "./project.js";
40
- import { downloadProxy, hasProxyEnv, packagedBinPath, proxyBinPath, proxyEnvPath, proxyInstalled, proxyLogPath, PROXY_VERSION, startProxy, stopProxy, tailProxyLog, writeProxyEnv, } from "./proxy-bin.js";
44
+ import { downloadProxy, hasProxyEnv, packagedBinPath, proxyBinPath, proxyEnvPath, proxyInstalled, proxyLogPath, hasProxyLog, PROXY_VERSION, startProxy, stopProxy, tailProxyLog, writeProxyEnv, } from "./proxy-bin.js";
41
45
  import { proxyHealth, waitHealthy } from "./selfhost.js";
42
46
  import { TAB_VERSION } from "./version.js";
43
47
  const say = (text) => console.error(text);
@@ -58,10 +62,15 @@ function usage() {
58
62
  cmd("use", "pick or change the Agent this folder runs as (.flocktab)"),
59
63
  cmd("agent create <name>", "a new Agent: --subscription (your own logins) or --api (the flock's key, metered)"),
60
64
  cmd("agent kind <agent> api|subscription", "change it"),
61
- cmd("accounts", "accounts your subscription Agents were seen on: plan, price, quota left"),
65
+ cmd("accounts", "accounts your subscription Agents were seen on: plan, price, quota used"),
66
+ cmd("pool", "several logins of one vendor; tab claude runs as the one with most room"),
67
+ cmd("pool add claude|codex <name>", "sign another login in (its own folder; --dir <path> to use one you have)"),
68
+ cmd("pool at <percent>", "move to another login once this much of a window is used (default 80)"),
62
69
  cmd("alias setup <name>...", "make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)"),
63
70
  cmd("alias remove <name>...", "undo that; `tab alias list` shows them"),
64
71
  cmd("status", "which flock, which Agent here, is the proxy up"),
72
+ cmd("log [-f] [--all]", "everything through the tab on this machine's Agents, one line per call; -f follows"),
73
+ cmd("log --proxy [-f]", "self-hosted: the local proxy's own log (cached tokens, milliseconds; no keys, logins or prompts)"),
65
74
  cmd("version", "tab and proxy versions"),
66
75
  cmd("list", "every Agent: state, spent of cap, window, project"),
67
76
  cmd("close | open [agent]", "the kill switch; omit the Agent for this folder's"),
@@ -509,7 +518,51 @@ async function status() {
509
518
  say(box(rows(pairs, 0).split("\n"), { title: "FlockTab", tone }));
510
519
  return tone === "ok" ? 0 : 1;
511
520
  }
512
- async function runAgent(name, args) {
521
+ /** `--pool`, `--no-pool` and `--as <member>` are tab's; everything else is the harness's. */
522
+ function takePoolFlags(argv) {
523
+ const args = [];
524
+ let noPool = false;
525
+ let asked = false;
526
+ let pinned;
527
+ for (let i = 0; i < argv.length; i += 1) {
528
+ const a = argv[i];
529
+ if (a === "--no-pool")
530
+ noPool = true;
531
+ else if (a === "--pool")
532
+ asked = true;
533
+ else if (a === "--as" && argv[i + 1])
534
+ pinned = argv[(i += 1)];
535
+ else if (a.startsWith("--as="))
536
+ pinned = a.slice(5);
537
+ else
538
+ args.push(a);
539
+ }
540
+ return { args, noPool, asked: asked || pinned !== undefined, ...(pinned ? { pinned } : {}) };
541
+ }
542
+ async function flockAccounts(config) {
543
+ return (await manage.api(config)("GET", "/api/cli/accounts")).accounts;
544
+ }
545
+ /** Each member with the login its folder holds and how used the vendor last said that account is. */
546
+ async function poolStandings(config, vendor, members) {
547
+ const accounts = await flockAccounts(config).catch(() => []);
548
+ return Promise.all(members.map(async (member) => {
549
+ const login = await localLogin(vendor, vendor === "anthropic" ? { claudeConfigDir: member.dir } : { codexHome: path.join(member.dir, "codex") });
550
+ const email = login?.email;
551
+ const account = email ? accounts.find((a) => a.provider === vendor && a.email?.toLowerCase() === email.toLowerCase()) : undefined;
552
+ return { member, used: account ? usedPct(account.quota) : undefined, ...(email ? { email } : {}) };
553
+ }));
554
+ }
555
+ /** Quiet: no call of this Agent in flight, and no reply from the vendor in the last 20 seconds. */
556
+ async function poolIdle(config, vendor, agentName) {
557
+ const call = manage.api(config);
558
+ const { rows } = await call("GET", "/api/cli/ledger?limit=20");
559
+ if (rows.some((r) => r.agent === agentName && r.status === "pending"))
560
+ return false;
561
+ const recent = Date.now() - 20_000;
562
+ return !(await flockAccounts(config)).some((a) => a.provider === vendor && new Date(a.lastSeenAt).getTime() > recent);
563
+ }
564
+ async function runAgent(name, argv) {
565
+ const { args, noPool, asked, pinned } = takePoolFlags(argv);
513
566
  const config = await ensureLogin();
514
567
  if (!config)
515
568
  return 2;
@@ -525,13 +578,43 @@ async function runAgent(name, args) {
525
578
  // consumer plan, is metered with the flock's key.
526
579
  const vendor = subscriptionVendorFor(name);
527
580
  const me = await whoami(config.proxyUrl, { key: agent.key, unlock: config.unlock });
528
- const kind = !("error" in me) && me.kind === "subscription" ? "subscription" : "api";
581
+ let kind = !("error" in me) && me.kind === "subscription" ? "subscription" : "api";
582
+ // A proxy older than 0.1.7 answers whoami without the kind. Guessing api
583
+ // would hand a subscription Agent's harness the tab key, so ask the console.
584
+ const { consoleUrl, token } = config;
585
+ if (!("error" in me) && me.kind === undefined && config.mode === "self-hosted") {
586
+ // Not restarted here: other harnesses may be streaming through it.
587
+ warn(`The local proxy is older than this tab. When nothing is running through it: ${bold("tab down && tab up")}`);
588
+ }
589
+ if (consoleUrl && token && ("error" in me || me.kind === undefined)) {
590
+ try {
591
+ const listed = await listAgents(consoleUrl, token);
592
+ if (listed.agents.find((a) => a.id === agent.agentId)?.kind === "subscription")
593
+ kind = "subscription";
594
+ }
595
+ catch {
596
+ // Console unreachable: stay on what the proxy said.
597
+ }
598
+ }
529
599
  const auth = kind === "subscription" && vendor ? "subscription" : "key";
530
600
  // On a subscription there is no provider key to unlock; the bare key rides in the URL.
531
601
  const key = auth === "subscription" ? agent.key : presentedKey({ key: agent.key, unlock: config.unlock });
532
602
  reportFolder(config.proxyUrl, key);
533
603
  const { spec, env } = envFor({ name, proxyUrl: config.proxyUrl, presentedKey: key, auth });
534
- if (auth === "subscription") {
604
+ const poolVendor = auth === "subscription" ? poolVendorFor(name) : undefined;
605
+ const pool = poolVendor ? await loadPool() : undefined;
606
+ const members = (poolVendor && pool?.members[poolVendor]) || [];
607
+ const pooled = members.length > 0 && !noPool;
608
+ if (asked && !pooled) {
609
+ fail(auth !== "subscription"
610
+ ? `A pool is for subscription Agents; this folder runs as an API-key Agent. ${dim("tab agent kind <agent> subscription")}`
611
+ : `No ${name} logins in the pool yet. ${dim(`tab pool add ${name} <name>`)}`);
612
+ return 2;
613
+ }
614
+ if (pooled) {
615
+ // The member's folder is where the harness keeps that login; tab sets the folder, never the login.
616
+ }
617
+ else if (auth === "subscription") {
535
618
  const login = describeLogin(await localLogin(vendor));
536
619
  const vendorName = vendor === "openai" ? "ChatGPT" : vendor === "xai" ? "SuperGrok" : vendor === "kimi" ? "Kimi" : "Claude";
537
620
  say(dim(`${spec.label} on your own ${vendorName} login${login ? `: ${login}` : ""}. FlockTab keeps the record and the kill switch; the account is read off each reply.`));
@@ -550,22 +633,154 @@ async function runAgent(name, args) {
550
633
  auth,
551
634
  });
552
635
  }
553
- const child = spawn(spec.command, [...(spec.args ?? []), ...args], { stdio: "inherit", env });
554
- return new Promise((resolve) => {
555
- child.on("error", (err) => {
556
- if (err.code === "ENOENT") {
557
- fail(`${spec.label} is not installed (${spec.command} not on PATH). ${dim(spec.install)}`);
558
- if (!knownClients().includes(name))
559
- say(dim(`Not a tab command either; see ${bold("tab help")}.`));
560
- resolve(127);
561
- return;
562
- }
563
- fail(err.message);
564
- resolve(1);
636
+ const launch = (launchArgs, launchEnv) => {
637
+ const child = spawn(spec.command, [...(spec.args ?? []), ...launchArgs], { stdio: "inherit", env: launchEnv });
638
+ const done = new Promise((resolve) => {
639
+ child.on("error", (err) => {
640
+ if (err.code === "ENOENT") {
641
+ fail(`${spec.label} is not installed (${spec.command} not on PATH). ${dim(spec.install)}`);
642
+ if (!knownClients().includes(name))
643
+ say(dim(`Not a tab command either; see ${bold("tab help")}.`));
644
+ resolve(127);
645
+ return;
646
+ }
647
+ fail(err.message);
648
+ resolve(1);
649
+ });
650
+ child.on("close", (code, signal) => resolve(code ?? (signal ? 128 : 1)));
565
651
  });
566
- child.on("close", (code, signal) => resolve(code ?? (signal ? 128 : 1)));
652
+ return { done, stop: () => void child.kill("SIGTERM") };
653
+ };
654
+ if (!pooled || !poolVendor || !pool)
655
+ return launch(args, env).done;
656
+ // Codex members each need their own home written before the first launch.
657
+ if (poolVendor === "openai") {
658
+ for (const member of members) {
659
+ await prepareCodexHome({ baseDir: member.dir, proxyUrl: config.proxyUrl, presentedKey: key, mode: config.mode, auth, userCodexHome: path.join(member.dir, "no-personal-login") });
660
+ }
661
+ }
662
+ return runPooled(args, {
663
+ harness: name,
664
+ at: pool.at,
665
+ swap: pool.swap,
666
+ ...(pinned ? { pinned } : {}),
667
+ standings: () => poolStandings(config, poolVendor, members),
668
+ idle: () => poolIdle(config, poolVendor, agent.agentName),
669
+ start: (standing, launchArgs) => launch(launchArgs, { ...env, ...memberEnv(poolVendor, standing.member) }),
670
+ wait: (ms, until) => new Promise((resolve) => {
671
+ const timer = setTimeout(() => resolve(true), ms);
672
+ void until.then(() => {
673
+ clearTimeout(timer);
674
+ resolve(false);
675
+ });
676
+ }),
677
+ say: (text) => say(dim(text)),
567
678
  });
568
679
  }
680
+ /**
681
+ * `tab pool`: several logins of one vendor on this machine. Each member is
682
+ * a folder the harness signs in to itself; tab only chooses which folder a
683
+ * launch uses, from the usage the vendors report.
684
+ */
685
+ async function poolCommand(argv) {
686
+ const [sub, ...rest] = argv;
687
+ const pool = await loadPool();
688
+ const vendorOf = (word) => poolVendorFor(word === "chatgpt" ? "codex" : (word ?? ""));
689
+ if (sub === "at") {
690
+ const pct = Number(rest[0]);
691
+ if (!Number.isFinite(pct) || pct < 1 || pct > 100) {
692
+ fail("tab pool at <percent>, from 1 to 100.");
693
+ return 2;
694
+ }
695
+ await savePool({ ...pool, at: Math.round(pct) });
696
+ ok(`The pool moves to another login at ${Math.round(pct)}% used.`);
697
+ return 0;
698
+ }
699
+ if (sub === "swap") {
700
+ if (rest[0] !== "auto" && rest[0] !== "launch") {
701
+ fail(`tab pool swap auto|launch. ${dim("auto: relaunch into the same conversation when idle. launch: only choose when a harness starts.")}`);
702
+ return 2;
703
+ }
704
+ await savePool({ ...pool, swap: rest[0] });
705
+ ok(rest[0] === "auto" ? "The pool moves a running harness when it is idle." : "The pool only chooses at launch.");
706
+ return 0;
707
+ }
708
+ if (sub === "add" || sub === "remove" || sub === "rm") {
709
+ const vendor = vendorOf(rest[0]);
710
+ const flags = manage.parseFlags(rest.slice(1));
711
+ const memberName = flags.args[0];
712
+ if (!vendor || !memberName) {
713
+ fail(`tab pool ${sub} claude|codex <name>${sub === "add" ? " [--dir <folder>]" : ""}`);
714
+ return 2;
715
+ }
716
+ if (sub !== "add") {
717
+ await savePool(removeMember(pool, vendor, memberName));
718
+ ok(`Removed ${bold(memberName)} from the pool. Its folder and login are left where they are.`);
719
+ return 0;
720
+ }
721
+ let next;
722
+ try {
723
+ next = addMember(pool, vendor, memberName, flags.opts.dir);
724
+ }
725
+ catch (err) {
726
+ fail(err.message);
727
+ return 2;
728
+ }
729
+ const member = next.members[vendor].at(-1);
730
+ const loginOf = () => localLogin(vendor, vendor === "anthropic" ? { claudeConfigDir: member.dir } : { codexHome: path.join(member.dir, "codex") });
731
+ if (!flags.opts.dir && vendor === "anthropic")
732
+ await prepareClaudeMember(member.dir);
733
+ if (vendor === "openai")
734
+ await mkdir(path.join(member.dir, "codex"), { recursive: true, mode: 0o700 });
735
+ if (!(await loginOf())?.email) {
736
+ const harness = vendor === "anthropic" ? "claude" : "codex";
737
+ say(dim(vendor === "anthropic" ? `Opening Claude Code in ${member.dir}. Sign in as the account for ${memberName} (/login), then /exit.` : `Signing Codex in for ${memberName}.`));
738
+ // Plain launch: no proxy, no tab key. This only creates the login, in the harness's own store.
739
+ const loginEnv = { ...process.env, ...memberEnv(vendor, member), PATH: pathWithoutShims() };
740
+ for (const k of ["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENAI_BASE_URL", "OPENAI_API_KEY"])
741
+ delete loginEnv[k];
742
+ const code = await new Promise((resolve) => {
743
+ const child = spawn(harness, vendor === "anthropic" ? [] : ["login"], { stdio: "inherit", env: loginEnv });
744
+ child.on("error", () => resolve(127));
745
+ child.on("close", (c) => resolve(c ?? 1));
746
+ });
747
+ if (code === 127) {
748
+ fail(`${harness} is not installed.`);
749
+ return 127;
750
+ }
751
+ }
752
+ const login = await loginOf();
753
+ if (!login?.email) {
754
+ fail(`No login found in ${member.dir}. Nothing was added; run the same command again to retry.`);
755
+ return 1;
756
+ }
757
+ await savePool(next);
758
+ ok(`${bold(memberName)} is in the pool as ${describeLogin(login)}.`);
759
+ return 0;
760
+ }
761
+ if (sub && sub !== "list" && sub !== "ls") {
762
+ fail(`Unknown: tab pool ${sub}. ${dim("tab pool | add | remove | at | swap")}`);
763
+ return 2;
764
+ }
765
+ const config = await ensureLogin();
766
+ if (!config)
767
+ return 2;
768
+ const vendors = ["anthropic", "openai"].filter((v) => (pool.members[v] ?? []).length > 0);
769
+ if (vendors.length === 0) {
770
+ 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.`));
771
+ return 0;
772
+ }
773
+ for (const vendor of vendors) {
774
+ const standings = await poolStandings(config, vendor, pool.members[vendor]);
775
+ say(heading(vendor === "anthropic" ? "Claude" : "ChatGPT (Codex)"));
776
+ say(rows(standings.map((s) => [
777
+ s.member.name,
778
+ `${s.email ?? yellow("not signed in")} ${s.used === undefined ? dim("not seen yet") : s.used >= pool.at ? yellow(`${Math.round(s.used)}% used`) : green(`${Math.round(s.used)}% used`)} ${dim(s.member.dir)}`,
779
+ ]), 0));
780
+ }
781
+ say(dim(`Moves at ${pool.at}% used (${pool.swap === "auto" ? "also while running, when idle" : "at launch only"}). tab claude --as <name> pins one; --no-pool uses your usual login.`));
782
+ return 0;
783
+ }
569
784
  /**
570
785
  * `tab alias codex claude`: shims so the plain commands run on the tab.
571
786
  * `tab alias` alone lists them; `tab unalias codex` removes one.
@@ -695,6 +910,29 @@ ${installed}` : "Updated.");
695
910
  await stopProxy();
696
911
  return (await bringUp(config.proxyUrl)) ? 0 : 1;
697
912
  }
913
+ /**
914
+ * `tab logs`: the local proxy's log. One line per call: kind, vendor, Agent,
915
+ * model, outcome, status, tokens (cached apart) and duration. The proxy never
916
+ * writes a key, a login, a prompt or an email there. `-f` follows it.
917
+ */
918
+ async function logs(args) {
919
+ const flags = manage.parseFlags(args);
920
+ const follow = args.includes("-f") || flags.opts.follow !== undefined;
921
+ const lines = Math.min(5000, Math.max(1, Number(flags.opts.lines ?? 50) || 50));
922
+ const file = proxyLogPath();
923
+ if (!(await hasProxyLog())) {
924
+ warn(`No local proxy log yet (${file}). It is written by the self-hosted proxy: ${bold("tab up")}.`);
925
+ return 1;
926
+ }
927
+ const [cmd, cmdArgs] = process.platform === "win32"
928
+ ? ["powershell", ["-NoProfile", "-Command", `Get-Content -Path '${file}' -Tail ${lines}${follow ? " -Wait" : ""}`]]
929
+ : ["tail", [`-n`, String(lines), ...(follow ? ["-f"] : []), file]];
930
+ const child = spawn(cmd, cmdArgs, { stdio: "inherit" });
931
+ return new Promise((resolve) => {
932
+ child.on("error", () => resolve(1));
933
+ child.on("close", (code) => resolve(code ?? 0));
934
+ });
935
+ }
698
936
  async function selfHosted(action) {
699
937
  const config = await loadConfig();
700
938
  const proxyUrl = config?.mode === "self-hosted" ? config.proxyUrl : LOCAL_PROXY;
@@ -751,6 +989,8 @@ async function managed(command, rest) {
751
989
  return await manage.outside(config, flags);
752
990
  case "live":
753
991
  return await manage.live(config, flags);
992
+ case "log":
993
+ return await manage.log(config, manage.parseFlags(rest.filter((a) => a !== "-f")), rest.includes("-f") || flags.opts.follow !== undefined);
754
994
  case "accounts":
755
995
  case "quota":
756
996
  return await manage.accounts(config, flags);
@@ -810,8 +1050,16 @@ async function main(argv) {
810
1050
  case "agent":
811
1051
  case "web":
812
1052
  return managed(command, rest);
1053
+ case "pool":
1054
+ return poolCommand(rest);
813
1055
  case "status":
814
1056
  return status();
1057
+ case "log":
1058
+ if (rest.includes("--proxy"))
1059
+ return logs(rest.filter((a) => a !== "--proxy"));
1060
+ return managed("log", rest);
1061
+ case "logs":
1062
+ return logs(rest);
815
1063
  case "up":
816
1064
  return selfHosted("up");
817
1065
  case "down":
package/dist/clients.js CHANGED
@@ -107,10 +107,14 @@ export function envFor(input) {
107
107
  return;
108
108
  }
109
109
  env.ANTHROPIC_BASE_URL = input.proxyUrl;
110
- env.ANTHROPIC_API_KEY = input.presentedKey;
111
- // Claude Code prefers an auth token when one is set; make sure a stale
112
- // one cannot win over the tab key.
113
- delete env.ANTHROPIC_AUTH_TOKEN;
110
+ // The tab key goes in as the auth token, not the API key. Claude Code
111
+ // asks a person to approve an `ANTHROPIC_API_KEY` once and remembers a
112
+ // No, after which it silently uses its own Claude login and the call
113
+ // arrives without the tab key. `ANTHROPIC_AUTH_TOKEN` is checked first,
114
+ // never asks, and switches the Claude login off for the session. It is
115
+ // sent as `Authorization: Bearer`, which the proxy reads like `x-api-key`.
116
+ env.ANTHROPIC_AUTH_TOKEN = input.presentedKey;
117
+ delete env.ANTHROPIC_API_KEY;
114
118
  };
115
119
  const setOpenAI = () => {
116
120
  if (auth === "subscription") {
package/dist/logins.js CHANGED
@@ -44,7 +44,9 @@ function jwtClaims(token) {
44
44
  export async function localLogin(vendor, opts = {}) {
45
45
  const home = opts.home ?? homedir();
46
46
  if (vendor === "anthropic") {
47
- const account = (await jsonFile(path.join(home, ".claude.json")))?.oauthAccount;
47
+ // With CLAUDE_CONFIG_DIR set (a pool member), Claude Code keeps the file inside that folder.
48
+ const file = opts.claudeConfigDir ? path.join(opts.claudeConfigDir, ".claude.json") : path.join(home, ".claude.json");
49
+ const account = (await jsonFile(file))?.oauthAccount;
48
50
  if (!account)
49
51
  return undefined;
50
52
  const email = str(account.emailAddress);
package/dist/manage.js CHANGED
@@ -182,7 +182,7 @@ export async function agent(config, flags) {
182
182
  /**
183
183
  * `tab accounts` (also `tab quota`): every account the flock's subscription
184
184
  * Agents were seen on, with the plan the vendor reported, its seat price,
185
- * what the last 30 days would have cost at list, and what the plan has left.
185
+ * what the last 30 days would have cost at list, and how much of the plan is used.
186
186
  */
187
187
  export async function accounts(config, flags) {
188
188
  const call = api(config);
@@ -193,9 +193,9 @@ export async function accounts(config, flags) {
193
193
  const left = (w) => {
194
194
  const pctLeft = Math.max(0, 100 - w.usedPct);
195
195
  const paint = pctLeft <= 10 ? red : pctLeft <= 30 ? yellow : green;
196
- return paint(`${pctLeft}%`);
196
+ return paint(`${Math.min(100, Math.max(0, w.usedPct))}%`);
197
197
  };
198
- return table(["vendor", "account", "plan", "/month", "30d at list", "calls", "left", "agents"], list.map((a) => [
198
+ return table(["vendor", "account", "plan", "/month", "30d at list", "calls", "used", "agents"], list.map((a) => [
199
199
  a.vendor,
200
200
  a.label ?? a.email ?? dim(a.id),
201
201
  a.planLabel ?? dim("unknown"),
@@ -298,6 +298,55 @@ export async function key(config, flags) {
298
298
  (flags.opts.show !== undefined ? `\n${issued.key}` : `\n${dim("Add --show to print it once.")}`));
299
299
  return 0;
300
300
  }
301
+ /** One call, one condensed line: when, which Agent, how it went, what it cost or was worth, what it used. */
302
+ export function logLine(r) {
303
+ const shadow = r.status === "shadow";
304
+ const status = shadow ? "SUBSCRIPTION" : (r.status ?? r.decision).toUpperCase();
305
+ const paint = status === "BLOCKED" || status === "CLOSED" ? red : status === "PENDING" ? yellow : green;
306
+ return `${dim(r.at)} ${bold(r.agent)} ${paint(status)} ${money(r.cents)}${shadow ? dim(" at list") : ""} ${dim(r.reason)}`;
307
+ }
308
+ /**
309
+ * `tab log`: everything that went through the tab for the Agents on this
310
+ * machine, one condensed line per call, hosted or self-hosted alike because
311
+ * it is the ledger's own record. `-f` follows it; `--all` is the whole flock.
312
+ */
313
+ export async function log(config, flags, follow) {
314
+ const call = api(config);
315
+ const limit = Math.min(500, Math.max(1, Number(flags.opts.lines ?? 30) || 30));
316
+ const mine = new Set(Object.values(config.agents ?? {}).map((a) => a.agentName.toLowerCase()));
317
+ const wanted = (r) => flags.opts.all !== undefined || mine.size === 0 || mine.has(r.agent.toLowerCase());
318
+ const fetchRows = async () => (await call("GET", `/api/cli/ledger?limit=${flags.opts.all !== undefined ? limit : limit * 4}`)).rows.filter(wanted).slice(0, limit);
319
+ const first = await fetchRows();
320
+ if (flags.json && !follow) {
321
+ out(JSON.stringify({ rows: first }, null, 2));
322
+ return 0;
323
+ }
324
+ if (first.length === 0 && !follow)
325
+ out(dim("Nothing yet. Run tab claude or tab codex in a project folder."));
326
+ const seen = new Set();
327
+ for (const r of [...first].reverse()) {
328
+ seen.add(r.id);
329
+ out(flags.json ? JSON.stringify(r) : logLine(r));
330
+ }
331
+ if (!follow)
332
+ return 0;
333
+ for (;;) {
334
+ await new Promise((resolve) => setTimeout(resolve, 2000));
335
+ let rows;
336
+ try {
337
+ rows = await fetchRows();
338
+ }
339
+ catch {
340
+ continue;
341
+ }
342
+ for (const r of [...rows].reverse()) {
343
+ if (seen.has(r.id))
344
+ continue;
345
+ seen.add(r.id);
346
+ out(flags.json ? JSON.stringify(r) : logLine(r));
347
+ }
348
+ }
349
+ }
301
350
  export async function ledger(config, flags) {
302
351
  const call = api(config);
303
352
  const params = new URLSearchParams();
@@ -311,9 +360,11 @@ export async function ledger(config, flags) {
311
360
  emit(flags.json, { timezone, rows: list }, () => list.length === 0
312
361
  ? dim("No decisions yet.")
313
362
  : table(["time", "agent", "action", "amount", "status", "reason"], list.map((r) => {
314
- const status = (r.status ?? r.decision).toUpperCase();
363
+ // A subscription call is recorded at list price; nothing was held, so there is no "of".
364
+ const shadow = r.status === "shadow";
365
+ const status = shadow ? "SUBSCRIPTION" : (r.status ?? r.decision).toUpperCase();
315
366
  const paint = status === "BLOCKED" || status === "CLOSED" ? red : status === "PENDING" ? yellow : green;
316
- const held = r.reservedCents && r.reservedCents !== r.cents ? dim(` of ${money(r.reservedCents)}`) : "";
367
+ const held = !shadow && r.reservedCents && r.reservedCents !== r.cents ? dim(` of ${money(r.reservedCents)}`) : "";
317
368
  return [r.at, r.agent, r.action.length > 28 ? `${r.action.slice(0, 27)}…` : r.action, `${money(r.cents)}${held}`, paint(status), r.reason];
318
369
  }), { right: [3] }) + `\n${dim(`times in ${timezone}`)}`);
319
370
  return 0;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Running a harness on the pool: choose the login at launch, watch what the
3
+ * vendors report, and when the login in use is over the threshold while
4
+ * another has room, wait for a quiet moment and relaunch into the same
5
+ * conversation as that other login.
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`}`;
9
+ export async function runPooled(args, deps) {
10
+ const all = await deps.standings();
11
+ let current = deps.pinned ? all.find((s) => s.member.name === deps.pinned) : pickMember(all, deps.at);
12
+ if (!current) {
13
+ 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
+ return 2;
15
+ }
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.`);
18
+ let launchArgs = args;
19
+ for (;;) {
20
+ deps.say(`Pool: running as ${label(current)}.`);
21
+ const child = deps.start(current, launchArgs);
22
+ let next;
23
+ if (!deps.pinned && deps.swap === "auto") {
24
+ while (await deps.wait(deps.intervalMs ?? 60_000, child.done)) {
25
+ // A failed look at the console is not a reason to touch a running harness.
26
+ const now = await deps.standings().catch(() => undefined);
27
+ const to = now ? shouldSwap(now, deps.at, current.member.name) : undefined;
28
+ if (to && (await deps.idle().catch(() => false))) {
29
+ next = to;
30
+ child.stop();
31
+ break;
32
+ }
33
+ }
34
+ }
35
+ const code = await child.done;
36
+ if (!next)
37
+ 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.`);
39
+ current = next;
40
+ launchArgs = relaunchArgs(deps.harness, args);
41
+ }
42
+ }
package/dist/pool.js ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * A subscription pool: several logins of one vendor on this machine, and the
3
+ * rule for which one a harness runs as. Each member is a folder the harness
4
+ * itself signs in to (`CLAUDE_CONFIG_DIR` for Claude Code, `CODEX_HOME` for
5
+ * Codex), so the login stays where the harness keeps it: `tab` never reads,
6
+ * copies or forwards a token. Usage comes from what the vendors report on
7
+ * every reply, already kept per account. When the login in use crosses the
8
+ * threshold and another has room, the harness is relaunched into the same
9
+ * conversation as that other login.
10
+ */
11
+ import { chmod, mkdir, readdir, readFile, symlink, writeFile, lstat } from "node:fs/promises";
12
+ import { homedir } from "node:os";
13
+ import path from "node:path";
14
+ import { configDir } from "./config.js";
15
+ export const DEFAULT_AT = 80;
16
+ export function poolPath(env = process.env) {
17
+ return path.join(configDir(env), "pool.json");
18
+ }
19
+ export function poolVendorFor(harness) {
20
+ if (harness === "claude")
21
+ return "anthropic";
22
+ if (harness === "codex")
23
+ return "openai";
24
+ return undefined;
25
+ }
26
+ export async function loadPool(env = process.env) {
27
+ const empty = { at: DEFAULT_AT, swap: "auto", members: {} };
28
+ try {
29
+ const raw = JSON.parse(await readFile(poolPath(env), "utf8"));
30
+ const at = typeof raw.at === "number" && raw.at >= 1 && raw.at <= 100 ? raw.at : DEFAULT_AT;
31
+ const members = {};
32
+ for (const vendor of ["anthropic", "openai"]) {
33
+ const list = raw.members?.[vendor];
34
+ if (Array.isArray(list))
35
+ members[vendor] = list.filter((m) => typeof m?.name === "string" && typeof m?.dir === "string").map((m) => ({ name: m.name, dir: m.dir }));
36
+ }
37
+ return { at, swap: raw.swap === "launch" ? "launch" : "auto", members };
38
+ }
39
+ catch {
40
+ return empty;
41
+ }
42
+ }
43
+ export async function savePool(pool, env = process.env) {
44
+ const file = poolPath(env);
45
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
46
+ await writeFile(file, `${JSON.stringify(pool, null, 2)}\n`, { mode: 0o600 });
47
+ await chmod(file, 0o600);
48
+ return file;
49
+ }
50
+ export function addMember(pool, vendor, name, dir, env = process.env) {
51
+ if (!/^[A-Za-z0-9][A-Za-z0-9._@-]{0,63}$/.test(name))
52
+ throw new Error("A member name is letters, digits, dot, dash, underscore or @, up to 64.");
53
+ const list = pool.members[vendor] ?? [];
54
+ if (list.some((m) => m.name === name))
55
+ throw new Error(`${name} is already in the pool.`);
56
+ const member = { name, dir: dir ? path.resolve(dir) : path.join(configDir(env), "pool", vendor, name) };
57
+ return { ...pool, members: { ...pool.members, [vendor]: [...list, member] } };
58
+ }
59
+ export function removeMember(pool, vendor, name) {
60
+ return { ...pool, members: { ...pool.members, [vendor]: (pool.members[vendor] ?? []).filter((m) => m.name !== name) } };
61
+ }
62
+ /** The env that makes a harness use this member's login folder. */
63
+ export function memberEnv(vendor, member) {
64
+ return vendor === "anthropic" ? { CLAUDE_CONFIG_DIR: member.dir } : { CODEX_HOME: path.join(member.dir, "codex") };
65
+ }
66
+ /** How used an account is: its fullest window that has not reset yet. */
67
+ export function usedPct(quota, now = new Date()) {
68
+ const live = quota.filter((w) => !w.resetsAt || new Date(w.resetsAt).getTime() > now.getTime());
69
+ if (quota.length === 0)
70
+ return undefined;
71
+ return live.reduce((max, w) => Math.max(max, w.usedPct), 0);
72
+ }
73
+ const room = (s) => s.used ?? 0;
74
+ /** Stay on the current login while it is under the threshold; else the one with most room. */
75
+ export function pickMember(all, at, current) {
76
+ if (all.length === 0)
77
+ return undefined;
78
+ const now = all.find((s) => s.member.name === current);
79
+ if (now && room(now) < at)
80
+ return now;
81
+ return [...all].sort((a, b) => room(a) - room(b))[0];
82
+ }
83
+ /** The login to move to, when the current one is over and another has room. */
84
+ export function shouldSwap(all, at, current) {
85
+ const now = all.find((s) => s.member.name === current);
86
+ if (!now || room(now) < at)
87
+ return undefined;
88
+ const best = pickMember(all, at, current);
89
+ return best && best.member.name !== current && room(best) < at ? best : undefined;
90
+ }
91
+ /** Arguments that reopen the conversation the previous login was in. */
92
+ export function relaunchArgs(harness, args) {
93
+ if (harness === "codex")
94
+ return ["resume", "--last"];
95
+ const kept = [];
96
+ for (let i = 0; i < args.length; i += 1) {
97
+ const a = args[i];
98
+ if (a === "--continue" || a === "-c")
99
+ continue;
100
+ // One-shot and resume arguments belong to the first launch only.
101
+ if (a === "--resume" || a === "-r" || a === "-p" || a === "--print") {
102
+ if (args[i + 1] && !args[i + 1].startsWith("-"))
103
+ i += 1;
104
+ continue;
105
+ }
106
+ kept.push(a);
107
+ }
108
+ return [...kept, "--continue"];
109
+ }
110
+ const NEVER_SHARED = new Set([".credentials.json", ".claude.json", ".claude.json.backup"]);
111
+ /**
112
+ * A Claude member folder: everything in `~/.claude` linked in (settings,
113
+ * skills, and `projects`, which is what lets `--continue` find the
114
+ * conversation under another login), except the login itself. The member's
115
+ * own `.claude.json` starts as a copy of the person's without the account.
116
+ */
117
+ export async function prepareClaudeMember(dir, home = homedir()) {
118
+ await mkdir(dir, { recursive: true, mode: 0o700 });
119
+ const shared = path.join(home, ".claude");
120
+ for (const entry of await readdir(shared).catch(() => [])) {
121
+ if (NEVER_SHARED.has(entry))
122
+ continue;
123
+ const target = path.join(dir, entry);
124
+ if (await lstat(target).then(() => true, () => false))
125
+ continue;
126
+ await symlink(path.join(shared, entry), target);
127
+ }
128
+ const own = path.join(dir, ".claude.json");
129
+ if (await lstat(own).then(() => true, () => false))
130
+ return;
131
+ let seed = {};
132
+ try {
133
+ const parsed = JSON.parse(await readFile(path.join(home, ".claude.json"), "utf8"));
134
+ if (parsed && typeof parsed === "object")
135
+ seed = { ...parsed };
136
+ }
137
+ catch {
138
+ // No file to start from: Claude Code makes its own.
139
+ }
140
+ delete seed.oauthAccount;
141
+ await writeFile(own, JSON.stringify(seed), { mode: 0o600 });
142
+ }
package/dist/proxy-bin.js CHANGED
@@ -8,12 +8,12 @@
8
8
  * runtime beyond the binary itself.
9
9
  */
10
10
  import { spawn } from "node:child_process";
11
- import { chmod, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
11
+ import { access, chmod, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
12
12
  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.5";
16
+ export const PROXY_VERSION = "0.1.7";
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")
@@ -214,3 +214,13 @@ export async function tailProxyLog(lines = 8, env = process.env) {
214
214
  return [];
215
215
  }
216
216
  }
217
+ /** Whether the local proxy has written a log yet. */
218
+ export async function hasProxyLog(env = process.env) {
219
+ try {
220
+ await access(proxyLogPath(env));
221
+ return true;
222
+ }
223
+ catch {
224
+ return false;
225
+ }
226
+ }
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.8";
2
+ export const TAB_VERSION = "0.1.10";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanamorilabs/tab",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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.5",
26
- "@hanamorilabs/flocktab-proxy-darwin-x64": "0.1.5",
27
- "@hanamorilabs/flocktab-proxy-linux-x64": "0.1.5",
28
- "@hanamorilabs/flocktab-proxy-linux-arm64": "0.1.5",
29
- "@hanamorilabs/flocktab-proxy-win-x64": "0.1.5"
25
+ "@hanamorilabs/flocktab-proxy-darwin-arm64": "0.1.7",
26
+ "@hanamorilabs/flocktab-proxy-darwin-x64": "0.1.7",
27
+ "@hanamorilabs/flocktab-proxy-linux-x64": "0.1.7",
28
+ "@hanamorilabs/flocktab-proxy-linux-arm64": "0.1.7",
29
+ "@hanamorilabs/flocktab-proxy-win-x64": "0.1.7"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^22.18.6",