@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/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 at = typeof raw.at === "number" && raw.at >= 1 && raw.at <= 100 ? raw.at : DEFAULT_AT;
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
- for (const vendor of ["anthropic", "openai"]) {
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 env that makes a harness use this member's login folder. */
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
- return vendor === "anthropic" ? { CLAUDE_CONFIG_DIR: member.dir } : { CODEX_HOME: path.join(member.dir, "codex") };
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
- /** How used an account is: its fullest window that has not reset yet. */
67
- export function usedPct(quota, now = new Date()) {
68
- const live = quota.filter((w) => !w.resetsAt || new Date(w.resetsAt).getTime() > now.getTime());
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 undefined;
71
- return live.reduce((max, w) => Math.max(max, w.usedPct), 0);
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
- /** Stay on the current login while it is under the threshold; else the one with most room. */
75
- export function pickMember(all, at, current) {
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 && room(now) < at)
213
+ if (now && !isOver(now, limits))
80
214
  return now;
81
- return [...all].sort((a, b) => room(a) - room(b))[0];
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 has room. */
84
- export function shouldSwap(all, at, current) {
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 || room(now) < at)
223
+ if (!now || !isOver(now, limits))
87
224
  return undefined;
88
- const best = pickMember(all, at, current);
89
- return best && best.member.name !== current && room(best) < at ? best : undefined;
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, PROJECT_FILE);
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, PROJECT_FILE);
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.7";
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
+ }