@hanamorilabs/tab 0.1.6 → 0.1.7

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
@@ -41,21 +41,25 @@ you would rather not have a file or a folder, for example in CI.
41
41
  ## On a subscription (Claude Max, ChatGPT)
42
42
 
43
43
  ```
44
- tab use --subscription --plan "Claude Max 5x" --price 100
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
45
46
  tab claude # your own Claude login through the tab
46
47
  tab codex # your own ChatGPT login (codex login once, inside tab codex)
47
- tab quota # 5h / 7d windows the vendor reported on the last call
48
- tab use --api-key # back to the metered tab
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
49
51
  ```
50
52
 
51
- The plan pays for the tokens; the tab still gates every call (kill switch,
52
- velocity, model and tool policy) and records it at list price as a
53
- `SUBSCRIPTION` row that never touches the cap. Claude Code gets
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
54
58
  `ANTHROPIC_BASE_URL=<proxy>/t/<key>/anthropic` and no API key; Codex gets a
55
59
  home whose `chatgpt_base_url` is `<proxy>/t/<key>/openai/backend-api/`, with
56
60
  your existing `~/.codex/auth.json` copied in once (never written back). The
57
- mode is the Agent's, read on every launch, so the console's **Runs on** panel
58
- and `tab use` agree. `docs/subscriptions.md` has the wire.
61
+ rows are read on every launch, so the console's **Subscriptions** panel and
62
+ `tab subscribe` agree. `docs/subscriptions.md` has the wire.
59
63
 
60
64
  ## Self-hosted
61
65
 
@@ -78,8 +82,9 @@ Codex shows `FlockTab - Self-hosted` as its provider.
78
82
  tab login approve this machine in the console, once
79
83
  tab <agent> [args] claude, codex, grok, kimi, gemini, or any command, on the tab
80
84
  tab use pick or change the Agent this folder runs as (.flocktab)
81
- tab use --subscription | --api-key this Agent on your Claude Max / ChatGPT plan, or back on the meter
82
- tab quota [agent] what the subscription has left: 5h and 7d windows
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
83
88
  tab alias setup <name>... make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)
84
89
  tab alias remove <name>... undo that; `tab alias list` shows them
85
90
  tab status flock, folder Agent, proxy health, provider key state
@@ -97,7 +102,7 @@ session from `tab login`. An Agent is named by slug or name; left out, it is
97
102
  this folder's Agent. Every read takes `--json`.
98
103
 
99
104
  ```
100
- tab list every Agent: state, mode (meter | plan), spent of cap, window, project
105
+ tab list every Agent: state, spent of cap, window, plans (claude, codex), project
101
106
  tab close my-project tab open my-project the kill switch (402 tab_closed on the next call)
102
107
  tab cap my-project 25 --window week cap in dollars, and the window
103
108
  tab rename my-project "My project (nightly)" the slug stays
@@ -142,6 +147,6 @@ proxy http://127.0.0.1:8787 (self-hosted) up
142
147
  flock Hanamori Labs
143
148
  folder flocktab-codex
144
149
  plan team
145
- mode metered (the flock's provider key, charged to the tab)
150
+ plans none; every harness on the metered tab
146
151
  byok provider keys on this box
147
152
  ```
package/dist/cli.js CHANGED
@@ -7,8 +7,9 @@
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 use --subscription this folder's Agent runs on your Claude Max / ChatGPT plan
11
- * tab quota what the plan has left (5h / 7d windows) for this Agent
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
12
13
  * tab alias setup codex make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)
13
14
  * tab alias remove codex undo that; `tab alias list` shows them
14
15
  * tab status which flock, which Agent here, is the proxy up
@@ -27,7 +28,7 @@ import { hostname } from "node:os";
27
28
  import { createInterface } from "node:readline/promises";
28
29
  import { stdin, stdout } from "node:process";
29
30
  import { bold, box, cyan, dim, green, heading, line, rows, underline, yellow } from "./ui.js";
30
- import { envFor, knownClients } from "./clients.js";
31
+ import { clientFor, envFor, knownClients } from "./clients.js";
31
32
  import { prepareCodexHome } from "./codex-home.js";
32
33
  import { ConsoleApiError, createAgent, issueAgentKey, listAgents } from "./console-api.js";
33
34
  import { consoleUrlFor, DeviceLoginError, startDeviceLogin, waitForApproval } from "./device-login.js";
@@ -55,9 +56,9 @@ function usage() {
55
56
  cmd(others.join(" | tab "), "the same, for those agents"),
56
57
  cmd("<command> [args]", "any other agent, both APIs pointed at the tab"),
57
58
  cmd("use", "pick or change the Agent this folder runs as (.flocktab)"),
58
- cmd("use --subscription", "this Agent runs on your Claude Max / ChatGPT plan (--plan \"Max 5x\" --price 100)"),
59
- cmd("use --api-key", "back to the metered tab with the flock's provider key"),
60
- cmd("quota [agent]", "what the subscription has left: 5h and 7d windows"),
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"),
61
62
  cmd("alias setup <name>...", "make plain `codex` run `tab codex` (shims in ~/.flocktab/bin)"),
62
63
  cmd("alias remove <name>...", "undo that; `tab alias list` shows them"),
63
64
  cmd("status", "which flock, which Agent here, is the proxy up"),
@@ -485,7 +486,8 @@ async function status() {
485
486
  }
486
487
  else {
487
488
  pairs.push(["plan", me.flock.plan]);
488
- pairs.push(["mode", me.authMode === "subscription" ? `${bold("subscription")} ${dim("(your Claude Max / ChatGPT login; FlockTab keeps the record)")}` : `metered ${dim("(the flock's provider key, charged to the tab)")}`]);
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")]);
489
491
  const byok = !me.byok
490
492
  ? dim("no provider key on this flock")
491
493
  : me.unlock === "none"
@@ -512,20 +514,20 @@ async function runAgent(name, args) {
512
514
  const agent = await resolveAgent(config);
513
515
  if (!agent)
514
516
  return 1;
515
- // The Agent's mode is the console's call, read fresh on every launch so a
516
- // switch there (or `tab use --subscription`) applies to the next run.
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;
517
523
  const me = await whoami(config.proxyUrl, { key: agent.key, unlock: config.unlock });
518
- const auth = !("error" in me) && me.authMode === "subscription" ? "subscription" : "key";
524
+ const auth = vendor && !("error" in me) && (me.subscriptions ?? []).includes(vendor) ? "subscription" : "key";
519
525
  // On a subscription there is no provider key to unlock; the bare key rides in the URL.
520
526
  const key = auth === "subscription" ? agent.key : presentedKey({ key: agent.key, unlock: config.unlock });
521
527
  reportFolder(config.proxyUrl, key);
522
528
  const { spec, env } = envFor({ name, proxyUrl: config.proxyUrl, presentedKey: key, auth });
523
529
  if (auth === "subscription") {
524
- if (spec.shape === "openai" && spec.isolatedHome !== "codex") {
525
- fail(`${spec.label} has no subscription route. Only Claude Code and Codex can run on a plan; switch this Agent back with ${bold("tab use --api-key")}.`);
526
- return 2;
527
- }
528
- say(dim(`${agent.agentName} runs on your own ${spec.isolatedHome === "codex" ? "ChatGPT" : "Claude"} login; FlockTab keeps the record and the kill switch.`));
530
+ say(dim(`${spec.label} runs on your own ${vendor === "openai" ? "ChatGPT" : "Claude"} login; FlockTab keeps the record and the kill switch.`));
529
531
  }
530
532
  // An alias shim named like the agent must not be what we spawn.
531
533
  env.PATH = pathWithoutShims();
@@ -607,39 +609,15 @@ function pathHint() {
607
609
  say(` ${bold(pathLine())}`);
608
610
  return 0;
609
611
  }
610
- /**
611
- * `tab use`: pick or change the Agent for this folder. With `--subscription`
612
- * or `--api-key` the folder's Agent (picked first if there is none) is
613
- * switched in the console; `--plan` and `--price` label the seat.
614
- */
615
- async function use(args) {
612
+ /** `tab use`: pick or change the Agent for this folder. */
613
+ async function use() {
616
614
  const config = await ensureLogin();
617
615
  if (!config)
618
616
  return 2;
619
- const flags = manage.parseFlags(args);
620
- const wantsSubscription = flags.opts.subscription !== undefined;
621
- const wantsKey = flags.opts["api-key"] !== undefined || flags.opts.apikey !== undefined;
622
- if (wantsSubscription && wantsKey) {
623
- fail("Pick one: tab use --subscription or tab use --api-key.");
624
- return 2;
625
- }
626
617
  if (!(await ensureProxy(config)))
627
618
  return 1;
628
- if (!wantsSubscription && !wantsKey) {
629
- const agent = await resolveAgent(config, { pick: true });
630
- return agent ? 0 : 1;
631
- }
632
- const agent = await resolveAgent(config);
633
- if (!agent)
634
- return 1;
635
- try {
636
- const project = await readProject(process.cwd());
637
- return await manage.setAuthMode(config, flags, project?.agent ?? agent.agentName, wantsSubscription ? "subscription" : "api_key");
638
- }
639
- catch (err) {
640
- fail(err instanceof Error ? err.message : String(err));
641
- return 1;
642
- }
619
+ const agent = await resolveAgent(config, { pick: true });
620
+ return agent ? 0 : 1;
643
621
  }
644
622
  /**
645
623
  * First `tab up` on a box: the provider keys the proxy will spend, typed
@@ -765,6 +743,10 @@ async function managed(command, rest) {
765
743
  return await manage.live(config, flags);
766
744
  case "quota":
767
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);
768
750
  default:
769
751
  return await manage.web(config, flags);
770
752
  }
@@ -790,7 +772,7 @@ async function main(argv) {
790
772
  ok("Forgot the session and keys on this machine.");
791
773
  return 0;
792
774
  case "use":
793
- return use(rest);
775
+ return use();
794
776
  case "alias":
795
777
  return alias(rest);
796
778
  case "unalias":
@@ -815,6 +797,8 @@ async function main(argv) {
815
797
  case "outside":
816
798
  case "live":
817
799
  case "quota":
800
+ case "subscribe":
801
+ case "unsubscribe":
818
802
  case "web":
819
803
  return managed(command, rest);
820
804
  case "status":
package/dist/manage.js CHANGED
@@ -59,7 +59,12 @@ export function parseFlags(argv) {
59
59
  }
60
60
  return flags;
61
61
  }
62
- const mode = (t) => (t.authMode === "subscription" ? `${yellow("plan")}${t.subscriptionPlan ? dim(` ${t.subscriptionPlan}`) : ""}` : "meter");
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
+ };
63
68
  async function tabsOf(call) {
64
69
  return (await call("GET", "/api/cli/tabs")).tabs;
65
70
  }
@@ -93,7 +98,7 @@ export async function list(config, flags) {
93
98
  const tabs = await tabsOf(api(config));
94
99
  emit(flags.json, { tabs }, () => tabs.length === 0
95
100
  ? dim("No Agents yet. Run tab claude or tab codex in a project folder.")
96
- : table(["agent", "state", "mode", "spent", "cap", "used", "window", "project"], tabs.map((t) => [t.name === t.slug ? t.slug : `${t.name} ${dim(t.slug)}`, state(t.state), mode(t), money(t.spentCents), money(t.capCents), pct(t.spentCents, t.capCents), t.window, t.projectName ?? dim("-")]), { right: [3, 4, 5] }));
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] }));
97
102
  return 0;
98
103
  }
99
104
  export async function setState(config, flags, next) {
@@ -141,25 +146,35 @@ export async function archive(config, flags, ask) {
141
146
  return 0;
142
147
  }
143
148
  /**
144
- * `tab use --subscription [--plan "Claude Max 5x"] [--price 100]` and
145
- * `tab use --api-key`: which login the Agent runs on. On a subscription the
146
- * vendor's plan pays; the tab still gates every call and records it at list
147
- * price as a shadow row, and the seat's monthly price sits beside the meter
148
- * in the project rollup.
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.
149
156
  */
150
- export async function setAuthMode(config, flags, given, authMode) {
157
+ export async function subscribe(config, flags, on) {
151
158
  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]' : ""}`);
152
163
  const slug = await resolveSlug(call, given);
153
- const body = { authMode };
164
+ const path = `/api/cli/tabs/${encodeURIComponent(slug)}/subscriptions/${encodeURIComponent(harness.toLowerCase())}`;
165
+ const body = {};
154
166
  if (flags.opts.plan !== undefined)
155
- body.subscriptionPlan = flags.opts.plan;
167
+ body.plan = flags.opts.plan;
156
168
  if (flags.opts.price !== undefined)
157
- body.subscriptionMonthlyDollars = flags.opts.price.replace(/^\$/, "");
158
- const { tab } = await call("PATCH", `/api/cli/tabs/${encodeURIComponent(slug)}`, body);
159
- emit(flags.json, { tab }, () => authMode === "subscription"
160
- ? `${bold(tab.name)} runs on a subscription${tab.subscriptionPlan ? ` (${tab.subscriptionPlan}${tab.subscriptionMonthlyCents ? `, ${money(tab.subscriptionMonthlyCents)}/mo` : ""})` : ""}.\n` +
161
- `${dim("tab claude uses your own Claude login, tab codex your ChatGPT login. The kill switch and policies still apply; calls land in the ledger at list price, never on the tab's spend.")}`
162
- : `${bold(tab.name)} is back on the metered tab: the flock's provider key, charged to the cap.`);
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.`);
163
178
  return 0;
164
179
  }
165
180
  /** `tab quota [agent]`: what the vendor said the plan has left, on the last call. */
@@ -168,8 +183,8 @@ export async function quota(config, flags) {
168
183
  const slug = await resolveSlug(call, flags.args[0]);
169
184
  const { tab, quota: windows } = await call("GET", `/api/cli/tabs/${encodeURIComponent(slug)}/quota`);
170
185
  emit(flags.json, { tab, quota: windows }, () => {
171
- if (tab.authMode !== "subscription")
172
- return `${bold(tab.name)} is on the metered tab; ${dim("tab use --subscription")} puts it on a plan.`;
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.`;
173
188
  if (windows.length === 0)
174
189
  return `${bold(tab.name)}: no quota reported yet. It shows after the first call through tab claude or tab codex.`;
175
190
  const left = (w) => {
@@ -178,7 +193,7 @@ export async function quota(config, flags) {
178
193
  return paint(`${pctLeft}% left`);
179
194
  };
180
195
  const when = (iso) => (iso ? new Date(iso).toLocaleString() : dim("-"));
181
- return (`${bold(tab.name)}${tab.subscriptionPlan ? dim(` ${tab.subscriptionPlan}`) : ""}\n` +
196
+ return (`${bold(tab.name)} ${plans(tab)}\n` +
182
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] }));
183
198
  });
184
199
  return 0;
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.3";
16
+ export const PROXY_VERSION = "0.1.4";
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")
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.6";
2
+ export const TAB_VERSION = "0.1.7";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanamorilabs/tab",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
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.3",
26
- "@hanamorilabs/flocktab-proxy-darwin-x64": "0.1.3",
27
- "@hanamorilabs/flocktab-proxy-linux-x64": "0.1.3",
28
- "@hanamorilabs/flocktab-proxy-linux-arm64": "0.1.3",
29
- "@hanamorilabs/flocktab-proxy-win-x64": "0.1.3"
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"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^22.18.6",