@hanamorilabs/tab 0.1.15 → 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 +63 -9
- package/dist/codex-home.js +9 -0
- package/dist/manage.js +23 -4
- package/dist/observability-events.js +80 -0
- package/dist/observability-hook.js +36 -0
- package/dist/observability-queue.js +194 -0
- package/dist/observability.js +249 -0
- package/dist/proxy-bin.js +1 -1
- package/dist/statusline.js +114 -0
- package/dist/tab-docs.js +32 -8
- package/dist/version.js +1 -1
- package/package.json +6 -6
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));
|
|
@@ -362,7 +365,9 @@ async function resolveAgent(config, opts = {}) {
|
|
|
362
365
|
}
|
|
363
366
|
if (!chosen) {
|
|
364
367
|
const root = await projectRoot(cwd);
|
|
365
|
-
|
|
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.
|
|
370
|
+
const suggested = agentNameFor(root);
|
|
366
371
|
say(heading(harness === "any" ? "Which Agent does this folder run as?" : `Which Agent does this folder run ${harness} as?`));
|
|
367
372
|
say(dim(`${root} · flock ${listed.flock.name}${harness === "any" ? "" : ` · one harness per Agent: this one is for ${harness}`}`));
|
|
368
373
|
// Only Agents of this harness, plus untied ones (made before harnesses were told apart) that can be tied now.
|
|
@@ -372,7 +377,7 @@ async function resolveAgent(config, opts = {}) {
|
|
|
372
377
|
`${bold(String(i + 1))} ${a.name}`,
|
|
373
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") : ""}`,
|
|
374
379
|
]),
|
|
375
|
-
[`${bold("n")} New Agent`, dim(`named ${suggested}, $50.00 cap`)],
|
|
380
|
+
[`${bold("n")} New Agent`, dim(`named ${suggested}${harness === "any" ? "" : ` (${harness})`}, $50.00 cap`)],
|
|
376
381
|
]));
|
|
377
382
|
const answer = await ask(`Choose 1-${options.length} or ${bold("n")} ${dim("[n]")}: `);
|
|
378
383
|
if (answer === "" || answer.toLowerCase() === "n") {
|
|
@@ -382,7 +387,7 @@ async function resolveAgent(config, opts = {}) {
|
|
|
382
387
|
[`${bold("2")} Subscription`, dim("your own Claude Max / ChatGPT / SuperGrok / Kimi logins; gated and recorded, never charged")],
|
|
383
388
|
]));
|
|
384
389
|
const which = await ask(`Which kind? ${bold("1")} or ${bold("2")} ${dim("[1]")}: `);
|
|
385
|
-
created = { name, kind: which.trim() === "2" ? "subscription" : "api", harness };
|
|
390
|
+
created = { name, slug: harnessAgentName(name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""), harness), kind: which.trim() === "2" ? "subscription" : "api", harness };
|
|
386
391
|
}
|
|
387
392
|
else {
|
|
388
393
|
const index = Number.parseInt(answer, 10);
|
|
@@ -396,7 +401,7 @@ async function resolveAgent(config, opts = {}) {
|
|
|
396
401
|
let issued;
|
|
397
402
|
try {
|
|
398
403
|
if (created) {
|
|
399
|
-
issued = await createAgent(config.consoleUrl, config.token, { name: created.name, kind: created.kind, harness: created.harness });
|
|
404
|
+
issued = await createAgent(config.consoleUrl, config.token, { name: created.name, slug: created.slug, kind: created.kind, harness: created.harness });
|
|
400
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." : "."}`);
|
|
401
406
|
}
|
|
402
407
|
else if (chosen) {
|
|
@@ -568,7 +573,12 @@ async function poolIdle(config, vendor, agentName) {
|
|
|
568
573
|
return !(await flockAccounts(config)).some((a) => a.provider === vendor && new Date(a.lastSeenAt).getTime() > recent);
|
|
569
574
|
}
|
|
570
575
|
async function runAgent(name, argv) {
|
|
571
|
-
const
|
|
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
|
+
}
|
|
572
582
|
const config = await ensureLogin();
|
|
573
583
|
if (!config)
|
|
574
584
|
return 2;
|
|
@@ -644,8 +654,17 @@ async function runAgent(name, argv) {
|
|
|
644
654
|
auth,
|
|
645
655
|
});
|
|
646
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."));
|
|
647
666
|
const launch = (launchArgs, launchEnv) => {
|
|
648
|
-
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 } });
|
|
649
668
|
const done = new Promise((resolve) => {
|
|
650
669
|
child.on("error", (err) => {
|
|
651
670
|
if (err.code === "ENOENT") {
|
|
@@ -663,11 +682,15 @@ async function runAgent(name, argv) {
|
|
|
663
682
|
return { done, stop: () => void child.kill("SIGTERM") };
|
|
664
683
|
};
|
|
665
684
|
if (!pooled || !poolVendor || !pool)
|
|
666
|
-
return launch(args, env).done;
|
|
685
|
+
return launch(args, env).done.finally(() => observation?.stop().catch(() => undefined));
|
|
667
686
|
// Codex members each need their own home written before the first launch;
|
|
668
687
|
// Codex, Grok and Kimi members share one conversations folder.
|
|
669
688
|
for (const member of members)
|
|
670
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
|
+
}
|
|
671
694
|
void registerPoolLogins(config, pool);
|
|
672
695
|
if (poolVendor === "openai") {
|
|
673
696
|
for (const member of members) {
|
|
@@ -690,7 +713,7 @@ async function runAgent(name, argv) {
|
|
|
690
713
|
});
|
|
691
714
|
}),
|
|
692
715
|
say: (text) => say(dim(text)),
|
|
693
|
-
});
|
|
716
|
+
}).finally(() => observation?.stop().catch(() => undefined));
|
|
694
717
|
}
|
|
695
718
|
/**
|
|
696
719
|
* `tab pool`: several logins of one vendor on this machine. Each member is
|
|
@@ -888,6 +911,26 @@ function pathHint() {
|
|
|
888
911
|
say(` ${bold(pathLine())}`);
|
|
889
912
|
return 0;
|
|
890
913
|
}
|
|
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
|
+
}
|
|
891
934
|
/** `tab use [claude|codex|grok|kimi]`: pick or change the Agent this folder runs a harness as; bare, the folder's default. */
|
|
892
935
|
async function use(args) {
|
|
893
936
|
const config = await ensureLogin();
|
|
@@ -1120,6 +1163,17 @@ async function main(argv) {
|
|
|
1120
1163
|
return poolCommand(rest);
|
|
1121
1164
|
case "status":
|
|
1122
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
|
+
}
|
|
1123
1177
|
case "log":
|
|
1124
1178
|
if (rest.includes("--proxy"))
|
|
1125
1179
|
return logs(rest.filter((a) => a !== "--proxy"));
|
package/dist/codex-home.js
CHANGED
|
@@ -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,
|
|
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 ?? (
|
|
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.
|
|
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
|
|
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.
|
|
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" },
|
|
@@ -220,7 +222,7 @@ export const TAB_COMMANDS = [
|
|
|
220
222
|
summary: "Every plan login your Subscription Agents were seen on, and how used it is.",
|
|
221
223
|
details: [
|
|
222
224
|
"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
|
|
225
|
+
"The console shows the same under Control, Subscriptions, where a label or a seat price of your own is set.",
|
|
224
226
|
],
|
|
225
227
|
see: ["pool", "agent"],
|
|
226
228
|
},
|
|
@@ -268,6 +270,22 @@ export const TAB_COMMANDS = [
|
|
|
268
270
|
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
271
|
see: ["login", "use", "up"],
|
|
270
272
|
},
|
|
273
|
+
{
|
|
274
|
+
name: "statusline",
|
|
275
|
+
overview: "statusline [harness]",
|
|
276
|
+
group: "watch",
|
|
277
|
+
usage: ["statusline [claude|codex|grok|kimi]", "statusline install grok|kimi"],
|
|
278
|
+
summary: "One line for a harness's status bar: the Agent and its tab, or the plan's windows.",
|
|
279
|
+
details: [
|
|
280
|
+
"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.",
|
|
281
|
+
"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.",
|
|
282
|
+
],
|
|
283
|
+
examples: [
|
|
284
|
+
{ cmd: "tab statusline claude", what: "the line, as Claude Code's status bar would show it" },
|
|
285
|
+
{ cmd: "tab statusline install grok", what: "add it to ~/.grok/config.toml" },
|
|
286
|
+
],
|
|
287
|
+
see: ["status", "accounts"],
|
|
288
|
+
},
|
|
271
289
|
{
|
|
272
290
|
name: "log",
|
|
273
291
|
overview: "log [-f] | log --proxy",
|
|
@@ -304,14 +322,18 @@ export const TAB_COMMANDS = [
|
|
|
304
322
|
{
|
|
305
323
|
name: "spend",
|
|
306
324
|
group: "watch",
|
|
307
|
-
usage: ["spend [agent|project|day]"],
|
|
308
|
-
summary: "What was spent through the tab, and outside it, grouped.",
|
|
309
|
-
details: [
|
|
325
|
+
usage: ["spend [agent|project|day|model]"],
|
|
326
|
+
summary: "What was spent through the tab, and outside it, grouped: by Agent, project, day, or model and effort.",
|
|
327
|
+
details: [
|
|
328
|
+
"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.",
|
|
329
|
+
"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.",
|
|
330
|
+
],
|
|
310
331
|
options: [
|
|
311
|
-
{ flag: "--by agent|project|day", what: "how to group" },
|
|
332
|
+
{ flag: "--by agent|project|day|model", what: "how to group" },
|
|
312
333
|
{ flag: "--since 7d", what: "how far back" },
|
|
313
334
|
{ flag: "--agent <agent>", what: "only one Agent" },
|
|
314
335
|
],
|
|
336
|
+
examples: [{ cmd: "tab spend model --since 30d", what: "a month per model and effort" }],
|
|
315
337
|
see: ["outside", "ledger"],
|
|
316
338
|
},
|
|
317
339
|
{
|
|
@@ -407,9 +429,11 @@ export const TAB_GLOSSARY = [
|
|
|
407
429
|
{ 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
430
|
{ 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
431
|
{ 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
|
|
432
|
+
{ 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
433
|
{ 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
434
|
{ 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." },
|
|
435
|
+
{ 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." },
|
|
436
|
+
{ 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
437
|
{ 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
438
|
{ 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
439
|
{ 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.
|
|
2
|
+
export const TAB_VERSION = "0.1.16";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanamorilabs/tab",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
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.
|
|
26
|
-
"@hanamorilabs/flocktab-proxy-darwin-x64": "0.1.
|
|
27
|
-
"@hanamorilabs/flocktab-proxy-linux-x64": "0.1.
|
|
28
|
-
"@hanamorilabs/flocktab-proxy-linux-arm64": "0.1.
|
|
29
|
-
"@hanamorilabs/flocktab-proxy-win-x64": "0.1.
|
|
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",
|