@hanamorilabs/tab 0.1.7 → 0.1.9

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
@@ -38,28 +38,34 @@ is keyed, and keeps it in the config file so `tab claude` is one command.
38
38
  Set `FLOCKTAB_PROXY_URL`, `FLOCKTAB_KEY` and `FLOCKTAB_UNLOCK` instead when
39
39
  you would rather not have a file or a folder, for example in CI.
40
40
 
41
- ## On a subscription (Claude Max, ChatGPT)
41
+ ## On a subscription (Claude Max, ChatGPT, SuperGrok, Kimi)
42
+
43
+ An Agent is **api** (the flock's provider key, metered) or **subscription**
44
+ (the harnesses' own logins), chosen when it is made: the first `tab claude`
45
+ in a folder asks, or
42
46
 
43
47
  ```
44
- tab subscribe claude --plan "Claude Max 5x" --price 100 # Claude Code on your Max plan
45
- tab subscribe codex # Codex on your ChatGPT plan
46
- tab claude # your own Claude login through the tab
47
- tab codex # your own ChatGPT login (codex login once, inside tab codex)
48
- tab grok # still metered: the flock's xAI key, charged to the cap
49
- tab quota # 5h / 7d windows the vendors reported on the last call
50
- tab unsubscribe codex # Codex back on the meter
48
+ tab agent create "me@joseairosa.com" --subscription
49
+ tab use # this folder runs as it
50
+ tab claude # your own Claude login through the tab
51
+ tab codex # your own ChatGPT login (codex login once, inside tab codex)
52
+ tab grok # your SuperGrok login
53
+ tab kimi # your Kimi Code login
54
+ tab accounts # accounts seen: plan, seat price, 30 days at list, what is left
55
+ tab agent kind me@joseairosa.com api # back to the meter
51
56
  ```
52
57
 
53
- A subscription belongs to the harness, not the Agent: Claude Code can be on
54
- Max while Codex and Grok on the same Agent stay on the meter. The plan pays
55
- for the tokens; the tab still gates every call (kill switch, velocity, model
56
- and tool policy) and records it at list price as a `SUBSCRIPTION` row that
57
- never touches the cap. Claude Code gets
58
- `ANTHROPIC_BASE_URL=<proxy>/t/<key>/anthropic` and no API key; Codex gets a
59
- home whose `chatgpt_base_url` is `<proxy>/t/<key>/openai/backend-api/`, with
60
- your existing `~/.codex/auth.json` copied in once (never written back). The
61
- rows are read on every launch, so the console's **Subscriptions** panel and
62
- `tab subscribe` agree. `docs/subscriptions.md` has the wire.
58
+ The plan pays for the tokens; the tab still gates every call (kill switch,
59
+ velocity, model and tool policy) and records it at list price as a
60
+ `SUBSCRIPTION` row that never touches the cap. The account is not declared:
61
+ the vendor names it on every reply (Anthropic's organization id, ChatGPT's
62
+ account id, the login token for xAI and Kimi), FlockTab files the call under
63
+ it, learns the email and plan tier, and prices the seat from the tier. Four
64
+ Max accounts are four accounts; `claude /login` to another one mid-session
65
+ and the next call lands on it. Claude Code gets
66
+ `ANTHROPIC_BASE_URL=<proxy>/t/<key>/anthropic` and no API key; Codex a home
67
+ whose `chatgpt_base_url` is the passthrough; Grok Build `GROK_XAI_API_BASE_URL`;
68
+ Kimi Code `KIMI_BASE_URL`. `docs/subscriptions.md` has the wire.
63
69
 
64
70
  ## Self-hosted
65
71
 
@@ -82,12 +88,14 @@ Codex shows `FlockTab - Self-hosted` as its provider.
82
88
  tab login approve this machine in the console, once
83
89
  tab <agent> [args] claude, codex, grok, kimi, gemini, or any command, on the tab
84
90
  tab use pick or change the Agent this folder runs as (.flocktab)
85
- tab subscribe claude|codex [agent] that harness on your own plan (--plan, --price)
86
- tab unsubscribe claude|codex [agent] back on the metered tab for that harness
87
- tab quota [agent] what the plans have left: 5h and 7d windows
91
+ tab agent create <name> --subscription|--api a new Agent of that kind (--cap 50)
92
+ tab agent kind <agent> api|subscription change it
93
+ tab accounts accounts your subscription Agents were seen on: plan, price, quota left
88
94
  tab alias setup <name>... make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)
89
95
  tab alias remove <name>... undo that; `tab alias list` shows them
90
96
  tab status flock, folder Agent, proxy health, provider key state
97
+ tab log [-f] [--all] everything through the tab for this machine's Agents, one line per call
98
+ tab log --proxy [-f] self-hosted: the local proxy's own log (cached tokens, milliseconds)
91
99
  tab version tab and proxy versions
92
100
  tab up | down self-hosted: start or stop the local proxy
93
101
  tab update the newest tab and proxy from npm; restarts a running local proxy
@@ -102,7 +110,7 @@ session from `tab login`. An Agent is named by slug or name; left out, it is
102
110
  this folder's Agent. Every read takes `--json`.
103
111
 
104
112
  ```
105
- tab list every Agent: state, spent of cap, window, plans (claude, codex), project
113
+ tab list every Agent: kind, state, spent of cap, window, project
106
114
  tab close my-project tab open my-project the kill switch (402 tab_closed on the next call)
107
115
  tab cap my-project 25 --window week cap in dollars, and the window
108
116
  tab rename my-project "My project (nightly)" the slug stays
@@ -147,6 +155,6 @@ proxy http://127.0.0.1:8787 (self-hosted) up
147
155
  flock Hanamori Labs
148
156
  folder flocktab-codex
149
157
  plan team
150
- plans none; every harness on the metered tab
158
+ kind api (the flock's provider key, metered on the tab)
151
159
  byok provider keys on this box
152
160
  ```
package/dist/cli.js CHANGED
@@ -7,9 +7,8 @@
7
7
  * tab codex [...args] Codex on the tab
8
8
  * tab <cmd> [...args] anything else, both APIs pointed at the tab
9
9
  * tab use pick (or change) the Agent this folder runs as
10
- * tab subscribe claude this folder's Agent runs Claude Code on your Claude Max plan
11
- * tab subscribe codex ...and Codex on your ChatGPT plan; tab unsubscribe undoes it
12
- * tab quota what the plans have left (5h / 7d windows) for this Agent
10
+ * tab agent create <name> --subscription an Agent whose harnesses use your own logins
11
+ * tab accounts the accounts your subscription Agents were seen on, with quota left
13
12
  * tab alias setup codex make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)
14
13
  * tab alias remove codex undo that; `tab alias list` shows them
15
14
  * tab status which flock, which Agent here, is the proxy up
@@ -28,7 +27,8 @@ import { hostname } from "node:os";
28
27
  import { createInterface } from "node:readline/promises";
29
28
  import { stdin, stdout } from "node:process";
30
29
  import { bold, box, cyan, dim, green, heading, line, rows, underline, yellow } from "./ui.js";
31
- import { clientFor, envFor, knownClients } from "./clients.js";
30
+ import { envFor, knownClients, subscriptionVendorFor } from "./clients.js";
31
+ import { describeLogin, localLogin } from "./logins.js";
32
32
  import { prepareCodexHome } from "./codex-home.js";
33
33
  import { ConsoleApiError, createAgent, issueAgentKey, listAgents } from "./console-api.js";
34
34
  import { consoleUrlFor, DeviceLoginError, startDeviceLogin, waitForApproval } from "./device-login.js";
@@ -37,7 +37,7 @@ import { reportFolder } from "./folder.js";
37
37
  import { listAliases, pathLine, pathWithoutShims, removeAlias, shimDir, shimDirOnPath, validAliasName, writeAlias } from "./alias.js";
38
38
  import * as manage from "./manage.js";
39
39
  import { agentNameFor, projectRoot, readProject, writeProject } from "./project.js";
40
- import { downloadProxy, hasProxyEnv, packagedBinPath, proxyBinPath, proxyEnvPath, proxyInstalled, proxyLogPath, PROXY_VERSION, startProxy, stopProxy, tailProxyLog, writeProxyEnv, } from "./proxy-bin.js";
40
+ import { downloadProxy, hasProxyEnv, packagedBinPath, proxyBinPath, proxyEnvPath, proxyInstalled, proxyLogPath, hasProxyLog, PROXY_VERSION, startProxy, stopProxy, tailProxyLog, writeProxyEnv, } from "./proxy-bin.js";
41
41
  import { proxyHealth, waitHealthy } from "./selfhost.js";
42
42
  import { TAB_VERSION } from "./version.js";
43
43
  const say = (text) => console.error(text);
@@ -56,12 +56,14 @@ function usage() {
56
56
  cmd(others.join(" | tab "), "the same, for those agents"),
57
57
  cmd("<command> [args]", "any other agent, both APIs pointed at the tab"),
58
58
  cmd("use", "pick or change the Agent this folder runs as (.flocktab)"),
59
- cmd("subscribe claude|codex", "run that harness on your own plan (--plan \"Max 5x\" --price 100)"),
60
- cmd("unsubscribe claude|codex", "back on the metered tab for that harness"),
61
- cmd("quota [agent]", "what the plans have left: 5h and 7d windows"),
59
+ cmd("agent create <name>", "a new Agent: --subscription (your own logins) or --api (the flock's key, metered)"),
60
+ cmd("agent kind <agent> api|subscription", "change it"),
61
+ cmd("accounts", "accounts your subscription Agents were seen on: plan, price, quota left"),
62
62
  cmd("alias setup <name>...", "make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)"),
63
63
  cmd("alias remove <name>...", "undo that; `tab alias list` shows them"),
64
64
  cmd("status", "which flock, which Agent here, is the proxy up"),
65
+ cmd("log [-f] [--all]", "everything through the tab on this machine's Agents, one line per call; -f follows"),
66
+ cmd("log --proxy [-f]", "self-hosted: the local proxy's own log (cached tokens, milliseconds; no keys, logins or prompts)"),
65
67
  cmd("version", "tab and proxy versions"),
66
68
  cmd("list", "every Agent: state, spent of cap, window, project"),
67
69
  cmd("close | open [agent]", "the kill switch; omit the Agent for this folder's"),
@@ -398,14 +400,19 @@ async function resolveAgent(config, opts = {}) {
398
400
  say(rows([
399
401
  ...options.map((a, i) => [
400
402
  `${bold(String(i + 1))} ${a.name}`,
401
- `${a.state === "open" ? green("open") : yellow(a.state)} ${dim(`$${(Number(a.capCents) / 100).toFixed(2)} cap`)}${agents[a.slug] ? dim(" keyed here") : a.hasKey ? dim(" keyed elsewhere") : ""}`,
403
+ `${a.state === "open" ? green("open") : yellow(a.state)} ${a.kind === "subscription" ? yellow("subscription") : dim("api")} ${dim(`$${(Number(a.capCents) / 100).toFixed(2)} cap`)}${agents[a.slug] ? dim(" keyed here") : a.hasKey ? dim(" keyed elsewhere") : ""}`,
402
404
  ]),
403
405
  [`${bold("n")} New Agent`, dim(`named ${suggested}, $50.00 cap`)],
404
406
  ]));
405
407
  const answer = await ask(`Choose 1-${options.length} or ${bold("n")} ${dim("[n]")}: `);
406
408
  if (answer === "" || answer.toLowerCase() === "n") {
407
409
  const name = (await ask(`${cyan("?")} Agent name ${dim(`[${suggested}]`)}: `)) || suggested;
408
- created = { name };
410
+ say(rows([
411
+ [`${bold("1")} API key`, dim("the flock's provider key; every call metered against the cap")],
412
+ [`${bold("2")} Subscription`, dim("your own Claude Max / ChatGPT / SuperGrok / Kimi logins; gated and recorded, never charged")],
413
+ ]));
414
+ const which = await ask(`Which kind? ${bold("1")} or ${bold("2")} ${dim("[1]")}: `);
415
+ created = { name, kind: which.trim() === "2" ? "subscription" : "api" };
409
416
  }
410
417
  else {
411
418
  const index = Number.parseInt(answer, 10);
@@ -419,8 +426,8 @@ async function resolveAgent(config, opts = {}) {
419
426
  let issued;
420
427
  try {
421
428
  if (created) {
422
- issued = await createAgent(config.consoleUrl, config.token, { name: created.name });
423
- ok(`Created Agent ${bold(issued.agent.name)} with a $50.00 cap. Change it in the console.`);
429
+ issued = await createAgent(config.consoleUrl, config.token, { name: created.name, kind: created.kind });
430
+ ok(`Created ${created.kind === "subscription" ? "subscription" : "API-key"} Agent ${bold(issued.agent.name)} with a $50.00 cap. Change it in the console.`);
424
431
  }
425
432
  else if (chosen) {
426
433
  if (chosen.hasKey && !agents[chosen.slug]) {
@@ -486,8 +493,7 @@ async function status() {
486
493
  }
487
494
  else {
488
495
  pairs.push(["plan", me.flock.plan]);
489
- const plans = (me.subscriptions ?? []).map((p) => (p === "anthropic" ? "claude" : p === "openai" ? "codex" : p));
490
- pairs.push(["plans", plans.length > 0 ? `${bold(plans.join(", "))} ${dim("on your own login; everything else metered")}` : dim("none; every harness on the metered tab")]);
496
+ pairs.push(["kind", me.kind === "subscription" ? `${bold("subscription")} ${dim("(your own logins; gated and recorded, never charged)")}` : `api ${dim("(the flock's provider key, metered on the tab)")}`]);
491
497
  const byok = !me.byok
492
498
  ? dim("no provider key on this flock")
493
499
  : me.unlock === "none"
@@ -514,20 +520,43 @@ async function runAgent(name, args) {
514
520
  const agent = await resolveAgent(config);
515
521
  if (!agent)
516
522
  return 1;
517
- // Which harness is this, and is it on the person's own plan? The
518
- // subscription belongs to the harness (Claude Code: anthropic, Codex:
519
- // openai), read fresh on every launch so `tab subscribe` or the console
520
- // applies to the next run. Any other harness is metered.
521
- const base = clientFor(name);
522
- const vendor = base.shape === "anthropic" ? "anthropic" : base.isolatedHome === "codex" ? "openai" : undefined;
523
+ // The Agent's kind decides: a subscription Agent's harness brings its own
524
+ // login (Claude Code its Claude one, Codex ChatGPT, Grok SuperGrok, Kimi
525
+ // Code its Kimi plan), read fresh on every launch so a change in the
526
+ // console applies to the next run. An api Agent, or a harness with no
527
+ // consumer plan, is metered with the flock's key.
528
+ const vendor = subscriptionVendorFor(name);
523
529
  const me = await whoami(config.proxyUrl, { key: agent.key, unlock: config.unlock });
524
- const auth = vendor && !("error" in me) && (me.subscriptions ?? []).includes(vendor) ? "subscription" : "key";
530
+ let kind = !("error" in me) && me.kind === "subscription" ? "subscription" : "api";
531
+ // A proxy older than 0.1.7 answers whoami without the kind. Guessing api
532
+ // would hand a subscription Agent's harness the tab key, so ask the console.
533
+ const { consoleUrl, token } = config;
534
+ if (!("error" in me) && me.kind === undefined && config.mode === "self-hosted") {
535
+ // Not restarted here: other harnesses may be streaming through it.
536
+ warn(`The local proxy is older than this tab. When nothing is running through it: ${bold("tab down && tab up")}`);
537
+ }
538
+ if (consoleUrl && token && ("error" in me || me.kind === undefined)) {
539
+ try {
540
+ const listed = await listAgents(consoleUrl, token);
541
+ if (listed.agents.find((a) => a.id === agent.agentId)?.kind === "subscription")
542
+ kind = "subscription";
543
+ }
544
+ catch {
545
+ // Console unreachable: stay on what the proxy said.
546
+ }
547
+ }
548
+ const auth = kind === "subscription" && vendor ? "subscription" : "key";
525
549
  // On a subscription there is no provider key to unlock; the bare key rides in the URL.
526
550
  const key = auth === "subscription" ? agent.key : presentedKey({ key: agent.key, unlock: config.unlock });
527
551
  reportFolder(config.proxyUrl, key);
528
552
  const { spec, env } = envFor({ name, proxyUrl: config.proxyUrl, presentedKey: key, auth });
529
553
  if (auth === "subscription") {
530
- say(dim(`${spec.label} runs on your own ${vendor === "openai" ? "ChatGPT" : "Claude"} login; FlockTab keeps the record and the kill switch.`));
554
+ const login = describeLogin(await localLogin(vendor));
555
+ const vendorName = vendor === "openai" ? "ChatGPT" : vendor === "xai" ? "SuperGrok" : vendor === "kimi" ? "Kimi" : "Claude";
556
+ say(dim(`${spec.label} on your own ${vendorName} login${login ? `: ${login}` : ""}. FlockTab keeps the record and the kill switch; the account is read off each reply.`));
557
+ }
558
+ else if (kind === "subscription") {
559
+ say(dim(`${spec.label} has no consumer plan FlockTab can pass through, so it runs metered on the flock's key.`));
531
560
  }
532
561
  // An alias shim named like the agent must not be what we spawn.
533
562
  env.PATH = pathWithoutShims();
@@ -685,6 +714,29 @@ ${installed}` : "Updated.");
685
714
  await stopProxy();
686
715
  return (await bringUp(config.proxyUrl)) ? 0 : 1;
687
716
  }
717
+ /**
718
+ * `tab logs`: the local proxy's log. One line per call: kind, vendor, Agent,
719
+ * model, outcome, status, tokens (cached apart) and duration. The proxy never
720
+ * writes a key, a login, a prompt or an email there. `-f` follows it.
721
+ */
722
+ async function logs(args) {
723
+ const flags = manage.parseFlags(args);
724
+ const follow = args.includes("-f") || flags.opts.follow !== undefined;
725
+ const lines = Math.min(5000, Math.max(1, Number(flags.opts.lines ?? 50) || 50));
726
+ const file = proxyLogPath();
727
+ if (!(await hasProxyLog())) {
728
+ warn(`No local proxy log yet (${file}). It is written by the self-hosted proxy: ${bold("tab up")}.`);
729
+ return 1;
730
+ }
731
+ const [cmd, cmdArgs] = process.platform === "win32"
732
+ ? ["powershell", ["-NoProfile", "-Command", `Get-Content -Path '${file}' -Tail ${lines}${follow ? " -Wait" : ""}`]]
733
+ : ["tail", [`-n`, String(lines), ...(follow ? ["-f"] : []), file]];
734
+ const child = spawn(cmd, cmdArgs, { stdio: "inherit" });
735
+ return new Promise((resolve) => {
736
+ child.on("error", () => resolve(1));
737
+ child.on("close", (code) => resolve(code ?? 0));
738
+ });
739
+ }
688
740
  async function selfHosted(action) {
689
741
  const config = await loadConfig();
690
742
  const proxyUrl = config?.mode === "self-hosted" ? config.proxyUrl : LOCAL_PROXY;
@@ -741,12 +793,13 @@ async function managed(command, rest) {
741
793
  return await manage.outside(config, flags);
742
794
  case "live":
743
795
  return await manage.live(config, flags);
796
+ case "log":
797
+ return await manage.log(config, manage.parseFlags(rest.filter((a) => a !== "-f")), rest.includes("-f") || flags.opts.follow !== undefined);
798
+ case "accounts":
744
799
  case "quota":
745
- return await manage.quota(config, flags);
746
- case "subscribe":
747
- return await manage.subscribe(config, flags, true);
748
- case "unsubscribe":
749
- return await manage.subscribe(config, flags, false);
800
+ return await manage.accounts(config, flags);
801
+ case "agent":
802
+ return await manage.agent(config, flags);
750
803
  default:
751
804
  return await manage.web(config, flags);
752
805
  }
@@ -797,12 +850,18 @@ async function main(argv) {
797
850
  case "outside":
798
851
  case "live":
799
852
  case "quota":
800
- case "subscribe":
801
- case "unsubscribe":
853
+ case "accounts":
854
+ case "agent":
802
855
  case "web":
803
856
  return managed(command, rest);
804
857
  case "status":
805
858
  return status();
859
+ case "log":
860
+ if (rest.includes("--proxy"))
861
+ return logs(rest.filter((a) => a !== "--proxy"));
862
+ return managed("log", rest);
863
+ case "logs":
864
+ return logs(rest);
806
865
  case "up":
807
866
  return selfHosted("up");
808
867
  case "down":
package/dist/clients.js CHANGED
@@ -76,6 +76,19 @@ export function openaiBase(proxyUrl, provider) {
76
76
  export function passthroughBase(proxyUrl, key, provider) {
77
77
  return `${proxyUrl}/t/${key}/${provider}`;
78
78
  }
79
+ /** Which vendor's login a harness carries: Claude Code its Claude one, Codex ChatGPT, Grok SuperGrok, Kimi Code a Kimi plan. */
80
+ export function subscriptionVendorFor(name) {
81
+ const spec = clientFor(name);
82
+ if (spec.shape === "anthropic")
83
+ return "anthropic";
84
+ if (spec.isolatedHome === "codex")
85
+ return "openai";
86
+ if (spec.provider === "xai")
87
+ return "xai";
88
+ if (spec.provider === "moonshot")
89
+ return "kimi";
90
+ return undefined;
91
+ }
79
92
  export function envFor(input) {
80
93
  const base = clientFor(input.name);
81
94
  const known = input.name in KNOWN;
@@ -94,17 +107,30 @@ export function envFor(input) {
94
107
  return;
95
108
  }
96
109
  env.ANTHROPIC_BASE_URL = input.proxyUrl;
97
- env.ANTHROPIC_API_KEY = input.presentedKey;
98
- // Claude Code prefers an auth token when one is set; make sure a stale
99
- // one cannot win over the tab key.
100
- delete env.ANTHROPIC_AUTH_TOKEN;
110
+ // The tab key goes in as the auth token, not the API key. Claude Code
111
+ // asks a person to approve an `ANTHROPIC_API_KEY` once and remembers a
112
+ // No, after which it silently uses its own Claude login and the call
113
+ // arrives without the tab key. `ANTHROPIC_AUTH_TOKEN` is checked first,
114
+ // never asks, and switches the Claude login off for the session. It is
115
+ // sent as `Authorization: Bearer`, which the proxy reads like `x-api-key`.
116
+ env.ANTHROPIC_AUTH_TOKEN = input.presentedKey;
117
+ delete env.ANTHROPIC_API_KEY;
101
118
  };
102
119
  const setOpenAI = () => {
103
120
  if (auth === "subscription") {
104
- // Only Codex has a subscription route (its own home points at it); a
105
- // plain OpenAI key would bypass the plan, so none is set.
121
+ // Each harness carries its own login; a plain OpenAI key would bypass
122
+ // the plan, so none is set. Codex's own home points at the passthrough;
123
+ // Grok Build and Kimi Code read a base URL of their own.
106
124
  delete env.OPENAI_BASE_URL;
107
125
  delete env.OPENAI_API_KEY;
126
+ if (spec.provider === "xai") {
127
+ env.GROK_XAI_API_BASE_URL = `${passthroughBase(input.proxyUrl, input.presentedKey, "xai")}/v1`;
128
+ delete env.XAI_API_KEY;
129
+ }
130
+ if (spec.provider === "moonshot") {
131
+ env.KIMI_BASE_URL = `${passthroughBase(input.proxyUrl, input.presentedKey, "kimi")}/coding/v1`;
132
+ delete env.KIMI_API_KEY;
133
+ }
108
134
  return;
109
135
  }
110
136
  env.OPENAI_BASE_URL = openaiBase(input.proxyUrl, spec.provider);
package/dist/logins.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Which account each harness is signed in with on this machine, read from
3
+ * the files the harnesses keep themselves. Used for one thing: telling the
4
+ * person, before a subscription Agent launches, which login is about to be
5
+ * used. Nothing here is sent anywhere; tokens are never read, only the
6
+ * account fields beside them.
7
+ */
8
+ import { readFile } from "node:fs/promises";
9
+ import { homedir } from "node:os";
10
+ import path from "node:path";
11
+ async function jsonFile(file) {
12
+ try {
13
+ const parsed = JSON.parse(await readFile(file, "utf8"));
14
+ return parsed && typeof parsed === "object" ? parsed : undefined;
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ }
20
+ function str(value) {
21
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
22
+ }
23
+ /** The claims of a JWT, unverified; only the account fields are looked at. */
24
+ function jwtClaims(token) {
25
+ if (typeof token !== "string")
26
+ return undefined;
27
+ const parts = token.split(".");
28
+ if (parts.length !== 3)
29
+ return undefined;
30
+ try {
31
+ const parsed = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
32
+ return parsed && typeof parsed === "object" ? parsed : undefined;
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ }
38
+ /**
39
+ * Claude Code keeps the signed-in account (not the token) in `~/.claude.json`
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.
43
+ */
44
+ export async function localLogin(vendor, opts = {}) {
45
+ const home = opts.home ?? homedir();
46
+ if (vendor === "anthropic") {
47
+ const account = (await jsonFile(path.join(home, ".claude.json")))?.oauthAccount;
48
+ if (!account)
49
+ return undefined;
50
+ const email = str(account.emailAddress);
51
+ const plan = str(account.organizationRateLimitTier) ?? str(account.organizationType);
52
+ const accountId = str(account.organizationUuid);
53
+ return { ...(email ? { email } : {}), ...(plan ? { plan } : {}), ...(accountId ? { accountId } : {}) };
54
+ }
55
+ if (vendor === "openai") {
56
+ const auth = await jsonFile(path.join(opts.codexHome ?? path.join(home, ".codex"), "auth.json"));
57
+ const tokens = auth?.tokens;
58
+ if (!tokens)
59
+ return undefined;
60
+ const claims = jwtClaims(tokens.id_token);
61
+ const authClaims = claims?.["https://api.openai.com/auth"];
62
+ const email = str(claims?.email);
63
+ const plan = str(authClaims?.chatgpt_plan_type);
64
+ const accountId = str(tokens.account_id) ?? str(authClaims?.chatgpt_account_id);
65
+ return { ...(email ? { email } : {}), ...(plan ? { plan } : {}), ...(accountId ? { accountId } : {}) };
66
+ }
67
+ return undefined;
68
+ }
69
+ /** "me@x.com (Claude Max 20x)" or nothing worth saying. */
70
+ export function describeLogin(login) {
71
+ if (!login)
72
+ return undefined;
73
+ const plan = login.plan?.replace(/^default_/, "").replaceAll("_", " ");
74
+ if (login.email && plan)
75
+ return `${login.email} (${plan})`;
76
+ return login.email ?? plan ?? (login.accountId ? `account ${login.accountId}` : undefined);
77
+ }
package/dist/manage.js CHANGED
@@ -59,12 +59,7 @@ export function parseFlags(argv) {
59
59
  }
60
60
  return flags;
61
61
  }
62
- /** `anthropic` is Claude Code, `openai` is Codex: say the harness, not the vendor. */
63
- const HARNESS = { anthropic: "claude", openai: "codex" };
64
- const plans = (t) => {
65
- const subs = t.subscriptions ?? [];
66
- return subs.length === 0 ? dim("-") : subs.map((s) => `${yellow(HARNESS[s.provider] ?? s.provider)}${s.plan ? dim(` ${s.plan}`) : ""}`).join(", ");
67
- };
62
+ const kindOf = (t) => (t.kind === "subscription" ? yellow("subscription") : "api");
68
63
  async function tabsOf(call) {
69
64
  return (await call("GET", "/api/cli/tabs")).tabs;
70
65
  }
@@ -98,7 +93,7 @@ export async function list(config, flags) {
98
93
  const tabs = await tabsOf(api(config));
99
94
  emit(flags.json, { tabs }, () => tabs.length === 0
100
95
  ? dim("No Agents yet. Run tab claude or tab codex in a project folder.")
101
- : table(["agent", "state", "spent", "cap", "used", "window", "plans", "project"], tabs.map((t) => [t.name === t.slug ? t.slug : `${t.name} ${dim(t.slug)}`, state(t.state), money(t.spentCents), money(t.capCents), pct(t.spentCents, t.capCents), t.window, plans(t), t.projectName ?? dim("-")]), { right: [2, 3, 4] }));
96
+ : table(["agent", "kind", "state", "spent", "cap", "used", "window", "project"], tabs.map((t) => [t.name === t.slug ? t.slug : `${t.name} ${dim(t.slug)}`, kindOf(t), state(t.state), money(t.spentCents), money(t.capCents), pct(t.spentCents, t.capCents), t.window, t.projectName ?? dim("-")]), { right: [3, 4, 5] }));
102
97
  return 0;
103
98
  }
104
99
  export async function setState(config, flags, next) {
@@ -146,55 +141,70 @@ export async function archive(config, flags, ask) {
146
141
  return 0;
147
142
  }
148
143
  /**
149
- * `tab subscribe claude [--plan "Claude Max 5x"] [--price 100]` and
150
- * `tab unsubscribe codex`: which harness of an Agent runs on the person's
151
- * own plan. The subscription belongs to the harness, so Claude Code can be
152
- * on Max while Codex and Grok on the same Agent stay on the meter. On a
153
- * plan the vendor pays; the tab still gates every call and records it at
154
- * list price as a shadow row, and the seat's monthly price sits beside the
155
- * meter in the project rollup.
144
+ * `tab agent create <name> [--subscription|--api] [--cap 50]` makes an Agent
145
+ * of one of the two kinds; `tab agent kind <agent> api|subscription` changes
146
+ * it. A subscription Agent's harnesses (Claude Code, Codex, Grok, Kimi Code)
147
+ * bring their own logins: every call is still gated by the tab and recorded
148
+ * at list price, filed under the account the vendor names, never charged.
156
149
  */
157
- export async function subscribe(config, flags, on) {
150
+ export async function agent(config, flags) {
158
151
  const call = api(config);
159
- const verb = on ? "subscribe" : "unsubscribe";
160
- const [harness, given] = flags.args;
161
- if (!harness)
162
- throw new ManageError(`tab ${verb} claude|codex [agent]${on ? ' [--plan "Claude Max 5x"] [--price 100]' : ""}`);
163
- const slug = await resolveSlug(call, given);
164
- const path = `/api/cli/tabs/${encodeURIComponent(slug)}/subscriptions/${encodeURIComponent(harness.toLowerCase())}`;
165
- const body = {};
166
- if (flags.opts.plan !== undefined)
167
- body.plan = flags.opts.plan;
168
- if (flags.opts.price !== undefined)
169
- body.monthlyDollars = flags.opts.price.replace(/^\$/, "");
170
- const { tab } = await call(on ? "PUT" : "DELETE", path, on ? body : undefined);
171
- const provider = harness.toLowerCase() === "codex" || harness.toLowerCase() === "chatgpt" || harness.toLowerCase() === "openai" ? "openai" : "anthropic";
172
- const seat = (tab.subscriptions ?? []).find((s) => s.provider === provider);
173
- const label = HARNESS[provider] ?? provider;
174
- emit(flags.json, { tab }, () => on
175
- ? `${bold(tab.name)}: ${bold(label)} runs on your own ${provider === "openai" ? "ChatGPT" : "Claude"} login${seat?.plan ? ` (${seat.plan}${seat.monthlyCents && seat.monthlyCents !== "0" ? `, ${money(seat.monthlyCents)}/mo` : ""})` : ""}.\n` +
176
- `${dim(`tab ${label} uses that login from the next run. The kill switch and policies still apply; calls land in the ledger at list price, never on the tab's spend. Other harnesses stay metered.`)}`
177
- : `${bold(tab.name)}: ${bold(label)} is back on the metered tab: the flock's provider key, charged to the cap.`);
178
- return 0;
152
+ const [verb, ...rest] = flags.args;
153
+ if (verb === "create" || verb === "new") {
154
+ const name = rest.join(" ").trim();
155
+ if (!name)
156
+ throw new ManageError('tab agent create <name> [--subscription|--api] [--cap 50]');
157
+ const kind = flags.opts.subscription !== undefined ? "subscription" : "api";
158
+ const body = { name, kind };
159
+ if (flags.opts.cap)
160
+ body.capDollars = flags.opts.cap.replace(/^\$/, "");
161
+ const made = await call("POST", "/api/cli/agents", body);
162
+ // The key comes back once; it is this machine's, so keep it like tab use would.
163
+ const agents = { ...(config.agents ?? {}), [made.agent.slug]: { key: made.key, agentId: made.agent.id, agentName: made.agent.name } };
164
+ await saveConfig({ ...config, agents });
165
+ emit(flags.json, { agent: made.agent, kind }, () => `Created ${kind === "subscription" ? bold("subscription") : bold("API-key")} Agent ${bold(made.agent.name)} ${dim(`(${made.agent.slug})`)}; its key is saved on this machine.\n` +
166
+ dim(kind === "subscription"
167
+ ? "Its harnesses use your own logins: tab claude your Claude one, tab codex ChatGPT, tab grok SuperGrok, tab kimi Kimi. Gated and recorded, never charged. tab use picks it for a folder."
168
+ : "Its calls use the flock's provider key and are metered against the cap. tab use picks it for a folder."));
169
+ return 0;
170
+ }
171
+ if (verb === "kind" || verb === "type") {
172
+ const [given, wanted] = rest;
173
+ if (!given || !wanted)
174
+ throw new ManageError("tab agent kind <agent> api|subscription");
175
+ const slug = await resolveSlug(call, given);
176
+ const { tab } = await call("PATCH", `/api/cli/tabs/${encodeURIComponent(slug)}`, { kind: wanted });
177
+ emit(flags.json, { tab }, () => `${bold(tab.name)} is now ${tab.kind === "subscription" ? bold("a subscription Agent") + dim(": its harnesses use your own logins from the next run") : bold("an API-key Agent") + dim(": metered with the flock's key from the next run")}.`);
178
+ return 0;
179
+ }
180
+ throw new ManageError("tab agent create <name> [--subscription|--api] | tab agent kind <agent> api|subscription");
179
181
  }
180
- /** `tab quota [agent]`: what the vendor said the plan has left, on the last call. */
181
- export async function quota(config, flags) {
182
+ /**
183
+ * `tab accounts` (also `tab quota`): every account the flock's subscription
184
+ * Agents were seen on, with the plan the vendor reported, its seat price,
185
+ * what the last 30 days would have cost at list, and what the plan has left.
186
+ */
187
+ export async function accounts(config, flags) {
182
188
  const call = api(config);
183
- const slug = await resolveSlug(call, flags.args[0]);
184
- const { tab, quota: windows } = await call("GET", `/api/cli/tabs/${encodeURIComponent(slug)}/quota`);
185
- emit(flags.json, { tab, quota: windows }, () => {
186
- if ((tab.subscriptions ?? []).length === 0)
187
- return `${bold(tab.name)} has no harness on a plan; ${dim("tab subscribe claude")} or ${dim("tab subscribe codex")} puts one there.`;
188
- if (windows.length === 0)
189
- return `${bold(tab.name)}: no quota reported yet. It shows after the first call through tab claude or tab codex.`;
189
+ const { accounts: list } = await call("GET", "/api/cli/accounts");
190
+ emit(flags.json, { accounts: list }, () => {
191
+ if (list.length === 0)
192
+ return dim("No accounts seen yet. Run tab claude or tab codex on a subscription Agent; the account shows after the first call.");
190
193
  const left = (w) => {
191
194
  const pctLeft = Math.max(0, 100 - w.usedPct);
192
195
  const paint = pctLeft <= 10 ? red : pctLeft <= 30 ? yellow : green;
193
- return paint(`${pctLeft}% left`);
196
+ return paint(`${pctLeft}%`);
194
197
  };
195
- const when = (iso) => (iso ? new Date(iso).toLocaleString() : dim("-"));
196
- return (`${bold(tab.name)} ${plans(tab)}\n` +
197
- table(["provider", "window", "used", "left", "resets", "seen"], windows.map((w) => [w.provider, w.window, `${w.usedPct}%`, left(w), when(w.resetsAt), when(w.observedAt)]), { right: [2] }));
198
+ return table(["vendor", "account", "plan", "/month", "30d at list", "calls", "left", "agents"], list.map((a) => [
199
+ a.vendor,
200
+ a.label ?? a.email ?? dim(a.id),
201
+ a.planLabel ?? dim("unknown"),
202
+ a.monthlyCents ? money(a.monthlyCents) : dim("-"),
203
+ money(a.listCents),
204
+ String(a.calls),
205
+ a.quota.length === 0 ? dim("-") : a.quota.map((q) => `${q.window} ${left(q)}`).join(" "),
206
+ a.agents.join(", "),
207
+ ]), { right: [3, 4, 5] }) + `\n${dim("Set a price or label in the console, Control, Accounts.")}`;
198
208
  });
199
209
  return 0;
200
210
  }
@@ -288,6 +298,55 @@ export async function key(config, flags) {
288
298
  (flags.opts.show !== undefined ? `\n${issued.key}` : `\n${dim("Add --show to print it once.")}`));
289
299
  return 0;
290
300
  }
301
+ /** One call, one condensed line: when, which Agent, how it went, what it cost or was worth, what it used. */
302
+ export function logLine(r) {
303
+ const shadow = r.status === "shadow";
304
+ const status = shadow ? "SUBSCRIPTION" : (r.status ?? r.decision).toUpperCase();
305
+ const paint = status === "BLOCKED" || status === "CLOSED" ? red : status === "PENDING" ? yellow : green;
306
+ return `${dim(r.at)} ${bold(r.agent)} ${paint(status)} ${money(r.cents)}${shadow ? dim(" at list") : ""} ${dim(r.reason)}`;
307
+ }
308
+ /**
309
+ * `tab log`: everything that went through the tab for the Agents on this
310
+ * machine, one condensed line per call, hosted or self-hosted alike because
311
+ * it is the ledger's own record. `-f` follows it; `--all` is the whole flock.
312
+ */
313
+ export async function log(config, flags, follow) {
314
+ const call = api(config);
315
+ const limit = Math.min(500, Math.max(1, Number(flags.opts.lines ?? 30) || 30));
316
+ const mine = new Set(Object.values(config.agents ?? {}).map((a) => a.agentName.toLowerCase()));
317
+ const wanted = (r) => flags.opts.all !== undefined || mine.size === 0 || mine.has(r.agent.toLowerCase());
318
+ const fetchRows = async () => (await call("GET", `/api/cli/ledger?limit=${flags.opts.all !== undefined ? limit : limit * 4}`)).rows.filter(wanted).slice(0, limit);
319
+ const first = await fetchRows();
320
+ if (flags.json && !follow) {
321
+ out(JSON.stringify({ rows: first }, null, 2));
322
+ return 0;
323
+ }
324
+ if (first.length === 0 && !follow)
325
+ out(dim("Nothing yet. Run tab claude or tab codex in a project folder."));
326
+ const seen = new Set();
327
+ for (const r of [...first].reverse()) {
328
+ seen.add(r.id);
329
+ out(flags.json ? JSON.stringify(r) : logLine(r));
330
+ }
331
+ if (!follow)
332
+ return 0;
333
+ for (;;) {
334
+ await new Promise((resolve) => setTimeout(resolve, 2000));
335
+ let rows;
336
+ try {
337
+ rows = await fetchRows();
338
+ }
339
+ catch {
340
+ continue;
341
+ }
342
+ for (const r of [...rows].reverse()) {
343
+ if (seen.has(r.id))
344
+ continue;
345
+ seen.add(r.id);
346
+ out(flags.json ? JSON.stringify(r) : logLine(r));
347
+ }
348
+ }
349
+ }
291
350
  export async function ledger(config, flags) {
292
351
  const call = api(config);
293
352
  const params = new URLSearchParams();
@@ -301,9 +360,11 @@ export async function ledger(config, flags) {
301
360
  emit(flags.json, { timezone, rows: list }, () => list.length === 0
302
361
  ? dim("No decisions yet.")
303
362
  : table(["time", "agent", "action", "amount", "status", "reason"], list.map((r) => {
304
- const status = (r.status ?? r.decision).toUpperCase();
363
+ // A subscription call is recorded at list price; nothing was held, so there is no "of".
364
+ const shadow = r.status === "shadow";
365
+ const status = shadow ? "SUBSCRIPTION" : (r.status ?? r.decision).toUpperCase();
305
366
  const paint = status === "BLOCKED" || status === "CLOSED" ? red : status === "PENDING" ? yellow : green;
306
- const held = r.reservedCents && r.reservedCents !== r.cents ? dim(` of ${money(r.reservedCents)}`) : "";
367
+ const held = !shadow && r.reservedCents && r.reservedCents !== r.cents ? dim(` of ${money(r.reservedCents)}`) : "";
307
368
  return [r.at, r.agent, r.action.length > 28 ? `${r.action.slice(0, 27)}…` : r.action, `${money(r.cents)}${held}`, paint(status), r.reason];
308
369
  }), { right: [3] }) + `\n${dim(`times in ${timezone}`)}`);
309
370
  return 0;
package/dist/proxy-bin.js CHANGED
@@ -8,12 +8,12 @@
8
8
  * runtime beyond the binary itself.
9
9
  */
10
10
  import { spawn } from "node:child_process";
11
- import { chmod, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
11
+ import { access, chmod, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
12
12
  import { createRequire } from "node:module";
13
13
  import path from "node:path";
14
14
  import { configDir } from "./config.js";
15
15
  /** The proxy release `tab up` fetches. Bump with each proxy tag. */
16
- export const PROXY_VERSION = "0.1.4";
16
+ export const PROXY_VERSION = "0.1.7";
17
17
  export const RELEASES = "https://github.com/joseairosa/flocktab/releases/download";
18
18
  export function targetFor(platform = process.platform, arch = process.arch) {
19
19
  if (platform === "darwin")
@@ -214,3 +214,13 @@ export async function tailProxyLog(lines = 8, env = process.env) {
214
214
  return [];
215
215
  }
216
216
  }
217
+ /** Whether the local proxy has written a log yet. */
218
+ export async function hasProxyLog(env = process.env) {
219
+ try {
220
+ await access(proxyLogPath(env));
221
+ return true;
222
+ }
223
+ catch {
224
+ return false;
225
+ }
226
+ }
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Written by scripts/write-version.mjs from package.json at build; `tab version` prints it. */
2
- export const TAB_VERSION = "0.1.7";
2
+ export const TAB_VERSION = "0.1.9";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanamorilabs/tab",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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.4",
26
- "@hanamorilabs/flocktab-proxy-darwin-x64": "0.1.4",
27
- "@hanamorilabs/flocktab-proxy-linux-x64": "0.1.4",
28
- "@hanamorilabs/flocktab-proxy-linux-arm64": "0.1.4",
29
- "@hanamorilabs/flocktab-proxy-win-x64": "0.1.4"
25
+ "@hanamorilabs/flocktab-proxy-darwin-arm64": "0.1.7",
26
+ "@hanamorilabs/flocktab-proxy-darwin-x64": "0.1.7",
27
+ "@hanamorilabs/flocktab-proxy-linux-x64": "0.1.7",
28
+ "@hanamorilabs/flocktab-proxy-linux-arm64": "0.1.7",
29
+ "@hanamorilabs/flocktab-proxy-win-x64": "0.1.7"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^22.18.6",