@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/pool.js
CHANGED
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
* threshold and another has room, the harness is relaunched into the same
|
|
9
9
|
* conversation as that other login.
|
|
10
10
|
*/
|
|
11
|
-
import { chmod, mkdir, readdir, readFile, symlink, writeFile, lstat } from "node:fs/promises";
|
|
11
|
+
import { chmod, mkdir, readdir, readFile, readlink, symlink, unlink, writeFile, lstat } from "node:fs/promises";
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import path from "node:path";
|
|
14
|
+
import { moveInto } from "./codex-home.js";
|
|
14
15
|
import { configDir } from "./config.js";
|
|
16
|
+
export const POOL_VENDORS = ["anthropic", "openai", "xai", "kimi"];
|
|
15
17
|
export const DEFAULT_AT = 80;
|
|
18
|
+
export const DEFAULT_GUARD = 95;
|
|
16
19
|
export function poolPath(env = process.env) {
|
|
17
20
|
return path.join(configDir(env), "pool.json");
|
|
18
21
|
}
|
|
@@ -21,20 +24,33 @@ export function poolVendorFor(harness) {
|
|
|
21
24
|
return "anthropic";
|
|
22
25
|
if (harness === "codex")
|
|
23
26
|
return "openai";
|
|
27
|
+
if (harness === "grok")
|
|
28
|
+
return "xai";
|
|
29
|
+
if (harness === "kimi")
|
|
30
|
+
return "kimi";
|
|
24
31
|
return undefined;
|
|
25
32
|
}
|
|
26
33
|
export async function loadPool(env = process.env) {
|
|
27
|
-
const empty = { at: DEFAULT_AT, swap: "auto", members: {} };
|
|
34
|
+
const empty = { at: DEFAULT_AT, atByVendor: {}, guard: DEFAULT_GUARD, guardByVendor: {}, swap: "auto", members: {} };
|
|
28
35
|
try {
|
|
29
36
|
const raw = JSON.parse(await readFile(poolPath(env), "utf8"));
|
|
30
|
-
const
|
|
37
|
+
const valid = (n) => typeof n === "number" && n >= 1 && n <= 100;
|
|
38
|
+
const at = valid(raw.at) ? raw.at : DEFAULT_AT;
|
|
31
39
|
const members = {};
|
|
32
|
-
|
|
40
|
+
const atByVendor = {};
|
|
41
|
+
const guardByVendor = {};
|
|
42
|
+
for (const vendor of POOL_VENDORS) {
|
|
43
|
+
const own = raw.atByVendor?.[vendor];
|
|
44
|
+
if (valid(own))
|
|
45
|
+
atByVendor[vendor] = own;
|
|
46
|
+
const ownGuard = raw.guardByVendor?.[vendor];
|
|
47
|
+
if (valid(ownGuard))
|
|
48
|
+
guardByVendor[vendor] = ownGuard;
|
|
33
49
|
const list = raw.members?.[vendor];
|
|
34
50
|
if (Array.isArray(list))
|
|
35
51
|
members[vendor] = list.filter((m) => typeof m?.name === "string" && typeof m?.dir === "string").map((m) => ({ name: m.name, dir: m.dir }));
|
|
36
52
|
}
|
|
37
|
-
return { at, swap: raw.swap === "launch" ? "launch" : "auto", members };
|
|
53
|
+
return { at, atByVendor, guard: valid(raw.guard) ? raw.guard : DEFAULT_GUARD, guardByVendor, swap: raw.swap === "launch" ? "launch" : "auto", members };
|
|
38
54
|
}
|
|
39
55
|
catch {
|
|
40
56
|
return empty;
|
|
@@ -47,6 +63,46 @@ export async function savePool(pool, env = process.env) {
|
|
|
47
63
|
await chmod(file, 0o600);
|
|
48
64
|
return file;
|
|
49
65
|
}
|
|
66
|
+
/** The threshold one vendor's logins move at: its own, else the shared one. */
|
|
67
|
+
export function thresholdFor(pool, vendor) {
|
|
68
|
+
return pool.atByVendor[vendor] ?? pool.at;
|
|
69
|
+
}
|
|
70
|
+
/** Both limits for one vendor: its own where set, else the shared ones. */
|
|
71
|
+
export function limitsFor(pool, vendor) {
|
|
72
|
+
return { at: thresholdFor(pool, vendor), guard: pool.guardByVendor[vendor] ?? pool.guard };
|
|
73
|
+
}
|
|
74
|
+
/** Set the weekly guard, shared or one vendor's; `undefined` for a vendor clears its override. */
|
|
75
|
+
export function setGuard(pool, pct, vendor) {
|
|
76
|
+
if (pct !== undefined && (!Number.isFinite(pct) || pct < 1 || pct > 100))
|
|
77
|
+
throw new Error("A threshold is a percent from 1 to 100.");
|
|
78
|
+
if (!vendor) {
|
|
79
|
+
if (pct === undefined)
|
|
80
|
+
throw new Error("A threshold is a percent from 1 to 100.");
|
|
81
|
+
return { ...pool, guard: Math.round(pct) };
|
|
82
|
+
}
|
|
83
|
+
const guardByVendor = { ...pool.guardByVendor };
|
|
84
|
+
if (pct === undefined)
|
|
85
|
+
delete guardByVendor[vendor];
|
|
86
|
+
else
|
|
87
|
+
guardByVendor[vendor] = Math.round(pct);
|
|
88
|
+
return { ...pool, guardByVendor };
|
|
89
|
+
}
|
|
90
|
+
/** Set the shared threshold, or one vendor's; `undefined` for a vendor clears its override. */
|
|
91
|
+
export function setThreshold(pool, pct, vendor) {
|
|
92
|
+
if (pct !== undefined && (!Number.isFinite(pct) || pct < 1 || pct > 100))
|
|
93
|
+
throw new Error("A threshold is a percent from 1 to 100.");
|
|
94
|
+
if (!vendor) {
|
|
95
|
+
if (pct === undefined)
|
|
96
|
+
throw new Error("A threshold is a percent from 1 to 100.");
|
|
97
|
+
return { ...pool, at: Math.round(pct) };
|
|
98
|
+
}
|
|
99
|
+
const atByVendor = { ...pool.atByVendor };
|
|
100
|
+
if (pct === undefined)
|
|
101
|
+
delete atByVendor[vendor];
|
|
102
|
+
else
|
|
103
|
+
atByVendor[vendor] = Math.round(pct);
|
|
104
|
+
return { ...pool, atByVendor };
|
|
105
|
+
}
|
|
50
106
|
export function addMember(pool, vendor, name, dir, env = process.env) {
|
|
51
107
|
if (!/^[A-Za-z0-9][A-Za-z0-9._@-]{0,63}$/.test(name))
|
|
52
108
|
throw new Error("A member name is letters, digits, dot, dash, underscore or @, up to 64.");
|
|
@@ -59,34 +115,115 @@ export function addMember(pool, vendor, name, dir, env = process.env) {
|
|
|
59
115
|
export function removeMember(pool, vendor, name) {
|
|
60
116
|
return { ...pool, members: { ...pool.members, [vendor]: (pool.members[vendor] ?? []).filter((m) => m.name !== name) } };
|
|
61
117
|
}
|
|
62
|
-
/** The
|
|
118
|
+
/** The folder the harness itself treats as home for this member. */
|
|
119
|
+
export function memberHome(vendor, member) {
|
|
120
|
+
return vendor === "openai" ? path.join(member.dir, "codex") : member.dir;
|
|
121
|
+
}
|
|
122
|
+
/** The env that makes a harness use this member's login folder. Each name is the harness's own. */
|
|
63
123
|
export function memberEnv(vendor, member) {
|
|
64
|
-
|
|
124
|
+
const home = memberHome(vendor, member);
|
|
125
|
+
if (vendor === "anthropic")
|
|
126
|
+
return { CLAUDE_CONFIG_DIR: home };
|
|
127
|
+
if (vendor === "openai")
|
|
128
|
+
return { CODEX_HOME: home };
|
|
129
|
+
if (vendor === "xai")
|
|
130
|
+
return { GROK_HOME: home };
|
|
131
|
+
// Two CLIs answer to `kimi`: Kimi Code reads KIMI_CODE_HOME, the older kimi-cli KIMI_SHARE_DIR.
|
|
132
|
+
return { KIMI_CODE_HOME: home, KIMI_SHARE_DIR: home };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Codex, Grok Build and Kimi Code keep conversations in `sessions` inside
|
|
136
|
+
* their home. Every member links that to one place, so the login that takes
|
|
137
|
+
* over finds the conversation the other was in. A home
|
|
138
|
+
* that already has its own `sessions` is left as it is.
|
|
139
|
+
*/
|
|
140
|
+
export async function prepareSharedSessions(vendor, member, env = process.env, userHome = homedir()) {
|
|
141
|
+
if (vendor === "anthropic")
|
|
142
|
+
return;
|
|
143
|
+
const home = memberHome(vendor, member);
|
|
144
|
+
const link = path.join(home, "sessions");
|
|
145
|
+
const poolShared = path.join(configDir(env), "pool", vendor, ".shared", "sessions");
|
|
146
|
+
const existing = await lstat(link).catch(() => undefined);
|
|
147
|
+
const pointsAt = existing?.isSymbolicLink() ? await readlink(link).catch(() => undefined) : undefined;
|
|
148
|
+
// A folder of its own, or a link somewhere we did not choose, is left alone.
|
|
149
|
+
if (existing && pointsAt !== poolShared)
|
|
150
|
+
return;
|
|
151
|
+
// The person's own conversations where that harness has a home here, so a
|
|
152
|
+
// pooled launch can resume what was started without the pool; else one
|
|
153
|
+
// folder shared by this vendor's members.
|
|
154
|
+
const own = { openai: [".codex"], xai: [".grok"], kimi: [".kimi-code", ".kimi"] }[vendor].map((d) => path.join(userHome, d));
|
|
155
|
+
let shared = poolShared;
|
|
156
|
+
for (const dir of own) {
|
|
157
|
+
if (path.resolve(dir) !== path.resolve(home) && (await lstat(dir).then((s) => s.isDirectory(), () => false))) {
|
|
158
|
+
shared = path.join(dir, "sessions");
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (existing && shared === poolShared)
|
|
163
|
+
return;
|
|
164
|
+
await mkdir(shared, { recursive: true, mode: 0o700 });
|
|
165
|
+
await mkdir(home, { recursive: true, mode: 0o700 });
|
|
166
|
+
if (existing) {
|
|
167
|
+
// An earlier link to the pool-only folder: carry its conversations over, then point at the person's own.
|
|
168
|
+
await moveInto(poolShared, shared).catch(() => undefined);
|
|
169
|
+
await unlink(link);
|
|
170
|
+
}
|
|
171
|
+
await symlink(shared, link);
|
|
172
|
+
}
|
|
173
|
+
/** `5h` is 300 minutes, `7d` 10080; a name that is not a length sorts last. */
|
|
174
|
+
export function windowMinutes(window) {
|
|
175
|
+
const m = /^(\d+)([mhd])$/.exec(window);
|
|
176
|
+
return m ? Number(m[1]) * (m[2] === "d" ? 1440 : m[2] === "h" ? 60 : 1) : Number.MAX_SAFE_INTEGER;
|
|
65
177
|
}
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
178
|
+
/**
|
|
179
|
+
* How used an account is. The short window (Claude's 5 hours, Codex's
|
|
180
|
+
* primary) is the usage: it runs out first and comes back within hours. The
|
|
181
|
+
* long one (the week) is kept apart as `longUsed`, and only matters once it
|
|
182
|
+
* is nearly spent. A window past its reset counts as nothing used.
|
|
183
|
+
*/
|
|
184
|
+
export function usage(quota, now = new Date()) {
|
|
69
185
|
if (quota.length === 0)
|
|
70
|
-
return
|
|
71
|
-
|
|
186
|
+
return {};
|
|
187
|
+
const sorted = [...quota].sort((x, y) => windowMinutes(x.window) - windowMinutes(y.window));
|
|
188
|
+
const pct = (w) => (!w.resetsAt || new Date(w.resetsAt).getTime() > now.getTime() ? w.usedPct : 0);
|
|
189
|
+
const short = sorted[0];
|
|
190
|
+
const long = sorted.length > 1 ? sorted.at(-1) : undefined;
|
|
191
|
+
return { used: pct(short), ...(long ? { longUsed: pct(long) } : {}) };
|
|
72
192
|
}
|
|
73
193
|
const room = (s) => s.used ?? 0;
|
|
74
|
-
|
|
75
|
-
export function
|
|
194
|
+
const longRoom = (s) => s.longUsed ?? 0;
|
|
195
|
+
export function isOver(s, limits) {
|
|
196
|
+
return room(s) >= limits.at || longRoom(s) >= limits.guard;
|
|
197
|
+
}
|
|
198
|
+
/** The vendor refuses every call: a window is fully spent. */
|
|
199
|
+
const spent = (s) => room(s) >= 100 || longRoom(s) >= 100;
|
|
200
|
+
/** Least short-window usage first, in steps of five points; within a step, the emptier week first. */
|
|
201
|
+
function byRoom(a, b) {
|
|
202
|
+
return Math.floor(room(a) / 5) - Math.floor(room(b) / 5) || longRoom(a) - longRoom(b) || room(a) - room(b);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Stay on the current login while it is fine; else the login with most
|
|
206
|
+
* room among those that are fine; and when none is, the least used that the
|
|
207
|
+
* vendor would still answer, rather than refusing to run.
|
|
208
|
+
*/
|
|
209
|
+
export function pickMember(all, limits, current) {
|
|
76
210
|
if (all.length === 0)
|
|
77
211
|
return undefined;
|
|
78
212
|
const now = all.find((s) => s.member.name === current);
|
|
79
|
-
if (now &&
|
|
213
|
+
if (now && !isOver(now, limits))
|
|
80
214
|
return now;
|
|
81
|
-
|
|
215
|
+
const fine = all.filter((s) => !isOver(s, limits)).sort(byRoom);
|
|
216
|
+
if (fine.length > 0)
|
|
217
|
+
return fine[0];
|
|
218
|
+
return [...all].sort((a, b) => Number(spent(a)) - Number(spent(b)) || byRoom(a, b))[0];
|
|
82
219
|
}
|
|
83
|
-
/** The login to move to, when the current one is over and another
|
|
84
|
-
export function shouldSwap(all,
|
|
220
|
+
/** The login to move to, when the current one is over and another is not. */
|
|
221
|
+
export function shouldSwap(all, limits, current) {
|
|
85
222
|
const now = all.find((s) => s.member.name === current);
|
|
86
|
-
if (!now ||
|
|
223
|
+
if (!now || !isOver(now, limits))
|
|
87
224
|
return undefined;
|
|
88
|
-
const best = pickMember(all,
|
|
89
|
-
return best && best.member.name !== current &&
|
|
225
|
+
const best = pickMember(all, limits, current);
|
|
226
|
+
return best && best.member.name !== current && !isOver(best, limits) ? best : undefined;
|
|
90
227
|
}
|
|
91
228
|
/** Arguments that reopen the conversation the previous login was in. */
|
|
92
229
|
export function relaunchArgs(harness, args) {
|
|
@@ -95,7 +232,7 @@ export function relaunchArgs(harness, args) {
|
|
|
95
232
|
const kept = [];
|
|
96
233
|
for (let i = 0; i < args.length; i += 1) {
|
|
97
234
|
const a = args[i];
|
|
98
|
-
if (a === "--continue" || a === "-c")
|
|
235
|
+
if (a === "--continue" || a === "-c" || a === "-C")
|
|
99
236
|
continue;
|
|
100
237
|
// One-shot and resume arguments belong to the first launch only.
|
|
101
238
|
if (a === "--resume" || a === "-r" || a === "-p" || a === "--print") {
|
package/dist/project.js
CHANGED
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
import { access, readFile, writeFile } from "node:fs/promises";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
export const PROJECT_FILE = ".flocktab";
|
|
11
|
+
/**
|
|
12
|
+
* The file's name. `tabdev` sets `FLOCKTAB_PROJECT_FILE=.flocktabdev` so a
|
|
13
|
+
* checkout under test never rewrites the folder's real `.flocktab`.
|
|
14
|
+
*/
|
|
15
|
+
export function projectFileName(env = process.env) {
|
|
16
|
+
const name = env.FLOCKTAB_PROJECT_FILE?.trim();
|
|
17
|
+
return name && /^\.[A-Za-z0-9._-]{1,40}$/.test(name) ? name : PROJECT_FILE;
|
|
18
|
+
}
|
|
11
19
|
async function exists(file) {
|
|
12
20
|
try {
|
|
13
21
|
await access(file);
|
|
@@ -21,7 +29,7 @@ async function exists(file) {
|
|
|
21
29
|
export async function findProjectFile(cwd) {
|
|
22
30
|
let dir = path.resolve(cwd);
|
|
23
31
|
for (;;) {
|
|
24
|
-
const candidate = path.join(dir,
|
|
32
|
+
const candidate = path.join(dir, projectFileName());
|
|
25
33
|
if (await exists(candidate))
|
|
26
34
|
return candidate;
|
|
27
35
|
if (await exists(path.join(dir, ".git")))
|
|
@@ -60,7 +68,7 @@ export async function projectRoot(cwd) {
|
|
|
60
68
|
}
|
|
61
69
|
}
|
|
62
70
|
export async function writeProject(dir, config) {
|
|
63
|
-
const file = path.join(dir,
|
|
71
|
+
const file = path.join(dir, projectFileName());
|
|
64
72
|
await writeFile(file, `${JSON.stringify(config)}\n`);
|
|
65
73
|
return file;
|
|
66
74
|
}
|
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.8";
|
|
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,32 @@
|
|
|
1
|
+
import { presentedKey } from "./config.js";
|
|
2
|
+
import { requestJson } from "./network.js";
|
|
3
|
+
/** A fresh authenticated identity also proves the hosted proxy is ready for this Agent. */
|
|
4
|
+
export async function whoami(proxyUrl, login, fetchImpl = fetch) {
|
|
5
|
+
try {
|
|
6
|
+
const { response, body } = await requestJson(`${proxyUrl}/v1/whoami`, {
|
|
7
|
+
headers: { authorization: `Bearer ${presentedKey(login)}` },
|
|
8
|
+
}, fetchImpl);
|
|
9
|
+
if (!response.ok)
|
|
10
|
+
return {
|
|
11
|
+
error: body.error?.message ?? `whoami returned ${response.status}`,
|
|
12
|
+
};
|
|
13
|
+
if (!body.agent?.id ||
|
|
14
|
+
typeof body.agent.name !== "string" ||
|
|
15
|
+
typeof body.flock?.plan !== "string" ||
|
|
16
|
+
typeof body.byok !== "boolean") {
|
|
17
|
+
return { error: "proxy returned an invalid identity" };
|
|
18
|
+
}
|
|
19
|
+
if (body.kind !== undefined &&
|
|
20
|
+
body.kind !== "api" &&
|
|
21
|
+
body.kind !== "subscription")
|
|
22
|
+
return { error: "proxy returned an invalid Agent kind" };
|
|
23
|
+
return body;
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
return {
|
|
27
|
+
error: error instanceof DOMException && error.name === "TimeoutError"
|
|
28
|
+
? "proxy timed out"
|
|
29
|
+
: "proxy unreachable",
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|