@hanamorilabs/tab 0.1.11 → 0.1.13
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/README.md +19 -1
- package/dist/alias.js +4 -2
- package/dist/cli.js +137 -101
- package/dist/clients.js +5 -1
- package/dist/codex-home.js +46 -2
- package/dist/console-api.js +3 -3
- package/dist/folder.js +3 -2
- package/dist/help.js +105 -0
- package/dist/logins.js +27 -2
- package/dist/manage.js +71 -48
- package/dist/network.js +78 -0
- package/dist/pool-run.js +13 -7
- package/dist/pool-view.js +87 -0
- package/dist/pool.js +159 -22
- package/dist/project.js +10 -2
- package/dist/proxy-bin.js +1 -1
- package/dist/proxy-identity.js +32 -0
- package/dist/tab-docs.js +429 -0
- package/dist/version.js +1 -1
- package/package.json +7 -7
package/dist/help.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tab help`, drawn from the one command reference (`tab-docs.ts`, generated
|
|
3
|
+
* from packages/shared): the overview by group, one command in full, the
|
|
4
|
+
* glossary, and the refusals a call can meet.
|
|
5
|
+
*/
|
|
6
|
+
import { findTabCommand, TAB_COMMAND_GROUPS, TAB_COMMANDS, TAB_ERRORS, TAB_GLOSSARY } from "./tab-docs.js";
|
|
7
|
+
import { bold, cyan, dim, heading, rows, width, yellow } from "./ui.js";
|
|
8
|
+
const DOCS_URL = "https://console.flocktab.com/docs";
|
|
9
|
+
/** Break a paragraph at word boundaries so it fits `cols`, each line after `indent` spaces. */
|
|
10
|
+
export function wrap(text, cols, indent = 0) {
|
|
11
|
+
const room = Math.max(24, cols - indent);
|
|
12
|
+
const lines = [];
|
|
13
|
+
let line = "";
|
|
14
|
+
for (const word of text.split(/\s+/).filter(Boolean)) {
|
|
15
|
+
if (line && width(line) + 1 + width(word) > room) {
|
|
16
|
+
lines.push(line);
|
|
17
|
+
line = word;
|
|
18
|
+
}
|
|
19
|
+
else
|
|
20
|
+
line = line ? `${line} ${word}` : word;
|
|
21
|
+
}
|
|
22
|
+
if (line)
|
|
23
|
+
lines.push(line);
|
|
24
|
+
return lines.map((l) => `${" ".repeat(indent)}${l}`).join("\n");
|
|
25
|
+
}
|
|
26
|
+
const columns = () => Math.min(100, Math.max(60, process.stdout.columns ?? 100));
|
|
27
|
+
export function renderOverview(opts) {
|
|
28
|
+
const cols = opts.cols ?? columns();
|
|
29
|
+
const out = [`${heading("tab")} ${dim("- run any AI agent on a FlockTab tab: a hard cap, a kill switch and a record of every call")}`, ""];
|
|
30
|
+
// One width for the usage column across every group, so the summaries line up.
|
|
31
|
+
const first = (c) => `tab ${c.overview ?? c.usage[0]}`;
|
|
32
|
+
const left = Math.min(42, Math.max(...TAB_COMMANDS.map((c) => width(first(c)))));
|
|
33
|
+
for (const group of TAB_COMMAND_GROUPS) {
|
|
34
|
+
out.push(bold(group.title));
|
|
35
|
+
out.push(dim(wrap(group.blurb, cols, 2)));
|
|
36
|
+
for (const c of TAB_COMMANDS.filter((x) => x.group === group.id)) {
|
|
37
|
+
const name = first(c);
|
|
38
|
+
const label = width(name) > left ? `${name.slice(0, left - 1)}…` : name;
|
|
39
|
+
const text = wrap(c.summary, cols, left + 4).trimStart();
|
|
40
|
+
out.push(` ${cyan(label)}${" ".repeat(left - width(label))} ${text}`);
|
|
41
|
+
}
|
|
42
|
+
out.push("");
|
|
43
|
+
}
|
|
44
|
+
out.push(`${bold("tab help <command>")} ${dim("everything about one command: what it does, its options, examples")}`);
|
|
45
|
+
out.push(`${bold("tab help glossary")} ${dim("what is what: Agent, tab, kind, hold, unlock, pool, ...")}`);
|
|
46
|
+
out.push(`${bold("tab help errors")} ${dim("every refusal a call can meet, and what to do")}`);
|
|
47
|
+
out.push("");
|
|
48
|
+
out.push(dim(`Docs ${DOCS_URL} Config ${opts.configPath} or FLOCKTAB_PROXY_URL, FLOCKTAB_KEY, FLOCKTAB_UNLOCK`));
|
|
49
|
+
return out.join("\n");
|
|
50
|
+
}
|
|
51
|
+
export function renderCommand(doc, cols = columns()) {
|
|
52
|
+
const out = [`${heading(`tab ${doc.name}`)}${doc.aliases?.length ? dim(` also: ${doc.aliases.join(", ")}`) : ""}`, wrap(doc.summary, cols), ""];
|
|
53
|
+
out.push(bold("Usage"));
|
|
54
|
+
for (const u of doc.usage)
|
|
55
|
+
out.push(` ${cyan(`tab ${u}`)}`);
|
|
56
|
+
out.push("");
|
|
57
|
+
for (const p of doc.details)
|
|
58
|
+
out.push(wrap(p, cols), "");
|
|
59
|
+
if (doc.options?.length) {
|
|
60
|
+
out.push(bold("Options"));
|
|
61
|
+
const w = Math.max(...doc.options.map((o) => width(o.flag)));
|
|
62
|
+
for (const o of doc.options)
|
|
63
|
+
out.push(` ${yellow(o.flag)}${" ".repeat(w - width(o.flag))} ${wrap(o.what, cols, w + 4).trimStart()}`);
|
|
64
|
+
out.push("");
|
|
65
|
+
}
|
|
66
|
+
if (doc.examples?.length) {
|
|
67
|
+
out.push(bold("Examples"));
|
|
68
|
+
const w = Math.max(...doc.examples.map((e) => width(e.cmd)));
|
|
69
|
+
for (const e of doc.examples)
|
|
70
|
+
out.push(` ${cyan(e.cmd)}${" ".repeat(w - width(e.cmd))} ${dim(wrap(e.what, cols, w + 4).trimStart())}`);
|
|
71
|
+
out.push("");
|
|
72
|
+
}
|
|
73
|
+
if (doc.see?.length)
|
|
74
|
+
out.push(dim(`See also: ${doc.see.map((s) => `tab help ${s}`).join(" · ")}`));
|
|
75
|
+
return out.join("\n").trimEnd();
|
|
76
|
+
}
|
|
77
|
+
export function renderGlossary(cols = columns()) {
|
|
78
|
+
const out = [heading("What is what"), ""];
|
|
79
|
+
for (const e of [...TAB_GLOSSARY].sort((a, b) => a.term.replace(/^[^A-Za-z]+/, "").localeCompare(b.term.replace(/^[^A-Za-z]+/, "")))) {
|
|
80
|
+
out.push(`${bold(e.term)}${e.also?.length ? dim(` (${e.also.join(", ")})`) : ""}`);
|
|
81
|
+
out.push(wrap(e.meaning, cols, 2), "");
|
|
82
|
+
}
|
|
83
|
+
return out.join("\n").trimEnd();
|
|
84
|
+
}
|
|
85
|
+
export function renderErrors(cols = columns()) {
|
|
86
|
+
const out = [heading("When a call is refused"), wrap("A refusal always happens before the provider or vendor is reached, and carries an x-flocktab-reason header naming the rule.", cols), ""];
|
|
87
|
+
for (const e of TAB_ERRORS) {
|
|
88
|
+
out.push(`${yellow(String(e.status))} ${bold(e.code)}`);
|
|
89
|
+
out.push(wrap(e.when, cols, 2));
|
|
90
|
+
out.push(dim(wrap(`Do: ${e.fix}`, cols, 2)), "");
|
|
91
|
+
}
|
|
92
|
+
return out.join("\n").trimEnd();
|
|
93
|
+
}
|
|
94
|
+
/** What `tab help [word]` prints, or undefined when the word names nothing. */
|
|
95
|
+
export function renderHelp(word, opts) {
|
|
96
|
+
if (!word)
|
|
97
|
+
return renderOverview(opts);
|
|
98
|
+
if (word === "glossary" || word === "terms")
|
|
99
|
+
return renderGlossary();
|
|
100
|
+
if (word === "errors" || word === "error")
|
|
101
|
+
return renderErrors();
|
|
102
|
+
const doc = findTabCommand(word);
|
|
103
|
+
return doc ? renderCommand(doc) : undefined;
|
|
104
|
+
}
|
|
105
|
+
export { rows };
|
package/dist/logins.js
CHANGED
|
@@ -38,8 +38,8 @@ function jwtClaims(token) {
|
|
|
38
38
|
/**
|
|
39
39
|
* Claude Code keeps the signed-in account (not the token) in `~/.claude.json`
|
|
40
40
|
* under `oauthAccount`; Codex keeps its ChatGPT login in `auth.json` under
|
|
41
|
-
* the home it runs with
|
|
42
|
-
*
|
|
41
|
+
* the home it runs with; Grok Build keeps email and id in `auth.json`; Kimi
|
|
42
|
+
* Code's account is the subject of its login token.
|
|
43
43
|
*/
|
|
44
44
|
export async function localLogin(vendor, opts = {}) {
|
|
45
45
|
const home = opts.home ?? homedir();
|
|
@@ -66,6 +66,31 @@ export async function localLogin(vendor, opts = {}) {
|
|
|
66
66
|
const accountId = str(tokens.account_id) ?? str(authClaims?.chatgpt_account_id);
|
|
67
67
|
return { ...(email ? { email } : {}), ...(plan ? { plan } : {}), ...(accountId ? { accountId } : {}) };
|
|
68
68
|
}
|
|
69
|
+
if (vendor === "xai") {
|
|
70
|
+
// Grok Build: `auth.json` is one entry per issuer, with the account's email and id beside the token.
|
|
71
|
+
const auth = await jsonFile(path.join(opts.grokHome ?? path.join(home, ".grok"), "auth.json"));
|
|
72
|
+
const entry = Object.values(auth ?? {}).find((v) => Boolean(v) && typeof v === "object");
|
|
73
|
+
if (!entry)
|
|
74
|
+
return undefined;
|
|
75
|
+
const email = str(entry.email);
|
|
76
|
+
// The proxy files Grok calls under the login token's subject, then its user id: read it the same way.
|
|
77
|
+
const claims = jwtClaims(entry.key);
|
|
78
|
+
const accountId = str(claims?.sub) ?? str(claims?.user_id) ?? str(entry.user_id);
|
|
79
|
+
return email || accountId ? { ...(email ? { email } : {}), ...(accountId ? { accountId } : {}) } : undefined;
|
|
80
|
+
}
|
|
81
|
+
if (vendor === "kimi") {
|
|
82
|
+
// Kimi Code names no email; the login token's subject is the account the proxy files calls under.
|
|
83
|
+
// Kimi Code keeps its home in `~/.kimi-code`, the older kimi-cli in `~/.kimi`; same file inside.
|
|
84
|
+
const homes = opts.kimiShareDir ? [opts.kimiShareDir] : [path.join(home, ".kimi-code"), path.join(home, ".kimi")];
|
|
85
|
+
let cred;
|
|
86
|
+
for (const dir of homes) {
|
|
87
|
+
cred = await jsonFile(path.join(dir, "credentials", "kimi-code.json"));
|
|
88
|
+
if (cred)
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
const accountId = str(jwtClaims(cred?.access_token)?.sub);
|
|
92
|
+
return accountId ? { accountId } : undefined;
|
|
93
|
+
}
|
|
69
94
|
return undefined;
|
|
70
95
|
}
|
|
71
96
|
/** "me@x.com (Claude Max 20x)" or nothing worth saying. */
|
package/dist/manage.js
CHANGED
|
@@ -8,6 +8,7 @@ import { spawn } from "node:child_process";
|
|
|
8
8
|
import { readProject } from "./project.js";
|
|
9
9
|
import { saveConfig } from "./config.js";
|
|
10
10
|
import { ConsoleApiError } from "./console-api.js";
|
|
11
|
+
import { requestJson, watch } from "./network.js";
|
|
11
12
|
import { bold, dim, green, money, red, rows, table, yellow } from "./ui.js";
|
|
12
13
|
export class ManageError extends Error {
|
|
13
14
|
}
|
|
@@ -17,13 +18,13 @@ export function api(config, fetchImpl = fetch) {
|
|
|
17
18
|
const token = config.token;
|
|
18
19
|
if (!base || !token)
|
|
19
20
|
throw new ManageError("This machine is not logged in. Run tab login.");
|
|
20
|
-
return async (method, path, body) => {
|
|
21
|
-
const res = await
|
|
21
|
+
return async (method, path, body, signal) => {
|
|
22
|
+
const { response: res, body: json } = await requestJson(`${base}${path}`, {
|
|
22
23
|
method,
|
|
24
|
+
...(signal ? { signal } : {}),
|
|
23
25
|
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
24
26
|
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
25
|
-
});
|
|
26
|
-
const json = (await res.json().catch(() => ({})));
|
|
27
|
+
}, fetchImpl);
|
|
27
28
|
if (!res.ok) {
|
|
28
29
|
const detail = json.error ?? `console answered ${res.status}`;
|
|
29
30
|
throw new ConsoleApiError(res.status === 401 ? "Session expired. Run tab login." : json.hint ? `${detail} (${json.hint})` : detail, res.status);
|
|
@@ -65,13 +66,13 @@ async function tabsOf(call) {
|
|
|
65
66
|
}
|
|
66
67
|
/** Slug or name (case-insensitive); omitted means this folder's Agent. */
|
|
67
68
|
export async function resolveSlug(call, given, cwd = process.cwd()) {
|
|
68
|
-
const tabs = await tabsOf(call);
|
|
69
69
|
if (!given) {
|
|
70
70
|
const project = await readProject(cwd);
|
|
71
71
|
if (!project)
|
|
72
72
|
throw new ManageError("No Agent named and this folder has none yet (tab use), so say which: tab <command> <agent>.");
|
|
73
73
|
return project.agent;
|
|
74
74
|
}
|
|
75
|
+
const tabs = await tabsOf(call);
|
|
75
76
|
const want = given.trim().toLowerCase();
|
|
76
77
|
const hit = tabs.find((t) => t.slug.toLowerCase() === want) ?? tabs.find((t) => t.name.toLowerCase() === want);
|
|
77
78
|
if (!hit)
|
|
@@ -197,7 +198,7 @@ export async function accounts(config, flags) {
|
|
|
197
198
|
};
|
|
198
199
|
return table(["vendor", "account", "plan", "/month", "30d at list", "calls", "used", "agents"], list.map((a) => [
|
|
199
200
|
a.vendor,
|
|
200
|
-
a.label ?? a.email ?? dim(a.id),
|
|
201
|
+
a.label ?? a.email ?? dim(`account ${(a.externalId ?? a.id).slice(0, 12)}`),
|
|
201
202
|
a.planLabel ?? dim("unknown"),
|
|
202
203
|
a.monthlyCents ? money(a.monthlyCents) : dim("-"),
|
|
203
204
|
money(a.listCents),
|
|
@@ -310,48 +311,79 @@ export function logLine(r) {
|
|
|
310
311
|
* machine, one condensed line per call, hosted or self-hosted alike because
|
|
311
312
|
* it is the ledger's own record. `-f` follows it; `--all` is the whole flock.
|
|
312
313
|
*/
|
|
313
|
-
export async function log(config, flags, follow) {
|
|
314
|
+
export async function log(config, flags, follow, signal) {
|
|
314
315
|
const call = api(config);
|
|
315
|
-
const limit = Math.min(500, Math.max(1, Number(flags.opts.lines ?? 30) || 30));
|
|
316
|
-
const mine = new Set(Object.values(config.agents ?? {}).map((a) => a.
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
316
|
+
const limit = Math.min(500, Math.max(1, Math.floor(Number(flags.opts.lines ?? 30) || 30)));
|
|
317
|
+
const mine = [...new Set(Object.values(config.agents ?? {}).map((a) => a.agentId).filter(Boolean))].sort();
|
|
318
|
+
const all = flags.opts.all !== undefined;
|
|
319
|
+
if (!all && mine.length > 200)
|
|
320
|
+
throw new ManageError("This machine has more than 200 Agents. Use tab ledger <agent>, or --all explicitly for the flock.");
|
|
321
|
+
if (!all && mine.length === 0) {
|
|
322
|
+
out(flags.json ? JSON.stringify({ rows: [] }) : dim("No Agent IDs saved on this machine. Run tab use, or add --all for the flock."));
|
|
323
|
+
return 0;
|
|
324
|
+
}
|
|
325
|
+
const params = new URLSearchParams({ limit: String(limit) });
|
|
326
|
+
if (!all)
|
|
327
|
+
for (const id of mine)
|
|
328
|
+
params.append("agentId", id);
|
|
329
|
+
if (follow)
|
|
330
|
+
params.set("incremental", "1");
|
|
331
|
+
let cursor;
|
|
332
|
+
const fetchPage = async (requestSignal) => {
|
|
333
|
+
const query = new URLSearchParams(params);
|
|
334
|
+
if (cursor)
|
|
335
|
+
query.set("cursor", cursor);
|
|
336
|
+
const page = await call("GET", `/api/cli/ledger?${query}`, undefined, requestSignal);
|
|
337
|
+
if (follow && !page.cursor)
|
|
338
|
+
throw new ManageError("The console does not support incremental logs yet. Use tab ledger until it is updated.");
|
|
339
|
+
if (page.hasMore && page.cursor === cursor)
|
|
340
|
+
throw new ManageError("The ledger cursor did not advance.");
|
|
341
|
+
cursor = page.cursor;
|
|
342
|
+
return page;
|
|
343
|
+
};
|
|
344
|
+
const first = (await fetchPage(signal)).rows;
|
|
320
345
|
if (flags.json && !follow) {
|
|
321
346
|
out(JSON.stringify({ rows: first }, null, 2));
|
|
322
347
|
return 0;
|
|
323
348
|
}
|
|
324
349
|
if (first.length === 0 && !follow)
|
|
325
350
|
out(dim("Nothing yet. Run tab claude or tab codex in a project folder."));
|
|
326
|
-
const seen = new
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
351
|
+
const seen = new Map();
|
|
352
|
+
const print = (r) => {
|
|
353
|
+
const fingerprint = JSON.stringify(r);
|
|
354
|
+
if (seen.get(r.id) === fingerprint)
|
|
355
|
+
return;
|
|
356
|
+
seen.delete(r.id);
|
|
357
|
+
seen.set(r.id, fingerprint);
|
|
358
|
+
if (seen.size > 2_000)
|
|
359
|
+
seen.delete(seen.keys().next().value);
|
|
360
|
+
out(flags.json ? fingerprint : logLine(r));
|
|
361
|
+
};
|
|
362
|
+
for (const r of [...first].reverse())
|
|
363
|
+
print(r);
|
|
331
364
|
if (!follow)
|
|
332
365
|
return 0;
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
let rows;
|
|
366
|
+
await watch(async (requestSignal) => {
|
|
367
|
+
const bootstrap = !cursor;
|
|
336
368
|
try {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
369
|
+
const page = await fetchPage(requestSignal);
|
|
370
|
+
for (const row of bootstrap ? [...page.rows].reverse() : page.rows)
|
|
371
|
+
print(row);
|
|
372
|
+
return page.hasMore === true;
|
|
341
373
|
}
|
|
342
|
-
|
|
343
|
-
if (
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
out(flags.json ? JSON.stringify(r) : logLine(r));
|
|
374
|
+
catch (error) {
|
|
375
|
+
if (error instanceof ConsoleApiError && error.status === 400)
|
|
376
|
+
cursor = undefined;
|
|
377
|
+
throw error;
|
|
347
378
|
}
|
|
348
|
-
}
|
|
379
|
+
}, 2_000, { ...(signal ? { signal } : {}), onError: (error) => console.error(dim(error instanceof Error ? error.message : String(error))) });
|
|
380
|
+
return 0;
|
|
349
381
|
}
|
|
350
382
|
export async function ledger(config, flags) {
|
|
351
383
|
const call = api(config);
|
|
352
384
|
const params = new URLSearchParams();
|
|
353
385
|
if (flags.args[0])
|
|
354
|
-
params.set("agent",
|
|
386
|
+
params.set("agent", flags.args[0].trim());
|
|
355
387
|
if (flags.opts.limit)
|
|
356
388
|
params.set("limit", flags.opts.limit);
|
|
357
389
|
if (flags.opts.blocked !== undefined)
|
|
@@ -408,34 +440,25 @@ export async function outside(config, flags) {
|
|
|
408
440
|
});
|
|
409
441
|
return 0;
|
|
410
442
|
}
|
|
411
|
-
export async function live(config, flags) {
|
|
443
|
+
export async function live(config, flags, signal) {
|
|
412
444
|
const call = api(config);
|
|
413
|
-
const once = async () => {
|
|
414
|
-
const feed = await call("GET", "/api/cli/live");
|
|
445
|
+
const once = async (requestSignal) => {
|
|
446
|
+
const feed = await call("GET", flags.json ? "/api/cli/live" : "/api/cli/live?view=summary", undefined, requestSignal);
|
|
415
447
|
emit(flags.json, feed, () => `${dim("active")} ${feed.totals.active} ${dim("calls/min")} ${feed.totals.callsPerMinute} ${dim("blocked 5m")} ${feed.totals.blocked} ${dim("spend 5m")} ${money(feed.totals.windowCents)}\n` +
|
|
416
448
|
table(["agent", "pulse", "calls/min", "blocked", "5m", "last"], feed.agents.map((a) => [a.slug, a.pulse, String(a.callsPerMinute), String(a.blocked), money(a.windowCents), a.lastDecision ? `${a.lastDecision.toLowerCase()} ${dim(a.lastReason ?? "")}` : dim("-")]), { right: [2, 3, 4] }));
|
|
417
449
|
};
|
|
418
|
-
await once();
|
|
450
|
+
await once(signal);
|
|
419
451
|
if (flags.opts.watch === undefined)
|
|
420
452
|
return 0;
|
|
421
|
-
const every = Math.max(2, Number(flags.opts.watch) || 4) * 1000;
|
|
422
|
-
await
|
|
423
|
-
const timer = setInterval(() => {
|
|
424
|
-
out("");
|
|
425
|
-
once().catch((err) => out(red(String(err instanceof Error ? err.message : err))));
|
|
426
|
-
}, every);
|
|
427
|
-
process.on("SIGINT", () => {
|
|
428
|
-
clearInterval(timer);
|
|
429
|
-
resolve();
|
|
430
|
-
});
|
|
431
|
-
});
|
|
453
|
+
const every = Math.min(60, Math.max(2, Number(flags.opts.watch) || 4)) * 1000;
|
|
454
|
+
await watch(once, every, { ...(signal ? { signal } : {}), onError: (error) => console.error(red(error instanceof Error ? error.message : String(error))) });
|
|
432
455
|
return 0;
|
|
433
456
|
}
|
|
434
457
|
/** `tab web [agent]`: this folder's Agent in the console. */
|
|
435
458
|
export async function web(config, flags) {
|
|
436
459
|
const call = api(config);
|
|
437
|
-
const slug = flags.args[0]
|
|
438
|
-
const url = slug ? `${config.consoleUrl}/console/tabs/${encodeURIComponent(
|
|
460
|
+
const slug = flags.args[0] ? await resolveSlug(call, flags.args[0]) : (await readProject(process.cwd()))?.agent;
|
|
461
|
+
const url = slug ? `${config.consoleUrl}/console/tabs/${encodeURIComponent(slug)}` : `${config.consoleUrl}/console`;
|
|
439
462
|
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
440
463
|
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
441
464
|
try {
|
package/dist/network.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
function pause(ms, signal) {
|
|
2
|
+
signal.throwIfAborted();
|
|
3
|
+
return new Promise((resolve, reject) => {
|
|
4
|
+
const aborted = () => {
|
|
5
|
+
clearTimeout(timer);
|
|
6
|
+
reject(signal.reason);
|
|
7
|
+
};
|
|
8
|
+
const timer = setTimeout(() => {
|
|
9
|
+
signal.removeEventListener("abort", aborted);
|
|
10
|
+
resolve();
|
|
11
|
+
}, ms);
|
|
12
|
+
signal.addEventListener("abort", aborted, { once: true });
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
/** Use Node's shared fetch pool; bound both headers and body, including cancellation. */
|
|
16
|
+
export async function requestJson(url, init = {}, fetchImpl = fetch, timeoutMs = 15_000) {
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
const signal = init.signal
|
|
19
|
+
? AbortSignal.any([init.signal, controller.signal])
|
|
20
|
+
: controller.signal;
|
|
21
|
+
signal.throwIfAborted();
|
|
22
|
+
const timer = setTimeout(() => controller.abort(new DOMException("Console request timed out", "TimeoutError")), timeoutMs);
|
|
23
|
+
let onAbort;
|
|
24
|
+
const cancelled = new Promise((_resolve, reject) => {
|
|
25
|
+
onAbort = () => reject(signal.reason);
|
|
26
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
27
|
+
});
|
|
28
|
+
try {
|
|
29
|
+
return await Promise.race([
|
|
30
|
+
(async () => {
|
|
31
|
+
const response = await fetchImpl(url, { ...init, signal });
|
|
32
|
+
const body = (await response.json().catch(() => ({})));
|
|
33
|
+
return { response, body };
|
|
34
|
+
})(),
|
|
35
|
+
cancelled,
|
|
36
|
+
]);
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
signal.removeEventListener("abort", onAbort);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Completion-based watch loop. A true result drains the next cursor page immediately. */
|
|
44
|
+
export async function watch(task, intervalMs, options = {}) {
|
|
45
|
+
const controller = new AbortController();
|
|
46
|
+
const signal = options.signal ?? controller.signal;
|
|
47
|
+
const stop = () => controller.abort();
|
|
48
|
+
if (!options.signal) {
|
|
49
|
+
process.once("SIGINT", stop);
|
|
50
|
+
process.once("SIGTERM", stop);
|
|
51
|
+
}
|
|
52
|
+
let delay = intervalMs;
|
|
53
|
+
try {
|
|
54
|
+
while (!signal.aborted) {
|
|
55
|
+
await pause(delay, signal);
|
|
56
|
+
try {
|
|
57
|
+
const more = await task(signal);
|
|
58
|
+
delay = more ? 0 : intervalMs;
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (signal.aborted)
|
|
62
|
+
break;
|
|
63
|
+
options.onError?.(error);
|
|
64
|
+
delay = Math.min(Math.max(delay, intervalMs) * 2, 60_000);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (!signal.aborted)
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
if (!options.signal) {
|
|
74
|
+
process.removeListener("SIGINT", stop);
|
|
75
|
+
process.removeListener("SIGTERM", stop);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
package/dist/pool-run.js
CHANGED
|
@@ -4,29 +4,35 @@
|
|
|
4
4
|
* another has room, wait for a quiet moment and relaunch into the same
|
|
5
5
|
* conversation as that other login.
|
|
6
6
|
*/
|
|
7
|
-
import { pickMember, relaunchArgs, shouldSwap } from "./pool.js";
|
|
8
|
-
const label = (s) => `${s.member.name}${s.email ? ` (${s.email})` : ""}${s.used === undefined ? "" : `, ${Math.round(s.used)}% used`}`;
|
|
7
|
+
import { isOver, pickMember, relaunchArgs, shouldSwap } from "./pool.js";
|
|
8
|
+
const label = (s) => `${s.member.name}${s.email && s.email !== s.member.name ? ` (${s.email})` : ""}${s.used === undefined ? "" : `, ${Math.round(s.used)}% used`}`;
|
|
9
|
+
/** Which limit a login is over, in words. */
|
|
10
|
+
function why(s, limits) {
|
|
11
|
+
return (s.used ?? 0) >= limits.at ? `is over ${limits.at}%` : `has ${Math.round(s.longUsed ?? 0)}% of its long window used`;
|
|
12
|
+
}
|
|
9
13
|
export async function runPooled(args, deps) {
|
|
10
14
|
const all = await deps.standings();
|
|
11
|
-
let current = deps.pinned ? all.find((s) => s.member.name === deps.pinned) : pickMember(all, deps.
|
|
15
|
+
let current = deps.pinned ? all.find((s) => s.member.name === deps.pinned) : pickMember(all, deps.limits);
|
|
12
16
|
if (!current) {
|
|
13
17
|
deps.say(deps.pinned ? `No pool member named ${deps.pinned}. See tab pool.` : "The pool is empty. Add a login with tab pool add.");
|
|
14
18
|
return 2;
|
|
15
19
|
}
|
|
16
|
-
if (!deps.pinned && (current
|
|
17
|
-
deps.say(`Every login in the pool is over
|
|
20
|
+
if (!deps.pinned && isOver(current, deps.limits))
|
|
21
|
+
deps.say(`Every login in the pool is over its limit. Running as the least used.`);
|
|
18
22
|
let launchArgs = args;
|
|
19
23
|
for (;;) {
|
|
20
24
|
deps.say(`Pool: running as ${label(current)}.`);
|
|
21
25
|
const child = deps.start(current, launchArgs);
|
|
22
26
|
let next;
|
|
27
|
+
let seen = current;
|
|
23
28
|
if (!deps.pinned && deps.swap === "auto") {
|
|
24
29
|
while (await deps.wait(deps.intervalMs ?? 60_000, child.done)) {
|
|
25
30
|
// A failed look at the console is not a reason to touch a running harness.
|
|
26
31
|
const now = await deps.standings().catch(() => undefined);
|
|
27
|
-
const to = now ? shouldSwap(now, deps.
|
|
32
|
+
const to = now ? shouldSwap(now, deps.limits, current.member.name) : undefined;
|
|
28
33
|
if (to && (await deps.idle().catch(() => false))) {
|
|
29
34
|
next = to;
|
|
35
|
+
seen = now?.find((s) => s.member.name === current.member.name) ?? current;
|
|
30
36
|
child.stop();
|
|
31
37
|
break;
|
|
32
38
|
}
|
|
@@ -35,7 +41,7 @@ export async function runPooled(args, deps) {
|
|
|
35
41
|
const code = await child.done;
|
|
36
42
|
if (!next)
|
|
37
43
|
return code;
|
|
38
|
-
deps.say(`Pool: ${current.member.name}
|
|
44
|
+
deps.say(`Pool: ${current.member.name} ${why(seen, deps.limits)}. Continuing as ${label(next)}; the conversation carries over, the vendor's prompt cache does not.`);
|
|
39
45
|
current = next;
|
|
40
46
|
launchArgs = relaunchArgs(deps.harness, args);
|
|
41
47
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tab pool`, drawn: one table per vendor. Who each login is, its plan, how
|
|
3
|
+
* used the vendor last said it is (a bar, then each window with when it
|
|
4
|
+
* resets), and an arrow on the login the next launch would run as.
|
|
5
|
+
*/
|
|
6
|
+
import { isOver, pickMember, windowMinutes } from "./pool.js";
|
|
7
|
+
import { bold, dim, green, red, table, width, yellow } from "./ui.js";
|
|
8
|
+
const BAR = 10;
|
|
9
|
+
/** "2h 10m", "3d 4h", "12m": how long until a window resets. */
|
|
10
|
+
export function until(resetsAt, now) {
|
|
11
|
+
if (!resetsAt)
|
|
12
|
+
return undefined;
|
|
13
|
+
const ms = new Date(resetsAt).getTime() - now.getTime();
|
|
14
|
+
if (!Number.isFinite(ms) || ms <= 0)
|
|
15
|
+
return undefined;
|
|
16
|
+
const minutes = Math.round(ms / 60_000);
|
|
17
|
+
if (minutes < 60)
|
|
18
|
+
return `${Math.max(1, minutes)}m`;
|
|
19
|
+
const hours = Math.floor(minutes / 60);
|
|
20
|
+
if (hours < 24)
|
|
21
|
+
return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
|
|
22
|
+
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
|
|
23
|
+
}
|
|
24
|
+
function paint(pct, at) {
|
|
25
|
+
return pct >= at ? red : pct >= at * 0.75 ? yellow : green;
|
|
26
|
+
}
|
|
27
|
+
export function usageBar(pct, at) {
|
|
28
|
+
if (pct === undefined)
|
|
29
|
+
return dim("·".repeat(BAR));
|
|
30
|
+
const filled = Math.min(BAR, Math.max(0, Math.round((pct / 100) * BAR)));
|
|
31
|
+
return `${paint(pct, at)("█".repeat(filled))}${dim("░".repeat(BAR - filled))}`;
|
|
32
|
+
}
|
|
33
|
+
/** "claude max 20x" and "pro" as the harness files spell them, in the vendor's capitals. */
|
|
34
|
+
export function planName(plan) {
|
|
35
|
+
if (!plan)
|
|
36
|
+
return undefined;
|
|
37
|
+
return plan.replace(/\b[a-z]/g, (c) => c.toUpperCase()).replace(/\b(\d+)X\b/g, "$1x");
|
|
38
|
+
}
|
|
39
|
+
function windowsLine(windows, limits, now) {
|
|
40
|
+
const sorted = [...(windows ?? [])].sort((a, b) => windowMinutes(a.window) - windowMinutes(b.window));
|
|
41
|
+
const live = sorted.filter((w) => !w.resetsAt || new Date(w.resetsAt).getTime() > now.getTime());
|
|
42
|
+
if (!windows || windows.length === 0)
|
|
43
|
+
return dim("no call through FlockTab yet");
|
|
44
|
+
if (live.length === 0)
|
|
45
|
+
return dim("every window has reset");
|
|
46
|
+
return live
|
|
47
|
+
.map((w) => {
|
|
48
|
+
const left = until(w.resetsAt, now);
|
|
49
|
+
// The short window is judged by the threshold, the long one by its guard.
|
|
50
|
+
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}`) : ""}`;
|
|
52
|
+
})
|
|
53
|
+
.join(dim(" · "));
|
|
54
|
+
}
|
|
55
|
+
export function renderPool(sections, opts) {
|
|
56
|
+
const now = opts.now ?? new Date();
|
|
57
|
+
const out = [];
|
|
58
|
+
// One width for the login and plan columns, so the vendors line up under each other.
|
|
59
|
+
const whoOf = (s) => (s.email && s.email !== s.member.name ? `${s.member.name} ${dim(`(${s.email})`)}` : s.member.name);
|
|
60
|
+
const all = sections.flatMap((x) => x.standings);
|
|
61
|
+
const loginWidth = Math.max(5, ...all.map((s) => width(s.email ? whoOf(s) : `${whoOf(s)} not signed in`)));
|
|
62
|
+
const planWidth = Math.max(4, ...all.map((s) => width(planName(s.plan) ?? "-")));
|
|
63
|
+
for (const section of sections) {
|
|
64
|
+
const limits = section.limits ?? { at: opts.at, guard: opts.guard ?? 95 };
|
|
65
|
+
const at = limits.at;
|
|
66
|
+
const next = pickMember(section.standings, limits)?.member.name;
|
|
67
|
+
const over = section.standings.filter((s) => isOver(s, limits)).length;
|
|
68
|
+
const count = `${section.standings.length} ${section.standings.length === 1 ? "login" : "logins"}`;
|
|
69
|
+
out.push(`${bold(section.title)} ${dim(`${count} · moves at ${at}% · weekly guard ${limits.guard}%${over > 0 ? ` · ${over} over` : ""} · tab ${section.harness}`)}`);
|
|
70
|
+
out.push(table(["", "login".padEnd(loginWidth), "plan".padEnd(planWidth), "used", "", "windows", ...(opts.paths ? ["folder"] : [])], section.standings.map((s) => {
|
|
71
|
+
const who = whoOf(s);
|
|
72
|
+
return [
|
|
73
|
+
s.member.name === next ? green("→") : " ",
|
|
74
|
+
s.email ? who : `${who} ${yellow("not signed in")}`,
|
|
75
|
+
planName(s.plan) ?? dim("-"),
|
|
76
|
+
usageBar(s.used, at),
|
|
77
|
+
s.used === undefined ? dim(" -") : paint(s.used, at)(`${String(Math.round(s.used)).padStart(3)}%`),
|
|
78
|
+
windowsLine(s.windows, limits, now),
|
|
79
|
+
...(opts.paths ? [dim(s.member.dir)] : []),
|
|
80
|
+
];
|
|
81
|
+
}), { indent: 1 }));
|
|
82
|
+
out.push("");
|
|
83
|
+
}
|
|
84
|
+
out.push(dim(`${green("→")} runs next. The short window decides; the weekly one only from its guard up. A login over either gives way to the one with most room${opts.swap === "auto" ? ", also while running when idle" : ", at launch only"}.`));
|
|
85
|
+
out.push(dim("--as <login> pins one · --no-pool uses your usual login · tab pool at [vendor] [7d] <percent> · --paths shows folders"));
|
|
86
|
+
return out.join("\n");
|
|
87
|
+
}
|