@hanamorilabs/tab 0.1.14 → 0.1.16

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/dist/cli.js CHANGED
@@ -24,7 +24,7 @@
24
24
  */
25
25
  import { spawn } from "node:child_process";
26
26
  import { mkdir } from "node:fs/promises";
27
- import { hostname } from "node:os";
27
+ import { homedir, hostname } from "node:os";
28
28
  import path from "node:path";
29
29
  import { createInterface } from "node:readline/promises";
30
30
  import { stdin, stdout } from "node:process";
@@ -34,20 +34,24 @@ import { describeLogin, localLogin } from "./logins.js";
34
34
  import { prepareCodexHome } from "./codex-home.js";
35
35
  import { addMember, loadPool, memberEnv, memberHome, POOL_VENDORS, poolVendorFor, prepareClaudeMember, prepareSharedSessions, limitsFor, removeMember, savePool, setGuard, setThreshold, thresholdFor, usage as quotaUsage } from "./pool.js";
36
36
  import { runPooled } from "./pool-run.js";
37
+ import { claudeSettingsArg, installStatusLine, statusline } from "./statusline.js";
37
38
  import { renderPool } from "./pool-view.js";
38
39
  import { renderHelp } from "./help.js";
39
40
  import { findTabCommand } from "./tab-docs.js";
40
- import { ConsoleApiError, createAgent, issueAgentKey, listAgents } from "./console-api.js";
41
+ import { ConsoleApiError, createAgent, issueAgentKey, listAgents, setAgentHarness } from "./console-api.js";
41
42
  import { consoleUrlFor, DeviceLoginError, startDeviceLogin, waitForApproval } from "./device-login.js";
42
43
  import { clearConfig, configDir, configPath, HOSTED_PROXY, isLocalProxy, loadConfig, LOCAL_PROXY, normalizeProxyUrl, presentedKey, saveConfig, } from "./config.js";
43
44
  import { reportFolder } from "./folder.js";
44
45
  import { whoami } from "./proxy-identity.js";
45
46
  import { listAliases, pathLine, pathWithoutShims, removeAlias, shimDir, shimDirOnPath, validAliasName, writeAlias } from "./alias.js";
46
47
  import * as manage from "./manage.js";
47
- import { agentNameFor, projectRoot, readProject, writeProject } from "./project.js";
48
+ import { agentNameFor, assignProjectAgent, projectRoot, readProject } from "./project.js";
49
+ import { HARNESSES, agentNameFor as harnessAgentName } from "./tab-docs-shared.js";
48
50
  import { downloadProxy, hasProxyEnv, packagedBinPath, proxyBinPath, proxyEnvPath, proxyInstalled, proxyLogPath, hasProxyLog, PROXY_VERSION, startProxy, stopProxy, tailProxyLog, writeProxyEnv, } from "./proxy-bin.js";
49
51
  import { proxyHealth, waitHealthy } from "./selfhost.js";
50
52
  import { TAB_VERSION } from "./version.js";
53
+ import { takeObserveFlag } from "./observability-events.js";
54
+ import { prepareObservation } from "./observability.js";
51
55
  const say = (text) => console.error(text);
52
56
  const ok = (text) => say(line("ok", text));
53
57
  const warn = (text) => say(line("warn", text));
@@ -319,13 +323,23 @@ async function bringUp(proxyUrl) {
319
323
  * holds its key. Missing either: ask (or, with `.flocktab` present, mint the
320
324
  * key silently for this machine), save both, carry on.
321
325
  */
326
+ /** `claude`, `codex`, `grok`, `kimi`, else `any`: the harness a command is. */
327
+ function harnessOf(command) {
328
+ return HARNESSES.includes(command) && command !== "any" ? command : "any";
329
+ }
330
+ /**
331
+ * The Agent this folder runs `harness` as. One harness on one project is
332
+ * one Agent, so `tab claude` and `tab codex` in a folder are two Agents
333
+ * (that is what the plan counts), each asked for once.
334
+ */
322
335
  async function resolveAgent(config, opts = {}) {
323
336
  const agents = config.agents ?? {};
337
+ const harness = opts.harness ?? "any";
324
338
  // CI and scripts: FLOCKTAB_KEY is the Agent, no folder involved.
325
339
  if (agents.env && !opts.pick)
326
340
  return agents.env;
327
341
  const cwd = process.cwd();
328
- const project = opts.pick ? undefined : await readProject(cwd);
342
+ const project = opts.pick ? undefined : await readProject(cwd, harness);
329
343
  if (project && agents[project.agent])
330
344
  return agents[project.agent];
331
345
  if (!config.token || !config.consoleUrl) {
@@ -351,16 +365,19 @@ async function resolveAgent(config, opts = {}) {
351
365
  }
352
366
  if (!chosen) {
353
367
  const root = await projectRoot(cwd);
368
+ // The Agent is named after the project; the harness is a property, shown by its mark. The slug
369
+ // (unique, permanent) carries the harness so a project's Claude and Codex Agents are told apart.
354
370
  const suggested = agentNameFor(root);
355
- say(heading(`Which Agent does this folder run as?`));
356
- say(dim(`${root} · flock ${listed.flock.name}`));
357
- const options = listed.agents;
371
+ say(heading(harness === "any" ? "Which Agent does this folder run as?" : `Which Agent does this folder run ${harness} as?`));
372
+ say(dim(`${root} · flock ${listed.flock.name}${harness === "any" ? "" : ` · one harness per Agent: this one is for ${harness}`}`));
373
+ // Only Agents of this harness, plus untied ones (made before harnesses were told apart) that can be tied now.
374
+ const options = listed.agents.filter((a) => harness === "any" ? (a.harness ?? "any") === "any" : (a.harness ?? "any") === harness || (a.harness ?? "any") === "any");
358
375
  say(rows([
359
376
  ...options.map((a, i) => [
360
377
  `${bold(String(i + 1))} ${a.name}`,
361
- `${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") : ""}`,
378
+ `${a.state === "open" ? green("open") : yellow(a.state)} ${a.kind === "subscription" ? yellow("subscription") : dim("api")} ${dim(a.harness && a.harness !== "any" ? a.harness : "untied")}${a.kind === "api" ? dim(` $${(Number(a.capCents) / 100).toFixed(2)} cap`) : ""}${agents[a.slug] ? dim(" keyed here") : a.hasKey ? dim(" keyed elsewhere") : ""}`,
362
379
  ]),
363
- [`${bold("n")} New Agent`, dim(`named ${suggested}, $50.00 cap`)],
380
+ [`${bold("n")} New Agent`, dim(`named ${suggested}${harness === "any" ? "" : ` (${harness})`}, $50.00 cap`)],
364
381
  ]));
365
382
  const answer = await ask(`Choose 1-${options.length} or ${bold("n")} ${dim("[n]")}: `);
366
383
  if (answer === "" || answer.toLowerCase() === "n") {
@@ -370,7 +387,7 @@ async function resolveAgent(config, opts = {}) {
370
387
  [`${bold("2")} Subscription`, dim("your own Claude Max / ChatGPT / SuperGrok / Kimi logins; gated and recorded, never charged")],
371
388
  ]));
372
389
  const which = await ask(`Which kind? ${bold("1")} or ${bold("2")} ${dim("[1]")}: `);
373
- created = { name, kind: which.trim() === "2" ? "subscription" : "api" };
390
+ created = { name, slug: harnessAgentName(name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""), harness), kind: which.trim() === "2" ? "subscription" : "api", harness };
374
391
  }
375
392
  else {
376
393
  const index = Number.parseInt(answer, 10);
@@ -384,8 +401,8 @@ async function resolveAgent(config, opts = {}) {
384
401
  let issued;
385
402
  try {
386
403
  if (created) {
387
- issued = await createAgent(config.consoleUrl, config.token, { name: created.name, kind: created.kind });
388
- ok(`Created ${created.kind === "subscription" ? "subscription" : "API-key"} Agent ${bold(issued.agent.name)} with a $50.00 cap. Change it in the console.`);
404
+ issued = await createAgent(config.consoleUrl, config.token, { name: created.name, slug: created.slug, kind: created.kind, harness: created.harness });
405
+ ok(`Created ${created.kind === "subscription" ? "subscription" : "API-key"} ${created.harness === "any" ? "" : `${created.harness} `}Agent ${bold(issued.agent.name)}${created.kind === "api" ? " with a $50.00 cap. Change it in the console." : "."}`);
389
406
  }
390
407
  else if (chosen) {
391
408
  if (chosen.hasKey && !agents[chosen.slug]) {
@@ -395,6 +412,11 @@ async function resolveAgent(config, opts = {}) {
395
412
  return undefined;
396
413
  }
397
414
  issued = await issueAgentKey(config.consoleUrl, config.token, chosen.id);
415
+ // An untied Agent chosen for a harness is tied to it from now on, so the proxies enforce it.
416
+ if (harness !== "any" && (chosen.harness ?? "any") === "any") {
417
+ await setAgentHarness(config.consoleUrl, config.token, chosen.slug, harness);
418
+ say(dim(`${chosen.name} is now a ${harness} Agent.`));
419
+ }
398
420
  }
399
421
  else {
400
422
  return undefined;
@@ -415,8 +437,8 @@ async function resolveAgent(config, opts = {}) {
415
437
  config.agents = next.agents ?? {};
416
438
  if (next.unlock)
417
439
  config.unlock = next.unlock;
418
- const file = await writeProject(await projectRoot(cwd), { agent: issued.agent.slug });
419
- ok(`This folder runs as ${bold(issued.agent.name)} ${dim(`(${file})`)}`);
440
+ const file = await assignProjectAgent(await projectRoot(cwd), harness, issued.agent.slug);
441
+ ok(`This folder runs ${harness === "any" ? "" : `${harness} `}as ${bold(issued.agent.name)} ${dim(`(${file})`)}`);
420
442
  return login;
421
443
  }
422
444
  /** `tab version`: this CLI, and the proxy it would run (packaged, downloaded, or neither). */
@@ -551,14 +573,19 @@ async function poolIdle(config, vendor, agentName) {
551
573
  return !(await flockAccounts(config)).some((a) => a.provider === vendor && new Date(a.lastSeenAt).getTime() > recent);
552
574
  }
553
575
  async function runAgent(name, argv) {
554
- const { args, noPool, asked, pinned } = takePoolFlags(argv);
576
+ const observationFlags = takeObserveFlag(argv);
577
+ const { args, noPool, asked, pinned } = takePoolFlags(observationFlags.args);
578
+ if (observationFlags.observe && name !== "claude" && name !== "codex") {
579
+ fail("--observe supports Claude Code and Codex only.");
580
+ return 2;
581
+ }
555
582
  const config = await ensureLogin();
556
583
  if (!config)
557
584
  return 2;
558
585
  // Hosted whoami below proves readiness and current Agent kind in one round trip.
559
586
  if (config.mode === "self-hosted" && !(await ensureProxy(config)))
560
587
  return 1;
561
- const agent = await resolveAgent(config);
588
+ const agent = await resolveAgent(config, { harness: harnessOf(name) });
562
589
  if (!agent)
563
590
  return 1;
564
591
  // The Agent's kind decides: a subscription Agent's harness brings its own
@@ -627,8 +654,17 @@ async function runAgent(name, argv) {
627
654
  auth,
628
655
  });
629
656
  }
657
+ // Claude Code shows the tab in its status bar, from the command line so no file of the person's is touched.
658
+ const tabBin = process.env.FLOCKTAB_DEV ? "tabdev" : "tab";
659
+ const statusArgs = name === "claude" && !args.includes("--settings") && !args.includes("-p") && !args.includes("--print") ? claudeSettingsArg("claude", tabBin) : [];
660
+ const observation = observationFlags.observe
661
+ ? await prepareObservation({ root: configDir(), harness: name, command: spec.command, agentId: agent.agentId, config, env })
662
+ .catch((error) => { warn(error instanceof Error ? error.message : "Observability unavailable; continuing without collection."); return undefined; })
663
+ : undefined;
664
+ if (observation)
665
+ say(dim("Observability: metadata only. Review the FlockTab hooks in your harness; skipped hooks mean unavailable coverage."));
630
666
  const launch = (launchArgs, launchEnv) => {
631
- const child = spawn(spec.command, [...(spec.args ?? []), ...launchArgs], { stdio: "inherit", env: launchEnv });
667
+ const child = spawn(spec.command, [...(spec.args ?? []), ...statusArgs, ...(observation?.args ?? []), ...launchArgs], { stdio: "inherit", env: { ...launchEnv, ...observation?.env } });
632
668
  const done = new Promise((resolve) => {
633
669
  child.on("error", (err) => {
634
670
  if (err.code === "ENOENT") {
@@ -646,11 +682,15 @@ async function runAgent(name, argv) {
646
682
  return { done, stop: () => void child.kill("SIGTERM") };
647
683
  };
648
684
  if (!pooled || !poolVendor || !pool)
649
- return launch(args, env).done;
685
+ return launch(args, env).done.finally(() => observation?.stop().catch(() => undefined));
650
686
  // Codex members each need their own home written before the first launch;
651
687
  // Codex, Grok and Kimi members share one conversations folder.
652
688
  for (const member of members)
653
689
  await prepareSharedSessions(poolVendor, member);
690
+ if (poolVendor === "xai" || poolVendor === "kimi") {
691
+ for (const member of members)
692
+ await installStatusLine(poolVendor, memberHome(poolVendor, member), tabBin).catch(() => undefined);
693
+ }
654
694
  void registerPoolLogins(config, pool);
655
695
  if (poolVendor === "openai") {
656
696
  for (const member of members) {
@@ -673,7 +713,7 @@ async function runAgent(name, argv) {
673
713
  });
674
714
  }),
675
715
  say: (text) => say(dim(text)),
676
- });
716
+ }).finally(() => observation?.stop().catch(() => undefined));
677
717
  }
678
718
  /**
679
719
  * `tab pool`: several logins of one vendor on this machine. Each member is
@@ -871,14 +911,39 @@ function pathHint() {
871
911
  say(` ${bold(pathLine())}`);
872
912
  return 0;
873
913
  }
874
- /** `tab use`: pick or change the Agent for this folder. */
875
- async function use() {
914
+ /**
915
+ * `tab statusline install grok|kimi`: put `tab statusline` into the harness's
916
+ * own config, in the person's home. Asks first; that file is theirs.
917
+ */
918
+ async function statuslineInstall(args) {
919
+ const word = args[0];
920
+ const vendor = word === "grok" ? "xai" : word === "kimi" ? "kimi" : undefined;
921
+ if (!vendor) {
922
+ fail(`tab statusline install grok|kimi. ${dim("Claude Code gets it on every tab claude; Codex shows its own limits and takes no command.")}`);
923
+ return 2;
924
+ }
925
+ const home = vendor === "xai" ? path.join(homedir(), ".grok") : path.join(homedir(), ".kimi-code");
926
+ const file = path.join(home, vendor === "xai" ? "config.toml" : "tui.toml");
927
+ const go = await ask(`Add a status line running ${bold("tab statusline " + word)} to ${dim(file)}? ${dim("[y/N]")}: `);
928
+ if (!/^y(es)?$/i.test(go))
929
+ return 1;
930
+ const done = await installStatusLine(vendor, home, process.env.FLOCKTAB_DEV ? "tabdev" : "tab");
931
+ ok(done === "already" ? `${file} already has a status line; left as it is.` : `Added. It shows on the next ${word} launch.`);
932
+ return 0;
933
+ }
934
+ /** `tab use [claude|codex|grok|kimi]`: pick or change the Agent this folder runs a harness as; bare, the folder's default. */
935
+ async function use(args) {
876
936
  const config = await ensureLogin();
877
937
  if (!config)
878
938
  return 2;
879
939
  if (!(await ensureProxy(config)))
880
940
  return 1;
881
- const agent = await resolveAgent(config, { pick: true });
941
+ const word = args[0];
942
+ if (word && harnessOf(word) === "any") {
943
+ fail(`tab use [claude|codex|grok|kimi]: which harness to pick an Agent for; bare for the folder's default.`);
944
+ return 2;
945
+ }
946
+ const agent = await resolveAgent(config, { pick: true, harness: word ? harnessOf(word) : "any" });
882
947
  return agent ? 0 : 1;
883
948
  }
884
949
  /**
@@ -1065,7 +1130,7 @@ async function main(argv) {
1065
1130
  ok("Forgot the session and keys on this machine.");
1066
1131
  return 0;
1067
1132
  case "use":
1068
- return use();
1133
+ return use(rest);
1069
1134
  case "alias":
1070
1135
  return alias(rest);
1071
1136
  case "unalias":
@@ -1098,6 +1163,17 @@ async function main(argv) {
1098
1163
  return poolCommand(rest);
1099
1164
  case "status":
1100
1165
  return status();
1166
+ case "statusline": {
1167
+ if (rest[0] === "install")
1168
+ return statuslineInstall(rest.slice(1));
1169
+ const config = await loadConfig();
1170
+ if (!config) {
1171
+ console.log("FlockTab · not logged in");
1172
+ return 0;
1173
+ }
1174
+ console.log(await statusline(config, rest[0] && harnessOf(rest[0]) !== "any" ? harnessOf(rest[0]) : "any"));
1175
+ return 0;
1176
+ }
1101
1177
  case "log":
1102
1178
  if (rest.includes("--proxy"))
1103
1179
  return logs(rest.filter((a) => a !== "--proxy"));
@@ -30,8 +30,17 @@ export function codexSubscriptionToml(proxyUrl, presentedKey, model) {
30
30
  const lines = [
31
31
  `preferred_auth_method = "chatgpt"`,
32
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`)}`,
33
36
  ...(model ? [`model = ${JSON.stringify(model)}`] : []),
34
37
  ``,
38
+ // 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
+ ``,
35
44
  ];
36
45
  return lines.join("\n");
37
46
  }
@@ -32,3 +32,7 @@ export async function createAgent(consoleUrl, token, input, fetchImpl = fetch) {
32
32
  export async function issueAgentKey(consoleUrl, token, agentId, fetchImpl = fetch) {
33
33
  return call(consoleUrl, token, `/api/cli/agents/${encodeURIComponent(agentId)}/key`, { method: "POST" }, fetchImpl);
34
34
  }
35
+ /** Tie an untied Agent to a harness (or change it): `tab agent harness <agent> codex`. */
36
+ export async function setAgentHarness(consoleUrl, token, slug, harness, fetchImpl = fetch) {
37
+ return call(consoleUrl, token, `/api/cli/tabs/${encodeURIComponent(slug)}`, { method: "PATCH", body: JSON.stringify({ harness }) }, fetchImpl);
38
+ }
package/dist/manage.js CHANGED
@@ -61,6 +61,10 @@ export function parseFlags(argv) {
61
61
  return flags;
62
62
  }
63
63
  const kindOf = (t) => (t.kind === "subscription" ? yellow("subscription") : "api");
64
+ const harnessOf = (t) => (t.harness && t.harness !== "any" ? t.harness : dim("any"));
65
+ // A subscription never touches its cap: nothing to show but the kill switch.
66
+ const capOf = (t) => (t.kind === "subscription" ? dim("-") : money(t.capCents));
67
+ const usedOf = (t) => (t.kind === "subscription" ? dim("-") : pct(t.spentCents, t.capCents));
64
68
  async function tabsOf(call) {
65
69
  return (await call("GET", "/api/cli/tabs")).tabs;
66
70
  }
@@ -94,7 +98,7 @@ export async function list(config, flags) {
94
98
  const tabs = await tabsOf(api(config));
95
99
  emit(flags.json, { tabs }, () => tabs.length === 0
96
100
  ? dim("No Agents yet. Run tab claude or tab codex in a project folder.")
97
- : 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] }));
101
+ : table(["agent", "harness", "kind", "state", "spent", "cap", "used", "window", "project"], tabs.map((t) => [t.name === t.slug ? t.slug : `${t.name} ${dim(t.slug)}`, harnessOf(t), kindOf(t), state(t.state), t.kind === "subscription" ? dim("-") : money(t.spentCents), capOf(t), usedOf(t), t.window, t.projectName ?? dim("-")]), { right: [3, 4, 5] }));
98
102
  return 0;
99
103
  }
100
104
  export async function setState(config, flags, next) {
@@ -156,7 +160,7 @@ export async function agent(config, flags) {
156
160
  if (!name)
157
161
  throw new ManageError('tab agent create <name> [--subscription|--api] [--cap 50]');
158
162
  const kind = flags.opts.subscription !== undefined ? "subscription" : "api";
159
- const body = { name, kind };
163
+ const body = { name, kind, ...(flags.opts.harness ? { harness: flags.opts.harness.toLowerCase() } : {}) };
160
164
  if (flags.opts.cap)
161
165
  body.capDollars = flags.opts.cap.replace(/^\$/, "");
162
166
  const made = await call("POST", "/api/cli/agents", body);
@@ -169,6 +173,18 @@ export async function agent(config, flags) {
169
173
  : "Its calls use the flock's provider key and are metered against the cap. tab use picks it for a folder."));
170
174
  return 0;
171
175
  }
176
+ if (verb === "harness") {
177
+ const [given, wanted] = rest;
178
+ const harness = (wanted ?? "").toLowerCase();
179
+ if (!given || !["claude", "codex", "grok", "kimi", "any"].includes(harness))
180
+ throw new ManageError("tab agent harness <agent> claude|codex|grok|kimi|any");
181
+ const slug = await resolveSlug(call, given);
182
+ const { tab } = await call("PATCH", `/api/cli/tabs/${encodeURIComponent(slug)}`, { harness });
183
+ emit(flags.json, { tab }, () => harness === "any"
184
+ ? `${bold(tab.name)} is tied to no harness: it is metered on the tab and cannot pass a login through.`
185
+ : `${bold(tab.name)} is a ${bold(harness)} Agent. A ${harness} login passes through it; any other harness is refused before the vendor.`);
186
+ return 0;
187
+ }
172
188
  if (verb === "kind" || verb === "type") {
173
189
  const [given, wanted] = rest;
174
190
  if (!given || !wanted)
@@ -178,7 +194,7 @@ export async function agent(config, flags) {
178
194
  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")}.`);
179
195
  return 0;
180
196
  }
181
- throw new ManageError("tab agent create <name> [--subscription|--api] | tab agent kind <agent> api|subscription");
197
+ throw new ManageError("tab agent create <name> [--subscription|--api] [--harness claude|codex|grok|kimi] | tab agent kind <agent> api|subscription | tab agent harness <agent> claude|codex|grok|kimi|any");
182
198
  }
183
199
  /**
184
200
  * `tab accounts` (also `tab quota`): every account the flock's subscription
@@ -205,7 +221,7 @@ export async function accounts(config, flags) {
205
221
  String(a.calls),
206
222
  a.quota.length === 0 ? dim("-") : a.quota.map((q) => `${q.window} ${left(q)}`).join(" "),
207
223
  a.agents.join(", "),
208
- ]), { right: [3, 4, 5] }) + `\n${dim("Set a price or label in the console, Control, Accounts.")}`;
224
+ ]), { right: [3, 4, 5] }) + `\n${dim("Set a price or label in the console, Control, Subscriptions.")}`;
209
225
  });
210
226
  return 0;
211
227
  }
@@ -391,19 +407,19 @@ export async function ledger(config, flags) {
391
407
  const { rows: list, timezone } = await call("GET", `/api/cli/ledger?${params}`);
392
408
  emit(flags.json, { timezone, rows: list }, () => list.length === 0
393
409
  ? dim("No decisions yet.")
394
- : table(["time", "agent", "action", "amount", "status", "reason"], list.map((r) => {
410
+ : table(["time", "agent", "action", "model", "amount", "status", "reason"], list.map((r) => {
395
411
  // A subscription call is recorded at list price; nothing was held, so there is no "of".
396
412
  const shadow = r.status === "shadow";
397
413
  const status = shadow ? "SUBSCRIPTION" : (r.status ?? r.decision).toUpperCase();
398
414
  const paint = status === "BLOCKED" || status === "CLOSED" ? red : status === "PENDING" ? yellow : green;
399
415
  const held = !shadow && r.reservedCents && r.reservedCents !== r.cents ? dim(` of ${money(r.reservedCents)}`) : "";
400
- return [r.at, r.agent, r.action.length > 28 ? `${r.action.slice(0, 27)}…` : r.action, `${money(r.cents)}${held}`, paint(status), r.reason];
416
+ return [r.at, r.agent, r.action.length > 28 ? `${r.action.slice(0, 27)}…` : r.action, r.model ? `${r.model}${r.effort ? dim(` ${r.effort}`) : ""}${r.tier ? yellow(` ${r.tier}`) : ""}` : dim("-"), `${money(r.cents)}${held}`, paint(status), r.reason];
401
417
  }), { right: [3] }) + `\n${dim(`times in ${timezone}`)}`);
402
418
  return 0;
403
419
  }
404
420
  export async function spend(config, flags) {
405
421
  const call = api(config);
406
- const by = flags.opts.by ?? (flags.args[0] === "day" || flags.args[0] === "project" || flags.args[0] === "agent" ? flags.args[0] : "agent");
422
+ const by = flags.opts.by ?? (["day", "project", "agent", "model", "models"].includes(flags.args[0] ?? "") ? flags.args[0].replace(/s$/, "") : "agent");
407
423
  const params = new URLSearchParams({ by });
408
424
  if (flags.opts.since)
409
425
  params.set("since", flags.opts.since);
@@ -417,6 +433,25 @@ export async function spend(config, flags) {
417
433
  ? dim("Nothing settled in the window.")
418
434
  : table(["day", "calls", "settled", "in", "out"], days.map((d) => [d.day, String(d.calls), money(d.settledCents), d.promptTokens, d.completionTokens]), { right: [1, 2, 3, 4] });
419
435
  }
436
+ if (by === "model") {
437
+ const ms = data.models;
438
+ if (ms.length === 0)
439
+ return dim("No settled calls in the window.");
440
+ const total = ms.reduce((sum, m) => sum + BigInt(m.cents), 0n);
441
+ // Charged on a tab, or priced at list on a plan: two different dollars, told apart per row.
442
+ return `${table(["model", "effort", "tier", "harness", "paid by", "calls", "cost", "in", "out", "share"], ms.map((m) => [
443
+ m.model,
444
+ m.effort ?? dim("-"),
445
+ m.tier ? yellow(m.tier) : dim("-"),
446
+ m.harness === "any" ? dim("any") : m.harness,
447
+ m.kind === "shadow" ? yellow("plan (list)") : "tab",
448
+ String(m.calls),
449
+ money(m.cents),
450
+ m.promptTokens,
451
+ m.completionTokens,
452
+ total > 0n ? `${(Number((BigInt(m.cents) * 1000n) / total) / 10).toFixed(1)}%` : "-",
453
+ ]), { right: [5, 6, 7, 8, 9] })}\n${dim(`${money(total)} in the window (${data.since}); tab spend and list price on plans added together. --since 7d, --agent <agent>.`)}`;
454
+ }
420
455
  if (by === "project") {
421
456
  const ps = data.projects;
422
457
  const lines = table(["project", "agents", "meter", "outside", "total"], ps.map((p) => [p.name, String(p.agents), money(p.meterCents), money(p.outsideCents), money(BigInt(p.meterCents) + BigInt(p.outsideCents))]), { right: [1, 2, 3, 4] });
@@ -0,0 +1,80 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ export function takeObserveFlag(argv) {
3
+ let observe = false;
4
+ let passthrough = false;
5
+ const args = argv.filter((arg) => {
6
+ if (arg === "--")
7
+ passthrough = true;
8
+ if (!passthrough && arg === "--observe") {
9
+ observe = true;
10
+ return false;
11
+ }
12
+ return true;
13
+ });
14
+ return { observe, args };
15
+ }
16
+ const object = (v) => v !== null && typeof v === "object" && !Array.isArray(v) ? v : {};
17
+ // Do not truncate identifiers: doing so can merge distinct runtime identities.
18
+ export const identifier = (v) => typeof v === "string" && /^[A-Za-z0-9_./:@-]{1,128}$/.test(v) ? v : undefined;
19
+ const label = (v) => typeof v === "string" && /^[\p{L}\p{N}_./:@ \[\]-]{1,120}$/u.test(v) ? v : undefined;
20
+ const lifecycle = { SessionStart: "session_start", SessionEnd: "session_end", SubagentStart: "worker_start", SubagentStop: "worker_stop" };
21
+ export const CODEX_SEND_TOOLS = ["send_input", "send_message", "followup_task", "collaboration.send_message", "collaboration.followup_task"];
22
+ /** Input is never persisted. Only documented native identifiers cross this boundary. */
23
+ export function sanitizeHookEvent(raw, harness, now = new Date()) {
24
+ const data = object(raw);
25
+ const sessionId = identifier(data.session_id);
26
+ if (!sessionId || typeof data.hook_event_name !== "string")
27
+ return undefined;
28
+ const hook = data.hook_event_name;
29
+ const input = object(data.tool_input);
30
+ const workerId = identifier(data.agent_id);
31
+ const toolCallId = identifier(data.tool_use_id);
32
+ let kind = lifecycle[hook];
33
+ let recipientSessionId;
34
+ let recipientLabel;
35
+ let outcome;
36
+ if (!kind) {
37
+ if (hook !== "PostToolUse" && hook !== "PostToolUseFailure")
38
+ return undefined;
39
+ if (harness === "claude" && data.tool_name === "SendMessage") {
40
+ // A notify_when_idle-only subscription is not an inter-agent message.
41
+ if (!(typeof input.content === "string" && input.content.length > 0) && !(typeof input.message === "string" && input.message.length > 0))
42
+ return undefined;
43
+ // Current native schema is to/message; older versions use recipient/content.
44
+ if (input.message !== undefined && typeof input.message !== "string")
45
+ return undefined;
46
+ if (input.type !== undefined && input.type !== "message" && input.type !== "broadcast")
47
+ return undefined;
48
+ kind = "send";
49
+ recipientLabel = label(input.to ?? input.recipient);
50
+ }
51
+ else if (harness === "claude" && data.tool_name === "SubagentHandback" && workerId) {
52
+ // A nested worker can report to another worker; session_id alone cannot identify that parent.
53
+ kind = "report";
54
+ recipientLabel = "parent";
55
+ }
56
+ else if (harness === "codex" && typeof data.tool_name === "string" && CODEX_SEND_TOOLS.includes(data.tool_name)) {
57
+ kind = "send";
58
+ // send_input's id is a native thread id. Named collaboration targets are labels.
59
+ if (data.tool_name === "send_input")
60
+ recipientSessionId = identifier(input.id);
61
+ else
62
+ recipientLabel = label(input.target);
63
+ }
64
+ else
65
+ return undefined;
66
+ if (!toolCallId)
67
+ return undefined;
68
+ const response = object(data.tool_response);
69
+ const failed = hook === "PostToolUseFailure" || (harness === "claude" && (response.success === false || object(response.data).success === false));
70
+ outcome = failed ? "failed" : harness === "claude" ? "succeeded" : "observed";
71
+ }
72
+ if ((kind === "worker_start" || kind === "worker_stop") && !workerId)
73
+ return undefined;
74
+ // Native tool ids dedupe retries; lifecycle events can recur on resume and get fresh ids.
75
+ const id = toolCallId && (kind === "send" || kind === "report")
76
+ ? createHash("sha256").update(JSON.stringify([sessionId, workerId, toolCallId, kind, outcome])).digest("hex") : randomUUID();
77
+ return { id, kind, occurredAt: now.toISOString(), sessionId,
78
+ ...(workerId ? { workerId } : {}), ...(toolCallId && outcome ? { toolCallId } : {}),
79
+ ...(recipientSessionId ? { recipientSessionId } : {}), ...(recipientLabel ? { recipientLabel } : {}), ...(outcome ? { outcome } : {}) };
80
+ }
@@ -0,0 +1,36 @@
1
+ /** Internal hook executable. No credentials, network calls, logs, or raw-payload files. */
2
+ import { sanitizeHookEvent } from "./observability-events.js";
3
+ import { enqueueEvent } from "./observability-queue.js";
4
+ import { randomUUID } from "node:crypto";
5
+ async function recordGap() {
6
+ const dir = process.env.FLOCKTAB_OBSERVE_DIR;
7
+ if (!dir)
8
+ return;
9
+ await enqueueEvent(dir, { id: randomUUID(), kind: "gap", sessionId: "collector", occurredAt: new Date().toISOString(), droppedCount: 1 });
10
+ }
11
+ async function collect() {
12
+ const dir = process.env.FLOCKTAB_OBSERVE_DIR;
13
+ const harness = process.env.FLOCKTAB_OBSERVE_HARNESS;
14
+ if (!dir || (harness !== "claude" && harness !== "codex"))
15
+ return;
16
+ let size = 0;
17
+ const parts = [];
18
+ for await (const chunk of process.stdin) {
19
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
20
+ size += bytes.length;
21
+ if (size > 1024 * 1024) {
22
+ await recordGap();
23
+ return;
24
+ }
25
+ parts.push(bytes);
26
+ }
27
+ const event = sanitizeHookEvent(JSON.parse(Buffer.concat(parts).toString("utf8")), harness);
28
+ if (event)
29
+ await enqueueEvent(dir, event);
30
+ }
31
+ // Telemetry failure must never block or steer a tool. No payload-derived error text.
32
+ const timeout = setTimeout(() => { process.stdout.write("{}\n"); process.exit(0); }, 1500);
33
+ void collect().catch(() => recordGap().catch(() => undefined)).finally(() => {
34
+ clearTimeout(timeout);
35
+ process.stdout.write("{}\n");
36
+ });