@hanamorilabs/tab 0.1.9 → 0.1.11

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,7 +92,8 @@ 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
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";
@@ -58,7 +62,10 @@ 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 through the browser (--email to prefill, --dir <path> to use a folder 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"),
@@ -511,7 +518,51 @@ async function status() {
511
518
  say(box(rows(pairs, 0).split("\n"), { title: "FlockTab", tone }));
512
519
  return tone === "ok" ? 0 : 1;
513
520
  }
514
- 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);
515
566
  const config = await ensureLogin();
516
567
  if (!config)
517
568
  return 2;
@@ -550,7 +601,20 @@ async function runAgent(name, args) {
550
601
  const key = auth === "subscription" ? agent.key : presentedKey({ key: agent.key, unlock: config.unlock });
551
602
  reportFolder(config.proxyUrl, key);
552
603
  const { spec, env } = envFor({ name, proxyUrl: config.proxyUrl, presentedKey: key, auth });
553
- 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") {
554
618
  const login = describeLogin(await localLogin(vendor));
555
619
  const vendorName = vendor === "openai" ? "ChatGPT" : vendor === "xai" ? "SuperGrok" : vendor === "kimi" ? "Kimi" : "Claude";
556
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.`));
@@ -569,22 +633,162 @@ async function runAgent(name, args) {
569
633
  auth,
570
634
  });
571
635
  }
572
- const child = spawn(spec.command, [...(spec.args ?? []), ...args], { stdio: "inherit", env });
573
- return new Promise((resolve) => {
574
- child.on("error", (err) => {
575
- if (err.code === "ENOENT") {
576
- fail(`${spec.label} is not installed (${spec.command} not on PATH). ${dim(spec.install)}`);
577
- if (!knownClients().includes(name))
578
- say(dim(`Not a tab command either; see ${bold("tab help")}.`));
579
- resolve(127);
580
- return;
581
- }
582
- fail(err.message);
583
- 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)));
584
651
  });
585
- 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)),
586
678
  });
587
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
+ // `claude auth login` does the browser sign-in and exits: no session to open, no /login, no /exit.
738
+ const email = flags.opts.email ?? (memberName.includes("@") ? memberName : undefined);
739
+ say(dim(`Signing ${vendor === "anthropic" ? "Claude Code" : "Codex"} in for ${memberName}. Your browser opens; pick the account for this login.`));
740
+ // Plain launch: no proxy, no tab key. This only creates the login, in the harness's own store.
741
+ const loginEnv = { ...process.env, ...memberEnv(vendor, member), PATH: pathWithoutShims() };
742
+ for (const k of ["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENAI_BASE_URL", "OPENAI_API_KEY"])
743
+ delete loginEnv[k];
744
+ const run = (argv) => new Promise((resolve) => {
745
+ const child = spawn(harness, argv, { stdio: "inherit", env: loginEnv });
746
+ child.on("error", () => resolve(127));
747
+ child.on("close", (c) => resolve(c ?? 1));
748
+ });
749
+ let code = await run(vendor === "anthropic" ? ["auth", "login", "--claudeai", ...(email ? ["--email", email] : [])] : ["login"]);
750
+ if (vendor === "anthropic" && code !== 0 && code !== 127 && !(await loginOf())?.email) {
751
+ // A Claude Code too old for `auth login`: sign in from inside it.
752
+ say(dim("That did not sign in. Opening Claude Code instead: /login as this account, then /exit."));
753
+ code = await run([]);
754
+ }
755
+ if (code === 127) {
756
+ fail(`${harness} is not installed.`);
757
+ return 127;
758
+ }
759
+ }
760
+ const login = await loginOf();
761
+ if (!login?.email) {
762
+ fail(`No login found in ${member.dir}. Nothing was added; run the same command again to retry.`);
763
+ return 1;
764
+ }
765
+ await savePool(next);
766
+ ok(`${bold(memberName)} is in the pool as ${describeLogin(login)}.`);
767
+ return 0;
768
+ }
769
+ if (sub && sub !== "list" && sub !== "ls") {
770
+ fail(`Unknown: tab pool ${sub}. ${dim("tab pool | add | remove | at | swap")}`);
771
+ return 2;
772
+ }
773
+ const config = await ensureLogin();
774
+ if (!config)
775
+ return 2;
776
+ const vendors = ["anthropic", "openai"].filter((v) => (pool.members[v] ?? []).length > 0);
777
+ if (vendors.length === 0) {
778
+ 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.`));
779
+ return 0;
780
+ }
781
+ for (const vendor of vendors) {
782
+ const standings = await poolStandings(config, vendor, pool.members[vendor]);
783
+ say(heading(vendor === "anthropic" ? "Claude" : "ChatGPT (Codex)"));
784
+ say(rows(standings.map((s) => [
785
+ s.member.name,
786
+ `${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)}`,
787
+ ]), 0));
788
+ }
789
+ 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.`));
790
+ return 0;
791
+ }
588
792
  /**
589
793
  * `tab alias codex claude`: shims so the plain commands run on the tab.
590
794
  * `tab alias` alone lists them; `tab unalias codex` removes one.
@@ -854,6 +1058,8 @@ async function main(argv) {
854
1058
  case "agent":
855
1059
  case "web":
856
1060
  return managed(command, rest);
1061
+ case "pool":
1062
+ return poolCommand(rest);
857
1063
  case "status":
858
1064
  return status();
859
1065
  case "log":
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"),
@@ -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/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.9";
2
+ export const TAB_VERSION = "0.1.11";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanamorilabs/tab",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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",