@hanamorilabs/tab 0.1.15 → 0.1.17

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,6 +34,7 @@ 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";
@@ -49,6 +50,8 @@ import { HARNESSES, agentNameFor as harnessAgentName } from "./tab-docs-shared.j
49
50
  import { downloadProxy, hasProxyEnv, packagedBinPath, proxyBinPath, proxyEnvPath, proxyInstalled, proxyLogPath, hasProxyLog, PROXY_VERSION, startProxy, stopProxy, tailProxyLog, writeProxyEnv, } from "./proxy-bin.js";
50
51
  import { proxyHealth, waitHealthy } from "./selfhost.js";
51
52
  import { TAB_VERSION } from "./version.js";
53
+ import { takeObserveFlag } from "./observability-events.js";
54
+ import { prepareObservation } from "./observability.js";
52
55
  const say = (text) => console.error(text);
53
56
  const ok = (text) => say(line("ok", text));
54
57
  const warn = (text) => say(line("warn", text));
@@ -337,9 +340,12 @@ async function resolveAgent(config, opts = {}) {
337
340
  return agents.env;
338
341
  const cwd = process.cwd();
339
342
  const project = opts.pick ? undefined : await readProject(cwd, harness);
340
- if (project && agents[project.agent])
341
- return agents[project.agent];
343
+ // The key this machine holds for the folder's Agent. It is not proof the Agent still exists: the
344
+ // console may have archived it since, so the console is asked before anything launches on it.
345
+ const cached = project ? agents[project.agent] : undefined;
342
346
  if (!config.token || !config.consoleUrl) {
347
+ if (cached)
348
+ return cached;
343
349
  fail(`This machine is not logged in. Run ${bold("tab login")}.`);
344
350
  return undefined;
345
351
  }
@@ -348,6 +354,9 @@ async function resolveAgent(config, opts = {}) {
348
354
  listed = await listAgents(config.consoleUrl, config.token);
349
355
  }
350
356
  catch (err) {
357
+ // The console cannot answer; the key on hand still runs, and the proxy refuses on its own if the Agent is gone.
358
+ if (cached)
359
+ return cached;
351
360
  fail(err instanceof ConsoleApiError && err.status === 401 ? `Session expired. Run ${bold("tab login")}.` : String(err instanceof Error ? err.message : err));
352
361
  return undefined;
353
362
  }
@@ -356,13 +365,17 @@ async function resolveAgent(config, opts = {}) {
356
365
  let created;
357
366
  if (project) {
358
367
  chosen = byName.get(project.agent);
368
+ if (chosen && cached)
369
+ return cached;
359
370
  if (!chosen) {
360
- warn(`${project.file} names Agent ${bold(project.agent)}, which ${listed.flock.name} no longer has.`);
371
+ warn(`${project.file} names Agent ${bold(project.agent)}, which ${listed.flock.name} no longer has (archived or deleted). Pick the Agent this folder runs as now.`);
361
372
  }
362
373
  }
363
374
  if (!chosen) {
364
375
  const root = await projectRoot(cwd);
365
- const suggested = harnessAgentName(agentNameFor(root), harness);
376
+ // The Agent is named after the project; the harness is a property, shown by its mark. The slug
377
+ // (unique, permanent) carries the harness so a project's Claude and Codex Agents are told apart.
378
+ const suggested = agentNameFor(root);
366
379
  say(heading(harness === "any" ? "Which Agent does this folder run as?" : `Which Agent does this folder run ${harness} as?`));
367
380
  say(dim(`${root} · flock ${listed.flock.name}${harness === "any" ? "" : ` · one harness per Agent: this one is for ${harness}`}`));
368
381
  // Only Agents of this harness, plus untied ones (made before harnesses were told apart) that can be tied now.
@@ -372,7 +385,7 @@ async function resolveAgent(config, opts = {}) {
372
385
  `${bold(String(i + 1))} ${a.name}`,
373
386
  `${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") : ""}`,
374
387
  ]),
375
- [`${bold("n")} New Agent`, dim(`named ${suggested}, $50.00 cap`)],
388
+ [`${bold("n")} New Agent`, dim(`named ${suggested}${harness === "any" ? "" : ` (${harness})`}, $50.00 cap`)],
376
389
  ]));
377
390
  const answer = await ask(`Choose 1-${options.length} or ${bold("n")} ${dim("[n]")}: `);
378
391
  if (answer === "" || answer.toLowerCase() === "n") {
@@ -382,7 +395,7 @@ async function resolveAgent(config, opts = {}) {
382
395
  [`${bold("2")} Subscription`, dim("your own Claude Max / ChatGPT / SuperGrok / Kimi logins; gated and recorded, never charged")],
383
396
  ]));
384
397
  const which = await ask(`Which kind? ${bold("1")} or ${bold("2")} ${dim("[1]")}: `);
385
- created = { name, kind: which.trim() === "2" ? "subscription" : "api", harness };
398
+ created = { name, slug: harnessAgentName(name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""), harness), kind: which.trim() === "2" ? "subscription" : "api", harness };
386
399
  }
387
400
  else {
388
401
  const index = Number.parseInt(answer, 10);
@@ -396,7 +409,7 @@ async function resolveAgent(config, opts = {}) {
396
409
  let issued;
397
410
  try {
398
411
  if (created) {
399
- issued = await createAgent(config.consoleUrl, config.token, { name: created.name, kind: created.kind, harness: created.harness });
412
+ issued = await createAgent(config.consoleUrl, config.token, { name: created.name, slug: created.slug, kind: created.kind, harness: created.harness });
400
413
  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." : "."}`);
401
414
  }
402
415
  else if (chosen) {
@@ -568,7 +581,12 @@ async function poolIdle(config, vendor, agentName) {
568
581
  return !(await flockAccounts(config)).some((a) => a.provider === vendor && new Date(a.lastSeenAt).getTime() > recent);
569
582
  }
570
583
  async function runAgent(name, argv) {
571
- const { args, noPool, asked, pinned } = takePoolFlags(argv);
584
+ const observationFlags = takeObserveFlag(argv);
585
+ const { args, noPool, asked, pinned } = takePoolFlags(observationFlags.args);
586
+ if (observationFlags.observe && name !== "claude" && name !== "codex") {
587
+ fail("--observe supports Claude Code and Codex only.");
588
+ return 2;
589
+ }
572
590
  const config = await ensureLogin();
573
591
  if (!config)
574
592
  return 2;
@@ -644,8 +662,17 @@ async function runAgent(name, argv) {
644
662
  auth,
645
663
  });
646
664
  }
665
+ // Claude Code shows the tab in its status bar, from the command line so no file of the person's is touched.
666
+ const tabBin = process.env.FLOCKTAB_DEV ? "tabdev" : "tab";
667
+ const statusArgs = name === "claude" && !args.includes("--settings") && !args.includes("-p") && !args.includes("--print") ? claudeSettingsArg("claude", tabBin) : [];
668
+ const observation = observationFlags.observe
669
+ ? await prepareObservation({ root: configDir(), harness: name, command: spec.command, agentId: agent.agentId, config, env })
670
+ .catch((error) => { warn(error instanceof Error ? error.message : "Observability unavailable; continuing without collection."); return undefined; })
671
+ : undefined;
672
+ if (observation)
673
+ say(dim("Observability: metadata only. Review the FlockTab hooks in your harness; skipped hooks mean unavailable coverage."));
647
674
  const launch = (launchArgs, launchEnv) => {
648
- const child = spawn(spec.command, [...(spec.args ?? []), ...launchArgs], { stdio: "inherit", env: launchEnv });
675
+ const child = spawn(spec.command, [...(spec.args ?? []), ...statusArgs, ...(observation?.args ?? []), ...launchArgs], { stdio: "inherit", env: { ...launchEnv, ...observation?.env } });
649
676
  const done = new Promise((resolve) => {
650
677
  child.on("error", (err) => {
651
678
  if (err.code === "ENOENT") {
@@ -663,11 +690,15 @@ async function runAgent(name, argv) {
663
690
  return { done, stop: () => void child.kill("SIGTERM") };
664
691
  };
665
692
  if (!pooled || !poolVendor || !pool)
666
- return launch(args, env).done;
693
+ return launch(args, env).done.finally(() => observation?.stop().catch(() => undefined));
667
694
  // Codex members each need their own home written before the first launch;
668
695
  // Codex, Grok and Kimi members share one conversations folder.
669
696
  for (const member of members)
670
697
  await prepareSharedSessions(poolVendor, member);
698
+ if (poolVendor === "xai" || poolVendor === "kimi") {
699
+ for (const member of members)
700
+ await installStatusLine(poolVendor, memberHome(poolVendor, member), tabBin).catch(() => undefined);
701
+ }
671
702
  void registerPoolLogins(config, pool);
672
703
  if (poolVendor === "openai") {
673
704
  for (const member of members) {
@@ -690,7 +721,7 @@ async function runAgent(name, argv) {
690
721
  });
691
722
  }),
692
723
  say: (text) => say(dim(text)),
693
- });
724
+ }).finally(() => observation?.stop().catch(() => undefined));
694
725
  }
695
726
  /**
696
727
  * `tab pool`: several logins of one vendor on this machine. Each member is
@@ -888,6 +919,26 @@ function pathHint() {
888
919
  say(` ${bold(pathLine())}`);
889
920
  return 0;
890
921
  }
922
+ /**
923
+ * `tab statusline install grok|kimi`: put `tab statusline` into the harness's
924
+ * own config, in the person's home. Asks first; that file is theirs.
925
+ */
926
+ async function statuslineInstall(args) {
927
+ const word = args[0];
928
+ const vendor = word === "grok" ? "xai" : word === "kimi" ? "kimi" : undefined;
929
+ if (!vendor) {
930
+ fail(`tab statusline install grok|kimi. ${dim("Claude Code gets it on every tab claude; Codex shows its own limits and takes no command.")}`);
931
+ return 2;
932
+ }
933
+ const home = vendor === "xai" ? path.join(homedir(), ".grok") : path.join(homedir(), ".kimi-code");
934
+ const file = path.join(home, vendor === "xai" ? "config.toml" : "tui.toml");
935
+ const go = await ask(`Add a status line running ${bold("tab statusline " + word)} to ${dim(file)}? ${dim("[y/N]")}: `);
936
+ if (!/^y(es)?$/i.test(go))
937
+ return 1;
938
+ const done = await installStatusLine(vendor, home, process.env.FLOCKTAB_DEV ? "tabdev" : "tab");
939
+ ok(done === "already" ? `${file} already has a status line; left as it is.` : `Added. It shows on the next ${word} launch.`);
940
+ return 0;
941
+ }
891
942
  /** `tab use [claude|codex|grok|kimi]`: pick or change the Agent this folder runs a harness as; bare, the folder's default. */
892
943
  async function use(args) {
893
944
  const config = await ensureLogin();
@@ -1120,6 +1171,17 @@ async function main(argv) {
1120
1171
  return poolCommand(rest);
1121
1172
  case "status":
1122
1173
  return status();
1174
+ case "statusline": {
1175
+ if (rest[0] === "install")
1176
+ return statuslineInstall(rest.slice(1));
1177
+ const config = await loadConfig();
1178
+ if (!config) {
1179
+ console.log("FlockTab · not logged in");
1180
+ return 0;
1181
+ }
1182
+ console.log(await statusline(config, rest[0] && harnessOf(rest[0]) !== "any" ? harnessOf(rest[0]) : "any"));
1183
+ return 0;
1184
+ }
1123
1185
  case "log":
1124
1186
  if (rest.includes("--proxy"))
1125
1187
  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
  }
package/dist/manage.js CHANGED
@@ -221,7 +221,7 @@ export async function accounts(config, flags) {
221
221
  String(a.calls),
222
222
  a.quota.length === 0 ? dim("-") : a.quota.map((q) => `${q.window} ${left(q)}`).join(" "),
223
223
  a.agents.join(", "),
224
- ]), { 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.")}`;
225
225
  });
226
226
  return 0;
227
227
  }
@@ -407,19 +407,19 @@ export async function ledger(config, flags) {
407
407
  const { rows: list, timezone } = await call("GET", `/api/cli/ledger?${params}`);
408
408
  emit(flags.json, { timezone, rows: list }, () => list.length === 0
409
409
  ? dim("No decisions yet.")
410
- : table(["time", "agent", "action", "amount", "status", "reason"], list.map((r) => {
410
+ : table(["time", "agent", "action", "model", "amount", "status", "reason"], list.map((r) => {
411
411
  // A subscription call is recorded at list price; nothing was held, so there is no "of".
412
412
  const shadow = r.status === "shadow";
413
413
  const status = shadow ? "SUBSCRIPTION" : (r.status ?? r.decision).toUpperCase();
414
414
  const paint = status === "BLOCKED" || status === "CLOSED" ? red : status === "PENDING" ? yellow : green;
415
415
  const held = !shadow && r.reservedCents && r.reservedCents !== r.cents ? dim(` of ${money(r.reservedCents)}`) : "";
416
- 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];
417
417
  }), { right: [3] }) + `\n${dim(`times in ${timezone}`)}`);
418
418
  return 0;
419
419
  }
420
420
  export async function spend(config, flags) {
421
421
  const call = api(config);
422
- 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");
423
423
  const params = new URLSearchParams({ by });
424
424
  if (flags.opts.since)
425
425
  params.set("since", flags.opts.since);
@@ -433,6 +433,25 @@ export async function spend(config, flags) {
433
433
  ? dim("Nothing settled in the window.")
434
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] });
435
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
+ }
436
455
  if (by === "project") {
437
456
  const ps = data.projects;
438
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
+ });
@@ -0,0 +1,194 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { linkSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { readFile, readdir } from "node:fs/promises";
4
+ import path from "node:path";
5
+ export const QUEUE_LIMIT = 512;
6
+ const localLocks = new Map();
7
+ export class ObservationLockTimeout extends Error {
8
+ }
9
+ function readOwner(file) {
10
+ try {
11
+ const owner = JSON.parse(readFileSync(file, "utf8"));
12
+ return Number.isInteger(owner.pid) && owner.pid > 0 && typeof owner.token === "string" ? owner : undefined;
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
18
+ function alive(pid) {
19
+ try {
20
+ process.kill(pid, 0);
21
+ return true;
22
+ }
23
+ catch (error) {
24
+ return error.code !== "ESRCH";
25
+ }
26
+ }
27
+ function releaseOwned(file, token) {
28
+ if (readOwner(file)?.token === token)
29
+ rmSync(file, { force: true });
30
+ }
31
+ function reclaimDeadOwner(lock) {
32
+ // Serialize reapers. An unknown/legacy lock is never stolen based on age alone.
33
+ const reaper = `${lock}.reaping`;
34
+ try {
35
+ mkdirSync(reaper);
36
+ }
37
+ catch {
38
+ return;
39
+ }
40
+ try {
41
+ const owner = readOwner(lock);
42
+ if (owner && !alive(owner.pid))
43
+ releaseOwned(lock, owner.token);
44
+ }
45
+ finally {
46
+ rmSync(reaper, { recursive: true, force: true });
47
+ }
48
+ }
49
+ export async function withObservationLock(dir, action) {
50
+ // Sibling calls wait their turn before starting the cross-process deadline.
51
+ const previous = localLocks.get(dir) ?? Promise.resolve();
52
+ let done;
53
+ const current = new Promise((resolve) => { done = resolve; });
54
+ localLocks.set(dir, current);
55
+ await previous;
56
+ const lock = path.join(dir, ".lock");
57
+ const token = randomUUID();
58
+ const candidate = path.join(dir, `.owner-${token}`);
59
+ let acquired = false;
60
+ try {
61
+ writeFileSync(candidate, JSON.stringify({ pid: process.pid, token }), { mode: 0o600, flag: "wx" });
62
+ const deadline = Date.now() + 750;
63
+ for (;;) {
64
+ try {
65
+ linkSync(candidate, lock);
66
+ acquired = true;
67
+ break;
68
+ }
69
+ catch (error) {
70
+ if (error.code !== "EEXIST")
71
+ throw error;
72
+ reclaimDeadOwner(lock);
73
+ if (Date.now() >= deadline)
74
+ throw new ObservationLockTimeout("Observability queue is busy");
75
+ await new Promise((resolve) => setTimeout(resolve, 10));
76
+ }
77
+ }
78
+ return await action();
79
+ }
80
+ finally {
81
+ try {
82
+ if (acquired)
83
+ releaseOwned(lock, token);
84
+ rmSync(candidate, { force: true });
85
+ }
86
+ finally {
87
+ done();
88
+ if (localLocks.get(dir) === current)
89
+ localLocks.delete(dir);
90
+ }
91
+ }
92
+ }
93
+ const isEventFile = (file) => /^[a-zA-Z0-9-]+\.json$/.test(file) && file !== "run.json";
94
+ const eventFiles = async (dir) => (await readdir(dir)).filter(isEventFile);
95
+ function contentionGap(dir) {
96
+ const gap = { id: randomUUID(), kind: "gap", sessionId: "collector", occurredAt: new Date().toISOString(), droppedCount: 1 };
97
+ // Coalesce a contention episode into one lower-bound gap; no unbounded side queue.
98
+ const candidate = path.join(dir, `.gap-${gap.id}`);
99
+ try {
100
+ writeFileSync(candidate, JSON.stringify(gap), { mode: 0o600, flag: "wx" });
101
+ try {
102
+ linkSync(candidate, path.join(dir, "contention-gap.json"));
103
+ }
104
+ catch (error) {
105
+ if (error.code !== "EEXIST")
106
+ throw error;
107
+ }
108
+ }
109
+ finally {
110
+ rmSync(candidate, { force: true });
111
+ }
112
+ }
113
+ export async function enqueueEvent(dir, event, limit = QUEUE_LIMIT) {
114
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
115
+ try {
116
+ await withObservationLock(dir, () => {
117
+ const files = readdirSync(dir).filter(isEventFile);
118
+ const name = `${event.id}.json`;
119
+ if (files.includes(name))
120
+ return;
121
+ if (files.length >= limit) {
122
+ const file = path.join(dir, "dropped");
123
+ let dropped = 0;
124
+ try {
125
+ dropped = Number(readFileSync(file, "utf8")) || 0;
126
+ }
127
+ catch { /* First overflow. */ }
128
+ writeFileSync(file, String(Math.min(1_000_000, dropped + 1)), { mode: 0o600 });
129
+ return;
130
+ }
131
+ const temp = path.join(dir, `${event.id}.tmp`);
132
+ writeFileSync(temp, JSON.stringify(event), { mode: 0o600 });
133
+ renameSync(temp, path.join(dir, name));
134
+ });
135
+ }
136
+ catch (error) {
137
+ if (!(error instanceof ObservationLockTimeout))
138
+ throw error;
139
+ contentionGap(dir);
140
+ }
141
+ }
142
+ /** Network runs outside the hook and lock. Files disappear only after acknowledgment. */
143
+ export async function flushQueue(dir, run, send) {
144
+ await withObservationLock(dir, () => {
145
+ const file = path.join(dir, "dropped");
146
+ let dropped = 0;
147
+ try {
148
+ dropped = Number(readFileSync(file, "utf8")) || 0;
149
+ }
150
+ catch { /* No overflow. */ }
151
+ if (dropped > 0 && !readdirSync(dir).includes("gap.json")) {
152
+ const gap = { id: randomUUID(), kind: "gap", sessionId: `collector:${run.id}`, occurredAt: new Date().toISOString(), droppedCount: Math.min(1_000_000, dropped) };
153
+ writeFileSync(path.join(dir, "gap.json"), JSON.stringify(gap), { mode: 0o600 });
154
+ rmSync(file, { force: true });
155
+ }
156
+ });
157
+ const files = (await eventFiles(dir)).sort().slice(0, 100);
158
+ const events = [];
159
+ const included = [];
160
+ for (const file of files) {
161
+ const text = await readFile(path.join(dir, file), "utf8").catch(() => "");
162
+ if (!text || Buffer.byteLength(text) > 4096)
163
+ continue;
164
+ try {
165
+ const event = JSON.parse(text);
166
+ if (Buffer.byteLength(JSON.stringify({ version: 1, run, events: [...events, event] })) > 120_000)
167
+ break;
168
+ events.push(event);
169
+ included.push({ file, id: event.id });
170
+ }
171
+ catch { /* Partial files are not acknowledged. */ }
172
+ }
173
+ // Registration accompanies the first event batch; idle or untrusted hooks send nothing.
174
+ if (events.length === 0)
175
+ return true;
176
+ if (!(await send({ version: 1, run, events }).catch(() => false)))
177
+ return false;
178
+ await withObservationLock(dir, () => {
179
+ // Other uploaders can replace reusable gap filenames while this request is in flight.
180
+ for (const { file, id } of included) {
181
+ const target = path.join(dir, file);
182
+ let saved;
183
+ try {
184
+ saved = JSON.parse(readFileSync(target, "utf8"));
185
+ }
186
+ catch {
187
+ continue;
188
+ }
189
+ if (saved.id === id)
190
+ rmSync(target, { force: true });
191
+ }
192
+ });
193
+ return true;
194
+ }
@@ -0,0 +1,249 @@
1
+ import { execFile } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { link, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { CODEX_SEND_TOOLS, identifier } from "./observability-events.js";
7
+ import { flushQueue, withObservationLock } from "./observability-queue.js";
8
+ const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
9
+ async function isActive(dir) {
10
+ const pid = Number(await readFile(path.join(dir, "owner.pid"), "utf8").catch(() => "0"));
11
+ if (!Number.isInteger(pid) || pid <= 0)
12
+ return false;
13
+ try {
14
+ process.kill(pid, 0);
15
+ return true;
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ async function queueIsEmpty(dir) {
22
+ const files = await readdir(dir).catch((error) => { if (error.code === "ENOENT")
23
+ return []; throw error; });
24
+ return !files.some((f) => (f.endsWith(".json") && f !== "run.json") || f === "dropped");
25
+ }
26
+ export function observationHooks(harness, command) {
27
+ const group = (matcher) => [{ ...(matcher ? { matcher } : {}), hooks: [{ type: "command", command, timeout: 2 }] }];
28
+ const hooks = {
29
+ SessionStart: group(), SessionEnd: group(), SubagentStart: group(), SubagentStop: group(),
30
+ PostToolUse: group(harness === "claude" ? "^(SendMessage|SubagentHandback)$" : `^(${CODEX_SEND_TOOLS.map((s) => s.replaceAll(".", "\\.")).join("|")})$`),
31
+ };
32
+ if (harness === "claude")
33
+ hooks.PostToolUseFailure = group("^(SendMessage|SubagentHandback)$");
34
+ return hooks;
35
+ }
36
+ function toml(value) {
37
+ if (Array.isArray(value))
38
+ return `[${value.map(toml).join(",")}]`;
39
+ if (value !== null && typeof value === "object")
40
+ return `{${Object.entries(value).map(([k, v]) => `${JSON.stringify(k)}=${toml(v)}`).join(",")}}`;
41
+ return JSON.stringify(value);
42
+ }
43
+ /** Session config is a separate hook source: Codex merges hook sources across layers. */
44
+ export function codexObservationArgs(hooks) {
45
+ return Object.entries(hooks).flatMap(([event, groups]) => ["-c", `hooks.${event}=${toml(groups)}`]);
46
+ }
47
+ async function installedVersion(command, env) {
48
+ return new Promise((resolve) => execFile(command, ["--version"], { env, timeout: 3000, maxBuffer: 4096 }, (error, stdout) => resolve(error ? "unknown" : (stdout.match(/\b\d+\.\d+\.\d+\b/)?.[0] ?? "unknown"))));
49
+ }
50
+ export function supportedObservationVersion(harness, version) {
51
+ // Explicitly tested schema families; future minor lines need a compatibility review.
52
+ const [major, minor, patch] = version.split(".").map(Number);
53
+ return harness === "claude" ? major === 2 && minor === 1 && patch >= 278 : major === 0 && minor === 155 && patch >= 1;
54
+ }
55
+ async function installationId(root) {
56
+ const file = path.join(root, "installation-id");
57
+ const temp = path.join(root, `.installation-${randomUUID()}`);
58
+ await writeFile(temp, randomUUID(), { flag: "wx", mode: 0o600 });
59
+ try {
60
+ // A hard link publishes a complete file without replacing a concurrent winner.
61
+ await link(temp, file).catch((error) => { if (error.code !== "EEXIST")
62
+ throw error; });
63
+ }
64
+ finally {
65
+ await rm(temp, { force: true });
66
+ }
67
+ const saved = await readFile(file, "utf8");
68
+ if (!/^[a-f0-9-]{36}$/.test(saved))
69
+ throw new Error("Invalid observability installation identity");
70
+ return saved;
71
+ }
72
+ const publishedRunCount = async (runs) => (await readdir(runs)).filter((name) => /^[a-f0-9-]{36}$/.test(name)).length;
73
+ /** At capacity, try other queues without making an observed launch wait for a serial backlog. */
74
+ export async function recoverObservationCapacity(input) {
75
+ const controller = new AbortController();
76
+ const pending = [...input.oldRuns];
77
+ let available = false;
78
+ const expired = new Promise((resolve) => controller.signal.addEventListener("abort", () => resolve(), { once: true }));
79
+ const timer = setTimeout(() => controller.abort(), input.timeoutMs ?? 2000);
80
+ const recover = async () => {
81
+ while (!controller.signal.aborted && !available) {
82
+ const old = pending.shift();
83
+ if (!old)
84
+ return;
85
+ try {
86
+ const ok = await flushQueue(old.dir, old.run, (batch) => input.send(batch, controller.signal));
87
+ if (controller.signal.aborted)
88
+ return;
89
+ if (ok) {
90
+ if (await queueIsEmpty(old.dir))
91
+ await rm(old.dir, { recursive: true, force: true });
92
+ // One batch may not empty a queue. Retry successful progress within the same deadline.
93
+ else
94
+ pending.push(old);
95
+ }
96
+ if (await publishedRunCount(input.runs) < (input.limit ?? 32))
97
+ available = true;
98
+ }
99
+ catch { /* Keep unacknowledged metadata; another queue may be recoverable. */ }
100
+ }
101
+ };
102
+ try {
103
+ await Promise.race([Promise.all([recover(), recover()]), expired]);
104
+ }
105
+ finally {
106
+ clearTimeout(timer);
107
+ controller.abort();
108
+ }
109
+ return available;
110
+ }
111
+ /** Independent deadlines keep a failed backlog from dominating current collection or its peers. */
112
+ export function createObservationUploader(input) {
113
+ const now = input.now ?? Date.now;
114
+ const oldRuns = input.oldRuns.map((old) => ({ ...old, failures: 0, retryAt: 0 }));
115
+ let active;
116
+ let failures = 0;
117
+ let retryAt = 0;
118
+ const flush = (forceCurrent = false) => {
119
+ if (active)
120
+ return active;
121
+ active = (async () => {
122
+ if (forceCurrent || now() >= retryAt) {
123
+ const ok = await flushQueue(input.current.dir, input.current.run, input.send).catch(() => false);
124
+ failures = ok ? 0 : Math.min(failures + 1, 5);
125
+ retryAt = now() + 2000 * 2 ** failures;
126
+ }
127
+ const index = oldRuns.findIndex((old) => now() >= old.retryAt);
128
+ if (index < 0)
129
+ return;
130
+ const [old] = oldRuns.splice(index, 1);
131
+ if (!old)
132
+ return;
133
+ let ok = false;
134
+ let complete = false;
135
+ try {
136
+ ok = await flushQueue(old.dir, old.run, input.send).catch((error) => error.code === "ENOENT");
137
+ if (ok && await queueIsEmpty(old.dir)) {
138
+ await rm(old.dir, { recursive: true, force: true });
139
+ complete = true;
140
+ }
141
+ }
142
+ finally {
143
+ if (!complete) {
144
+ old.failures = ok ? 0 : Math.min(old.failures + 1, 5);
145
+ old.retryAt = now() + 2000 * 2 ** old.failures;
146
+ oldRuns.push(old);
147
+ }
148
+ }
149
+ })().catch(() => undefined).finally(() => { active = undefined; });
150
+ return active;
151
+ };
152
+ return { flush, finish: async () => { await active; await flush(true); } };
153
+ }
154
+ /** Called only after explicit --observe. Writes only beneath tab's private directory. */
155
+ export async function prepareObservation(input) {
156
+ if (!identifier(input.agentId))
157
+ throw new Error("Observability needs a named Agent; run tab use. Continuing without collection.");
158
+ if (!input.config.token || !input.config.consoleUrl)
159
+ throw new Error("Observability needs tab login (machine session)");
160
+ if (process.platform === "win32")
161
+ throw new Error("Observability hooks currently support macOS and Linux");
162
+ const version = await installedVersion(input.command, input.env);
163
+ if (!supportedObservationVersion(input.harness, version))
164
+ throw new Error(`Observability does not support ${input.harness} ${version}; tested families: Claude 2.1.278+, Codex 0.155.1+`);
165
+ const root = path.join(input.root, "observability");
166
+ await mkdir(root, { recursive: true, mode: 0o700 });
167
+ const runs = path.join(root, "runs");
168
+ await mkdir(runs, { recursive: true, mode: 0o700 });
169
+ // Never grow indefinitely when offline. Only expired, inactive collector-owned runs are removed.
170
+ const oldRuns = [];
171
+ for (const name of await readdir(runs)) {
172
+ const staging = /^\.initializing-[a-f0-9-]{36}$/.test(name);
173
+ if (!staging && !/^[a-f0-9-]{36}$/.test(name))
174
+ continue;
175
+ const dir = path.join(runs, name);
176
+ if (await isActive(dir))
177
+ continue;
178
+ const age = await stat(dir).then((s) => Date.now() - s.mtimeMs, () => 0);
179
+ if (staging) {
180
+ if (age > 7 * 86400_000)
181
+ await rm(dir, { recursive: true, force: true });
182
+ continue;
183
+ }
184
+ // Complete published runs can recover immediately. Only unknown/empty directories need grace.
185
+ if (age > 7 * 86400_000 || (age >= 60_000 && await queueIsEmpty(dir))) {
186
+ await rm(dir, { recursive: true, force: true });
187
+ continue;
188
+ }
189
+ try {
190
+ const run = JSON.parse(await readFile(path.join(dir, "run.json"), "utf8"));
191
+ if (run.agentId === input.agentId)
192
+ oldRuns.push({ dir, run });
193
+ }
194
+ catch { /* A concurrent launch may still be writing its run metadata. */ }
195
+ }
196
+ const url = `${input.config.consoleUrl.replace(/\/$/, "")}/api/cli/observability`;
197
+ const send = async (body, signal) => {
198
+ const timeout = AbortSignal.timeout(2000);
199
+ const response = await fetch(url, { method: "POST", headers: { authorization: `Bearer ${input.config.token}`, "content-type": "application/json" }, body: JSON.stringify(body), signal: signal ? AbortSignal.any([timeout, signal]) : timeout, redirect: "error" });
200
+ await response.body?.cancel();
201
+ return response.ok;
202
+ };
203
+ const runCount = () => publishedRunCount(runs);
204
+ if (await runCount() >= 32) {
205
+ const recovered = await recoverObservationCapacity({ runs, oldRuns, send });
206
+ if (!recovered && await runCount() >= 32)
207
+ throw new Error("Observability queue is full; retry when the console is reachable");
208
+ }
209
+ const run = { id: randomUUID(), agentId: input.agentId, installationId: await installationId(root), harness: input.harness, harnessVersion: version };
210
+ const dir = path.join(runs, run.id);
211
+ // Cleanup only considers UUID directories. Publish after ownership and plugin files are complete.
212
+ const staging = path.join(runs, `.initializing-${run.id}`);
213
+ await mkdir(staging, { mode: 0o700 });
214
+ const script = fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "./observability-hook.ts" : "./observability-hook.js", import.meta.url));
215
+ const command = `${quote(process.execPath)} ${quote(script)}`;
216
+ const hooks = observationHooks(input.harness, command);
217
+ let args = codexObservationArgs(hooks);
218
+ try {
219
+ await writeFile(path.join(staging, "run.json"), JSON.stringify(run), { mode: 0o600 });
220
+ await writeFile(path.join(staging, "owner.pid"), String(process.pid), { mode: 0o600 });
221
+ if (input.harness === "claude") {
222
+ const plugin = path.join(staging, "claude-plugin");
223
+ await mkdir(path.join(plugin, ".claude-plugin"), { recursive: true, mode: 0o700 });
224
+ await mkdir(path.join(plugin, "hooks"), { recursive: true, mode: 0o700 });
225
+ await writeFile(path.join(plugin, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "flocktab-observability", version: "1.0.0" }), { mode: 0o600 });
226
+ await writeFile(path.join(plugin, "hooks", "hooks.json"), JSON.stringify({ hooks }), { mode: 0o600 });
227
+ args = ["--plugin-dir", path.join(dir, "claude-plugin")];
228
+ }
229
+ await withObservationLock(runs, async () => {
230
+ if (await runCount() >= 32)
231
+ throw new Error("Observability queue is full; retry when the console is reachable");
232
+ await rename(staging, dir);
233
+ });
234
+ }
235
+ finally {
236
+ await rm(staging, { recursive: true, force: true });
237
+ }
238
+ const uploader = createObservationUploader({ current: { dir, run }, oldRuns, send });
239
+ void uploader.flush();
240
+ const timer = setInterval(() => { void uploader.flush(); }, 2000);
241
+ timer.unref();
242
+ return { args, env: { FLOCKTAB_OBSERVE_DIR: dir, FLOCKTAB_OBSERVE_HARNESS: input.harness }, stop: async () => {
243
+ clearInterval(timer);
244
+ await uploader.finish();
245
+ await rm(path.join(dir, "owner.pid"), { force: true });
246
+ if (await queueIsEmpty(dir))
247
+ await rm(dir, { recursive: true, force: true });
248
+ } };
249
+ }
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.10";
16
+ export const PROXY_VERSION = "0.1.12";
17
17
  export const RELEASES = "https://github.com/joseairosa/flocktab/releases/download";
18
18
  export function targetFor(platform = process.platform, arch = process.arch) {
19
19
  if (platform === "darwin")
@@ -0,0 +1,114 @@
1
+ /**
2
+ * `tab statusline`: one line for a harness's status bar. Which Agent this
3
+ * folder runs the harness as, and either the tab (spent of cap) or, on a
4
+ * subscription, the plan's windows as the vendor last reported them. Read
5
+ * from the console with a short cache, so a status bar that polls every
6
+ * few seconds costs one request a minute.
7
+ */
8
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
9
+ import path from "node:path";
10
+ import { configDir } from "./config.js";
11
+ import { readProject } from "./project.js";
12
+ import { api } from "./manage.js";
13
+ const CACHE_MS = 60_000;
14
+ function windowMinutes(window) {
15
+ const m = /^(\d+)([mhd])$/.exec(window);
16
+ return m ? Number(m[1]) * (m[2] === "d" ? 1440 : m[2] === "h" ? 60 : 1) : Number.MAX_SAFE_INTEGER;
17
+ }
18
+ function money(cents) {
19
+ const n = BigInt(cents);
20
+ return `$${n / 100n}.${(n % 100n).toString().padStart(2, "0")}`;
21
+ }
22
+ /** "5h 36% · 7d 29%", shortest window first; nothing when the vendor said nothing yet. */
23
+ export function planLine(accounts, now = new Date()) {
24
+ // The account most recently seen with quota: the login in use.
25
+ const withQuota = accounts.filter((a) => a.quota.length > 0);
26
+ const account = withQuota[0];
27
+ if (!account)
28
+ return undefined;
29
+ const windows = [...account.quota]
30
+ .filter((w) => !w.resetsAt || new Date(w.resetsAt).getTime() > now.getTime())
31
+ .sort((a, b) => windowMinutes(a.window) - windowMinutes(b.window));
32
+ const who = account.email ?? `account ${account.externalId.slice(0, 8)}`;
33
+ return `${who} · ${windows.length === 0 ? "windows reset" : windows.map((w) => `${w.window} ${Math.round(w.usedPct)}%`).join(" · ")}`;
34
+ }
35
+ /** The line itself, from what the console said. */
36
+ export function statusText(info, now = new Date()) {
37
+ const { tab } = info;
38
+ const closed = tab.state !== "open";
39
+ const head = `${tab.name}${closed ? " CLOSED" : ""}`;
40
+ if (tab.kind === "subscription") {
41
+ return `${head} · ${planLine(info.accounts ?? [], now) ?? "no call yet"}`;
42
+ }
43
+ const spent = BigInt(tab.spentCents);
44
+ const cap = BigInt(tab.capCents);
45
+ const pct = cap > 0n ? Number((spent * 100n) / cap) : 0;
46
+ return `${head} · ${money(tab.spentCents)} of ${money(tab.capCents)} / ${tab.window} (${pct}%)`;
47
+ }
48
+ /** The tab of this folder's Agent for `harness`, from a one-minute cache, else the console. */
49
+ export async function statusline(config, harness, cwd = process.cwd(), env = process.env) {
50
+ const project = await readProject(cwd, harness);
51
+ if (!project)
52
+ return "FlockTab · no Agent here (tab use)";
53
+ const login = config.agents?.[project.agent];
54
+ const cacheFile = path.join(configDir(env), "cache", `status-${project.agent}.json`);
55
+ try {
56
+ const cached = JSON.parse(await readFile(cacheFile, "utf8"));
57
+ if (Date.now() - cached.at < CACHE_MS)
58
+ return cached.text;
59
+ }
60
+ catch {
61
+ // No cache yet.
62
+ }
63
+ let text;
64
+ try {
65
+ const info = await api(config)("GET", `/api/cli/tabs/${encodeURIComponent(project.agent)}`);
66
+ text = `FlockTab · ${statusText(info)}`;
67
+ }
68
+ catch {
69
+ return `FlockTab · ${login?.agentName ?? project.agent} · console unreachable`;
70
+ }
71
+ try {
72
+ await mkdir(path.dirname(cacheFile), { recursive: true, mode: 0o700 });
73
+ await writeFile(cacheFile, JSON.stringify({ at: Date.now(), text }), { mode: 0o600 });
74
+ }
75
+ catch {
76
+ // A missing cache only costs a request.
77
+ }
78
+ return text;
79
+ }
80
+ /**
81
+ * How each harness is told to run `tab statusline`. Claude Code takes it on
82
+ * the command line, so every `tab claude` has it. Grok Build and Kimi Code
83
+ * read it only from a config file in their home: written into a pool
84
+ * member's home (which tab owns), and into the person's own home only by
85
+ * `tab statusline install`, which asks.
86
+ */
87
+ export function claudeSettingsArg(harness, tabBin = "tab") {
88
+ return ["--settings", JSON.stringify({ statusLine: { type: "command", command: `${tabBin} statusline ${harness}`, padding: 0 } })];
89
+ }
90
+ /** The lines Grok Build's config.toml needs; appended when `[ui.status_line]` is absent. */
91
+ export function grokStatusToml(tabBin = "tab") {
92
+ return `\n[ui.status_line]\ntype = "command"\ncommand = "${tabBin} statusline grok"\n`;
93
+ }
94
+ /** Kimi Code's tui.toml block; appended when `[status_line]` is absent. */
95
+ export function kimiStatusToml(tabBin = "tab") {
96
+ return `\n[status_line]\ncommand = "${tabBin} statusline kimi"\n`;
97
+ }
98
+ /** Add the status line to a harness home that does not have one. Returns what was done. */
99
+ export async function installStatusLine(vendor, home, tabBin = "tab") {
100
+ const file = vendor === "xai" ? path.join(home, "config.toml") : path.join(home, "tui.toml");
101
+ const marker = vendor === "xai" ? "[ui.status_line]" : "[status_line]";
102
+ let current = "";
103
+ try {
104
+ current = await readFile(file, "utf8");
105
+ }
106
+ catch {
107
+ // No file yet.
108
+ }
109
+ if (current.includes(marker))
110
+ return "already";
111
+ await mkdir(home, { recursive: true, mode: 0o700 });
112
+ await writeFile(file, `${current.replace(/\s*$/, "\n")}${vendor === "xai" ? grokStatusToml(tabBin) : kimiStatusToml(tabBin)}`);
113
+ return "written";
114
+ }
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 <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.",
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.",
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,18 +50,20 @@ 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. Everything after the name goes to the agent untouched.",
53
+ summary: "Run an agent on this folder's tab. Harness arguments pass through after tab's run options.",
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
56
  "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
57
  "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
58
  "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
59
  "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.",
60
61
  ],
61
62
  options: [
62
63
  { flag: "--as <login>", what: "run as one pool login and never move off it" },
63
64
  { flag: "--no-pool", what: "ignore the pool and use the agent's usual login" },
64
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 --" },
65
67
  ],
66
68
  examples: [
67
69
  { cmd: "tab claude", what: "Claude Code on this folder's tab" },
@@ -209,7 +211,10 @@ export const TAB_COMMANDS = [
209
211
  group: "agents",
210
212
  usage: ["archive [agent]"],
211
213
  summary: "Close an Agent for good and take it off the bill. Its history stays.",
212
- details: ["The tab closes for good, the Agent leaves the lists and stops counting towards the plan's Agents. Its ledger rows stay."],
214
+ details: [
215
+ "The tab closes for good, the Agent leaves the lists and stops counting towards the plan's Agents. Its ledger rows stay.",
216
+ "Its keys stop working everywhere (the proxies answer key_revoked). A folder still bound to it asks for an Agent again on the next launch.",
217
+ ],
213
218
  options: [{ flag: "--yes", what: "do not ask" }],
214
219
  },
215
220
  {
@@ -220,7 +225,7 @@ export const TAB_COMMANDS = [
220
225
  summary: "Every plan login your Subscription Agents were seen on, and how used it is.",
221
226
  details: [
222
227
  "Accounts are discovered, never typed in: the vendor names the login on every reply, and FlockTab files the call under it. Each row shows the plan, the seat price taken from the plan tier, what the last 30 days would have cost at list price, the number of calls, and the usage the vendor last reported per window.",
223
- "The console shows the same under Spend, Subscriptions; a label or a seat price of your own is set under Control, Accounts.",
228
+ "The console shows the same under Control, Subscriptions, where a label or a seat price of your own is set.",
224
229
  ],
225
230
  see: ["pool", "agent"],
226
231
  },
@@ -268,6 +273,22 @@ export const TAB_COMMANDS = [
268
273
  details: ["The first thing to run when something is off. Exit code 0 only when the machine is logged in, the folder has an Agent with a key here, and the proxy is healthy."],
269
274
  see: ["login", "use", "up"],
270
275
  },
276
+ {
277
+ name: "statusline",
278
+ overview: "statusline [harness]",
279
+ group: "watch",
280
+ usage: ["statusline [claude|codex|grok|kimi]", "statusline install grok|kimi"],
281
+ summary: "One line for a harness's status bar: the Agent and its tab, or the plan's windows.",
282
+ details: [
283
+ "Prints what this folder runs the harness as, then either spent of cap for an API-key Agent or, for a subscription, the login in use and each window's usage as the vendor last reported it (5h 36% · 7d 29%). Read from the console at most once a minute, so a status bar that refreshes every few seconds costs nothing.",
284
+ "Claude Code shows it on every tab claude, passed on the command line so no file of yours is touched (your own --settings wins). Grok Build and Kimi Code read a status line only from their config; a pool member gets it (that home is tab's), and tab statusline install grok|kimi adds it to your own config after asking. Codex takes no command and already shows its own limits.",
285
+ ],
286
+ examples: [
287
+ { cmd: "tab statusline claude", what: "the line, as Claude Code's status bar would show it" },
288
+ { cmd: "tab statusline install grok", what: "add it to ~/.grok/config.toml" },
289
+ ],
290
+ see: ["status", "accounts"],
291
+ },
271
292
  {
272
293
  name: "log",
273
294
  overview: "log [-f] | log --proxy",
@@ -304,14 +325,18 @@ export const TAB_COMMANDS = [
304
325
  {
305
326
  name: "spend",
306
327
  group: "watch",
307
- usage: ["spend [agent|project|day]"],
308
- summary: "What was spent through the tab, and outside it, grouped.",
309
- details: ["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."],
328
+ usage: ["spend [agent|project|day|model]"],
329
+ summary: "What was spent through the tab, and outside it, grouped: by Agent, project, day, or model and effort.",
330
+ details: [
331
+ "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.",
333
+ ],
310
334
  options: [
311
- { flag: "--by agent|project|day", what: "how to group" },
335
+ { flag: "--by agent|project|day|model", what: "how to group" },
312
336
  { flag: "--since 7d", what: "how far back" },
313
337
  { flag: "--agent <agent>", what: "only one Agent" },
314
338
  ],
339
+ examples: [{ cmd: "tab spend model --since 30d", what: "a month per model and effort" }],
315
340
  see: ["outside", "ledger"],
316
341
  },
317
342
  {
@@ -407,9 +432,11 @@ export const TAB_GLOSSARY = [
407
432
  { term: "Pool", meaning: "Several logins of one vendor on one machine, and tab choosing between them by reported usage. Each member is a folder the agent signs in to itself." },
408
433
  { term: "Threshold", meaning: "In the pool: the share of the short window at which a login gives way to one with more room. 80% unless set, per vendor if you like." },
409
434
  { term: "Weekly guard", meaning: "In the pool: the share of the long window from which a login gives way whatever its short window says. 95% unless set. Below it the weekly window is ignored." },
410
- { 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 Accounts." },
435
+ { 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." },
411
436
  { term: "Project", meaning: "A label grouping Agents for chargeback on FlockTab's own meter, optionally linked to a GitHub repository. Team plan and up." },
412
437
  { 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
+ { 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." },
413
440
  { 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." },
414
441
  { 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." },
415
442
  { term: ".flocktab", meaning: "A one-line file at a project's root naming the Agent that folder runs as. Safe to commit: it holds no key." },
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.15";
2
+ export const TAB_VERSION = "0.1.17";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanamorilabs/tab",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
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.10",
26
- "@hanamorilabs/flocktab-proxy-darwin-x64": "0.1.10",
27
- "@hanamorilabs/flocktab-proxy-linux-x64": "0.1.10",
28
- "@hanamorilabs/flocktab-proxy-linux-arm64": "0.1.10",
29
- "@hanamorilabs/flocktab-proxy-win-x64": "0.1.10"
25
+ "@hanamorilabs/flocktab-proxy-darwin-arm64": "0.1.12",
26
+ "@hanamorilabs/flocktab-proxy-darwin-x64": "0.1.12",
27
+ "@hanamorilabs/flocktab-proxy-linux-x64": "0.1.12",
28
+ "@hanamorilabs/flocktab-proxy-linux-arm64": "0.1.12",
29
+ "@hanamorilabs/flocktab-proxy-win-x64": "0.1.12"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^22.18.6",