@hanamorilabs/tab 0.1.10 → 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 +17 -1
- package/dist/alias.js +4 -2
- package/dist/cli.js +90 -55
- package/dist/clients.js +5 -1
- package/dist/console-api.js +3 -3
- package/dist/folder.js +3 -2
- package/dist/logins.js +25 -2
- package/dist/manage.js +70 -47
- package/dist/network.js +78 -0
- package/dist/pool-view.js +88 -0
- package/dist/pool.js +67 -7
- package/dist/project.js +10 -2
- package/dist/proxy-bin.js +1 -1
- package/dist/proxy-identity.js +32 -0
- package/dist/version.js +1 -1
- package/package.json +6 -6
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
|
-
|
|
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)
|
|
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 (
|
|
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
|
-
|
|
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
|
|
544
|
+
const login = await memberLogin(vendor, member);
|
|
550
545
|
const email = login?.email;
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
-
|
|
696
|
-
|
|
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}
|
|
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,30 +753,44 @@ async function poolCommand(argv) {
|
|
|
727
753
|
return 2;
|
|
728
754
|
}
|
|
729
755
|
const member = next.members[vendor].at(-1);
|
|
730
|
-
const loginOf = () =>
|
|
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
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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];
|
|
768
|
+
// `claude auth login` does the browser sign-in and exits: no session to open, no /login, no /exit.
|
|
769
|
+
const email = flags.opts.email ?? (memberName.includes("@") ? memberName : undefined);
|
|
770
|
+
say(dim(`Signing ${harness} in for ${memberName}. Your browser opens; pick the account for this login.`));
|
|
738
771
|
// Plain launch: no proxy, no tab key. This only creates the login, in the harness's own store.
|
|
739
772
|
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"])
|
|
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"])
|
|
741
774
|
delete loginEnv[k];
|
|
742
|
-
const
|
|
743
|
-
const child = spawn(harness,
|
|
775
|
+
const run = (argv) => new Promise((resolve) => {
|
|
776
|
+
const child = spawn(harness, argv, { stdio: "inherit", env: loginEnv });
|
|
744
777
|
child.on("error", () => resolve(127));
|
|
745
778
|
child.on("close", (c) => resolve(c ?? 1));
|
|
746
779
|
});
|
|
780
|
+
// Every one of them has its own sign-in command that opens the browser and exits.
|
|
781
|
+
let code = await run(vendor === "anthropic" ? ["auth", "login", "--claudeai", ...(email ? ["--email", email] : [])] : ["login"]);
|
|
782
|
+
if (vendor === "anthropic" && code !== 0 && code !== 127 && !(await signedIn())) {
|
|
783
|
+
// A Claude Code too old for `auth login`: sign in from inside it.
|
|
784
|
+
say(dim("That did not sign in. Opening Claude Code instead: /login as this account, then /exit."));
|
|
785
|
+
code = await run([]);
|
|
786
|
+
}
|
|
747
787
|
if (code === 127) {
|
|
748
788
|
fail(`${harness} is not installed.`);
|
|
749
789
|
return 127;
|
|
750
790
|
}
|
|
751
791
|
}
|
|
752
792
|
const login = await loginOf();
|
|
753
|
-
if (!
|
|
793
|
+
if (!(await signedIn())) {
|
|
754
794
|
fail(`No login found in ${member.dir}. Nothing was added; run the same command again to retry.`);
|
|
755
795
|
return 1;
|
|
756
796
|
}
|
|
@@ -758,27 +798,22 @@ async function poolCommand(argv) {
|
|
|
758
798
|
ok(`${bold(memberName)} is in the pool as ${describeLogin(login)}.`);
|
|
759
799
|
return 0;
|
|
760
800
|
}
|
|
761
|
-
if (sub && sub !== "list" && sub !== "ls") {
|
|
801
|
+
if (sub && sub !== "list" && sub !== "ls" && sub !== "--paths") {
|
|
762
802
|
fail(`Unknown: tab pool ${sub}. ${dim("tab pool | add | remove | at | swap")}`);
|
|
763
803
|
return 2;
|
|
764
804
|
}
|
|
765
805
|
const config = await ensureLogin();
|
|
766
806
|
if (!config)
|
|
767
807
|
return 2;
|
|
768
|
-
const vendors =
|
|
808
|
+
const vendors = POOL_VENDORS.filter((v) => (pool.members[v] ?? []).length > 0);
|
|
769
809
|
if (vendors.length === 0) {
|
|
770
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.`));
|
|
771
811
|
return 0;
|
|
772
812
|
}
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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.`));
|
|
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") }));
|
|
782
817
|
return 0;
|
|
783
818
|
}
|
|
784
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
|
-
|
|
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;
|
package/dist/console-api.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
42
|
-
*
|
|
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
|
|
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.
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
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
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
let rows;
|
|
366
|
+
await watch(async (requestSignal) => {
|
|
367
|
+
const bootstrap = !cursor;
|
|
336
368
|
try {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
343
|
-
if (
|
|
344
|
-
|
|
345
|
-
|
|
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",
|
|
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
|
|
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]
|
|
438
|
-
const url = slug ? `${config.consoleUrl}/console/tabs/${encodeURIComponent(
|
|
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 {
|
package/dist/network.js
ADDED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
26
|
-
"@hanamorilabs/flocktab-proxy-darwin-x64": "0.1.
|
|
27
|
-
"@hanamorilabs/flocktab-proxy-linux-x64": "0.1.
|
|
28
|
-
"@hanamorilabs/flocktab-proxy-linux-arm64": "0.1.
|
|
29
|
-
"@hanamorilabs/flocktab-proxy-win-x64": "0.1.
|
|
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",
|