@hanamorilabs/tab 0.1.20 → 0.1.22
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 +54 -5
- package/dist/codex-home.js +20 -0
- package/dist/manage.js +2 -1
- package/dist/pool-view.js +7 -8
- package/dist/pool.js +8 -5
- package/dist/proxy-bin.js +1 -1
- package/dist/quota-windows.js +37 -0
- package/dist/statusline.js +141 -19
- package/dist/tab-docs.js +26 -7
- package/dist/top.js +413 -0
- package/dist/version.js +1 -1
- package/package.json +6 -6
package/dist/cli.js
CHANGED
|
@@ -34,8 +34,9 @@ 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,
|
|
37
|
+
import { claudeSettingsArg, composedStatusline, installStatusLine } from "./statusline.js";
|
|
38
38
|
import { renderPool } from "./pool-view.js";
|
|
39
|
+
import { runTop } from "./top.js";
|
|
39
40
|
import { renderHelp } from "./help.js";
|
|
40
41
|
import { findTabCommand } from "./tab-docs.js";
|
|
41
42
|
import { ConsoleApiError, createAgent, issueAgentKey, listAgents, setAgentHarness } from "./console-api.js";
|
|
@@ -468,6 +469,35 @@ async function version() {
|
|
|
468
469
|
say(rows([["tab", tabVersion], ["proxy", `${PROXY_VERSION} ${dim(`(${where})`)}`]], 0));
|
|
469
470
|
return 0;
|
|
470
471
|
}
|
|
472
|
+
/** The pool as `tab pool` sees it, for the dashboard's Subscriptions panel: nothing when no login is pooled. */
|
|
473
|
+
async function poolSections(config) {
|
|
474
|
+
const pool = await loadPool();
|
|
475
|
+
const vendors = POOL_VENDORS.filter((v) => (pool.members[v] ?? []).length > 0);
|
|
476
|
+
if (vendors.length === 0)
|
|
477
|
+
return [];
|
|
478
|
+
const accounts = await flockAccounts(config).catch(() => []);
|
|
479
|
+
return Promise.all(vendors.map(async (vendor) => ({ vendor, title: VENDOR_NAMES[vendor], harness: VENDOR_HARNESS[vendor], limits: limitsFor(pool, vendor), standings: await poolStandings(config, vendor, pool.members[vendor], accounts) })));
|
|
480
|
+
}
|
|
481
|
+
/** `tab top [--every <seconds>] [--once]`: the flock on one screen, redrawn every few seconds. */
|
|
482
|
+
async function topCommand(argv) {
|
|
483
|
+
const config = await ensureLogin();
|
|
484
|
+
if (!config)
|
|
485
|
+
return 2;
|
|
486
|
+
const flags = manage.parseFlags(argv);
|
|
487
|
+
const every = Number(flags.opts.every ?? "2");
|
|
488
|
+
if (!Number.isFinite(every) || every < 1 || every > 3600) {
|
|
489
|
+
fail(`tab top [--every <seconds>] [--once]. ${dim("Refresh every 1 to 3600 seconds; the default is 2.")}`);
|
|
490
|
+
return 2;
|
|
491
|
+
}
|
|
492
|
+
const once = flags.args.includes("--once") || "once" in flags.opts;
|
|
493
|
+
try {
|
|
494
|
+
return await runTop({ call: manage.api(config), config, health: () => proxyHealth(config.proxyUrl), pool: () => poolSections(config) }, { every, once });
|
|
495
|
+
}
|
|
496
|
+
catch (err) {
|
|
497
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
498
|
+
return 1;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
471
501
|
async function status() {
|
|
472
502
|
const config = await loadConfig();
|
|
473
503
|
if (!config) {
|
|
@@ -588,7 +618,10 @@ async function poolIdle(config, vendor, agentName) {
|
|
|
588
618
|
const recent = Date.now() - 20_000;
|
|
589
619
|
return !(await flockAccounts(config)).some((a) => a.provider === vendor && new Date(a.lastSeenAt).getTime() > recent);
|
|
590
620
|
}
|
|
621
|
+
/** `FLOCKTAB_TIMING=1`: one line per launch step on stderr, with the seconds since the process began. */
|
|
622
|
+
const timing = process.env.FLOCKTAB_TIMING ? (label) => process.stderr.write(` ⏱ ${(process.uptime()).toFixed(2)}s ${label}\n`) : () => undefined;
|
|
591
623
|
async function runAgent(name, argv) {
|
|
624
|
+
timing("runAgent");
|
|
592
625
|
const observationFlags = takeObserveFlag(argv);
|
|
593
626
|
const { args, noPool, asked, pinned } = takePoolFlags(observationFlags.args);
|
|
594
627
|
if (observationFlags.observe && name !== "claude" && name !== "codex") {
|
|
@@ -598,12 +631,15 @@ async function runAgent(name, argv) {
|
|
|
598
631
|
const config = await ensureLogin();
|
|
599
632
|
if (!config)
|
|
600
633
|
return 2;
|
|
634
|
+
timing("login");
|
|
601
635
|
// Hosted whoami below proves readiness and current Agent kind in one round trip.
|
|
602
636
|
if (config.mode === "self-hosted" && !(await ensureProxy(config)))
|
|
603
637
|
return 1;
|
|
638
|
+
timing("proxy");
|
|
604
639
|
const agent = await resolveAgent(config, { harness: harnessOf(name) });
|
|
605
640
|
if (!agent)
|
|
606
641
|
return 1;
|
|
642
|
+
timing("agent");
|
|
607
643
|
// The Agent's kind decides: a subscription Agent's harness brings its own
|
|
608
644
|
// login (Claude Code its Claude one, Codex ChatGPT, Grok SuperGrok, Kimi
|
|
609
645
|
// Code its Kimi plan), read fresh on every launch so a change in the
|
|
@@ -611,6 +647,7 @@ async function runAgent(name, argv) {
|
|
|
611
647
|
// consumer plan, is metered with the flock's key.
|
|
612
648
|
const vendor = subscriptionVendorFor(name);
|
|
613
649
|
const me = await whoami(config.proxyUrl, { key: agent.key, unlock: config.unlock });
|
|
650
|
+
timing("whoami");
|
|
614
651
|
if (config.mode === "hosted" && "error" in me) {
|
|
615
652
|
fail(me.error);
|
|
616
653
|
return 1;
|
|
@@ -640,6 +677,7 @@ async function runAgent(name, argv) {
|
|
|
640
677
|
const { spec, env } = envFor({ name, proxyUrl: config.proxyUrl, presentedKey: key, auth });
|
|
641
678
|
const poolVendor = auth === "subscription" ? poolVendorFor(name) : undefined;
|
|
642
679
|
const pool = poolVendor ? await loadPool() : undefined;
|
|
680
|
+
timing("pool");
|
|
643
681
|
const members = (poolVendor && pool?.members[poolVendor]) || [];
|
|
644
682
|
const pooled = members.length > 0 && !noPool;
|
|
645
683
|
if (asked && !pooled) {
|
|
@@ -697,6 +735,7 @@ async function runAgent(name, argv) {
|
|
|
697
735
|
});
|
|
698
736
|
return { done, stop: () => void child.kill("SIGTERM") };
|
|
699
737
|
};
|
|
738
|
+
timing("home");
|
|
700
739
|
if (!pooled || !poolVendor || !pool)
|
|
701
740
|
return launch(args, env).done.finally(() => observation?.stop().catch(() => undefined));
|
|
702
741
|
// Codex members each need their own home written before the first launch;
|
|
@@ -713,14 +752,20 @@ async function runAgent(name, argv) {
|
|
|
713
752
|
await prepareCodexHome({ baseDir: member.dir, proxyUrl: config.proxyUrl, presentedKey: key, mode: config.mode, auth, userCodexHome: path.join(member.dir, "no-personal-login") });
|
|
714
753
|
}
|
|
715
754
|
}
|
|
755
|
+
timing("members");
|
|
716
756
|
return runPooled(args, {
|
|
717
757
|
harness: name,
|
|
718
758
|
limits: limitsFor(pool, poolVendor),
|
|
719
759
|
swap: pool.swap,
|
|
720
760
|
...(pinned ? { pinned } : {}),
|
|
721
|
-
standings: () => poolStandings(config, poolVendor, members),
|
|
761
|
+
standings: () => poolStandings(config, poolVendor, members).finally(() => timing("standings")),
|
|
722
762
|
idle: () => poolIdle(config, poolVendor, agent.agentName),
|
|
723
|
-
start: (standing, launchArgs) =>
|
|
763
|
+
start: (standing, launchArgs) => {
|
|
764
|
+
timing("launch");
|
|
765
|
+
// The status line says which pool login this run is on; the harness passes its environment to it.
|
|
766
|
+
const poolEnv = { FLOCKTAB_POOL_LOGIN: standing.member.name, FLOCKTAB_POOL_SIZE: String(members.length) };
|
|
767
|
+
return launch(launchArgs, { ...env, ...memberEnv(poolVendor, standing.member), ...poolEnv });
|
|
768
|
+
},
|
|
724
769
|
wait: (ms, until) => new Promise((resolve) => {
|
|
725
770
|
const timer = setTimeout(() => resolve(true), ms);
|
|
726
771
|
void until.then(() => {
|
|
@@ -944,7 +989,7 @@ async function statuslineInstall(args) {
|
|
|
944
989
|
if (!/^y(es)?$/i.test(go))
|
|
945
990
|
return 1;
|
|
946
991
|
const done = await installStatusLine(vendor, home, process.env.FLOCKTAB_DEV ? "tabdev" : "tab");
|
|
947
|
-
ok(done === "already" ? `${file} already
|
|
992
|
+
ok(done === "already" ? `${file} already runs tab's status line.` : done === "composed" ? `Added after yours: the file is backed up next to it, and your own line still runs first. It shows on the next ${word} launch.` : `Added. It shows on the next ${word} launch.`);
|
|
948
993
|
return 0;
|
|
949
994
|
}
|
|
950
995
|
/** `tab use [claude|codex|grok|kimi]`: pick or change the Agent this folder runs a harness as; bare, the folder's default. */
|
|
@@ -1179,6 +1224,8 @@ async function main(argv) {
|
|
|
1179
1224
|
return poolCommand(rest);
|
|
1180
1225
|
case "status":
|
|
1181
1226
|
return status();
|
|
1227
|
+
case "top":
|
|
1228
|
+
return topCommand(rest);
|
|
1182
1229
|
case "statusline": {
|
|
1183
1230
|
if (rest[0] === "install")
|
|
1184
1231
|
return statuslineInstall(rest.slice(1));
|
|
@@ -1187,7 +1234,9 @@ async function main(argv) {
|
|
|
1187
1234
|
console.log("FlockTab · not logged in");
|
|
1188
1235
|
return 0;
|
|
1189
1236
|
}
|
|
1190
|
-
|
|
1237
|
+
// The harness hands its state on stdin; the person's own status line gets the same, and runs first.
|
|
1238
|
+
const stdin = process.stdin.isTTY ? "" : await new Promise((resolve) => { let d = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (c) => { d += c; }); process.stdin.on("end", () => resolve(d)); process.stdin.on("error", () => resolve(d)); setTimeout(() => resolve(d), 500); });
|
|
1239
|
+
console.log(await composedStatusline(config, rest[0] && harnessOf(rest[0]) !== "any" ? harnessOf(rest[0]) : "any", stdin));
|
|
1191
1240
|
return 0;
|
|
1192
1241
|
}
|
|
1193
1242
|
case "log":
|
package/dist/codex-home.js
CHANGED
|
@@ -56,6 +56,16 @@ export function codexSubscriptionParts(proxyUrl, presentedKey, model) {
|
|
|
56
56
|
`requires_openai_auth = true`,
|
|
57
57
|
`supports_websockets = false`,
|
|
58
58
|
].join("\n"),
|
|
59
|
+
// A session that started on an API-key Agent remembers one of the metered provider ids; on
|
|
60
|
+
// this Agent those names run the login through the tab as well.
|
|
61
|
+
...["hosted", "self-hosted"].map((mode) => [
|
|
62
|
+
`[model_providers.${JSON.stringify(codexProviderId(mode))}]`,
|
|
63
|
+
`name = ${JSON.stringify(codexProviderId(mode))}`,
|
|
64
|
+
`base_url = ${JSON.stringify(codex)}`,
|
|
65
|
+
`wire_api = "responses"`,
|
|
66
|
+
`requires_openai_auth = true`,
|
|
67
|
+
`supports_websockets = false`,
|
|
68
|
+
].join("\n")),
|
|
59
69
|
],
|
|
60
70
|
};
|
|
61
71
|
}
|
|
@@ -111,6 +121,16 @@ export function codexKeyParts(proxyUrl, model, mode = "hosted") {
|
|
|
111
121
|
`env_key = "OPENAI_API_KEY"`,
|
|
112
122
|
`wire_api = "responses"`,
|
|
113
123
|
].join("\n"),
|
|
124
|
+
// A session that ran on a subscription Agent remembers the `flocktab` provider. Here that name
|
|
125
|
+
// is the metered provider under another id, so the session resumes on this Agent's key
|
|
126
|
+
// instead of being refused at the passthrough.
|
|
127
|
+
[
|
|
128
|
+
`[model_providers.${CODEX_TAB_PROVIDER}]`,
|
|
129
|
+
`name = "OpenAI through FlockTab"`,
|
|
130
|
+
`base_url = ${JSON.stringify(`${proxyUrl}/v1`)}`,
|
|
131
|
+
`env_key = "OPENAI_API_KEY"`,
|
|
132
|
+
`wire_api = "responses"`,
|
|
133
|
+
].join("\n"),
|
|
114
134
|
],
|
|
115
135
|
};
|
|
116
136
|
}
|
package/dist/manage.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* slug or a name; omitted, it is this folder's Agent from `.flocktab`.
|
|
6
6
|
*/
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
8
|
+
import { quotaWindowLabel } from "./quota-windows.js";
|
|
8
9
|
import { readProject } from "./project.js";
|
|
9
10
|
import { saveConfig } from "./config.js";
|
|
10
11
|
import { ConsoleApiError } from "./console-api.js";
|
|
@@ -219,7 +220,7 @@ export async function accounts(config, flags) {
|
|
|
219
220
|
a.monthlyCents ? money(a.monthlyCents) : dim("-"),
|
|
220
221
|
money(a.listCents),
|
|
221
222
|
String(a.calls),
|
|
222
|
-
a.quota.length === 0 ? dim("-") : a.quota.map((q) => `${q.window} ${left(q)}`).join(" "),
|
|
223
|
+
a.quota.length === 0 ? dim("-") : a.quota.map((q) => `${quotaWindowLabel(q.window)} ${left(q)}`).join(" "),
|
|
223
224
|
a.agents.join(", "),
|
|
224
225
|
]), { right: [3, 4, 5] }) + `\n${dim("Set a price or label in the console, Control, Subscriptions.")}`;
|
|
225
226
|
});
|
package/dist/pool-view.js
CHANGED
|
@@ -3,23 +3,22 @@
|
|
|
3
3
|
* used the vendor last said it is (a bar, then each window with when it
|
|
4
4
|
* resets), and an arrow on the login the next launch would run as.
|
|
5
5
|
*/
|
|
6
|
-
import { isOver, pickMember
|
|
6
|
+
import { isOver, pickMember } from "./pool.js";
|
|
7
|
+
import { compareQuotaWindows, quotaWindowLabel } from "./quota-windows.js";
|
|
7
8
|
import { bold, dim, green, red, table, width, yellow } from "./ui.js";
|
|
8
9
|
const BAR = 10;
|
|
9
|
-
/** "
|
|
10
|
+
/** "0h 12m", "2h 10m", "3d 04h": how long until a window resets, always six characters so columns hold. */
|
|
10
11
|
export function until(resetsAt, now) {
|
|
11
12
|
if (!resetsAt)
|
|
12
13
|
return undefined;
|
|
13
14
|
const ms = new Date(resetsAt).getTime() - now.getTime();
|
|
14
15
|
if (!Number.isFinite(ms) || ms <= 0)
|
|
15
16
|
return undefined;
|
|
16
|
-
const minutes = Math.round(ms / 60_000);
|
|
17
|
-
if (minutes < 60)
|
|
18
|
-
return `${Math.max(1, minutes)}m`;
|
|
17
|
+
const minutes = Math.max(1, Math.round(ms / 60_000));
|
|
19
18
|
const hours = Math.floor(minutes / 60);
|
|
20
19
|
if (hours < 24)
|
|
21
20
|
return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
|
|
22
|
-
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
|
|
21
|
+
return `${Math.floor(hours / 24)}d ${String(hours % 24).padStart(2, "0")}h`;
|
|
23
22
|
}
|
|
24
23
|
function paint(pct, at) {
|
|
25
24
|
return pct >= at ? red : pct >= at * 0.75 ? yellow : green;
|
|
@@ -37,7 +36,7 @@ export function planName(plan) {
|
|
|
37
36
|
return plan.replace(/\b[a-z]/g, (c) => c.toUpperCase()).replace(/\b(\d+)X\b/g, "$1x");
|
|
38
37
|
}
|
|
39
38
|
function windowsLine(windows, limits, now) {
|
|
40
|
-
const sorted = [...(windows ?? [])].sort((a, b) =>
|
|
39
|
+
const sorted = [...(windows ?? [])].sort((a, b) => compareQuotaWindows(a.window, b.window));
|
|
41
40
|
const live = sorted.filter((w) => !w.resetsAt || new Date(w.resetsAt).getTime() > now.getTime());
|
|
42
41
|
if (!windows || windows.length === 0)
|
|
43
42
|
return dim("no call through FlockTab yet");
|
|
@@ -48,7 +47,7 @@ function windowsLine(windows, limits, now) {
|
|
|
48
47
|
const left = until(w.resetsAt, now);
|
|
49
48
|
// The short window is judged by the threshold, the long one by its guard.
|
|
50
49
|
const at = sorted.length > 1 && w === sorted.at(-1) ? limits.guard : limits.at;
|
|
51
|
-
return `${w.window} ${paint(w.usedPct, at)(`${Math.round(w.usedPct)}%`)}${left ? dim(` resets in ${left}`) : ""}`;
|
|
50
|
+
return `${quotaWindowLabel(w.window)} ${paint(w.usedPct, at)(`${Math.round(w.usedPct)}%`)}${left ? dim(` resets in ${left}`) : ""}`;
|
|
52
51
|
})
|
|
53
52
|
.join(dim(" · "));
|
|
54
53
|
}
|
package/dist/pool.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* conversation as that other login.
|
|
10
10
|
*/
|
|
11
11
|
import { chmod, mkdir, readdir, readFile, readlink, symlink, unlink, writeFile, lstat } from "node:fs/promises";
|
|
12
|
+
import { compareQuotaWindows, quotaWindowIsLong, quotaWindowMinutes } from "./quota-windows.js";
|
|
12
13
|
import { homedir } from "node:os";
|
|
13
14
|
import path from "node:path";
|
|
14
15
|
import { moveInto } from "./codex-home.js";
|
|
@@ -172,8 +173,7 @@ export async function prepareSharedSessions(vendor, member, env = process.env, u
|
|
|
172
173
|
}
|
|
173
174
|
/** `5h` is 300 minutes, `7d` 10080; a name that is not a length sorts last. */
|
|
174
175
|
export function windowMinutes(window) {
|
|
175
|
-
|
|
176
|
-
return m ? Number(m[1]) * (m[2] === "d" ? 1440 : m[2] === "h" ? 60 : 1) : Number.MAX_SAFE_INTEGER;
|
|
176
|
+
return quotaWindowMinutes(window);
|
|
177
177
|
}
|
|
178
178
|
/**
|
|
179
179
|
* How used an account is. The short window (Claude's 5 hours, Codex's
|
|
@@ -184,11 +184,14 @@ export function windowMinutes(window) {
|
|
|
184
184
|
export function usage(quota, now = new Date()) {
|
|
185
185
|
if (quota.length === 0)
|
|
186
186
|
return {};
|
|
187
|
-
const sorted = [...quota].sort((x, y) =>
|
|
187
|
+
const sorted = [...quota].sort((x, y) => compareQuotaWindows(x.window, y.window));
|
|
188
188
|
const pct = (w) => (!w.resetsAt || new Date(w.resetsAt).getTime() > now.getTime() ? w.usedPct : 0);
|
|
189
189
|
const short = sorted[0];
|
|
190
|
-
|
|
191
|
-
|
|
190
|
+
// The week is whichever weekly window is fullest: Claude Max meters Fable on its own (7d_oi)
|
|
191
|
+
// beside the general 7d, and a login out of Fable is out for a Fable run.
|
|
192
|
+
const longs = sorted.filter((w) => quotaWindowIsLong(w.window));
|
|
193
|
+
const longUsed = longs.length > 0 ? Math.max(...longs.map(pct)) : undefined;
|
|
194
|
+
return { used: pct(short), ...(longUsed !== undefined ? { longUsed } : {}) };
|
|
192
195
|
}
|
|
193
196
|
const room = (s) => s.used ?? 0;
|
|
194
197
|
const longRoom = (s) => s.longUsed ?? 0;
|
package/dist/proxy-bin.js
CHANGED
|
@@ -13,7 +13,7 @@ import { createRequire } from "node:module";
|
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import { configDir } from "./config.js";
|
|
15
15
|
/** The proxy release `tab up` fetches. Bump with each proxy tag. */
|
|
16
|
-
export const PROXY_VERSION = "0.1.
|
|
16
|
+
export const PROXY_VERSION = "0.1.17";
|
|
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,37 @@
|
|
|
1
|
+
// GENERATED from packages/shared/src/quota-windows.ts by scripts/sync-docs.mjs. Edit that file, not this one.
|
|
2
|
+
/**
|
|
3
|
+
* A vendor's quota windows, as the wire names them: "5h", "7d" (Claude Max,
|
|
4
|
+
* both models), "7d_oi" (Claude Max, the weekly window Fable has of its own;
|
|
5
|
+
* "oi" is the vendor's tag for it), "5h"/"7d" (ChatGPT). A window's name is
|
|
6
|
+
* its length, then an optional `_<tag>` for a model-specific one.
|
|
7
|
+
*/
|
|
8
|
+
/** Which models a tagged window belongs to, in the person's words. */
|
|
9
|
+
const TAGS = {
|
|
10
|
+
oi: "Fable",
|
|
11
|
+
};
|
|
12
|
+
export function quotaWindowMinutes(window) {
|
|
13
|
+
const m = /^(\d+)([mhd])(?:_[a-z0-9]+)?$/.exec(window);
|
|
14
|
+
return m ? Number(m[1]) * (m[2] === "d" ? 1440 : m[2] === "h" ? 60 : 1) : Number.MAX_SAFE_INTEGER;
|
|
15
|
+
}
|
|
16
|
+
/** "Fable 7d" for "7d_oi"; a plain window is its own label. */
|
|
17
|
+
export function quotaWindowLabel(window) {
|
|
18
|
+
const m = /^(\d+[mhd])_([a-z0-9]+)$/.exec(window);
|
|
19
|
+
if (!m)
|
|
20
|
+
return window;
|
|
21
|
+
const tag = TAGS[m[2]] ?? m[2];
|
|
22
|
+
return `${tag} ${m[1]}`;
|
|
23
|
+
}
|
|
24
|
+
/** The model a tagged window is for ("Fable"), or undefined for a window every model shares. */
|
|
25
|
+
export function quotaWindowModel(window) {
|
|
26
|
+
const m = /^\d+[mhd]_([a-z0-9]+)$/.exec(window);
|
|
27
|
+
return m ? (TAGS[m[1]] ?? m[1]) : undefined;
|
|
28
|
+
}
|
|
29
|
+
/** A week-long window (or longer): the one that only matters once it is nearly spent. */
|
|
30
|
+
export function quotaWindowIsLong(window) {
|
|
31
|
+
const minutes = quotaWindowMinutes(window);
|
|
32
|
+
return minutes !== Number.MAX_SAFE_INTEGER && minutes > 1440;
|
|
33
|
+
}
|
|
34
|
+
/** Sort order for windows: shortest first; a model's own window after the general one of the same length. */
|
|
35
|
+
export function compareQuotaWindows(a, b) {
|
|
36
|
+
return quotaWindowMinutes(a) - quotaWindowMinutes(b) || (quotaWindowModel(a) ? 1 : 0) - (quotaWindowModel(b) ? 1 : 0);
|
|
37
|
+
}
|
package/dist/statusline.js
CHANGED
|
@@ -5,39 +5,51 @@
|
|
|
5
5
|
* from the console with a short cache, so a status bar that polls every
|
|
6
6
|
* few seconds costs one request a minute.
|
|
7
7
|
*/
|
|
8
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
8
|
+
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
9
|
+
import { compareQuotaWindows, quotaWindowLabel } from "./quota-windows.js";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
9
12
|
import path from "node:path";
|
|
10
13
|
import { configDir } from "./config.js";
|
|
11
14
|
import { readProject } from "./project.js";
|
|
12
15
|
import { api } from "./manage.js";
|
|
13
16
|
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
17
|
function money(cents) {
|
|
19
18
|
const n = BigInt(cents);
|
|
20
19
|
return `$${n / 100n}.${(n % 100n).toString().padStart(2, "0")}`;
|
|
21
20
|
}
|
|
22
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* "5h 36% · 7d 29%", shortest window first; nothing before the first call. A vendor that
|
|
23
|
+
* names the login but reports no usage on the wire (SuperGrok, Kimi) gets the login and says so.
|
|
24
|
+
*/
|
|
23
25
|
export function planLine(accounts, now = new Date()) {
|
|
24
26
|
// The account most recently seen with quota: the login in use.
|
|
25
27
|
const withQuota = accounts.filter((a) => a.quota.length > 0);
|
|
26
|
-
const account = withQuota[0];
|
|
28
|
+
const account = withQuota[0] ?? accounts[0];
|
|
27
29
|
if (!account)
|
|
28
30
|
return undefined;
|
|
31
|
+
if (account.quota.length === 0)
|
|
32
|
+
return `${account.email ?? `account ${account.externalId.slice(0, 8)}`} · usage not reported by the vendor`;
|
|
29
33
|
const windows = [...account.quota]
|
|
30
34
|
.filter((w) => !w.resetsAt || new Date(w.resetsAt).getTime() > now.getTime())
|
|
31
|
-
.sort((a, b) =>
|
|
35
|
+
.sort((a, b) => compareQuotaWindows(a.window, b.window));
|
|
32
36
|
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(" · ")}`;
|
|
37
|
+
return `${who} · ${windows.length === 0 ? "windows reset" : windows.map((w) => `${quotaWindowLabel(w.window)} ${Math.round(w.usedPct)}%`).join(" · ")}`;
|
|
34
38
|
}
|
|
35
|
-
/** The line itself, from what the console said. */
|
|
36
|
-
export function statusText(info, now = new Date()) {
|
|
39
|
+
/** The line itself, from what the console said, and the pool login this run is on when there is one. */
|
|
40
|
+
export function statusText(info, now = new Date(), pool) {
|
|
37
41
|
const { tab } = info;
|
|
38
42
|
const closed = tab.state !== "open";
|
|
39
43
|
const head = `${tab.name}${closed ? " CLOSED" : ""}`;
|
|
40
44
|
if (tab.kind === "subscription") {
|
|
45
|
+
if (pool) {
|
|
46
|
+
// The pool picked this login for the run: its own windows, not another login's that the
|
|
47
|
+
// console saw last on this Agent; the login is said once.
|
|
48
|
+
const mine = (info.accounts ?? []).filter((a) => a.email === pool.login || a.externalId === pool.login);
|
|
49
|
+
const plan = planLine(mine, now);
|
|
50
|
+
const windows = plan && plan.startsWith(`${pool.login} · `) ? plan.slice(pool.login.length + 3) : plan;
|
|
51
|
+
return `${head} · pool ${pool.login} (${pool.size}) · ${windows ?? "no call yet on this login"}`;
|
|
52
|
+
}
|
|
41
53
|
return `${head} · ${planLine(info.accounts ?? [], now) ?? "no call yet"}`;
|
|
42
54
|
}
|
|
43
55
|
const spent = BigInt(tab.spentCents);
|
|
@@ -45,13 +57,22 @@ export function statusText(info, now = new Date()) {
|
|
|
45
57
|
const pct = cap > 0n ? Number((spent * 100n) / cap) : 0;
|
|
46
58
|
return `${head} · ${money(tab.spentCents)} of ${money(tab.capCents)} / ${tab.window} (${pct}%)`;
|
|
47
59
|
}
|
|
60
|
+
/** The pool login a pooled launch put in the harness's environment, which it passes on to us. */
|
|
61
|
+
export function poolFromEnv(env = process.env) {
|
|
62
|
+
const login = env.FLOCKTAB_POOL_LOGIN?.trim();
|
|
63
|
+
if (!login)
|
|
64
|
+
return undefined;
|
|
65
|
+
const size = Number(env.FLOCKTAB_POOL_SIZE);
|
|
66
|
+
return { login, size: Number.isFinite(size) && size > 0 ? size : 1 };
|
|
67
|
+
}
|
|
48
68
|
/** The tab of this folder's Agent for `harness`, from a one-minute cache, else the console. */
|
|
49
69
|
export async function statusline(config, harness, cwd = process.cwd(), env = process.env) {
|
|
50
70
|
const project = await readProject(cwd, harness);
|
|
51
71
|
if (!project)
|
|
52
72
|
return "FlockTab · no Agent here (tab use)";
|
|
53
73
|
const login = config.agents?.[project.agent];
|
|
54
|
-
const
|
|
74
|
+
const pool = poolFromEnv(env);
|
|
75
|
+
const cacheFile = path.join(configDir(env), "cache", `status-${project.agent}${pool ? `-${pool.login.replace(/[^a-z0-9]+/gi, "_")}` : ""}.json`);
|
|
55
76
|
try {
|
|
56
77
|
const cached = JSON.parse(await readFile(cacheFile, "utf8"));
|
|
57
78
|
if (Date.now() - cached.at < CACHE_MS)
|
|
@@ -63,7 +84,7 @@ export async function statusline(config, harness, cwd = process.cwd(), env = pro
|
|
|
63
84
|
let text;
|
|
64
85
|
try {
|
|
65
86
|
const info = await api(config)("GET", `/api/cli/tabs/${encodeURIComponent(project.agent)}`);
|
|
66
|
-
text = `FlockTab · ${statusText(info)}`;
|
|
87
|
+
text = `FlockTab · ${statusText(info, new Date(), poolFromEnv(env))}`;
|
|
67
88
|
}
|
|
68
89
|
catch {
|
|
69
90
|
return `FlockTab · ${login?.agentName ?? project.agent} · console unreachable`;
|
|
@@ -77,6 +98,86 @@ export async function statusline(config, harness, cwd = process.cwd(), env = pro
|
|
|
77
98
|
}
|
|
78
99
|
return text;
|
|
79
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* The status line command the person had before tab, if any. Their own files are read, never
|
|
103
|
+
* changed: Claude Code's `settings.local.json` then `settings.json`, Grok Build's `config.toml`,
|
|
104
|
+
* Kimi Code's `tui.toml`. When `tab statusline install` took the slot in a Grok or Kimi config,
|
|
105
|
+
* the original command it set aside under `<tab home>/statusline/` is what counts.
|
|
106
|
+
*/
|
|
107
|
+
export async function theirStatusCommand(harness, env = process.env) {
|
|
108
|
+
const ours = (cmd) => /\btab(dev)? statusline\b/.test(cmd);
|
|
109
|
+
try {
|
|
110
|
+
const saved = (await readFile(path.join(configDir(env), "statusline", `${harness}.original`), "utf8")).trim();
|
|
111
|
+
if (saved && !ours(saved))
|
|
112
|
+
return saved;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Nothing set aside.
|
|
116
|
+
}
|
|
117
|
+
const home = env.HOME || homedir();
|
|
118
|
+
if (harness === "claude") {
|
|
119
|
+
for (const file of ["settings.local.json", "settings.json"]) {
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(await readFile(path.join(home, ".claude", file), "utf8"));
|
|
122
|
+
const cmd = parsed.statusLine?.type === "command" ? parsed.statusLine.command?.trim() : undefined;
|
|
123
|
+
if (cmd && !ours(cmd))
|
|
124
|
+
return cmd;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// No such file, or not JSON.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
const file = harness === "grok" ? path.join(home, ".grok", "config.toml") : harness === "kimi" ? path.join(home, ".kimi-code", "tui.toml") : undefined;
|
|
133
|
+
if (!file)
|
|
134
|
+
return undefined;
|
|
135
|
+
try {
|
|
136
|
+
const cmd = statusCommandIn(await readFile(file, "utf8"), harness === "grok" ? "[ui.status_line]" : "[status_line]");
|
|
137
|
+
return cmd && !ours(cmd) ? cmd : undefined;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** The `command = "…"` of one TOML table, read by line so no TOML parser is needed. */
|
|
144
|
+
export function statusCommandIn(toml, marker) {
|
|
145
|
+
const lines = toml.split("\n");
|
|
146
|
+
const start = lines.findIndex((l) => l.trim() === marker);
|
|
147
|
+
if (start < 0)
|
|
148
|
+
return undefined;
|
|
149
|
+
for (const line of lines.slice(start + 1)) {
|
|
150
|
+
if (/^\s*\[/.test(line))
|
|
151
|
+
break;
|
|
152
|
+
const m = /^\s*command\s*=\s*"((?:[^"\\]|\\.)*)"/.exec(line);
|
|
153
|
+
if (m)
|
|
154
|
+
return m[1].replace(/\\"/g, '"');
|
|
155
|
+
}
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
/** Run the person's own status command with the same stdin the harness gave us; all its lines, or nothing. */
|
|
159
|
+
export function runTheirs(command, stdin, env = process.env) {
|
|
160
|
+
return new Promise((resolve) => {
|
|
161
|
+
const child = spawn("/bin/sh", ["-c", command], { env, stdio: ["pipe", "pipe", "ignore"] });
|
|
162
|
+
let out = "";
|
|
163
|
+
const timer = setTimeout(() => { child.kill("SIGKILL"); resolve(undefined); }, 4000);
|
|
164
|
+
child.stdout.on("data", (d) => { out += d.toString(); });
|
|
165
|
+
child.on("error", () => { clearTimeout(timer); resolve(undefined); });
|
|
166
|
+
child.on("close", () => { clearTimeout(timer); const text = out.replace(/\s+$/, ""); resolve(text.trim() ? text : undefined); });
|
|
167
|
+
// A command that exits before reading stdin (echo, true) closes the pipe.
|
|
168
|
+
// That EPIPE is on stdin, not the child, and must not escape the status line.
|
|
169
|
+
child.stdin.on("error", () => { });
|
|
170
|
+
child.stdin.end(stdin);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/** tab's line added after the person's own (every line of it), never instead of it. */
|
|
174
|
+
export async function composedStatusline(config, harness, stdin, env = process.env) {
|
|
175
|
+
const mine = statusline(config, harness, process.cwd(), env);
|
|
176
|
+
const theirs = await theirStatusCommand(harness === "any" ? "claude" : harness, env);
|
|
177
|
+
const output = theirs ? await runTheirs(theirs, stdin, env) : undefined;
|
|
178
|
+
const ours = await mine;
|
|
179
|
+
return output ? `${output}\n${ours}` : ours;
|
|
180
|
+
}
|
|
80
181
|
/**
|
|
81
182
|
* How each harness is told to run `tab statusline`. Claude Code takes it on
|
|
82
183
|
* the command line, so every `tab claude` has it. Grok Build and Kimi Code
|
|
@@ -95,10 +196,15 @@ export function grokStatusToml(tabBin = "tab") {
|
|
|
95
196
|
export function kimiStatusToml(tabBin = "tab") {
|
|
96
197
|
return `\n[status_line]\ncommand = "${tabBin} statusline kimi"\n`;
|
|
97
198
|
}
|
|
98
|
-
/**
|
|
99
|
-
|
|
199
|
+
/**
|
|
200
|
+
* Put tab's status line into a harness home. A status line already there is not lost: the file is
|
|
201
|
+
* copied to `<file>.bak-<date>` first and the original command is set aside under
|
|
202
|
+
* `<tab home>/statusline/<harness>.original`, which `tab statusline` runs and adds its own line to.
|
|
203
|
+
*/
|
|
204
|
+
export async function installStatusLine(vendor, home, tabBin = "tab", tabHome = configDir()) {
|
|
100
205
|
const file = vendor === "xai" ? path.join(home, "config.toml") : path.join(home, "tui.toml");
|
|
101
206
|
const marker = vendor === "xai" ? "[ui.status_line]" : "[status_line]";
|
|
207
|
+
const harness = vendor === "xai" ? "grok" : "kimi";
|
|
102
208
|
let current = "";
|
|
103
209
|
try {
|
|
104
210
|
current = await readFile(file, "utf8");
|
|
@@ -106,9 +212,25 @@ export async function installStatusLine(vendor, home, tabBin = "tab") {
|
|
|
106
212
|
catch {
|
|
107
213
|
// No file yet.
|
|
108
214
|
}
|
|
109
|
-
if (current.includes(marker))
|
|
110
|
-
return "already";
|
|
111
215
|
await mkdir(home, { recursive: true, mode: 0o700 });
|
|
112
|
-
|
|
113
|
-
|
|
216
|
+
if (!current.includes(marker)) {
|
|
217
|
+
await writeFile(file, `${current.replace(/\s*$/, "\n")}${vendor === "xai" ? grokStatusToml(tabBin) : kimiStatusToml(tabBin)}`);
|
|
218
|
+
return "written";
|
|
219
|
+
}
|
|
220
|
+
const existing = statusCommandIn(current, marker);
|
|
221
|
+
if (!existing || /\btab(dev)? statusline\b/.test(existing))
|
|
222
|
+
return "already";
|
|
223
|
+
// Theirs stays: backed up whole, its command set aside, and run first by tab's line.
|
|
224
|
+
const stamp = new Date().toISOString().slice(0, 10);
|
|
225
|
+
await copyFile(file, `${file}.bak-${stamp}`);
|
|
226
|
+
await mkdir(path.join(tabHome, "statusline"), { recursive: true, mode: 0o700 });
|
|
227
|
+
await writeFile(path.join(tabHome, "statusline", `${harness}.original`), `${existing}\n`, { mode: 0o600 });
|
|
228
|
+
const lines = current.split("\n");
|
|
229
|
+
const start = lines.findIndex((l) => l.trim() === marker);
|
|
230
|
+
for (let i = start + 1; i < lines.length && !/^\s*\[/.test(lines[i]); i++) {
|
|
231
|
+
if (/^\s*command\s*=/.test(lines[i]))
|
|
232
|
+
lines[i] = `command = "${tabBin} statusline ${harness}"`;
|
|
233
|
+
}
|
|
234
|
+
await writeFile(file, lines.join("\n"));
|
|
235
|
+
return "composed";
|
|
114
236
|
}
|
package/dist/tab-docs.js
CHANGED
|
@@ -259,7 +259,7 @@ export const TAB_COMMANDS = [
|
|
|
259
259
|
{ cmd: "tab pool add claude me@example.com", what: "sign a Claude login in; the email is prefilled" },
|
|
260
260
|
{ cmd: "tab pool", what: "every login: plan, usage bar, each window and its reset, and which runs next" },
|
|
261
261
|
{ cmd: "tab pool at claude 70", what: "Claude logins give way at 70% of the 5-hour window" },
|
|
262
|
-
{ cmd: "tab pool at 7d 90", what: "any login gives way once 90% of its week is used" },
|
|
262
|
+
{ cmd: "tab pool at 7d 90", what: "any login gives way once 90% of its week is used (for Claude, the general week or Fable's own, whichever is fuller)" },
|
|
263
263
|
{ cmd: "tab claude --as me@example.com", what: "pin one login for this run" },
|
|
264
264
|
],
|
|
265
265
|
see: ["accounts", "claude"],
|
|
@@ -272,6 +272,22 @@ export const TAB_COMMANDS = [
|
|
|
272
272
|
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."],
|
|
273
273
|
see: ["login", "use", "up"],
|
|
274
274
|
},
|
|
275
|
+
{
|
|
276
|
+
name: "top",
|
|
277
|
+
overview: "top [--every <seconds>] [--once]",
|
|
278
|
+
group: "watch",
|
|
279
|
+
usage: ["top", "top --every 5", "top --once"],
|
|
280
|
+
summary: "The flock on one screen, live: Agents, subscriptions and their windows, the pool, outside spend, the ledger.",
|
|
281
|
+
details: [
|
|
282
|
+
"An always-on dashboard in the terminal, redrawn every 2 seconds (--every sets it). At the top: the flock, its plan, Agents used of the plan's limit, whether the proxy answers. Then who is working right now, calls per minute and this window's spend with their trend; every Agent with its harness, project, spent of cap, calls per minute, calls blocked and its last decision; every subscription login with each of its windows (for Claude, the general 5h and 7d windows and Fable 7d, Fable's own week) and how long until each resets, with the pool's next pick marked; outside spend by project over the last 30 days with the unattributed share; and the ledger's latest decisions as they happen.",
|
|
283
|
+
"q, Escape or Ctrl-C leaves and the terminal is as it was; r redraws at once. --once prints one screen and returns, for a pipe or a quick look. A panel the console cannot answer on a tick is a line at the bottom, and the last good read stays on screen. Everything comes from the console the other commands read, so nothing here disagrees with tab list, tab pool, tab accounts or the Console.",
|
|
284
|
+
],
|
|
285
|
+
options: [
|
|
286
|
+
{ flag: "--every <seconds>", what: "how often to redraw (default 2, 1 to 3600)" },
|
|
287
|
+
{ flag: "--once", what: "one screen, then return" },
|
|
288
|
+
],
|
|
289
|
+
see: ["list", "pool", "accounts", "live", "status"],
|
|
290
|
+
},
|
|
275
291
|
{
|
|
276
292
|
name: "statusline",
|
|
277
293
|
overview: "statusline [harness]",
|
|
@@ -279,8 +295,8 @@ export const TAB_COMMANDS = [
|
|
|
279
295
|
usage: ["statusline [claude|codex|grok|kimi]", "statusline install grok|kimi"],
|
|
280
296
|
summary: "One line for a harness's status bar: the Agent and its tab, or the plan's windows.",
|
|
281
297
|
details: [
|
|
282
|
-
"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.",
|
|
283
|
-
"Claude Code
|
|
298
|
+
"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%). On a pooled run it names the pool login the run is on and how many logins the pool has (pool me@x.com (4)). Read from the console at most once a minute, so a status bar that refreshes every few seconds costs nothing.",
|
|
299
|
+
"Your own status line stays. tab runs it first (Claude Code's from settings.local.json or settings.json, Grok's and Kimi's from their config, with the same stdin the harness gives) and adds its own line after it, every line of yours kept. Claude Code shows the pair 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 puts it in your own config after asking: the file is backed up next to it first and a command already there is set aside and keeps running first. Codex takes no command and already shows its own limits.",
|
|
284
300
|
],
|
|
285
301
|
examples: [
|
|
286
302
|
{ cmd: "tab statusline claude", what: "the line, as Claude Code's status bar would show it" },
|
|
@@ -343,15 +359,18 @@ export const TAB_COMMANDS = [
|
|
|
343
359
|
group: "watch",
|
|
344
360
|
usage: ["outside"],
|
|
345
361
|
summary: "The outside-spend connections and their totals per project.",
|
|
346
|
-
details: [
|
|
362
|
+
details: [
|
|
363
|
+
"Connections are made in the console (Control, Outside spend): provider admin APIs and cloud billing. They answer whether a dollar went through a tab or around it.",
|
|
364
|
+
"The console pulls every connected source on its own, once an hour; the last sync shows per connection.",
|
|
365
|
+
],
|
|
347
366
|
see: ["spend", "project"],
|
|
348
367
|
},
|
|
349
368
|
{
|
|
350
369
|
name: "live",
|
|
351
370
|
group: "watch",
|
|
352
371
|
usage: ["live"],
|
|
353
|
-
summary: "Who is
|
|
354
|
-
details: ["Each Agent's pulse (
|
|
372
|
+
summary: "Who is active right now.",
|
|
373
|
+
details: ["Each Agent's pulse (active, quiet, idle, stopped), calls per minute and last decision. Active means a call was admitted in the last minute. Quiet means calls in the last five minutes, but not the last minute."],
|
|
355
374
|
options: [{ flag: "--watch N", what: "refresh every N seconds" }],
|
|
356
375
|
},
|
|
357
376
|
{
|
|
@@ -427,7 +446,7 @@ export const TAB_GLOSSARY = [
|
|
|
427
446
|
{ term: "Control plane", meaning: "The part of flocktab.com a self-hosted proxy settles through: identity, reserve, commit, refund, and for subscriptions the account and its quota." },
|
|
428
447
|
{ term: "Passthrough", meaning: "How a Subscription Agent's calls travel: /t/<tab key>/<vendor>/... on either proxy. The agent's own login is forwarded untouched; FlockTab adds nothing to the request and keeps no copy of the login." },
|
|
429
448
|
{ term: "Account", also: ["vendor account", "subscription"], meaning: "One plan login (a Claude Max, ChatGPT, SuperGrok or Kimi account) as the vendor named it on the wire. Discovered on first sight, with its plan tier, seat price and reported usage. Nobody types one in." },
|
|
430
|
-
{ term: "Quota window", also: ["5h", "7d"], meaning: "A vendor's own usage limit over a period, reported on every reply: Claude's 5-hour and 7-day windows, Codex's primary and secondary. FlockTab keeps the latest figure and its reset time per account." },
|
|
449
|
+
{ term: "Quota window", also: ["5h", "7d"], meaning: "A vendor's own usage limit over a period, reported on every reply: Claude's 5-hour and 7-day windows plus Fable's own 7-day window (shown as Fable 7d), Codex's primary and secondary. FlockTab keeps the latest figure and its reset time per account." },
|
|
431
450
|
{ 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." },
|
|
432
451
|
{ 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." },
|
|
433
452
|
{ 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." },
|
package/dist/top.js
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import { isOver, pickMember } from "./pool.js";
|
|
2
|
+
import { planName, until, usageBar } from "./pool-view.js";
|
|
3
|
+
import { compareQuotaWindows, quotaWindowIsLong, quotaWindowLabel } from "./quota-windows.js";
|
|
4
|
+
import { bold, cyan, dim, green, magenta, money, red, stripAnsi, width, yellow } from "./ui.js";
|
|
5
|
+
export const HISTORY = 40;
|
|
6
|
+
const SPARK = "▁▂▃▄▅▆▇█";
|
|
7
|
+
/** A sparkline of the last `n` values, scaled to their own maximum. */
|
|
8
|
+
export function sparkline(values, n = 24) {
|
|
9
|
+
const tail = values.slice(-n);
|
|
10
|
+
if (tail.length === 0)
|
|
11
|
+
return "";
|
|
12
|
+
const max = Math.max(...tail);
|
|
13
|
+
if (max <= 0)
|
|
14
|
+
return dim(SPARK[0].repeat(tail.length));
|
|
15
|
+
return tail.map((v) => SPARK[Math.min(SPARK.length - 1, Math.round((v / max) * (SPARK.length - 1)))]).join("");
|
|
16
|
+
}
|
|
17
|
+
/** "3s", "2m", "1h 05m", "3d": how long ago. */
|
|
18
|
+
export function ago(iso, now) {
|
|
19
|
+
if (!iso)
|
|
20
|
+
return "-";
|
|
21
|
+
const ms = now.getTime() - new Date(iso).getTime();
|
|
22
|
+
if (!Number.isFinite(ms) || ms < 0)
|
|
23
|
+
return "now";
|
|
24
|
+
const s = Math.round(ms / 1000);
|
|
25
|
+
if (s < 60)
|
|
26
|
+
return `${s}s`;
|
|
27
|
+
const m = Math.floor(s / 60);
|
|
28
|
+
if (m < 60)
|
|
29
|
+
return `${m}m`;
|
|
30
|
+
const h = Math.floor(m / 60);
|
|
31
|
+
if (h < 24)
|
|
32
|
+
return `${h}h ${String(m % 60).padStart(2, "0")}m`;
|
|
33
|
+
return `${Math.floor(h / 24)}d`;
|
|
34
|
+
}
|
|
35
|
+
/** Cut a coloured line to `cols` visible characters, keeping escape sequences intact and closing them. */
|
|
36
|
+
export function clip(line, cols) {
|
|
37
|
+
if (width(line) <= cols)
|
|
38
|
+
return line;
|
|
39
|
+
let out = "";
|
|
40
|
+
let seen = 0;
|
|
41
|
+
for (let i = 0; i < line.length;) {
|
|
42
|
+
if (line[i] === "\u001b") {
|
|
43
|
+
const end = line.indexOf("m", i);
|
|
44
|
+
if (end === -1)
|
|
45
|
+
break;
|
|
46
|
+
out += line.slice(i, end + 1);
|
|
47
|
+
i = end + 1;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const ch = line.codePointAt(i);
|
|
51
|
+
const chStr = String.fromCodePoint(ch);
|
|
52
|
+
const w = width(chStr);
|
|
53
|
+
if (seen + w > cols - 1)
|
|
54
|
+
break;
|
|
55
|
+
out += chStr;
|
|
56
|
+
seen += w;
|
|
57
|
+
i += chStr.length;
|
|
58
|
+
}
|
|
59
|
+
// Close an open colour only when one was opened; with colour off there is nothing to close.
|
|
60
|
+
return `${out}${out.includes("\u001b[") ? "\u001b[0m" : ""}${dim("…")}`;
|
|
61
|
+
}
|
|
62
|
+
function pad(text, cols, right = false) {
|
|
63
|
+
const gap = Math.max(0, cols - width(text));
|
|
64
|
+
return right ? `${" ".repeat(gap)}${text}` : `${text}${" ".repeat(gap)}`;
|
|
65
|
+
}
|
|
66
|
+
function pulseMark(pulse) {
|
|
67
|
+
switch (pulse) {
|
|
68
|
+
case "working":
|
|
69
|
+
return green("●");
|
|
70
|
+
case "recent":
|
|
71
|
+
return cyan("◐");
|
|
72
|
+
case "idle":
|
|
73
|
+
return dim("○");
|
|
74
|
+
default:
|
|
75
|
+
return red("■");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** ALLOWED and SETTLED green, SHADOW (a subscription call priced at list) cyan, PENDING yellow, the rest red. */
|
|
79
|
+
function decisionMark(decision) {
|
|
80
|
+
const d = decision.toUpperCase();
|
|
81
|
+
if (d.startsWith("ALLOW") || d === "OK" || d === "SETTLED")
|
|
82
|
+
return green(d);
|
|
83
|
+
if (d === "SHADOW")
|
|
84
|
+
return cyan(d);
|
|
85
|
+
if (d.startsWith("PEND"))
|
|
86
|
+
return yellow(d);
|
|
87
|
+
if (d.startsWith("FLAG"))
|
|
88
|
+
return magenta(d);
|
|
89
|
+
if (d === "REFUNDED")
|
|
90
|
+
return dim(d);
|
|
91
|
+
return red(d);
|
|
92
|
+
}
|
|
93
|
+
const failingConnector = (c) => c.status !== "active";
|
|
94
|
+
function pctOf(spent, cap) {
|
|
95
|
+
const c = Number(cap);
|
|
96
|
+
if (!Number.isFinite(c) || c <= 0)
|
|
97
|
+
return undefined;
|
|
98
|
+
return Math.min(999, (Number(spent) / c) * 100);
|
|
99
|
+
}
|
|
100
|
+
function tone(pct) {
|
|
101
|
+
if (pct === undefined)
|
|
102
|
+
return dim;
|
|
103
|
+
return pct >= 100 ? red : pct >= 75 ? yellow : green;
|
|
104
|
+
}
|
|
105
|
+
function section(title, note, cols) {
|
|
106
|
+
const head = `${bold(title)} ${dim(note)}`;
|
|
107
|
+
const rule = Math.max(0, cols - width(head) - 1);
|
|
108
|
+
return `${head} ${dim("─".repeat(rule))}`;
|
|
109
|
+
}
|
|
110
|
+
function header(snap, cols, now) {
|
|
111
|
+
const proxy = snap.proxy.health.ok ? green("● proxy") : red(`■ proxy ${snap.proxy.health.reason}`);
|
|
112
|
+
const agents = snap.flock.agentLimit === null ? `${snap.flock.agents} Agents` : `${snap.flock.agents}/${snap.flock.agentLimit} Agents`;
|
|
113
|
+
const agentsTone = snap.flock.agentLimit !== null && snap.flock.agents >= snap.flock.agentLimit ? yellow : (t) => t;
|
|
114
|
+
const clock = now.toLocaleTimeString("en-GB", { hour12: false });
|
|
115
|
+
const left = `${bold(magenta("FlockTab"))} ${bold("top")} ${bold(snap.flock.name)} ${dim("·")} ${planName(snap.flock.plan) ?? snap.flock.plan} ${dim("·")} ${agentsTone(agents)} ${dim("·")} ${proxy} ${dim(snap.proxy.mode)}`;
|
|
116
|
+
const right = `${dim("q quit · r refresh")} ${clock}`;
|
|
117
|
+
const gap = Math.max(1, cols - width(left) - width(right));
|
|
118
|
+
return [`${left}${" ".repeat(gap)}${right}`];
|
|
119
|
+
}
|
|
120
|
+
function totalsLine(snap) {
|
|
121
|
+
const t = snap.live?.totals;
|
|
122
|
+
if (!t)
|
|
123
|
+
return dim("live totals unavailable");
|
|
124
|
+
const cpm = snap.history.map((h) => h.callsPerMinute);
|
|
125
|
+
const cents = snap.history.map((h) => h.windowCents);
|
|
126
|
+
const parts = [
|
|
127
|
+
`${green(String(t.active))} working`,
|
|
128
|
+
`${bold(t.callsPerMinute.toFixed(1))} calls/min ${cyan(sparkline(cpm))}`,
|
|
129
|
+
`${(t.blocked > 0 ? red : dim)(String(t.blocked))} blocked`,
|
|
130
|
+
`${bold(money(t.windowCents))} this window ${cyan(sparkline(cents))}`,
|
|
131
|
+
];
|
|
132
|
+
if (snap.outside)
|
|
133
|
+
parts.push(`outside 30d ${bold(money(sumCents(snap.outside.byProject.map((p) => p.cents))))}`);
|
|
134
|
+
return parts.join(dim(" · "));
|
|
135
|
+
}
|
|
136
|
+
function sumCents(list) {
|
|
137
|
+
return list.reduce((sum, c) => sum + BigInt(String(c).replace(/[^0-9-]/g, "") || "0"), 0n);
|
|
138
|
+
}
|
|
139
|
+
function agentsPanel(snap, cols, max, now) {
|
|
140
|
+
const out = [];
|
|
141
|
+
const agents = snap.live?.agents ?? [];
|
|
142
|
+
const shown = [...agents].sort((a, b) => rank(a) - rank(b) || b.callsPerMinute - a.callsPerMinute || a.name.localeCompare(b.name));
|
|
143
|
+
out.push(section("Agents", `${agents.filter((a) => a.pulse === "working").length} working · ${agents.filter((a) => a.tabState !== "open").length} closed`, cols));
|
|
144
|
+
if (shown.length === 0) {
|
|
145
|
+
out.push(dim(" no Agents yet · tab claude in a project folder makes one"));
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
const nameW = Math.min(24, Math.max(5, ...shown.map((a) => width(a.name))));
|
|
149
|
+
const projW = Math.min(18, Math.max(7, ...shown.map((a) => width(a.projectName ?? "-"))));
|
|
150
|
+
out.push(dim(` ${pad("", 1)} ${pad("agent", nameW)} ${pad("harness", 7)} ${pad("project", projW)} ${pad("spent / cap · login", 32)} ${pad("c/min", 5, true)} ${pad("blk", 3, true)} ${pad("last", 8)} ${pad("ago", 6)} reason`));
|
|
151
|
+
for (const a of shown.slice(0, max)) {
|
|
152
|
+
const pct = a.kind === "subscription" ? undefined : pctOf(a.spentCents, a.capCents);
|
|
153
|
+
// A subscription Agent shows the login its latest call landed on instead of a cap it does not have.
|
|
154
|
+
const spent = a.kind === "subscription" ? (a.lastAccount ? cyan(clip(a.lastAccount, 32)) : dim("subscription · no call yet")) : `${tone(pct)(money(a.spentCents))} ${dim(`/ ${money(a.capCents)}`)}`;
|
|
155
|
+
const bar = a.kind === "subscription" ? "" : usageBar(pct, 100);
|
|
156
|
+
const last = a.lastDecision ? decisionMark(a.lastDecision) : dim("-");
|
|
157
|
+
const reason = a.lastReason ?? "";
|
|
158
|
+
out.push(` ${pulseMark(a.pulse)} ${pad(clip(a.name, nameW), nameW)} ${pad(a.harness === "any" ? dim("any") : a.harness, 7)} ${pad(clip(a.projectName ?? dim("-"), projW), projW)} ${pad(`${spent}${bar ? ` ${bar}` : ""}`, 32)} ${pad((a.callsPerMinute > 0 ? bold : dim)(a.callsPerMinute.toFixed(1)), 5, true)} ${pad((a.blocked > 0 ? red : dim)(String(a.blocked)), 3, true)} ${pad(last, 8)} ${pad(dim(ago(a.lastAt, now)), 6)} ${dim(reason)}`);
|
|
159
|
+
}
|
|
160
|
+
if (shown.length > max)
|
|
161
|
+
out.push(dim(` … ${shown.length - max} more`));
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
function rank(a) {
|
|
165
|
+
return a.pulse === "working" ? 0 : a.pulse === "recent" ? 1 : a.pulse === "idle" ? 2 : 3;
|
|
166
|
+
}
|
|
167
|
+
const VENDOR_TITLES = { anthropic: "Claude", openai: "ChatGPT", xai: "Grok", kimi: "Kimi" };
|
|
168
|
+
function standingFor(pool, account) {
|
|
169
|
+
for (const s of pool ?? []) {
|
|
170
|
+
if (s.vendor !== account.provider)
|
|
171
|
+
continue;
|
|
172
|
+
const limits = s.limits ?? { at: 80, guard: 95 };
|
|
173
|
+
const next = pickMember(s.standings, limits)?.member.name;
|
|
174
|
+
for (const st of s.standings) {
|
|
175
|
+
if (account.email && st.email && st.email.toLowerCase() === account.email.toLowerCase())
|
|
176
|
+
return { standing: st, next: st.member.name === next, over: isOver(st, limits) };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
function subscriptionsPanel(snap, cols, now) {
|
|
182
|
+
const out = [];
|
|
183
|
+
const accounts = snap.accounts ?? [];
|
|
184
|
+
const pooled = (snap.pool ?? []).reduce((n, s) => n + s.standings.length, 0);
|
|
185
|
+
out.push(section("Subscriptions", `${accounts.length} ${accounts.length === 1 ? "login" : "logins"}${pooled > 0 ? ` · ${pooled} in the pool` : ""}`, cols));
|
|
186
|
+
if (accounts.length === 0) {
|
|
187
|
+
out.push(dim(" no subscription login seen yet · tab claude with a subscription Agent"));
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
const whoW = Math.min(30, Math.max(5, ...accounts.map((a) => width(a.email ?? "account"))));
|
|
191
|
+
const planW = Math.max(4, ...accounts.map((a) => width(planName(a.planLabel ?? undefined) ?? "-")));
|
|
192
|
+
const live = (a) => a.quota.filter((w) => !w.resetsAt || new Date(w.resetsAt).getTime() > now.getTime());
|
|
193
|
+
// One slot per window the vendor has on any of its logins, so the columns hold across rows:
|
|
194
|
+
// a login that has not reported a window yet (Fable before its first Fable call) leaves the slot blank.
|
|
195
|
+
const slots = new Map();
|
|
196
|
+
for (const a of accounts) {
|
|
197
|
+
const names = slots.get(a.provider) ?? [];
|
|
198
|
+
for (const w of live(a))
|
|
199
|
+
if (!names.includes(w.window))
|
|
200
|
+
names.push(w.window);
|
|
201
|
+
slots.set(a.provider, names.sort(compareQuotaWindows));
|
|
202
|
+
}
|
|
203
|
+
// Label width per vendor: Claude's "Fable 7d" must not widen ChatGPT's "7d".
|
|
204
|
+
const labelWidth = (provider) => Math.max(2, ...(slots.get(provider) ?? []).map((w) => width(quotaWindowLabel(w))));
|
|
205
|
+
for (const a of [...accounts].sort((x, y) => x.provider.localeCompare(y.provider) || (x.email ?? "").localeCompare(y.email ?? ""))) {
|
|
206
|
+
const pool = standingFor(snap.pool, a);
|
|
207
|
+
const mark = pool?.next ? green("→") : pool?.over ? red("×") : pool ? dim("·") : " ";
|
|
208
|
+
const mine = new Map(live(a).map((w) => [w.window, w]));
|
|
209
|
+
const labelW = labelWidth(a.provider);
|
|
210
|
+
const cellW = labelW + 1 + 10 + 1 + 4 + 1 + 7; // label, bar, "100%", reset ("11h 00m")
|
|
211
|
+
const cells = (slots.get(a.provider) ?? []).map((name) => {
|
|
212
|
+
const w = mine.get(name);
|
|
213
|
+
if (!w)
|
|
214
|
+
return pad("", cellW);
|
|
215
|
+
const at = quotaWindowIsLong(name) ? 95 : 80;
|
|
216
|
+
const label = quotaWindowLabel(name);
|
|
217
|
+
const paint = w.usedPct >= at ? red : w.usedPct >= at * 0.75 ? yellow : green;
|
|
218
|
+
const left = until(w.resetsAt, now);
|
|
219
|
+
return `${pad((label.startsWith("Fable") ? magenta : dim)(label), labelW)} ${usageBar(w.usedPct, at)} ${paint(`${String(Math.round(w.usedPct)).padStart(3)}%`)} ${pad(left ? dim(left) : "", 7)}`;
|
|
220
|
+
});
|
|
221
|
+
const line = ` ${mark} ${pad(VENDOR_TITLES[a.provider] ?? a.vendor, 7)} ${pad(clip(a.email ?? dim("account"), whoW), whoW)} ${pad(planName(a.planLabel ?? undefined) ?? dim("-"), planW)} ${cells.length > 0 ? cells.join(dim(" ")) : dim("no window reported yet")} ${dim(`${a.calls} calls · seen ${ago(a.lastSeenAt, now)}`)}`;
|
|
222
|
+
out.push(line);
|
|
223
|
+
}
|
|
224
|
+
if (pooled > 0)
|
|
225
|
+
out.push(dim(` ${green("→")} the pool's next pick · ${red("×")} over its limit · Fable's own week beside Claude's general one`));
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
function outsidePanel(snap, cols, max, now) {
|
|
229
|
+
const out = [];
|
|
230
|
+
const o = snap.outside;
|
|
231
|
+
if (!o)
|
|
232
|
+
return out;
|
|
233
|
+
const total = sumCents(o.byProject.map((p) => p.cents));
|
|
234
|
+
const failing = o.connections.filter(failingConnector);
|
|
235
|
+
out.push(section("Outside spend", `last 30 days · ${money(total)} · ${o.connections.length} ${o.connections.length === 1 ? "connector" : "connectors"}${failing.length > 0 ? ` · ${failing.length} failing` : ""}`, cols));
|
|
236
|
+
if (o.byProject.length === 0 && o.connections.length === 0) {
|
|
237
|
+
out.push(dim(" no connector yet · Console → Outside spend"));
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
const rows = [...o.byProject].sort((x, y) => Number(BigInt(y.cents) - BigInt(x.cents)));
|
|
241
|
+
const nameW = Math.min(28, Math.max(7, ...rows.map((p) => width(p.projectName))));
|
|
242
|
+
const barW = 20;
|
|
243
|
+
for (const p of rows.slice(0, max)) {
|
|
244
|
+
const cents = BigInt(p.cents);
|
|
245
|
+
const share = total > 0n ? Number((cents * 1000n) / total) / 10 : 0;
|
|
246
|
+
const filled = Math.round((share / 100) * barW);
|
|
247
|
+
const unattributed = p.projectId === null;
|
|
248
|
+
out.push(` ${pad(clip(unattributed ? yellow(p.projectName) : p.projectName, nameW), nameW)} ${(unattributed ? yellow : cyan)("█".repeat(filled))}${dim("░".repeat(barW - filled))} ${pad(money(cents), 10, true)} ${dim(`${share.toFixed(0)}%`)}`);
|
|
249
|
+
}
|
|
250
|
+
if (rows.length > max)
|
|
251
|
+
out.push(dim(` … ${rows.length - max} more`));
|
|
252
|
+
for (const c of failing.slice(0, 2))
|
|
253
|
+
out.push(` ${red("■")} ${c.label} ${dim(c.lastError ?? c.status)}${c.lastSyncAt ? dim(` · synced ${ago(c.lastSyncAt, now)} ago`) : ""}`);
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
function tapePanel(snap, cols, max) {
|
|
257
|
+
const out = [];
|
|
258
|
+
const tape = snap.live?.tape ?? [];
|
|
259
|
+
out.push(section("Ledger", "latest decisions", cols));
|
|
260
|
+
if (tape.length === 0) {
|
|
261
|
+
out.push(dim(" no call yet"));
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
const agentW = Math.min(20, Math.max(5, ...tape.map((t) => width(t.agent))));
|
|
265
|
+
for (const t of tape.slice(0, max)) {
|
|
266
|
+
const time = t.at.length > 8 ? t.at.slice(-8) : t.at;
|
|
267
|
+
const bad = !["ALLOWED", "SETTLED", "SHADOW", "PENDING", "REFUNDED"].includes(t.decision.toUpperCase());
|
|
268
|
+
out.push(` ${dim(time)} ${pad(clip(t.agent, agentW), agentW)} ${pad(decisionMark(t.decision), 8)} ${pad(t.amount, 8, true)} ${pad(clip(t.action, 30), 30)} ${pad(t.model ? dim(clip(t.model, 34)) : "", 34)} ${bad ? red(t.reason) : dim(t.reason)}`);
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
/** The whole screen as lines, each cut to the terminal's width; never more lines than `rows`. */
|
|
273
|
+
export function renderTop(snap, size, now = snap.at) {
|
|
274
|
+
const cols = Math.max(60, size.columns);
|
|
275
|
+
const rows = Math.max(12, size.rows);
|
|
276
|
+
const fixed = 1 + 1 + 1; // header, totals, a blank line
|
|
277
|
+
const subs = subscriptionsPanel(snap, cols, now);
|
|
278
|
+
const errors = snap.errors.map((e) => red(` ! ${e}`));
|
|
279
|
+
// Whatever is left after the header, the logins and any errors is split
|
|
280
|
+
// between the Agents, the outside projects and the tape, Agents first.
|
|
281
|
+
const agentCount = snap.live?.agents.length ?? 0;
|
|
282
|
+
const outsideCount = snap.outside ? Math.min(6, snap.outside.byProject.length + Math.min(2, snap.outside.connections.filter(failingConnector).length)) : 0;
|
|
283
|
+
let budget = rows - fixed - subs.length - errors.length - 1;
|
|
284
|
+
const agentRows = Math.max(3, Math.min(agentCount + 2, Math.floor(budget * 0.45)));
|
|
285
|
+
budget -= agentRows + 1;
|
|
286
|
+
const outsideRows = snap.outside ? Math.max(0, Math.min(outsideCount + 1, Math.floor(budget * 0.35))) : 0;
|
|
287
|
+
budget -= outsideRows > 0 ? outsideRows + 1 : 0;
|
|
288
|
+
const tapeRows = Math.max(2, budget);
|
|
289
|
+
const lines = [
|
|
290
|
+
...header(snap, cols, now),
|
|
291
|
+
totalsLine(snap),
|
|
292
|
+
"",
|
|
293
|
+
...agentsPanel(snap, cols, Math.max(1, agentRows - 2), now),
|
|
294
|
+
"",
|
|
295
|
+
...subs,
|
|
296
|
+
"",
|
|
297
|
+
...(outsideRows > 0 ? [...outsidePanel(snap, cols, Math.max(1, outsideRows - 1), now), ""] : []),
|
|
298
|
+
...tapePanel(snap, cols, Math.max(1, tapeRows - 1)),
|
|
299
|
+
...errors,
|
|
300
|
+
];
|
|
301
|
+
return lines.slice(0, rows).map((l) => clip(l, cols));
|
|
302
|
+
}
|
|
303
|
+
/** One read of every panel; a panel that fails is a line at the bottom, never a blank screen. */
|
|
304
|
+
export async function fetchSnapshot(deps, previous) {
|
|
305
|
+
const errors = [];
|
|
306
|
+
const note = (what) => (err) => {
|
|
307
|
+
errors.push(`${what}: ${err instanceof Error ? err.message : String(err)}`);
|
|
308
|
+
return undefined;
|
|
309
|
+
};
|
|
310
|
+
const [flock, live, accounts, outside, health, pool] = await Promise.all([
|
|
311
|
+
deps.call("GET", "/api/cli/agents").catch(note("agents")),
|
|
312
|
+
deps.call("GET", "/api/cli/live").catch(note("live")),
|
|
313
|
+
deps.call("GET", "/api/cli/accounts").catch(note("accounts")),
|
|
314
|
+
deps.call("GET", "/api/cli/outside").catch(note("outside")),
|
|
315
|
+
deps.health().catch((err) => ({ ok: false, reason: err instanceof Error ? err.message : String(err) })),
|
|
316
|
+
deps.pool ? deps.pool().catch(note("pool")) : Promise.resolve(undefined),
|
|
317
|
+
]);
|
|
318
|
+
const history = [...(previous?.history ?? [])];
|
|
319
|
+
if (live) {
|
|
320
|
+
history.push({ callsPerMinute: live.totals.callsPerMinute, windowCents: Number(live.totals.windowCents) });
|
|
321
|
+
while (history.length > HISTORY)
|
|
322
|
+
history.shift();
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
at: new Date(),
|
|
326
|
+
flock: flock
|
|
327
|
+
? { name: flock.flock.name, plan: flock.flock.plan, agents: flock.agents.length, agentLimit: flock.flock.agentLimit ?? null }
|
|
328
|
+
: (previous?.flock ?? { name: deps.config.flockName ?? "flock", plan: "solo", agents: 0, agentLimit: null }),
|
|
329
|
+
proxy: { url: deps.config.proxyUrl, mode: deps.config.mode, health },
|
|
330
|
+
...(live ? { live } : previous?.live ? { live: previous.live } : {}),
|
|
331
|
+
...(accounts ? { accounts: accounts.accounts } : previous?.accounts ? { accounts: previous.accounts } : {}),
|
|
332
|
+
...(outside ? { outside } : previous?.outside ? { outside: previous.outside } : {}),
|
|
333
|
+
...(pool ? { pool } : previous?.pool ? { pool: previous.pool } : {}),
|
|
334
|
+
errors,
|
|
335
|
+
history,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
const ALT_ON = "\u001b[?1049h\u001b[?25l";
|
|
339
|
+
const ALT_OFF = "\u001b[?25h\u001b[?1049l";
|
|
340
|
+
/** Draw in place: home, each line cleared to its end, then everything below. No flicker from a full clear. */
|
|
341
|
+
function paint(lines, out) {
|
|
342
|
+
out.write(`\u001b[H${lines.map((l) => `${l}\u001b[K`).join("\n")}\u001b[J`);
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Runs until `q`, Escape or Ctrl-C. `--every <seconds>` sets the refresh (2 by
|
|
346
|
+
* default, never under 1); `--once` prints one screen and returns, for a pipe
|
|
347
|
+
* or a quick look.
|
|
348
|
+
*/
|
|
349
|
+
export async function runTop(deps, opts, io = {}) {
|
|
350
|
+
const stdout = io.stdout ?? process.stdout;
|
|
351
|
+
const stdin = io.stdin ?? process.stdin;
|
|
352
|
+
// A pipe has no size: COLUMNS if the shell exports it, else wide enough that nothing useful is cut.
|
|
353
|
+
const size = () => ({ columns: stdout.columns || Number(process.env.COLUMNS) || 160, rows: stdout.rows || Number(process.env.LINES) || 40 });
|
|
354
|
+
let snap = await fetchSnapshot(deps);
|
|
355
|
+
if (opts.once) {
|
|
356
|
+
stdout.write(`${renderTop(snap, { columns: size().columns, rows: 200 }).join("\n")}\n`);
|
|
357
|
+
return 0;
|
|
358
|
+
}
|
|
359
|
+
const every = Math.max(1, opts.every) * 1000;
|
|
360
|
+
let stopped = false;
|
|
361
|
+
let wake;
|
|
362
|
+
const stop = () => {
|
|
363
|
+
stopped = true;
|
|
364
|
+
wake?.();
|
|
365
|
+
};
|
|
366
|
+
const onKey = (chunk) => {
|
|
367
|
+
const key = chunk.toString();
|
|
368
|
+
if (key === "q" || key === "Q" || key === "\u0003" || key === "\u001b")
|
|
369
|
+
stop();
|
|
370
|
+
else if (key === "r" || key === "R")
|
|
371
|
+
wake?.();
|
|
372
|
+
};
|
|
373
|
+
const onResize = () => paint(renderTop(snap, size(), new Date()), stdout);
|
|
374
|
+
const interactive = Boolean(stdin.isTTY && typeof stdin.setRawMode === "function");
|
|
375
|
+
stdout.write(ALT_ON);
|
|
376
|
+
if (interactive) {
|
|
377
|
+
stdin.setRawMode(true);
|
|
378
|
+
stdin.resume();
|
|
379
|
+
stdin.on("data", onKey);
|
|
380
|
+
}
|
|
381
|
+
stdout.on("resize", onResize);
|
|
382
|
+
process.once("SIGINT", stop);
|
|
383
|
+
process.once("SIGTERM", stop);
|
|
384
|
+
try {
|
|
385
|
+
while (!stopped) {
|
|
386
|
+
paint(renderTop(snap, size(), new Date()), stdout);
|
|
387
|
+
await new Promise((resolve) => {
|
|
388
|
+
wake = resolve;
|
|
389
|
+
setTimeout(resolve, every).unref();
|
|
390
|
+
});
|
|
391
|
+
wake = undefined;
|
|
392
|
+
if (stopped)
|
|
393
|
+
break;
|
|
394
|
+
snap = await fetchSnapshot(deps, snap);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
finally {
|
|
398
|
+
stdout.off("resize", onResize);
|
|
399
|
+
process.off("SIGINT", stop);
|
|
400
|
+
process.off("SIGTERM", stop);
|
|
401
|
+
if (interactive) {
|
|
402
|
+
stdin.off("data", onKey);
|
|
403
|
+
stdin.setRawMode(false);
|
|
404
|
+
stdin.pause();
|
|
405
|
+
}
|
|
406
|
+
stdout.write(ALT_OFF);
|
|
407
|
+
}
|
|
408
|
+
return 0;
|
|
409
|
+
}
|
|
410
|
+
/** For tests: the visible text of a screen. */
|
|
411
|
+
export function plain(lines) {
|
|
412
|
+
return lines.map(stripAnsi).join("\n");
|
|
413
|
+
}
|
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.22";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanamorilabs/tab",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.22",
|
|
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.17",
|
|
26
|
+
"@hanamorilabs/flocktab-proxy-darwin-x64": "0.1.17",
|
|
27
|
+
"@hanamorilabs/flocktab-proxy-linux-x64": "0.1.17",
|
|
28
|
+
"@hanamorilabs/flocktab-proxy-linux-arm64": "0.1.17",
|
|
29
|
+
"@hanamorilabs/flocktab-proxy-win-x64": "0.1.17"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@types/node": "^22.18.6",
|