@hanamorilabs/tab 0.1.11 → 0.1.12

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
@@ -93,7 +93,7 @@ tab use pick or change the Agent this folder runs as (.flocktab)
93
93
  tab agent create <name> --subscription|--api a new Agent of that kind (--cap 50)
94
94
  tab agent kind <agent> api|subscription change it
95
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
96
+ tab pool several logins of one vendor: add claude|codex|grok|kimi <name>, at [vendor] <percent>, swap auto|launch
97
97
  tab alias setup <name>... make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)
98
98
  tab alias remove <name>... undo that; `tab alias list` shows them
99
99
  tab status flock, folder Agent, proxy health, provider key state
@@ -161,3 +161,19 @@ plan team
161
161
  kind api (the flock's provider key, metered on the tab)
162
162
  byok provider keys on this box
163
163
  ```
164
+
165
+ ## Trying a change before it is released
166
+
167
+ `tabdev` is this checkout's CLI run from source, beside the `tab` npm
168
+ installed. It keeps everything of its own: `~/.flocktabdev` for the login,
169
+ keys, pool and proxy files, and `.flocktabdev` as the per-folder file. Nothing
170
+ it does touches the production `tab`. Log it in once with `tabdev login`, and
171
+ give its folders Agents of their own: choosing an Agent that production
172
+ already holds a key for would replace that key (it asks first).
173
+
174
+ ```
175
+ node apps/tab/scripts/link-dev.mjs # writes ~/.local/bin/tabdev
176
+ tabdev version # "0.1.x + dev (this checkout's source)"
177
+ tabdev claude # exactly what the next release would do
178
+ node apps/tab/scripts/link-dev.mjs --remove
179
+ ```
package/dist/alias.js CHANGED
@@ -65,10 +65,12 @@ export function shimDirOnPath(env = process.env, platform = process.platform) {
65
65
  /** PATH for the child agent: everything except the shim folder. */
66
66
  export function pathWithoutShims(env = process.env, platform = process.platform) {
67
67
  const sep = platform === "win32" ? ";" : ":";
68
- const dir = path.resolve(shimDir(env));
68
+ // Also the default home's shims: a CLI run with FLOCKTAB_HOME elsewhere (tabdev)
69
+ // must not spawn a production `claude` shim that re-enters the other tab.
70
+ const dirs = new Set([path.resolve(shimDir(env)), path.resolve(shimDir({}))]);
69
71
  return (env.PATH ?? "")
70
72
  .split(sep)
71
- .filter((p) => p && path.resolve(p) !== dir)
73
+ .filter((p) => p && !dirs.has(path.resolve(p)))
72
74
  .join(sep);
73
75
  }
74
76
  /** The line to add to a shell rc so the shims win. */
package/dist/cli.js CHANGED
@@ -32,12 +32,14 @@ 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, poolVendorFor, prepareClaudeMember, removeMember, savePool, usedPct } from "./pool.js";
35
+ import { addMember, loadPool, memberEnv, memberHome, POOL_VENDORS, poolVendorFor, prepareClaudeMember, prepareSharedSessions, removeMember, savePool, setThreshold, thresholdFor, usedPct } from "./pool.js";
36
36
  import { runPooled } from "./pool-run.js";
37
+ import { renderPool } from "./pool-view.js";
37
38
  import { ConsoleApiError, createAgent, issueAgentKey, listAgents } from "./console-api.js";
38
39
  import { consoleUrlFor, DeviceLoginError, startDeviceLogin, waitForApproval } from "./device-login.js";
39
40
  import { clearConfig, configDir, configPath, HOSTED_PROXY, isLocalProxy, loadConfig, LOCAL_PROXY, normalizeProxyUrl, presentedKey, saveConfig, } from "./config.js";
40
41
  import { reportFolder } from "./folder.js";
42
+ import { whoami } from "./proxy-identity.js";
41
43
  import { listAliases, pathLine, pathWithoutShims, removeAlias, shimDir, shimDirOnPath, validAliasName, writeAlias } from "./alias.js";
42
44
  import * as manage from "./manage.js";
43
45
  import { agentNameFor, projectRoot, readProject, writeProject } from "./project.js";
@@ -64,8 +66,8 @@ function usage() {
64
66
  cmd("agent kind <agent> api|subscription", "change it"),
65
67
  cmd("accounts", "accounts your subscription Agents were seen on: plan, price, quota used"),
66
68
  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)"),
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"),
69
71
  cmd("alias setup <name>...", "make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)"),
70
72
  cmd("alias remove <name>...", "undo that; `tab alias list` shows them"),
71
73
  cmd("status", "which flock, which Agent here, is the proxy up"),
@@ -95,22 +97,6 @@ function usage() {
95
97
  say(`
96
98
  ${dim("Config")} ${configPath()} ${dim("or FLOCKTAB_PROXY_URL, FLOCKTAB_KEY, FLOCKTAB_UNLOCK")}`);
97
99
  }
98
- async function whoami(proxyUrl, login) {
99
- try {
100
- const response = await fetch(`${proxyUrl}/v1/whoami`, {
101
- headers: { authorization: `Bearer ${presentedKey(login)}` },
102
- });
103
- const body = (await response.json().catch(() => ({})));
104
- if (!response.ok) {
105
- const message = body.error?.message;
106
- return { error: message ?? `whoami returned ${response.status}` };
107
- }
108
- return body;
109
- }
110
- catch {
111
- return { error: "proxy unreachable" };
112
- }
113
- }
114
100
  /**
115
101
  * Without a terminal (CI, a script, `printf ... | tab up`) every answer
116
102
  * comes from stdin, read once and handed out line by line; closing a
@@ -474,7 +460,9 @@ async function version() {
474
460
  const bin = proxyBinPath();
475
461
  const installed = await proxyInstalled();
476
462
  const where = packaged ? `packaged, ${packaged}` : installed ? `downloaded, ${bin}` : "not installed; tab up fetches it";
477
- say(rows([["tab", TAB_VERSION], ["proxy", `${PROXY_VERSION} ${dim(`(${where})`)}`]], 0));
463
+ // `tabdev` (scripts/link-dev.mjs) runs this checkout's source, not the npm install.
464
+ const tabVersion = process.env.FLOCKTAB_DEV ? `${TAB_VERSION} + dev ${dim("(this checkout's source)")}` : TAB_VERSION;
465
+ say(rows([["tab", tabVersion], ["proxy", `${PROXY_VERSION} ${dim(`(${where})`)}`]], 0));
478
466
  return 0;
479
467
  }
480
468
  async function status() {
@@ -539,17 +527,28 @@ function takePoolFlags(argv) {
539
527
  }
540
528
  return { args, noPool, asked: asked || pinned !== undefined, ...(pinned ? { pinned } : {}) };
541
529
  }
530
+ /** The login a member's folder holds, read from the harness's own files (account fields only). */
531
+ function memberLogin(vendor, member) {
532
+ const home = memberHome(vendor, member);
533
+ return localLogin(vendor, vendor === "anthropic" ? { claudeConfigDir: home } : vendor === "openai" ? { codexHome: home } : vendor === "xai" ? { grokHome: home } : { kimiShareDir: home });
534
+ }
535
+ const VENDOR_NAMES = { anthropic: "Claude", openai: "ChatGPT (Codex)", xai: "Grok", kimi: "Kimi" };
536
+ const VENDOR_HARNESS = { anthropic: "claude", openai: "codex", xai: "grok", kimi: "kimi" };
542
537
  async function flockAccounts(config) {
543
538
  return (await manage.api(config)("GET", "/api/cli/accounts")).accounts;
544
539
  }
545
540
  /** 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(() => []);
541
+ async function poolStandings(config, vendor, members, known) {
542
+ const accounts = known ?? (await flockAccounts(config).catch(() => []));
548
543
  return Promise.all(members.map(async (member) => {
549
- const login = await localLogin(vendor, vendor === "anthropic" ? { claudeConfigDir: member.dir } : { codexHome: path.join(member.dir, "codex") });
544
+ const login = await memberLogin(vendor, member);
550
545
  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 } : {}) };
546
+ // By email where the vendor names one, else by the account id the proxy files calls under.
547
+ const account = accounts.find((a) => a.provider === vendor && ((email && a.email?.toLowerCase() === email.toLowerCase()) || (login?.accountId && a.externalId === login.accountId)));
548
+ const shown = email ?? (login?.accountId ? `account ${login.accountId.slice(0, 12)}` : undefined);
549
+ // The vendor's plan name once seen on the wire; before that, what the harness's own file says.
550
+ 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 } : {}) };
553
552
  }));
554
553
  }
555
554
  /** Quiet: no call of this Agent in flight, and no reply from the vendor in the last 20 seconds. */
@@ -566,7 +565,8 @@ async function runAgent(name, argv) {
566
565
  const config = await ensureLogin();
567
566
  if (!config)
568
567
  return 2;
569
- if (!(await ensureProxy(config)))
568
+ // Hosted whoami below proves readiness and current Agent kind in one round trip.
569
+ if (config.mode === "self-hosted" && !(await ensureProxy(config)))
570
570
  return 1;
571
571
  const agent = await resolveAgent(config);
572
572
  if (!agent)
@@ -578,6 +578,10 @@ async function runAgent(name, argv) {
578
578
  // consumer plan, is metered with the flock's key.
579
579
  const vendor = subscriptionVendorFor(name);
580
580
  const me = await whoami(config.proxyUrl, { key: agent.key, unlock: config.unlock });
581
+ if (config.mode === "hosted" && "error" in me) {
582
+ fail(me.error);
583
+ return 1;
584
+ }
581
585
  let kind = !("error" in me) && me.kind === "subscription" ? "subscription" : "api";
582
586
  // A proxy older than 0.1.7 answers whoami without the kind. Guessing api
583
587
  // would hand a subscription Agent's harness the tab key, so ask the console.
@@ -653,7 +657,10 @@ async function runAgent(name, argv) {
653
657
  };
654
658
  if (!pooled || !poolVendor || !pool)
655
659
  return launch(args, env).done;
656
- // Codex members each need their own home written before the first launch.
660
+ // Codex members each need their own home written before the first launch;
661
+ // Codex, Grok and Kimi members share one conversations folder.
662
+ for (const member of members)
663
+ await prepareSharedSessions(poolVendor, member);
657
664
  if (poolVendor === "openai") {
658
665
  for (const member of members) {
659
666
  await prepareCodexHome({ baseDir: member.dir, proxyUrl: config.proxyUrl, presentedKey: key, mode: config.mode, auth, userCodexHome: path.join(member.dir, "no-personal-login") });
@@ -661,7 +668,7 @@ async function runAgent(name, argv) {
661
668
  }
662
669
  return runPooled(args, {
663
670
  harness: name,
664
- at: pool.at,
671
+ at: thresholdFor(pool, poolVendor),
665
672
  swap: pool.swap,
666
673
  ...(pinned ? { pinned } : {}),
667
674
  standings: () => poolStandings(config, poolVendor, members),
@@ -686,14 +693,33 @@ async function poolCommand(argv) {
686
693
  const [sub, ...rest] = argv;
687
694
  const pool = await loadPool();
688
695
  const vendorOf = (word) => poolVendorFor(word === "chatgpt" ? "codex" : (word ?? ""));
696
+ const HARNESSES = "claude|codex|grok|kimi";
689
697
  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.");
698
+ // `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];
701
+ 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.")}`);
693
704
  return 2;
694
705
  }
695
- await savePool({ ...pool, at: Math.round(pct) });
696
- ok(`The pool moves to another login at ${Math.round(pct)}% used.`);
706
+ let next;
707
+ try {
708
+ next = setThreshold(pool, clear ? undefined : Number(value), vendor);
709
+ }
710
+ catch (err) {
711
+ fail(err.message);
712
+ return 2;
713
+ }
714
+ await savePool(next);
715
+ if (!vendor) {
716
+ const own = POOL_VENDORS.filter((v) => next.atByVendor[v] !== undefined).map((v) => `${VENDOR_HARNESS[v]} ${next.atByVendor[v]}%`);
717
+ ok(`The pool moves to another login at ${next.at}% used.${own.length > 0 ? dim(` Still their own: ${own.join(", ")}.`) : ""}`);
718
+ }
719
+ else if (clear)
720
+ ok(`${VENDOR_NAMES[vendor]} logins move at the shared ${next.at}% again.`);
721
+ else
722
+ ok(`${VENDOR_NAMES[vendor]} logins move at ${thresholdFor(next, vendor)}% used. ${dim(`Everyone else at ${next.at}%.`)}`);
697
723
  return 0;
698
724
  }
699
725
  if (sub === "swap") {
@@ -710,7 +736,7 @@ async function poolCommand(argv) {
710
736
  const flags = manage.parseFlags(rest.slice(1));
711
737
  const memberName = flags.args[0];
712
738
  if (!vendor || !memberName) {
713
- fail(`tab pool ${sub} claude|codex <name>${sub === "add" ? " [--dir <folder>]" : ""}`);
739
+ fail(`tab pool ${sub} ${HARNESSES} <name>${sub === "add" ? " [--dir <folder>] [--email <address>]" : ""}`);
714
740
  return 2;
715
741
  }
716
742
  if (sub !== "add") {
@@ -727,27 +753,33 @@ async function poolCommand(argv) {
727
753
  return 2;
728
754
  }
729
755
  const member = next.members[vendor].at(-1);
730
- const loginOf = () => localLogin(vendor, vendor === "anthropic" ? { claudeConfigDir: member.dir } : { codexHome: path.join(member.dir, "codex") });
756
+ const loginOf = () => memberLogin(vendor, member);
757
+ const signedIn = async () => {
758
+ const l = await loginOf();
759
+ return Boolean(l?.email ?? l?.accountId);
760
+ };
731
761
  if (!flags.opts.dir && vendor === "anthropic")
732
762
  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";
763
+ await mkdir(memberHome(vendor, member), { recursive: true, mode: 0o700 });
764
+ if (!flags.opts.dir)
765
+ await prepareSharedSessions(vendor, member);
766
+ if (!(await signedIn())) {
767
+ const harness = VENDOR_HARNESS[vendor];
737
768
  // `claude auth login` does the browser sign-in and exits: no session to open, no /login, no /exit.
738
769
  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.`));
770
+ say(dim(`Signing ${harness} in for ${memberName}. Your browser opens; pick the account for this login.`));
740
771
  // Plain launch: no proxy, no tab key. This only creates the login, in the harness's own store.
741
772
  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"])
773
+ for (const k of ["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENAI_BASE_URL", "OPENAI_API_KEY", "XAI_API_KEY", "GROK_XAI_API_BASE_URL", "KIMI_API_KEY", "KIMI_BASE_URL", "KIMI_CODE_BASE_URL"])
743
774
  delete loginEnv[k];
744
775
  const run = (argv) => new Promise((resolve) => {
745
776
  const child = spawn(harness, argv, { stdio: "inherit", env: loginEnv });
746
777
  child.on("error", () => resolve(127));
747
778
  child.on("close", (c) => resolve(c ?? 1));
748
779
  });
780
+ // Every one of them has its own sign-in command that opens the browser and exits.
749
781
  let code = await run(vendor === "anthropic" ? ["auth", "login", "--claudeai", ...(email ? ["--email", email] : [])] : ["login"]);
750
- if (vendor === "anthropic" && code !== 0 && code !== 127 && !(await loginOf())?.email) {
782
+ if (vendor === "anthropic" && code !== 0 && code !== 127 && !(await signedIn())) {
751
783
  // A Claude Code too old for `auth login`: sign in from inside it.
752
784
  say(dim("That did not sign in. Opening Claude Code instead: /login as this account, then /exit."));
753
785
  code = await run([]);
@@ -758,7 +790,7 @@ async function poolCommand(argv) {
758
790
  }
759
791
  }
760
792
  const login = await loginOf();
761
- if (!login?.email) {
793
+ if (!(await signedIn())) {
762
794
  fail(`No login found in ${member.dir}. Nothing was added; run the same command again to retry.`);
763
795
  return 1;
764
796
  }
@@ -766,27 +798,22 @@ async function poolCommand(argv) {
766
798
  ok(`${bold(memberName)} is in the pool as ${describeLogin(login)}.`);
767
799
  return 0;
768
800
  }
769
- if (sub && sub !== "list" && sub !== "ls") {
801
+ if (sub && sub !== "list" && sub !== "ls" && sub !== "--paths") {
770
802
  fail(`Unknown: tab pool ${sub}. ${dim("tab pool | add | remove | at | swap")}`);
771
803
  return 2;
772
804
  }
773
805
  const config = await ensureLogin();
774
806
  if (!config)
775
807
  return 2;
776
- const vendors = ["anthropic", "openai"].filter((v) => (pool.members[v] ?? []).length > 0);
808
+ const vendors = POOL_VENDORS.filter((v) => (pool.members[v] ?? []).length > 0);
777
809
  if (vendors.length === 0) {
778
810
  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
811
  return 0;
780
812
  }
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.`));
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) })));
816
+ say(renderPool(sections, { at: pool.at, swap: pool.swap, paths: argv.includes("--paths") }));
790
817
  return 0;
791
818
  }
792
819
  /**
package/dist/clients.js CHANGED
@@ -128,7 +128,11 @@ export function envFor(input) {
128
128
  delete env.XAI_API_KEY;
129
129
  }
130
130
  if (spec.provider === "moonshot") {
131
- env.KIMI_BASE_URL = `${passthroughBase(input.proxyUrl, input.presentedKey, "kimi")}/coding/v1`;
131
+ // Kimi Code (the `~/.kimi-code` CLI) reads its plan endpoint from KIMI_CODE_BASE_URL;
132
+ // the older kimi-cli reads KIMI_BASE_URL. Its sign-in host is separate and untouched.
133
+ const kimiBase = `${passthroughBase(input.proxyUrl, input.presentedKey, "kimi")}/coding/v1`;
134
+ env.KIMI_CODE_BASE_URL = kimiBase;
135
+ env.KIMI_BASE_URL = kimiBase;
132
136
  delete env.KIMI_API_KEY;
133
137
  }
134
138
  return;
@@ -3,6 +3,7 @@
3
3
  * make one, key one. All three are `/api/cli/*` with `Bearer ft_cli_...`.
4
4
  * Injectable fetch so the folder picker is testable without a console.
5
5
  */
6
+ import { requestJson } from "./network.js";
6
7
  export class ConsoleApiError extends Error {
7
8
  status;
8
9
  constructor(message, status) {
@@ -12,11 +13,10 @@ export class ConsoleApiError extends Error {
12
13
  }
13
14
  }
14
15
  async function call(consoleUrl, token, path, init, fetchImpl) {
15
- const response = await fetchImpl(`${consoleUrl}${path}`, {
16
+ const { response, body } = await requestJson(`${consoleUrl}${path}`, {
16
17
  ...init,
17
18
  headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...(init.headers ?? {}) },
18
- });
19
- const body = (await response.json().catch(() => ({})));
19
+ }, fetchImpl);
20
20
  if (!response.ok) {
21
21
  const detail = body.error ?? `console answered ${response.status}`;
22
22
  throw new ConsoleApiError(body.hint ? `${detail} (${body.hint})` : detail, response.status);
package/dist/folder.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { requestJson } from "./network.js";
2
3
  function parseGitRepo(remote) {
3
4
  const trimmed = remote.trim();
4
5
  const ssh = /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/i.exec(trimmed);
@@ -34,14 +35,14 @@ export function gitRepoFromCwd(cwd) {
34
35
  export function reportFolder(proxyUrl, presentedKey) {
35
36
  const cwd = process.cwd();
36
37
  const git = gitRepoFromCwd(cwd);
37
- void fetch(`${proxyUrl}/v1/here`, {
38
+ void requestJson(`${proxyUrl}/v1/here`, {
38
39
  method: "POST",
39
40
  headers: {
40
41
  authorization: `Bearer ${presentedKey}`,
41
42
  "content-type": "application/json",
42
43
  },
43
44
  body: JSON.stringify({ cwd, git }),
44
- }).catch(() => {
45
+ }, fetch, 5_000).catch(() => {
45
46
  // The Agent still runs if this misses.
46
47
  });
47
48
  }
package/dist/logins.js CHANGED
@@ -38,8 +38,8 @@ function jwtClaims(token) {
38
38
  /**
39
39
  * Claude Code keeps the signed-in account (not the token) in `~/.claude.json`
40
40
  * under `oauthAccount`; Codex keeps its ChatGPT login in `auth.json` under
41
- * the home it runs with. Grok Build and Kimi Code are asked nothing: their
42
- * accounts are read off the wire by the proxy.
41
+ * the home it runs with; Grok Build keeps email and id in `auth.json`; Kimi
42
+ * Code's account is the subject of its login token.
43
43
  */
44
44
  export async function localLogin(vendor, opts = {}) {
45
45
  const home = opts.home ?? homedir();
@@ -66,6 +66,29 @@ export async function localLogin(vendor, opts = {}) {
66
66
  const accountId = str(tokens.account_id) ?? str(authClaims?.chatgpt_account_id);
67
67
  return { ...(email ? { email } : {}), ...(plan ? { plan } : {}), ...(accountId ? { accountId } : {}) };
68
68
  }
69
+ if (vendor === "xai") {
70
+ // Grok Build: `auth.json` is one entry per issuer, with the account's email and id beside the token.
71
+ const auth = await jsonFile(path.join(opts.grokHome ?? path.join(home, ".grok"), "auth.json"));
72
+ const entry = Object.values(auth ?? {}).find((v) => Boolean(v) && typeof v === "object");
73
+ if (!entry)
74
+ return undefined;
75
+ const email = str(entry.email);
76
+ const accountId = str(entry.user_id);
77
+ return email || accountId ? { ...(email ? { email } : {}), ...(accountId ? { accountId } : {}) } : undefined;
78
+ }
79
+ if (vendor === "kimi") {
80
+ // Kimi Code names no email; the login token's subject is the account the proxy files calls under.
81
+ // Kimi Code keeps its home in `~/.kimi-code`, the older kimi-cli in `~/.kimi`; same file inside.
82
+ const homes = opts.kimiShareDir ? [opts.kimiShareDir] : [path.join(home, ".kimi-code"), path.join(home, ".kimi")];
83
+ let cred;
84
+ for (const dir of homes) {
85
+ cred = await jsonFile(path.join(dir, "credentials", "kimi-code.json"));
86
+ if (cred)
87
+ break;
88
+ }
89
+ const accountId = str(jwtClaims(cred?.access_token)?.sub);
90
+ return accountId ? { accountId } : undefined;
91
+ }
69
92
  return undefined;
70
93
  }
71
94
  /** "me@x.com (Claude Max 20x)" or nothing worth saying. */
package/dist/manage.js CHANGED
@@ -8,6 +8,7 @@ import { spawn } from "node:child_process";
8
8
  import { readProject } from "./project.js";
9
9
  import { saveConfig } from "./config.js";
10
10
  import { ConsoleApiError } from "./console-api.js";
11
+ import { requestJson, watch } from "./network.js";
11
12
  import { bold, dim, green, money, red, rows, table, yellow } from "./ui.js";
12
13
  export class ManageError extends Error {
13
14
  }
@@ -17,13 +18,13 @@ export function api(config, fetchImpl = fetch) {
17
18
  const token = config.token;
18
19
  if (!base || !token)
19
20
  throw new ManageError("This machine is not logged in. Run tab login.");
20
- return async (method, path, body) => {
21
- const res = await fetchImpl(`${base}${path}`, {
21
+ return async (method, path, body, signal) => {
22
+ const { response: res, body: json } = await requestJson(`${base}${path}`, {
22
23
  method,
24
+ ...(signal ? { signal } : {}),
23
25
  headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
24
26
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
25
- });
26
- const json = (await res.json().catch(() => ({})));
27
+ }, fetchImpl);
27
28
  if (!res.ok) {
28
29
  const detail = json.error ?? `console answered ${res.status}`;
29
30
  throw new ConsoleApiError(res.status === 401 ? "Session expired. Run tab login." : json.hint ? `${detail} (${json.hint})` : detail, res.status);
@@ -65,13 +66,13 @@ async function tabsOf(call) {
65
66
  }
66
67
  /** Slug or name (case-insensitive); omitted means this folder's Agent. */
67
68
  export async function resolveSlug(call, given, cwd = process.cwd()) {
68
- const tabs = await tabsOf(call);
69
69
  if (!given) {
70
70
  const project = await readProject(cwd);
71
71
  if (!project)
72
72
  throw new ManageError("No Agent named and this folder has none yet (tab use), so say which: tab <command> <agent>.");
73
73
  return project.agent;
74
74
  }
75
+ const tabs = await tabsOf(call);
75
76
  const want = given.trim().toLowerCase();
76
77
  const hit = tabs.find((t) => t.slug.toLowerCase() === want) ?? tabs.find((t) => t.name.toLowerCase() === want);
77
78
  if (!hit)
@@ -310,48 +311,79 @@ export function logLine(r) {
310
311
  * machine, one condensed line per call, hosted or self-hosted alike because
311
312
  * it is the ledger's own record. `-f` follows it; `--all` is the whole flock.
312
313
  */
313
- export async function log(config, flags, follow) {
314
+ export async function log(config, flags, follow, signal) {
314
315
  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();
316
+ const limit = Math.min(500, Math.max(1, Math.floor(Number(flags.opts.lines ?? 30) || 30)));
317
+ const mine = [...new Set(Object.values(config.agents ?? {}).map((a) => a.agentId).filter(Boolean))].sort();
318
+ const all = flags.opts.all !== undefined;
319
+ if (!all && mine.length > 200)
320
+ throw new ManageError("This machine has more than 200 Agents. Use tab ledger <agent>, or --all explicitly for the flock.");
321
+ if (!all && mine.length === 0) {
322
+ out(flags.json ? JSON.stringify({ rows: [] }) : dim("No Agent IDs saved on this machine. Run tab use, or add --all for the flock."));
323
+ return 0;
324
+ }
325
+ const params = new URLSearchParams({ limit: String(limit) });
326
+ if (!all)
327
+ for (const id of mine)
328
+ params.append("agentId", id);
329
+ if (follow)
330
+ params.set("incremental", "1");
331
+ let cursor;
332
+ const fetchPage = async (requestSignal) => {
333
+ const query = new URLSearchParams(params);
334
+ if (cursor)
335
+ query.set("cursor", cursor);
336
+ const page = await call("GET", `/api/cli/ledger?${query}`, undefined, requestSignal);
337
+ if (follow && !page.cursor)
338
+ throw new ManageError("The console does not support incremental logs yet. Use tab ledger until it is updated.");
339
+ if (page.hasMore && page.cursor === cursor)
340
+ throw new ManageError("The ledger cursor did not advance.");
341
+ cursor = page.cursor;
342
+ return page;
343
+ };
344
+ const first = (await fetchPage(signal)).rows;
320
345
  if (flags.json && !follow) {
321
346
  out(JSON.stringify({ rows: first }, null, 2));
322
347
  return 0;
323
348
  }
324
349
  if (first.length === 0 && !follow)
325
350
  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
- }
351
+ const seen = new Map();
352
+ const print = (r) => {
353
+ const fingerprint = JSON.stringify(r);
354
+ if (seen.get(r.id) === fingerprint)
355
+ return;
356
+ seen.delete(r.id);
357
+ seen.set(r.id, fingerprint);
358
+ if (seen.size > 2_000)
359
+ seen.delete(seen.keys().next().value);
360
+ out(flags.json ? fingerprint : logLine(r));
361
+ };
362
+ for (const r of [...first].reverse())
363
+ print(r);
331
364
  if (!follow)
332
365
  return 0;
333
- for (;;) {
334
- await new Promise((resolve) => setTimeout(resolve, 2000));
335
- let rows;
366
+ await watch(async (requestSignal) => {
367
+ const bootstrap = !cursor;
336
368
  try {
337
- rows = await fetchRows();
338
- }
339
- catch {
340
- continue;
369
+ const page = await fetchPage(requestSignal);
370
+ for (const row of bootstrap ? [...page.rows].reverse() : page.rows)
371
+ print(row);
372
+ return page.hasMore === true;
341
373
  }
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));
374
+ catch (error) {
375
+ if (error instanceof ConsoleApiError && error.status === 400)
376
+ cursor = undefined;
377
+ throw error;
347
378
  }
348
- }
379
+ }, 2_000, { ...(signal ? { signal } : {}), onError: (error) => console.error(dim(error instanceof Error ? error.message : String(error))) });
380
+ return 0;
349
381
  }
350
382
  export async function ledger(config, flags) {
351
383
  const call = api(config);
352
384
  const params = new URLSearchParams();
353
385
  if (flags.args[0])
354
- params.set("agent", await resolveSlug(call, flags.args[0]));
386
+ params.set("agent", flags.args[0].trim());
355
387
  if (flags.opts.limit)
356
388
  params.set("limit", flags.opts.limit);
357
389
  if (flags.opts.blocked !== undefined)
@@ -408,34 +440,25 @@ export async function outside(config, flags) {
408
440
  });
409
441
  return 0;
410
442
  }
411
- export async function live(config, flags) {
443
+ export async function live(config, flags, signal) {
412
444
  const call = api(config);
413
- const once = async () => {
414
- const feed = await call("GET", "/api/cli/live");
445
+ const once = async (requestSignal) => {
446
+ const feed = await call("GET", flags.json ? "/api/cli/live" : "/api/cli/live?view=summary", undefined, requestSignal);
415
447
  emit(flags.json, feed, () => `${dim("active")} ${feed.totals.active} ${dim("calls/min")} ${feed.totals.callsPerMinute} ${dim("blocked 5m")} ${feed.totals.blocked} ${dim("spend 5m")} ${money(feed.totals.windowCents)}\n` +
416
448
  table(["agent", "pulse", "calls/min", "blocked", "5m", "last"], feed.agents.map((a) => [a.slug, a.pulse, String(a.callsPerMinute), String(a.blocked), money(a.windowCents), a.lastDecision ? `${a.lastDecision.toLowerCase()} ${dim(a.lastReason ?? "")}` : dim("-")]), { right: [2, 3, 4] }));
417
449
  };
418
- await once();
450
+ await once(signal);
419
451
  if (flags.opts.watch === undefined)
420
452
  return 0;
421
- const every = Math.max(2, Number(flags.opts.watch) || 4) * 1000;
422
- await new Promise((resolve) => {
423
- const timer = setInterval(() => {
424
- out("");
425
- once().catch((err) => out(red(String(err instanceof Error ? err.message : err))));
426
- }, every);
427
- process.on("SIGINT", () => {
428
- clearInterval(timer);
429
- resolve();
430
- });
431
- });
453
+ const every = Math.min(60, Math.max(2, Number(flags.opts.watch) || 4)) * 1000;
454
+ await watch(once, every, { ...(signal ? { signal } : {}), onError: (error) => console.error(red(error instanceof Error ? error.message : String(error))) });
432
455
  return 0;
433
456
  }
434
457
  /** `tab web [agent]`: this folder's Agent in the console. */
435
458
  export async function web(config, flags) {
436
459
  const call = api(config);
437
- const slug = flags.args[0] || (await readProject(process.cwd()))?.agent;
438
- const url = slug ? `${config.consoleUrl}/console/tabs/${encodeURIComponent(await resolveSlug(call, slug))}` : `${config.consoleUrl}/console`;
460
+ const slug = flags.args[0] ? await resolveSlug(call, flags.args[0]) : (await readProject(process.cwd()))?.agent;
461
+ const url = slug ? `${config.consoleUrl}/console/tabs/${encodeURIComponent(slug)}` : `${config.consoleUrl}/console`;
439
462
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
440
463
  const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
441
464
  try {
@@ -0,0 +1,78 @@
1
+ function pause(ms, signal) {
2
+ signal.throwIfAborted();
3
+ return new Promise((resolve, reject) => {
4
+ const aborted = () => {
5
+ clearTimeout(timer);
6
+ reject(signal.reason);
7
+ };
8
+ const timer = setTimeout(() => {
9
+ signal.removeEventListener("abort", aborted);
10
+ resolve();
11
+ }, ms);
12
+ signal.addEventListener("abort", aborted, { once: true });
13
+ });
14
+ }
15
+ /** Use Node's shared fetch pool; bound both headers and body, including cancellation. */
16
+ export async function requestJson(url, init = {}, fetchImpl = fetch, timeoutMs = 15_000) {
17
+ const controller = new AbortController();
18
+ const signal = init.signal
19
+ ? AbortSignal.any([init.signal, controller.signal])
20
+ : controller.signal;
21
+ signal.throwIfAborted();
22
+ const timer = setTimeout(() => controller.abort(new DOMException("Console request timed out", "TimeoutError")), timeoutMs);
23
+ let onAbort;
24
+ const cancelled = new Promise((_resolve, reject) => {
25
+ onAbort = () => reject(signal.reason);
26
+ signal.addEventListener("abort", onAbort, { once: true });
27
+ });
28
+ try {
29
+ return await Promise.race([
30
+ (async () => {
31
+ const response = await fetchImpl(url, { ...init, signal });
32
+ const body = (await response.json().catch(() => ({})));
33
+ return { response, body };
34
+ })(),
35
+ cancelled,
36
+ ]);
37
+ }
38
+ finally {
39
+ clearTimeout(timer);
40
+ signal.removeEventListener("abort", onAbort);
41
+ }
42
+ }
43
+ /** Completion-based watch loop. A true result drains the next cursor page immediately. */
44
+ export async function watch(task, intervalMs, options = {}) {
45
+ const controller = new AbortController();
46
+ const signal = options.signal ?? controller.signal;
47
+ const stop = () => controller.abort();
48
+ if (!options.signal) {
49
+ process.once("SIGINT", stop);
50
+ process.once("SIGTERM", stop);
51
+ }
52
+ let delay = intervalMs;
53
+ try {
54
+ while (!signal.aborted) {
55
+ await pause(delay, signal);
56
+ try {
57
+ const more = await task(signal);
58
+ delay = more ? 0 : intervalMs;
59
+ }
60
+ catch (error) {
61
+ if (signal.aborted)
62
+ break;
63
+ options.onError?.(error);
64
+ delay = Math.min(Math.max(delay, intervalMs) * 2, 60_000);
65
+ }
66
+ }
67
+ }
68
+ catch (error) {
69
+ if (!signal.aborted)
70
+ throw error;
71
+ }
72
+ finally {
73
+ if (!options.signal) {
74
+ process.removeListener("SIGINT", stop);
75
+ process.removeListener("SIGTERM", stop);
76
+ }
77
+ }
78
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * `tab pool`, drawn: one table per vendor. Who each login is, its plan, how
3
+ * used the vendor last said it is (a bar, then each window with when it
4
+ * resets), and an arrow on the login the next launch would run as.
5
+ */
6
+ import { pickMember } from "./pool.js";
7
+ import { bold, dim, green, red, table, width, yellow } from "./ui.js";
8
+ const BAR = 10;
9
+ /** "2h 10m", "3d 4h", "12m": how long until a window resets. */
10
+ export function until(resetsAt, now) {
11
+ if (!resetsAt)
12
+ return undefined;
13
+ const ms = new Date(resetsAt).getTime() - now.getTime();
14
+ if (!Number.isFinite(ms) || ms <= 0)
15
+ return undefined;
16
+ const minutes = Math.round(ms / 60_000);
17
+ if (minutes < 60)
18
+ return `${Math.max(1, minutes)}m`;
19
+ const hours = Math.floor(minutes / 60);
20
+ if (hours < 24)
21
+ return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
22
+ return `${Math.floor(hours / 24)}d ${hours % 24}h`;
23
+ }
24
+ function paint(pct, at) {
25
+ return pct >= at ? red : pct >= at * 0.75 ? yellow : green;
26
+ }
27
+ export function usageBar(pct, at) {
28
+ if (pct === undefined)
29
+ return dim("·".repeat(BAR));
30
+ const filled = Math.min(BAR, Math.max(0, Math.round((pct / 100) * BAR)));
31
+ return `${paint(pct, at)("█".repeat(filled))}${dim("░".repeat(BAR - filled))}`;
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
+ /** "claude max 20x" and "pro" as the harness files spell them, in the vendor's capitals. */
39
+ export function planName(plan) {
40
+ if (!plan)
41
+ return undefined;
42
+ return plan.replace(/\b[a-z]/g, (c) => c.toUpperCase()).replace(/\b(\d+)X\b/g, "$1x");
43
+ }
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());
46
+ if (!windows || windows.length === 0)
47
+ return dim("no call through FlockTab yet");
48
+ if (live.length === 0)
49
+ return dim("every window has reset");
50
+ return live
51
+ .map((w) => {
52
+ const left = until(w.resetsAt, now);
53
+ return `${w.window} ${paint(w.usedPct, at)(`${Math.round(w.usedPct)}%`)}${left ? dim(` resets in ${left}`) : ""}`;
54
+ })
55
+ .join(dim(" · "));
56
+ }
57
+ export function renderPool(sections, opts) {
58
+ const now = opts.now ?? new Date();
59
+ const out = [];
60
+ // One width for the login and plan columns, so the vendors line up under each other.
61
+ const whoOf = (s) => (s.email && s.email !== s.member.name ? `${s.member.name} ${dim(`(${s.email})`)}` : s.member.name);
62
+ const all = sections.flatMap((x) => x.standings);
63
+ const loginWidth = Math.max(5, ...all.map((s) => width(s.email ? whoOf(s) : `${whoOf(s)} not signed in`)));
64
+ const planWidth = Math.max(4, ...all.map((s) => width(planName(s.plan) ?? "-")));
65
+ 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;
69
+ 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}`)}`);
71
+ out.push(table(["", "login".padEnd(loginWidth), "plan".padEnd(planWidth), "used", "", "windows", ...(opts.paths ? ["folder"] : [])], section.standings.map((s) => {
72
+ const who = whoOf(s);
73
+ return [
74
+ s.member.name === next ? green("→") : " ",
75
+ s.email ? who : `${who} ${yellow("not signed in")}`,
76
+ planName(s.plan) ?? dim("-"),
77
+ usageBar(s.used, at),
78
+ s.used === undefined ? dim(" -") : paint(s.used, at)(`${String(Math.round(s.used)).padStart(3)}%`),
79
+ windowsLine(s.windows, at, now),
80
+ ...(opts.paths ? [dim(s.member.dir)] : []),
81
+ ];
82
+ }), { indent: 1 }));
83
+ out.push("");
84
+ }
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"));
87
+ return out.join("\n");
88
+ }
package/dist/pool.js CHANGED
@@ -12,6 +12,7 @@ import { chmod, mkdir, readdir, readFile, symlink, writeFile, lstat } from "node
12
12
  import { homedir } from "node:os";
13
13
  import path from "node:path";
14
14
  import { configDir } from "./config.js";
15
+ export const POOL_VENDORS = ["anthropic", "openai", "xai", "kimi"];
15
16
  export const DEFAULT_AT = 80;
16
17
  export function poolPath(env = process.env) {
17
18
  return path.join(configDir(env), "pool.json");
@@ -21,20 +22,29 @@ export function poolVendorFor(harness) {
21
22
  return "anthropic";
22
23
  if (harness === "codex")
23
24
  return "openai";
25
+ if (harness === "grok")
26
+ return "xai";
27
+ if (harness === "kimi")
28
+ return "kimi";
24
29
  return undefined;
25
30
  }
26
31
  export async function loadPool(env = process.env) {
27
- const empty = { at: DEFAULT_AT, swap: "auto", members: {} };
32
+ const empty = { at: DEFAULT_AT, atByVendor: {}, swap: "auto", members: {} };
28
33
  try {
29
34
  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;
35
+ const valid = (n) => typeof n === "number" && n >= 1 && n <= 100;
36
+ const at = valid(raw.at) ? raw.at : DEFAULT_AT;
31
37
  const members = {};
32
- for (const vendor of ["anthropic", "openai"]) {
38
+ const atByVendor = {};
39
+ for (const vendor of POOL_VENDORS) {
40
+ const own = raw.atByVendor?.[vendor];
41
+ if (valid(own))
42
+ atByVendor[vendor] = own;
33
43
  const list = raw.members?.[vendor];
34
44
  if (Array.isArray(list))
35
45
  members[vendor] = list.filter((m) => typeof m?.name === "string" && typeof m?.dir === "string").map((m) => ({ name: m.name, dir: m.dir }));
36
46
  }
37
- return { at, swap: raw.swap === "launch" ? "launch" : "auto", members };
47
+ return { at, atByVendor, swap: raw.swap === "launch" ? "launch" : "auto", members };
38
48
  }
39
49
  catch {
40
50
  return empty;
@@ -47,6 +57,26 @@ export async function savePool(pool, env = process.env) {
47
57
  await chmod(file, 0o600);
48
58
  return file;
49
59
  }
60
+ /** The threshold one vendor's logins move at: its own, else the shared one. */
61
+ export function thresholdFor(pool, vendor) {
62
+ return pool.atByVendor[vendor] ?? pool.at;
63
+ }
64
+ /** Set the shared threshold, or one vendor's; `undefined` for a vendor clears its override. */
65
+ export function setThreshold(pool, pct, vendor) {
66
+ if (pct !== undefined && (!Number.isFinite(pct) || pct < 1 || pct > 100))
67
+ throw new Error("A threshold is a percent from 1 to 100.");
68
+ if (!vendor) {
69
+ if (pct === undefined)
70
+ throw new Error("A threshold is a percent from 1 to 100.");
71
+ return { ...pool, at: Math.round(pct) };
72
+ }
73
+ const atByVendor = { ...pool.atByVendor };
74
+ if (pct === undefined)
75
+ delete atByVendor[vendor];
76
+ else
77
+ atByVendor[vendor] = Math.round(pct);
78
+ return { ...pool, atByVendor };
79
+ }
50
80
  export function addMember(pool, vendor, name, dir, env = process.env) {
51
81
  if (!/^[A-Za-z0-9][A-Za-z0-9._@-]{0,63}$/.test(name))
52
82
  throw new Error("A member name is letters, digits, dot, dash, underscore or @, up to 64.");
@@ -59,9 +89,39 @@ export function addMember(pool, vendor, name, dir, env = process.env) {
59
89
  export function removeMember(pool, vendor, name) {
60
90
  return { ...pool, members: { ...pool.members, [vendor]: (pool.members[vendor] ?? []).filter((m) => m.name !== name) } };
61
91
  }
62
- /** The env that makes a harness use this member's login folder. */
92
+ /** The folder the harness itself treats as home for this member. */
93
+ export function memberHome(vendor, member) {
94
+ return vendor === "openai" ? path.join(member.dir, "codex") : member.dir;
95
+ }
96
+ /** The env that makes a harness use this member's login folder. Each name is the harness's own. */
63
97
  export function memberEnv(vendor, member) {
64
- return vendor === "anthropic" ? { CLAUDE_CONFIG_DIR: member.dir } : { CODEX_HOME: path.join(member.dir, "codex") };
98
+ const home = memberHome(vendor, member);
99
+ if (vendor === "anthropic")
100
+ return { CLAUDE_CONFIG_DIR: home };
101
+ if (vendor === "openai")
102
+ return { CODEX_HOME: home };
103
+ if (vendor === "xai")
104
+ return { GROK_HOME: home };
105
+ // Two CLIs answer to `kimi`: Kimi Code reads KIMI_CODE_HOME, the older kimi-cli KIMI_SHARE_DIR.
106
+ return { KIMI_CODE_HOME: home, KIMI_SHARE_DIR: home };
107
+ }
108
+ /**
109
+ * 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
112
+ * that already has its own `sessions` is left as it is.
113
+ */
114
+ export async function prepareSharedSessions(vendor, member, env = process.env) {
115
+ if (vendor === "anthropic")
116
+ return;
117
+ const home = memberHome(vendor, member);
118
+ const link = path.join(home, "sessions");
119
+ if (await lstat(link).then(() => true, () => false))
120
+ return;
121
+ const shared = path.join(configDir(env), "pool", vendor, ".shared", "sessions");
122
+ await mkdir(shared, { recursive: true, mode: 0o700 });
123
+ await mkdir(home, { recursive: true, mode: 0o700 });
124
+ await symlink(shared, link);
65
125
  }
66
126
  /** How used an account is: its fullest window that has not reset yet. */
67
127
  export function usedPct(quota, now = new Date()) {
@@ -95,7 +155,7 @@ export function relaunchArgs(harness, args) {
95
155
  const kept = [];
96
156
  for (let i = 0; i < args.length; i += 1) {
97
157
  const a = args[i];
98
- if (a === "--continue" || a === "-c")
158
+ if (a === "--continue" || a === "-c" || a === "-C")
99
159
  continue;
100
160
  // One-shot and resume arguments belong to the first launch only.
101
161
  if (a === "--resume" || a === "-r" || a === "-p" || a === "--print") {
package/dist/project.js CHANGED
@@ -8,6 +8,14 @@
8
8
  import { access, readFile, writeFile } from "node:fs/promises";
9
9
  import path from "node:path";
10
10
  export const PROJECT_FILE = ".flocktab";
11
+ /**
12
+ * The file's name. `tabdev` sets `FLOCKTAB_PROJECT_FILE=.flocktabdev` so a
13
+ * checkout under test never rewrites the folder's real `.flocktab`.
14
+ */
15
+ export function projectFileName(env = process.env) {
16
+ const name = env.FLOCKTAB_PROJECT_FILE?.trim();
17
+ return name && /^\.[A-Za-z0-9._-]{1,40}$/.test(name) ? name : PROJECT_FILE;
18
+ }
11
19
  async function exists(file) {
12
20
  try {
13
21
  await access(file);
@@ -21,7 +29,7 @@ async function exists(file) {
21
29
  export async function findProjectFile(cwd) {
22
30
  let dir = path.resolve(cwd);
23
31
  for (;;) {
24
- const candidate = path.join(dir, PROJECT_FILE);
32
+ const candidate = path.join(dir, projectFileName());
25
33
  if (await exists(candidate))
26
34
  return candidate;
27
35
  if (await exists(path.join(dir, ".git")))
@@ -60,7 +68,7 @@ export async function projectRoot(cwd) {
60
68
  }
61
69
  }
62
70
  export async function writeProject(dir, config) {
63
- const file = path.join(dir, PROJECT_FILE);
71
+ const file = path.join(dir, projectFileName());
64
72
  await writeFile(file, `${JSON.stringify(config)}\n`);
65
73
  return file;
66
74
  }
package/dist/proxy-bin.js CHANGED
@@ -13,7 +13,7 @@ import { createRequire } from "node:module";
13
13
  import path from "node:path";
14
14
  import { configDir } from "./config.js";
15
15
  /** The proxy release `tab up` fetches. Bump with each proxy tag. */
16
- export const PROXY_VERSION = "0.1.7";
16
+ export const PROXY_VERSION = "0.1.8";
17
17
  export const RELEASES = "https://github.com/joseairosa/flocktab/releases/download";
18
18
  export function targetFor(platform = process.platform, arch = process.arch) {
19
19
  if (platform === "darwin")
@@ -0,0 +1,32 @@
1
+ import { presentedKey } from "./config.js";
2
+ import { requestJson } from "./network.js";
3
+ /** A fresh authenticated identity also proves the hosted proxy is ready for this Agent. */
4
+ export async function whoami(proxyUrl, login, fetchImpl = fetch) {
5
+ try {
6
+ const { response, body } = await requestJson(`${proxyUrl}/v1/whoami`, {
7
+ headers: { authorization: `Bearer ${presentedKey(login)}` },
8
+ }, fetchImpl);
9
+ if (!response.ok)
10
+ return {
11
+ error: body.error?.message ?? `whoami returned ${response.status}`,
12
+ };
13
+ if (!body.agent?.id ||
14
+ typeof body.agent.name !== "string" ||
15
+ typeof body.flock?.plan !== "string" ||
16
+ typeof body.byok !== "boolean") {
17
+ return { error: "proxy returned an invalid identity" };
18
+ }
19
+ if (body.kind !== undefined &&
20
+ body.kind !== "api" &&
21
+ body.kind !== "subscription")
22
+ return { error: "proxy returned an invalid Agent kind" };
23
+ return body;
24
+ }
25
+ catch (error) {
26
+ return {
27
+ error: error instanceof DOMException && error.name === "TimeoutError"
28
+ ? "proxy timed out"
29
+ : "proxy unreachable",
30
+ };
31
+ }
32
+ }
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.11";
2
+ export const TAB_VERSION = "0.1.12";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanamorilabs/tab",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
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.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"
25
+ "@hanamorilabs/flocktab-proxy-darwin-arm64": "0.1.8",
26
+ "@hanamorilabs/flocktab-proxy-darwin-x64": "0.1.8",
27
+ "@hanamorilabs/flocktab-proxy-linux-x64": "0.1.8",
28
+ "@hanamorilabs/flocktab-proxy-linux-arm64": "0.1.8",
29
+ "@hanamorilabs/flocktab-proxy-win-x64": "0.1.8"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^22.18.6",