@hanamorilabs/tab 0.1.18 → 0.1.19

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.
@@ -8,7 +8,9 @@
8
8
  * therefore points `CODEX_HOME` at a directory it owns, with an API-key auth
9
9
  * file and one provider: the tab, over the Responses wire the proxy serves.
10
10
  *
11
- * The person's own `~/.codex` is never read or written.
11
+ * The person's own `~/.codex` is never written. Its `config.toml` is read:
12
+ * the tab's config starts from it (approval policy, sandbox, model, MCP
13
+ * servers, trusted folders), with the tab's few keys layered on top.
12
14
  */
13
15
  import { chmod, copyFile, lstat, mkdir, readdir, readFile, rename, rm, symlink, unlink, writeFile } from "node:fs/promises";
14
16
  import { homedir } from "node:os";
@@ -24,40 +26,97 @@ export function codexProviderId(mode) {
24
26
  /**
25
27
  * On a ChatGPT subscription Codex talks to `chatgpt_base_url` with its own
26
28
  * login, so that is what moves: to the passthrough, where the tab keeps the
27
- * record. No provider block; the default OpenAI provider is the ChatGPT one.
29
+ * record. The provider is a ChatGPT-auth one of the tab's (`flocktab`), so
30
+ * Codex is told there is no WebSocket to open. Its id is what `/status` shows.
28
31
  */
29
- export function codexSubscriptionToml(proxyUrl, presentedKey, model) {
30
- const lines = [
31
- `preferred_auth_method = "chatgpt"`,
32
- `chatgpt_base_url = ${JSON.stringify(`${passthroughBase(proxyUrl, presentedKey, "openai")}/backend-api/`)}`,
33
- // Codex 0.155+ sends the model calls of a ChatGPT login to `openai_base_url` (fixed to
34
- // chatgpt.com/backend-api/codex unless set); `chatgpt_base_url` only covers the rest.
35
- `openai_base_url = ${JSON.stringify(`${passthroughBase(proxyUrl, presentedKey, "openai")}/backend-api/codex`)}`,
36
- ...(model ? [`model = ${JSON.stringify(model)}`] : []),
37
- ``,
32
+ export const CODEX_TAB_PROVIDER = "flocktab";
33
+ export function codexSubscriptionParts(proxyUrl, presentedKey, model) {
34
+ const codex = `${passthroughBase(proxyUrl, presentedKey, "openai")}/backend-api/codex`;
35
+ return {
36
+ top: [
37
+ `preferred_auth_method = "chatgpt"`,
38
+ `model_provider = ${JSON.stringify(CODEX_TAB_PROVIDER)}`,
39
+ `chatgpt_base_url = ${JSON.stringify(`${passthroughBase(proxyUrl, presentedKey, "openai")}/backend-api/`)}`,
40
+ // Codex 0.155+ sends the model calls of a ChatGPT login to `openai_base_url` (fixed to
41
+ // chatgpt.com/backend-api/codex unless set); `chatgpt_base_url` only covers the rest. Kept for a
42
+ // profile of the person's own that names the built-in "openai" provider: it still runs through the tab.
43
+ `openai_base_url = ${JSON.stringify(codex)}`,
44
+ ...(model ? [`model = ${JSON.stringify(model)}`] : []),
45
+ ],
38
46
  // Codex 0.155+ opens a WebSocket for responses on the ChatGPT provider. The tab is plain HTTP
39
- // (no upgrade on either proxy), so with it on the first prompt hangs. Off, Codex uses HTTP streaming.
40
- `[features]`,
41
- `responses_websockets = false`,
42
- `responses_websockets_v2 = false`,
43
- ``,
44
- ];
45
- return lines.join("\n");
47
+ // (no upgrade on either proxy). Codex 0.155.1 ignores these two flags, so the provider below
48
+ // says so as well (`supports_websockets = false`); the flags stay for the builds that read them.
49
+ features: [`responses_websockets = false`, `responses_websockets_v2 = false`],
50
+ tables: [
51
+ [
52
+ `[model_providers.${CODEX_TAB_PROVIDER}]`,
53
+ `name = "OpenAI through FlockTab"`,
54
+ `base_url = ${JSON.stringify(codex)}`,
55
+ `wire_api = "responses"`,
56
+ `requires_openai_auth = true`,
57
+ `supports_websockets = false`,
58
+ ].join("\n"),
59
+ ],
60
+ };
46
61
  }
47
- export function codexConfigToml(proxyUrl, model, mode = "hosted") {
62
+ export function codexSubscriptionToml(proxyUrl, presentedKey, model) {
63
+ const parts = codexSubscriptionParts(proxyUrl, presentedKey, model);
64
+ return [...parts.top, ``, `[features]`, ...parts.features, ``, ...parts.tables, ``].join("\n");
65
+ }
66
+ /**
67
+ * The person's own config with the tab's keys on top. TOML forbids a key or
68
+ * table twice, so the lines the tab sets are dropped from theirs first; the
69
+ * tab's top-level keys go before any table, its `[features]` keys join an
70
+ * existing `[features]` table, and its own tables go last. Trust answers
71
+ * Codex wrote under the tab (`[projects."…"]`) are carried from the previous
72
+ * tab config when the person's own file does not name that folder.
73
+ */
74
+ export function layerCodexConfig(input) {
75
+ const setKeys = new Set([...input.top, ...(input.features ?? [])].map((line) => line.split("=")[0].trim()));
76
+ const kept = input.theirs
77
+ .split("\n")
78
+ .filter((line) => {
79
+ const key = line.split("=")[0].trim().replace(/^features\./, "");
80
+ return !(setKeys.has(key) && /=/.test(line));
81
+ })
82
+ .join("\n")
83
+ .trimEnd();
84
+ const lead = [...input.top, ""].join("\n");
85
+ let body = kept;
86
+ if (input.features?.length) {
87
+ if (/^\[features\]\s*$/m.test(body))
88
+ body = body.replace(/^\[features\]\s*$/m, ["[features]", ...input.features].join("\n"));
89
+ else
90
+ body = `${body}\n\n[features]\n${input.features.join("\n")}`;
91
+ }
92
+ for (const table of input.tables ?? [])
93
+ body = `${body}\n\n${table}`;
94
+ let out = `${lead}\n${body.trim()}\n`;
95
+ for (const block of input.previous?.match(/^\[projects\."[^"\n]+"\]\n(?:(?!\[)[^\n]*\n?)*/gm) ?? []) {
96
+ const header = block.split("\n")[0];
97
+ if (!out.includes(header))
98
+ out = `${out}\n${block.trimEnd()}\n`;
99
+ }
100
+ return out;
101
+ }
102
+ export function codexKeyParts(proxyUrl, model, mode = "hosted") {
48
103
  const id = codexProviderId(mode);
49
- const lines = [
50
- `model_provider = ${JSON.stringify(id)}`,
51
- ...(model ? [`model = ${JSON.stringify(model)}`] : []),
52
- ``,
53
- `[model_providers.${JSON.stringify(id)}]`,
54
- `name = ${JSON.stringify(id)}`,
55
- `base_url = ${JSON.stringify(`${proxyUrl}/v1`)}`,
56
- `env_key = "OPENAI_API_KEY"`,
57
- `wire_api = "responses"`,
58
- ``,
59
- ];
60
- return lines.join("\n");
104
+ return {
105
+ top: [`model_provider = ${JSON.stringify(id)}`, ...(model ? [`model = ${JSON.stringify(model)}`] : [])],
106
+ tables: [
107
+ [
108
+ `[model_providers.${JSON.stringify(id)}]`,
109
+ `name = ${JSON.stringify(id)}`,
110
+ `base_url = ${JSON.stringify(`${proxyUrl}/v1`)}`,
111
+ `env_key = "OPENAI_API_KEY"`,
112
+ `wire_api = "responses"`,
113
+ ].join("\n"),
114
+ ],
115
+ };
116
+ }
117
+ export function codexConfigToml(proxyUrl, model, mode = "hosted") {
118
+ const parts = codexKeyParts(proxyUrl, model, mode);
119
+ return [...parts.top, ``, ...parts.tables, ``].join("\n");
61
120
  }
62
121
  /** Write the home and return its path. Files are owner-only. */
63
122
  export async function prepareCodexHome(input) {
@@ -66,14 +125,18 @@ export async function prepareCodexHome(input) {
66
125
  await chmod(home, 0o700);
67
126
  const auth = path.join(home, "auth.json");
68
127
  const config = path.join(home, "config.toml");
128
+ const theirs = await readFile(input.userConfig ?? path.join(homedir(), ".codex", "config.toml"), "utf8").catch(() => "");
129
+ const previous = await readFile(config, "utf8").catch(() => undefined);
69
130
  if (input.auth === "subscription") {
70
131
  await adoptChatGptLogin(auth, input.userCodexHome ?? path.join(homedir(), ".codex"));
71
- await writeFile(config, codexSubscriptionToml(input.proxyUrl, input.presentedKey, input.model), { mode: 0o600 });
132
+ const parts = codexSubscriptionParts(input.proxyUrl, input.presentedKey, input.model);
133
+ await writeFile(config, layerCodexConfig({ theirs, previous, top: parts.top, features: parts.features, tables: parts.tables }), { mode: 0o600 });
72
134
  }
73
135
  else {
74
136
  await writeFile(auth, codexAuthJson(input.presentedKey), { mode: 0o600 });
75
137
  await chmod(auth, 0o600);
76
- await writeFile(config, codexConfigToml(input.proxyUrl, input.model, input.mode ?? "hosted"), { mode: 0o600 });
138
+ const parts = codexKeyParts(input.proxyUrl, input.model, input.mode ?? "hosted");
139
+ await writeFile(config, layerCodexConfig({ theirs, previous, top: parts.top, tables: parts.tables }), { mode: 0o600 });
77
140
  }
78
141
  await chmod(config, 0o600);
79
142
  await shareConversations(home, input.userCodexHome ?? path.join(homedir(), ".codex"));
package/dist/tab-docs.js CHANGED
@@ -38,7 +38,7 @@ export const TAB_COMMANDS = [
38
38
  usage: ["use", "use claude|codex|grok|kimi"],
39
39
  summary: "Pick or change the Agent this folder runs a harness as.",
40
40
  details: [
41
- "An Agent is one harness on one project. tab claude and tab codex in the same folder are two Agents (that is what the plan counts), each asked for once: the first run of a harness in a folder lists the flock's Agents of that harness (and untied ones it can tie), or makes a new one named after the folder (its slug is <folder>-<harness>, since a project's Claude and Codex Agents are two) and asks its kind. The choice goes into .flocktab at the git root, one entry per harness, and a key for that Agent is minted and kept on this machine.",
41
+ "An Agent is one harness on one project. tab claude and tab codex in the same folder are two Agents (that is what the plan counts), each asked for once: the first run of a harness in a folder lists the flock's Agents of that harness (and untied ones it can tie), or makes a new one named <folder>-<harness> and asks its kind. The choice goes into .flocktab at the git root, one entry per harness, and a key for that Agent is minted and kept on this machine.",
42
42
  "tab use codex changes the answer for one harness; bare tab use sets the folder's default, which every other command (tab python …) runs as. An older .flocktab with a single Agent applies to every harness until one is set apart.",
43
43
  "An Agent that already holds a key on another machine can be used here too, but minting a key here replaces the one there. tab asks before doing it.",
44
44
  ],
@@ -50,20 +50,19 @@ export const TAB_COMMANDS = [
50
50
  aliases: ["codex", "grok", "kimi", "gemini", "aider", "cursor", "<command>"],
51
51
  group: "run",
52
52
  usage: ["claude [args]", "codex [args]", "grok [args]", "kimi [args]", "<any command> [args]"],
53
- summary: "Run an agent on this folder's tab. Harness arguments pass through after tab's run options.",
53
+ summary: "Run an agent on this folder's tab. Everything after the name goes to the agent untouched.",
54
54
  details: [
55
55
  "tab sets the environment that agent reads (its base URL and key, or for Codex a home folder of its own) and starts it. It does not wrap or parse the agent's traffic; the proxy does the gating.",
56
+ "Codex's tab home starts from your own ~/.codex/config.toml: approval policy, sandbox mode, model and effort, MCP servers, profiles and trusted folders all apply under tab codex. Only the tab's keys (where the calls go, the flocktab provider) are layered on top.",
56
57
  "On an API-key Agent the agent presents the tab's key, the flock's provider key pays, and every call is held, settled and charged to the cap. A call that would pass the cap is refused before the provider with 402 tab_closed.",
57
58
  "On a Subscription Agent the agent keeps its own login (Claude Max, ChatGPT, SuperGrok, Kimi). The tab key rides in the base URL, the same kill switch and policies apply before the vendor is reached, the call is recorded at list price under the account the vendor names, and nothing is charged to the cap.",
58
59
  "Any other command works too: tab points both the OpenAI and the Anthropic variables at the proxy and runs it. An agent with no consumer plan on a Subscription Agent runs metered on the flock's key, and tab says so.",
59
60
  "With logins in the pool for that vendor, a Subscription Agent runs as the login with most room. See tab pool.",
60
- "Opt in with tab claude --observe or tab codex --observe to show native sessions, workers and observed messages in Observability. Review and trust the added hooks in the harness normally; collection is unavailable if hooks are skipped. Only identifiers, timestamps and tool outcomes leave the machine, never prompts, message bodies or transcripts. A successful send is not a read receipt. Collection is experimental: Claude 2.1.278+ in the 2.1 family and Codex 0.155.1+ in the 0.155 family, on macOS/Linux. Offline metadata is bounded and retried on another observed launch of the same Agent; inactive queues older than seven days are removed on the next observed launch, and overflow is counted as a coverage gap.",
61
61
  ],
62
62
  options: [
63
63
  { flag: "--as <login>", what: "run as one pool login and never move off it" },
64
64
  { flag: "--no-pool", what: "ignore the pool and use the agent's usual login" },
65
65
  { flag: "--pool", what: "require the pool; fail rather than fall back when it cannot be used" },
66
- { flag: "--observe", what: "opt in to metadata-only native communication hooks for this Claude/Codex launch; put before --" },
67
66
  ],
68
67
  examples: [
69
68
  { cmd: "tab claude", what: "Claude Code on this folder's tab" },
@@ -329,7 +328,7 @@ export const TAB_COMMANDS = [
329
328
  summary: "What was spent through the tab, and outside it, grouped: by Agent, project, day, or model and effort.",
330
329
  details: [
331
330
  "Meter spend is what went through FlockTab. Outside spend is what the connected billing sources report (Team plan and up), so the two can be told apart.",
332
- "tab spend model lists each model with the reasoning effort the requests asked for (OpenAI's reasoning effort, Anthropic's effort or thinking budget) and the speed tier (fast, priority, flex), how many calls, what they cost and how they were paid: charged to a tab, or priced at list on a plan. The console shows the same under Spend, Models.",
331
+ "tab spend model lists each model with the reasoning effort the requests asked for (OpenAI's reasoning effort, Anthropic's effort or thinking budget), how many calls, what they cost and how they were paid: charged to a tab, or priced at list on a plan. The console shows the same under Spend, Models.",
333
332
  ],
334
333
  options: [
335
334
  { flag: "--by agent|project|day|model", what: "how to group" },
@@ -435,7 +434,6 @@ export const TAB_GLOSSARY = [
435
434
  { term: "Account switch", meaning: "An Agent's call landing on a different account of the same vendor than its previous one: a new login mid-session, another machine, or the pool moving. Shown on the Agent's page and under Control, Subscriptions." },
436
435
  { term: "Project", meaning: "A label grouping Agents for chargeback on FlockTab's own meter, optionally linked to a GitHub repository. Team plan and up." },
437
436
  { term: "Outside spend", meaning: "Money that did not go through a tab, read from provider admin APIs and cloud billing, so it can be told apart from metered spend. Team plan and up." },
438
- { term: "Tier", also: ["service tier", "fast mode", "speed"], meaning: "A speed the request asked for, in the request's own words: OpenAI's and xAI's service_tier (fast, priority, flex), Anthropic's speed (fast). Vendors price these apart, so it is kept on every call and shown beside the model on the ledger and under Spend, Models." },
439
437
  { term: "Effort", also: ["reasoning effort", "thinking budget"], meaning: "How hard a request asked the model to think, in the request's own words: OpenAI's reasoning effort (low, medium, high, xhigh), Anthropic's effort or a thinking budget in tokens. Kept on every call, shown on the ledger and under Spend, Models, because it is where the money goes." },
440
438
  { term: "Ledger", meaning: "Every decision, one row per call: allowed, settled, refunded, blocked and why. Money is whole cents, never a fraction. If the ledger cannot be reached, no call goes out." },
441
439
  { term: "Fail closed", meaning: "When FlockTab cannot be sure a call fits (the ledger is down, the key is unknown, the unlock is missing) the call is refused rather than let through." },
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.18";
2
+ export const TAB_VERSION = "0.1.19";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanamorilabs/tab",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
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",