@prplkane/roundtable 0.0.0-stage → 0.6.3
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/CHANGELOG.md +513 -0
- package/LICENSE +21 -0
- package/README.md +188 -3
- package/SECURITY.md +299 -0
- package/docs/workflow.md +1325 -0
- package/package.json +52 -6
- package/scripts/agent.mjs +40 -0
- package/scripts/export.mjs +20 -0
- package/scripts/mcp.mjs +175 -0
- package/scripts/run-debate.mjs +70 -0
- package/scripts/server.mjs +28 -0
- package/scripts/setup.mjs +182 -0
- package/skills/roundtable/SKILL.md +55 -0
- package/src/adapters/agy.js +7 -0
- package/src/adapters/catalogue.js +49 -0
- package/src/adapters/claude.js +6 -0
- package/src/adapters/codex.js +6 -0
- package/src/adapters/common.js +304 -0
- package/src/adapters/config.js +432 -0
- package/src/adapters/endpoint.js +178 -0
- package/src/adapters/errors.js +69 -0
- package/src/adapters/index.js +11 -0
- package/src/adapters/models.js +106 -0
- package/src/adapters/quota.js +305 -0
- package/src/adapters/spawn.js +178 -0
- package/src/agent-client.js +108 -0
- package/src/cli.js +44 -0
- package/src/contract.js +256 -0
- package/src/diagnostics.js +133 -0
- package/src/doctor.js +96 -0
- package/src/engine.js +902 -0
- package/src/estimate.js +331 -0
- package/src/export.js +307 -0
- package/src/fake.js +75 -0
- package/src/folder-picker.js +46 -0
- package/src/history.js +220 -0
- package/src/local-http.js +39 -0
- package/src/local-server.js +82 -0
- package/src/notes.js +16 -0
- package/src/personas.js +49 -0
- package/src/preflight.js +126 -0
- package/src/progress.js +30 -0
- package/src/prompts.js +278 -0
- package/src/research.js +113 -0
- package/src/review-view.js +157 -0
- package/src/seats.js +272 -0
- package/src/server.js +913 -0
- package/src/settings.js +27 -0
- package/src/setup-toml.js +124 -0
- package/src/snapshot.js +93 -0
- package/src/store.js +303 -0
- package/src/terminal.js +53 -0
- package/src/terms.js +52 -0
- package/src/ui/app.js +1967 -0
- package/src/ui/avatars.js +132 -0
- package/src/ui/ceremony.js +87 -0
- package/src/ui/design/app.css +714 -0
- package/src/ui/design/preset-dusk.css +91 -0
- package/src/ui/design/tokens.css +129 -0
- package/src/ui/diagnostics.js +61 -0
- package/src/ui/help.js +122 -0
- package/src/ui/icons.js +64 -0
- package/src/ui/index.html +272 -0
- package/src/ui/loader.js +73 -0
- package/src/ui/logo.svg +4 -0
- package/src/ui/scan.js +57 -0
- package/src/ui/scribe.js +64 -0
- package/src/ui/sound.js +80 -0
- package/src/validate.js +112 -0
- package/src/version.js +5 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// What went wrong with a CLI call, in a word the engine and the board can act on:
|
|
2
|
+
//
|
|
3
|
+
// quota the account's limit is used up: no point calling this pool again this debate
|
|
4
|
+
// auth not logged in, or the login expired: same
|
|
5
|
+
// missing the CLI is not installed: same
|
|
6
|
+
// network the service could not be reached: worth one retry
|
|
7
|
+
// timeout the call ran past its time
|
|
8
|
+
// killed stopped by the human, the wall clock or a shutdown
|
|
9
|
+
// policy the provider boundary could not be trusted: stop this seat for the review
|
|
10
|
+
// other anything else: the seat fails for the round, as before
|
|
11
|
+
//
|
|
12
|
+
// classifyError({ message, stdout, stderr, code, label }, now) -> { kind, message, resetsAt, detail }
|
|
13
|
+
// Pure: tested on captured CLI text. `message` is the plain sentence for the board; `detail` keeps
|
|
14
|
+
// the CLI's own words.
|
|
15
|
+
|
|
16
|
+
const QUOTA = /usage limit|limit reached|hit your (?:usage )?limit|rate[- ]?limit|quota|too many requests|\b429\b|out of credits|insufficient credits|credit balance/i;
|
|
17
|
+
const AUTH = /not logged in|please (?:run )?\/?login|log ?in (?:again|required|first)|unauthori[sz]ed|\b401\b|invalid api key|authentication (?:failed|required|error)|session expired|token (?:has )?expired|re-?authenticate/i;
|
|
18
|
+
const NETWORK = /ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|socket hang up|fetch failed|getaddrinfo|network (?:error|is unreachable)|connection (?:error|reset|refused)|\b50[234]\b|overloaded|\b529\b|service unavailable/i;
|
|
19
|
+
const LOGIN = { claude: 'claude /login', codex: 'codex login', antigravity: 'agy (sign in in a terminal)' };
|
|
20
|
+
|
|
21
|
+
const MONTHS = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 };
|
|
22
|
+
|
|
23
|
+
// When the limit lifts, if the CLI says: "try again at Oct 27th, 2026 12:51 PM" (codex), or a
|
|
24
|
+
// trailing "|<epoch seconds>" (claude). Milliseconds, or null.
|
|
25
|
+
export function resetTime(text, now = Date.now()) {
|
|
26
|
+
const epoch = /\|(\d{10})\b/.exec(text);
|
|
27
|
+
if (epoch) return Number(epoch[1]) * 1000;
|
|
28
|
+
const m = /(?:try again|resets?|available again)\s+(?:at|on)?\s*([A-Z][a-z]{2})[a-z]*\.?\s+(\d{1,2})(?:st|nd|rd|th)?,?\s*(\d{4})?,?\s*(?:at\s*)?(\d{1,2}):(\d{2})\s*([AP]M)?/i.exec(text);
|
|
29
|
+
if (!m) return null;
|
|
30
|
+
const month = MONTHS[m[1].toLowerCase()];
|
|
31
|
+
if (month == null) return null;
|
|
32
|
+
let hour = Number(m[4]) % 12;
|
|
33
|
+
if (m[6]?.toUpperCase() === 'PM') hour += 12; else if (!m[6]) hour = Number(m[4]);
|
|
34
|
+
const year = m[3] ? Number(m[3]) : new Date(now).getFullYear();
|
|
35
|
+
return new Date(year, month, Number(m[2]), hour, Number(m[5])).getTime();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const when = (ms) => new Date(ms).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
|
39
|
+
|
|
40
|
+
export function classifyError({ message = '', stdout = '', stderr = '', code = null, label = 'The CLI' } = {}, now = Date.now()) {
|
|
41
|
+
// the CLI's own last words carry the reason; the wrapper's message frames it
|
|
42
|
+
const text = [message, stderr, stdout.split(/\r?\n/).filter((l) => /error|fail|limit|login|denied|quota/i.test(l)).slice(-3).join('\n')].join('\n');
|
|
43
|
+
const detail = String(message).slice(0, 600);
|
|
44
|
+
if (code === 'RESEARCH_LIMIT') return { kind: 'budget', message: 'The reported search-operation limit was exceeded. Research stopped before discussion.', resetsAt: null, detail };
|
|
45
|
+
if (code === 'RESEARCH_INVALID') return { kind: 'format', message: 'Research did not produce usable web evidence. Discussion was not started; check the research details before trying again.', resetsAt: null, detail };
|
|
46
|
+
if (code === 'TOOL_POLICY') return { kind: 'policy', message: 'Provider tool policy rejected this call. This seat is stopped for the review.', resetsAt: null, detail };
|
|
47
|
+
if (code === 'PROVIDER_PROTOCOL') return { kind: 'policy', message: 'Provider output could not be validated. This seat is stopped for the review.', resetsAt: null, detail };
|
|
48
|
+
if (code === 'OUTPUT_LIMIT') return { kind: 'policy', message: 'Provider output exceeded the local size limit. This seat is stopped for the review.', resetsAt: null, detail };
|
|
49
|
+
if (code === 'ISOLATION_REQUIRED') return { kind: 'policy', message: 'Antigravity cannot disable all shared customizations or MCP discovery. Enable Allow limited isolation explicitly, or choose another provider.', resetsAt: null, detail };
|
|
50
|
+
if (code === 'TERMS_ACK_REQUIRED') return { kind: 'policy', message: `${label} was not called: its maker says wrapping it in third-party applications is unsupported on some plans and can put the account at risk. Acknowledge its notice from a ${label} card on the setup screen, or choose another provider.`, resetsAt: null, detail };
|
|
51
|
+
if (/unknown (?:option|argument)|unexpected argument|unrecognized (?:option|argument)/i.test(text)) return { kind: 'compatibility', message: `${label} does not support a required CLI option. Update the CLI; Roundtable will not retry with weaker restrictions.`, resetsAt: null, detail };
|
|
52
|
+
if (code === 'ENOENT' || /not found on PATH/i.test(text)) return { kind: 'missing', message: `${label} CLI not found on this machine.`, resetsAt: null, detail };
|
|
53
|
+
if (code === 'KILLED') return { kind: 'killed', message: 'Stopped before it answered.', resetsAt: null, detail };
|
|
54
|
+
if (code === 'TIMEOUT') return { kind: 'timeout', message: `${label} did not answer in time.`, resetsAt: null, detail };
|
|
55
|
+
if (code === 'STALL') return { kind: 'timeout', message: `${label}'s web search stalled: no progress for ${detail.match(/\d+ s/)?.[0] || 'a while'}.`, resetsAt: null, detail };
|
|
56
|
+
if (code === 'CLI_FAILURE' && /idle timeout|timeout waiting for (?:SSE|stream)/i.test(text)) return {
|
|
57
|
+
kind: 'timeout', message: `${label}'s provider connection stalled before completion.`, resetsAt: null, detail,
|
|
58
|
+
};
|
|
59
|
+
if (QUOTA.test(text)) {
|
|
60
|
+
const resetsAt = resetTime(text, now);
|
|
61
|
+
return { kind: 'quota', message: `${label}: usage limit reached${resetsAt ? `, resets ${when(resetsAt)}` : ''}.`, resetsAt, detail };
|
|
62
|
+
}
|
|
63
|
+
if (AUTH.test(text)) return { kind: 'auth', message: `${label}: not logged in. Run ${LOGIN[label.toLowerCase()] || 'its login'} in a terminal.`, resetsAt: null, detail };
|
|
64
|
+
if (NETWORK.test(text)) return { kind: 'network', message: `${label}: could not reach the service.`, resetsAt: null, detail };
|
|
65
|
+
return { kind: 'other', message: String(message).slice(0, 300), resetsAt: null, detail };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Kinds after which calling the same pool again in this debate only fails again.
|
|
69
|
+
export const POOL_FATAL = new Set(['quota', 'auth', 'missing']);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { run as claude } from './claude.js';
|
|
2
|
+
import { run as codex } from './codex.js';
|
|
3
|
+
import { run as agy } from './agy.js';
|
|
4
|
+
import { run as api } from './endpoint.js';
|
|
5
|
+
import { ADAPTERS, poolsByAccount } from './config.js';
|
|
6
|
+
import { readCodexQuota, readAgyQuota, readClaudeQuota } from './quota.js';
|
|
7
|
+
|
|
8
|
+
export const adapters = { claude, codex, agy, api };
|
|
9
|
+
// Vendors whose quota needs a separate read (no model call). The others ride on replies or have none.
|
|
10
|
+
export const quotaReaders = { claude: readClaudeQuota, codex: readCodexQuota, agy: readAgyQuota };
|
|
11
|
+
export { ADAPTERS, poolsByAccount };
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Model names and supported effort levels. Pure and browser-safe. Account access is decided
|
|
2
|
+
// by the vendor CLI, not inferred from a subscription name in the picker.
|
|
3
|
+
//
|
|
4
|
+
// From the vendors' own pages; re-check when a vendor ships or retires a model:
|
|
5
|
+
// claude https://code.claude.com/docs/en/model-config
|
|
6
|
+
// codex https://learn.chatgpt.com/docs/models
|
|
7
|
+
// agy `agy models` already lists what the signed-in account can use; nothing to add here.
|
|
8
|
+
// Its quota groups (which limits a model counts against) are matched below.
|
|
9
|
+
|
|
10
|
+
export const MODELS = {
|
|
11
|
+
claude: [
|
|
12
|
+
{ id: 'fable', label: 'Fable 5.1' },
|
|
13
|
+
{ id: 'opus', label: 'Opus 5.5', note: 'default' },
|
|
14
|
+
{ id: 'sonnet', label: 'Sonnet 5.5' },
|
|
15
|
+
// Pin the intended Haiku version instead of relying on the CLI alias.
|
|
16
|
+
{ id: 'claude-haiku-4-5', label: 'Haiku 4.5', note: 'lightest' },
|
|
17
|
+
],
|
|
18
|
+
codex: [
|
|
19
|
+
{ id: 'gpt-6-astra', label: 'GPT-6 Astra' },
|
|
20
|
+
{ id: 'gpt-6.1-sol', label: 'GPT-6.1 Sol' },
|
|
21
|
+
{ id: 'gpt-6-sol', label: 'GPT-6 Sol' },
|
|
22
|
+
{ id: 'gpt-6.1-terra', label: 'GPT-6.1 Terra' },
|
|
23
|
+
// efforts: only where a model takes fewer levels than its vendor offers (`codex debug models`
|
|
24
|
+
// supported_reasoning_levels: the Luna models stop at max)
|
|
25
|
+
{ id: 'gpt-6-luna', label: 'GPT-6 Luna', note: 'lightest', efforts: ['low', 'medium', 'high', 'xhigh', 'max'] },
|
|
26
|
+
],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Broad capability tiers for the picker, not a benchmark ranking across vendors.
|
|
30
|
+
// Unknown models retain their provider order after the known families.
|
|
31
|
+
export function modelsByCapability(models = []) {
|
|
32
|
+
const tier = (model) => {
|
|
33
|
+
const name = `${model.id} ${model.label || ''}`.toLowerCase();
|
|
34
|
+
for (const [pattern, rank] of [[/\b(fable|astra)\b/, 6], [/\b(opus|argon)\b|\bgemini\b.*\bpro\b/, 5], [/\b(sol|sonnet)\b/, 4], [/\bterra\b/, 3], [/\b(flash|120b)\b/, 2], [/\b(haiku|luna|20b)\b/, 1]]) {
|
|
35
|
+
if (pattern.test(name)) return rank;
|
|
36
|
+
}
|
|
37
|
+
return 0;
|
|
38
|
+
};
|
|
39
|
+
return models.slice().sort((a, b) => tier(b) - tier(a));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The effort levels a model takes: its vendor's levels, narrowed where the table names fewer.
|
|
43
|
+
// A model the table does not know (a typed id, the CLI default) keeps the vendor's levels.
|
|
44
|
+
export function modelEfforts(vendor, model, levels = []) {
|
|
45
|
+
const known = (MODELS[vendor] || []).find((m) => m.id === String(model || '').trim());
|
|
46
|
+
return known?.efforts ? levels.filter((l) => known.efforts.includes(l)) : levels;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Retired for ChatGPT sign-in: a call with one of these fails. id -> [date, replacement].
|
|
50
|
+
export const RETIRED = {
|
|
51
|
+
codex: {
|
|
52
|
+
'gpt-5.5': ['2026-10-14', 'gpt-6.1-sol'], 'gpt-5.4': ['2026-08-31', 'gpt-6-sol'], 'gpt-5.4-mini': ['2026-08-31', 'gpt-6-luna'],
|
|
53
|
+
'gpt-5.3-codex-spark': ['2026-09-14', 'gpt-6.1-sol'], 'gpt-5.3-codex': ['deprecated', 'gpt-6.1-sol'], 'gpt-5.2': ['deprecated', 'gpt-6.1-sol'],
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export function retired(vendor, id) {
|
|
58
|
+
const r = RETIRED[vendor]?.[id];
|
|
59
|
+
return r ? { date: r[0], instead: r[1] } : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// agy ids carry the effort level ("gemini-4-argon-high"): one entry per model, the levels it has.
|
|
63
|
+
export function agyFamilies(models = []) {
|
|
64
|
+
const fam = new Map();
|
|
65
|
+
for (const m of models) {
|
|
66
|
+
const hit = /^(.*)-(low|medium|high)$/.exec(m.id);
|
|
67
|
+
const id = hit ? hit[1] : m.id;
|
|
68
|
+
const label = String(m.label || id).replace(/\s*\((low|medium|high)\)\s*$/i, '');
|
|
69
|
+
if (!fam.has(id)) fam.set(id, { id, label, levels: [] });
|
|
70
|
+
if (hit) fam.get(id).levels.push(hit[2]);
|
|
71
|
+
}
|
|
72
|
+
const order = ['low', 'medium', 'high'];
|
|
73
|
+
return [...fam.values()].map((f) => ({ ...f, levels: f.levels.sort((a, b) => order.indexOf(a) - order.indexOf(b)) }));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// agy's quota comes in groups, each with its own windows (quota.js agyQuota). Which group a model
|
|
77
|
+
// draws on is read from the CLI's own words: every word of the group's name, or of a model the
|
|
78
|
+
// group lists ("Gemini Flash", "GPT-OSS"), is in the id. The longest such name wins. A model none
|
|
79
|
+
// of them names goes to the one group of its family, if there is only one: an Argon model on a
|
|
80
|
+
// plan without a separate Argon group counts against the Gemini group. null: no group can be
|
|
81
|
+
// told, and every window applies.
|
|
82
|
+
const words = (s) => String(s || '').toLowerCase().split(/[^a-z0-9.]+/).filter(Boolean);
|
|
83
|
+
export function quotaGroup(groups, model) {
|
|
84
|
+
const id = words(model);
|
|
85
|
+
if (!id.length || !Array.isArray(groups)) return null;
|
|
86
|
+
const names = (g) => [g.name, ...(g.models || [])].map(words);
|
|
87
|
+
let best = null, score = 0;
|
|
88
|
+
for (const g of groups) for (const w of names(g)) if (w.length > score && w.every((x) => id.includes(x))) { best = g.name; score = w.length; }
|
|
89
|
+
if (best) return best;
|
|
90
|
+
const family = groups.filter((g) => names(g).some((w) => w[0] === id[0]));
|
|
91
|
+
return family.length === 1 ? family[0].name : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The windows that apply to these models: their groups' when each model's group is known, else
|
|
95
|
+
// every window (a quota without groups, a default model, an id no group names).
|
|
96
|
+
export function quotaWindows(quota, models = []) {
|
|
97
|
+
const windows = quota?.windows || [];
|
|
98
|
+
if (!quota?.groups?.length) return windows;
|
|
99
|
+
const groups = new Set();
|
|
100
|
+
for (const m of [].concat(models)) {
|
|
101
|
+
const g = quotaGroup(quota.groups, m);
|
|
102
|
+
if (!g) return windows;
|
|
103
|
+
groups.add(g);
|
|
104
|
+
}
|
|
105
|
+
return groups.size ? windows.filter((w) => groups.has(w.group)) : windows;
|
|
106
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// Remaining subscription quota per vendor.
|
|
2
|
+
//
|
|
3
|
+
// One shape for the board, whatever the vendor reports:
|
|
4
|
+
// { windows: [{ label: '5h' | '7d' | ..., used: 0..1, resetsAt: epoch seconds | null }],
|
|
5
|
+
// plan: string | null, at: epoch ms }
|
|
6
|
+
// A vendor whose limits differ by model adds groups: each window names its `group`, and
|
|
7
|
+
// `groups: [{ name, models }]` lets models.js quotaGroup say which group a seat's model uses.
|
|
8
|
+
//
|
|
9
|
+
// claude separate read, no model call: `claude -p /usage --output-format json` is answered by
|
|
10
|
+
// the CLI itself (no turn, zero tokens, local_command "usage") -> sentences in `result`
|
|
11
|
+
// ("Current session: 53% used · resets Nov 2, 4:59am (Europe/Berlin)"). Each reply also
|
|
12
|
+
// carries a rate_limit_event (rate_limit_info.unifiedWindows, utilization 0..1), used
|
|
13
|
+
// when that read gives nothing.
|
|
14
|
+
// codex separate read, no model call: `codex app-server` over stdio, JSON-RPC
|
|
15
|
+
// account/rateLimits/read -> rateLimits.{primary,secondary}.usedPercent (0..100).
|
|
16
|
+
// agy separate read, no model call: `agy -p /usage --output-format json` is answered by the
|
|
17
|
+
// CLI itself (no turn, zero tokens) -> command.data.groups[].buckets[] with window,
|
|
18
|
+
// remaining_fraction (0..1) and reset_time (ISO, UTC). Groups are model families.
|
|
19
|
+
import { resolveBinary, runProcess, cleanEnv, dataDir } from './spawn.js';
|
|
20
|
+
import { VERSION } from '../version.js';
|
|
21
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
22
|
+
import { mkdirSync } from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
|
|
25
|
+
// Keys seen in rate_limit_info.unifiedWindows (the third appeared in the first real debate after the
|
|
26
|
+
// spike). An unknown key is shown with its underscores as spaces rather than hidden.
|
|
27
|
+
const WINDOW_LABELS = { five_hour: '5h', seven_day: '7d', seven_day_overage_included: '7d incl. overage' };
|
|
28
|
+
const windowKeyLabel = (k) => WINDOW_LABELS[k] || k.replace(/_/g, ' ');
|
|
29
|
+
|
|
30
|
+
// claude: rate_limit_info from the last rate_limit_event of a stream-json call.
|
|
31
|
+
export function claudeQuota(info, at = Date.now()) {
|
|
32
|
+
const wins = info?.unifiedWindows;
|
|
33
|
+
if (!wins || typeof wins !== 'object') return null;
|
|
34
|
+
const windows = Object.entries(wins)
|
|
35
|
+
.filter(([, w]) => w && typeof w.utilization === 'number')
|
|
36
|
+
.map(([k, w]) => ({ label: windowKeyLabel(k), used: w.utilization, resetsAt: w.resetsAt ?? null }));
|
|
37
|
+
return windows.length ? { windows, plan: null, at } : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// claude: the text `claude -p /usage` prints. Only the limit lines are read:
|
|
41
|
+
// Current session: 53% used · resets Nov 2, 4:59am (Europe/Berlin)
|
|
42
|
+
// Current week (all models): 57% used · resets Nov 2, 8:59pm (Europe/Berlin)
|
|
43
|
+
// Current week (Fable): 75% used · resets Nov 2, 8:59pm (Europe/Berlin)
|
|
44
|
+
// The same answer summarises local activity (requests, sessions, skills, MCP servers); none of it
|
|
45
|
+
// is kept. Labels match the reply's rate_limit_event (5h, 7d), so calibration compares like with
|
|
46
|
+
// like; a week for one model family becomes "7d <family>".
|
|
47
|
+
const CLAUDE_LIMIT = /^\s*Current (session|week)(?:\s*\(([^)]+)\))?\s*:\s*(\d{1,3}(?:\.\d+)?)%\s+used\b(?:\s*\S\s*resets\s+(.+?))?\s*$/i;
|
|
48
|
+
export function claudeUsage(text, at = Date.now()) {
|
|
49
|
+
const windows = [];
|
|
50
|
+
for (const line of String(text || '').split(/\r?\n/)) {
|
|
51
|
+
const m = CLAUDE_LIMIT.exec(line);
|
|
52
|
+
if (!m) continue;
|
|
53
|
+
const family = m[2] && !/^all models$/i.test(m[2].trim()) ? m[2].trim() : null;
|
|
54
|
+
const label = m[1].toLowerCase() === 'session' ? '5h' : family ? `7d ${family}` : '7d';
|
|
55
|
+
if (windows.some((w) => w.label === label)) continue;
|
|
56
|
+
windows.push({ label, used: Math.min(1, Number(m[3]) / 100), resetsAt: m[4] ? claudeResetAt(m[4], at) : null });
|
|
57
|
+
}
|
|
58
|
+
return windows.length ? { windows, plan: null, at } : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// "Nov 2, 4:59am (Europe/Berlin)", or "4:59am (Europe/Berlin)" for later today: a wall-clock time
|
|
62
|
+
// in a named zone, without a year. The next such moment from `now`, in epoch seconds; null when
|
|
63
|
+
// the text or the zone cannot be read (the reply's figure then supplies exact reset times).
|
|
64
|
+
const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
|
65
|
+
function zoneParts(ms, zone) {
|
|
66
|
+
const parts = new Intl.DateTimeFormat('en-US', { timeZone: zone, hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' }).formatToParts(ms);
|
|
67
|
+
const n = Object.fromEntries(parts.filter((p) => p.type !== 'literal').map((p) => [p.type, Number(p.value)]));
|
|
68
|
+
return n;
|
|
69
|
+
}
|
|
70
|
+
function zoneToEpochMs(year, month, day, hour, minute, zone) {
|
|
71
|
+
const wall = Date.UTC(year, month, day, hour, minute);
|
|
72
|
+
const offset = (ms) => { const p = zoneParts(ms, zone); return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - ms; };
|
|
73
|
+
const first = wall - offset(wall);
|
|
74
|
+
return wall - offset(first); // a second pass settles a daylight-saving change between the two
|
|
75
|
+
}
|
|
76
|
+
export function claudeResetAt(text, now = Date.now()) {
|
|
77
|
+
const m = /^(?:([a-z]{3})[a-z]*\.?\s+(\d{1,2}),?\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(([^)]+)\)\s*$/i.exec(String(text).trim());
|
|
78
|
+
if (!m) return null;
|
|
79
|
+
let today;
|
|
80
|
+
try { today = zoneParts(now, m[6].trim()); } catch { return null; } // a zone this runtime does not know
|
|
81
|
+
if (Number(m[3]) < 1 || Number(m[3]) > 12) return null; // a 12-hour clock: 13pm is no time
|
|
82
|
+
const hour = (Number(m[3]) % 12) + (m[5].toLowerCase() === 'pm' ? 12 : 0), minute = Number(m[4] || 0);
|
|
83
|
+
if (minute > 59) return null;
|
|
84
|
+
const month = m[1] ? MONTHS.indexOf(m[1].toLowerCase()) : today.month - 1;
|
|
85
|
+
if (month < 0) return null;
|
|
86
|
+
// A dated reset up to a day past is a display a little behind, not next year's; an undated one
|
|
87
|
+
// already past means tomorrow.
|
|
88
|
+
const candidates = m[1] ? [[today.year, month, Number(m[2])], [today.year + 1, month, Number(m[2])]]
|
|
89
|
+
: [[today.year, month, today.day], [today.year, month, today.day + 1]];
|
|
90
|
+
const grace = m[1] ? 86_400_000 : 60_000;
|
|
91
|
+
for (const [y, mo, d] of candidates) {
|
|
92
|
+
const t = zoneToEpochMs(y, mo, d, hour, minute, m[6].trim());
|
|
93
|
+
if (t >= now - grace) return Math.round(t / 1000);
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The read is free only when the CLI answered it as a local command: no turn, no cost, every
|
|
99
|
+
// token counter zero. Anything else, an unreadable answer included, is not shown free.
|
|
100
|
+
const CLAUDE_TOKENS = ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens'];
|
|
101
|
+
export function claudeReadWasFree(obj) {
|
|
102
|
+
const u = obj?.usage;
|
|
103
|
+
return !!u && typeof u === 'object' && obj.num_turns === 0 && obj.local_command === 'usage'
|
|
104
|
+
&& (obj.total_cost_usd ?? 0) === 0 && CLAUDE_TOKENS.every((k) => (u[k] ?? 0) === 0);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The seats' lockdown (config.js claude args) minus --disable-slash-commands, which /usage needs.
|
|
108
|
+
// --model haiku: should a CLI ever send "/usage" to a model, the stray turn is the cheapest one,
|
|
109
|
+
// and the latch below stops every later read.
|
|
110
|
+
export const CLAUDE_USAGE_ARGS = ['-p', '/usage', '--output-format', 'json', '--model', 'haiku', '--permission-mode', 'plan',
|
|
111
|
+
'--tools', '', '--disallowedTools', 'mcp__*', '--setting-sources', 'local', '--strict-mcp-config', '--safe-mode', '--no-session-persistence'];
|
|
112
|
+
|
|
113
|
+
// A reader with its own latch, as for agy: an answer that is not shown free, or a read cut off by
|
|
114
|
+
// the timeout, stops every later read until the server restarts. A proven-free answer without
|
|
115
|
+
// limit lines, or with an error, is no figure this time; the next ordinary read may work.
|
|
116
|
+
// It runs from an empty folder
|
|
117
|
+
// so no project settings or instructions load, with the seats' cleaned environment (a shell
|
|
118
|
+
// another agent CLI opened changes how the CLI treats slash commands). home: a named profile's
|
|
119
|
+
// CLAUDE_CONFIG_DIR. run, resolve and cwd are injectable so tests never spawn the CLI.
|
|
120
|
+
export function claudeQuotaReader({ run = runProcess, resolve = resolveBinary, warn = console.error, cwd = null } = {}) {
|
|
121
|
+
let stopped = null;
|
|
122
|
+
const stop = (why) => { stopped = why; warn(`[quota] claude: ${why}; Claude quota reads are off until the server restarts`); return null; };
|
|
123
|
+
const read = async ({ timeoutMs = 30_000, home = null } = {}) => {
|
|
124
|
+
if (stopped) return null;
|
|
125
|
+
let bin;
|
|
126
|
+
try { bin = resolve('claude'); } catch { return null; }
|
|
127
|
+
const dir = cwd || path.join(dataDir(), 'quota', 'claude');
|
|
128
|
+
let out;
|
|
129
|
+
try {
|
|
130
|
+
mkdirSync(dir, { recursive: true });
|
|
131
|
+
out = (await run(bin, CLAUDE_USAGE_ARGS, { timeoutMs, cwd: dir, env: cleanEnv(home ? { env: 'CLAUDE_CONFIG_DIR', dir: home } : null) })).stdout;
|
|
132
|
+
} catch (e) {
|
|
133
|
+
if (e.code === 'TIMEOUT') return stop(`/usage gave no answer within ${timeoutMs} ms, so it may have reached a model`);
|
|
134
|
+
return null; // it did not start, or the server is shutting down
|
|
135
|
+
}
|
|
136
|
+
const obj = lastJson(out);
|
|
137
|
+
if (!claudeReadWasFree(obj)) return stop(obj ? '/usage did not answer as a local command (a turn, tokens or a cost)' : '/usage gave no JSON answer');
|
|
138
|
+
if (obj.is_error) return null;
|
|
139
|
+
return claudeUsage(obj.result);
|
|
140
|
+
};
|
|
141
|
+
read.stopped = () => stopped;
|
|
142
|
+
return read;
|
|
143
|
+
}
|
|
144
|
+
export const readClaudeQuota = claudeQuotaReader();
|
|
145
|
+
|
|
146
|
+
// codex: the result object of account/rateLimits/read.
|
|
147
|
+
export function codexQuota(result, at = Date.now()) {
|
|
148
|
+
const rl = result?.rateLimits;
|
|
149
|
+
if (!rl) return null;
|
|
150
|
+
const windows = [rl.primary, rl.secondary]
|
|
151
|
+
.filter((w) => w && typeof w.usedPercent === 'number')
|
|
152
|
+
.map((w) => ({ label: windowLabel(w.windowDurationMins), used: w.usedPercent / 100, resetsAt: w.resetsAt ?? null }));
|
|
153
|
+
return windows.length ? { windows, plan: rl.planType ?? null, at } : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// agy: the parsed answer to `-p /usage`. A window is labelled by its group's short name and its
|
|
157
|
+
// span ("Gemini 5h", "Claude and GPT 7d"), unique in the pool, since calibration matches windows
|
|
158
|
+
// by label. A bucket without a figure (the CLI shows a disabled one dimmed) is left out, and so is
|
|
159
|
+
// a group left with no window.
|
|
160
|
+
const AGY_SPANS = { '5h': '5h', weekly: '7d' };
|
|
161
|
+
const groupTag = (name) => String(name).replace(/\s+models$/i, '');
|
|
162
|
+
// "Models within this group: Gemini Flash, Gemini Pro" -> ['Gemini Flash', 'Gemini Pro']
|
|
163
|
+
const groupModels = (text) => { const m = /group:\s*(.+?)\.?$/i.exec(String(text || '')); return m ? m[1].split(',').map((s) => s.trim()).filter(Boolean) : []; };
|
|
164
|
+
export function agyQuota(obj, at = Date.now()) {
|
|
165
|
+
const data = obj?.command?.name === 'usage' ? obj.command.data : null;
|
|
166
|
+
if (!Array.isArray(data?.groups)) return null;
|
|
167
|
+
const windows = [], groups = [];
|
|
168
|
+
for (const g of data.groups) {
|
|
169
|
+
if (typeof g?.name !== 'string' || !g.name) continue;
|
|
170
|
+
const own = (Array.isArray(g.buckets) ? g.buckets : [])
|
|
171
|
+
.filter((b) => typeof b?.remaining_fraction === 'number' && Number.isFinite(b.remaining_fraction))
|
|
172
|
+
.map((b) => {
|
|
173
|
+
const reset = Date.parse(b.reset_time);
|
|
174
|
+
return { label: `${groupTag(g.name)} ${AGY_SPANS[b.window] || b.window || 'window'}`, used: Math.min(1, Math.max(0, 1 - b.remaining_fraction)),
|
|
175
|
+
resetsAt: Number.isFinite(reset) ? Math.round(reset / 1000) : null, group: g.name };
|
|
176
|
+
});
|
|
177
|
+
if (!own.length) continue;
|
|
178
|
+
windows.push(...own);
|
|
179
|
+
groups.push({ name: g.name, models: groupModels(g.description) });
|
|
180
|
+
}
|
|
181
|
+
return windows.length ? { windows, groups, plan: null, at } : null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// The read is free only when the CLI answered it itself: no turn, no conversation left behind,
|
|
185
|
+
// and every token counter zero. Anything else, an unreadable answer included, is not shown free.
|
|
186
|
+
export function agyReadWasFree(obj) {
|
|
187
|
+
const u = obj?.usage;
|
|
188
|
+
return !!u && typeof u === 'object' && obj.num_turns === 0 && !obj.conversation_id
|
|
189
|
+
&& Object.values(u).every((n) => n === 0);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// The JSON object on stdout: the whole output when it is one (printed over several lines or one),
|
|
193
|
+
// else the last line that is one (a notice printed before it is skipped).
|
|
194
|
+
function lastJson(text) {
|
|
195
|
+
const whole = String(text || '').trim();
|
|
196
|
+
if (whole.startsWith('{')) { try { return JSON.parse(whole); } catch { /* not one object: scan the lines */ } }
|
|
197
|
+
for (const line of whole.split(/\r?\n/).reverse()) {
|
|
198
|
+
if (!line.trim().startsWith('{')) continue;
|
|
199
|
+
try { return JSON.parse(line); } catch { /* keep looking */ }
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// A reader with its own latch. `-p /usage` reaches a model only if a CLI stops answering it
|
|
205
|
+
// itself; the reader runs after every Antigravity reply, so the first answer that is not shown
|
|
206
|
+
// free (a turn, tokens, a conversation, or a read cut off by the timeout) stops every later read
|
|
207
|
+
// until the server restarts: one stray call at most, never one per reply. Slash commands stay
|
|
208
|
+
// on here (the seats turn them off): /usage is the expansion this read needs. The cleaned
|
|
209
|
+
// environment is load-bearing: run from a shell that another agent CLI opened (Claude Code's
|
|
210
|
+
// variables set), agy 1.4.12 sent "/usage" to a model instead of answering it, a turn of 18k
|
|
211
|
+
// tokens each time. The argv below is the one measured free; the seats' deny rules are not
|
|
212
|
+
// added to it, since that was not measured, and the latch bounds a CLI that stops answering.
|
|
213
|
+
// run and resolve are injectable so tests never spawn the CLI.
|
|
214
|
+
// agy: the parsed answer to `-p /credits`, the AI credits that take over once a group's limits
|
|
215
|
+
// are used: command.data.remaining_credits. null when the answer holds no such number.
|
|
216
|
+
export function agyCredits(obj) {
|
|
217
|
+
const n = obj?.status === 'SUCCESS' && obj.command?.name === 'credits' ? obj.command.data?.remaining_credits : undefined;
|
|
218
|
+
return typeof n === 'number' && Number.isFinite(n) && n >= 0 ? n : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// How often /credits is asked: a balance that moves only once a group's limits are used up, so
|
|
222
|
+
// not after every reply as /usage is.
|
|
223
|
+
export const AGY_CREDITS_EVERY_MS = 10 * 60_000;
|
|
224
|
+
|
|
225
|
+
export function agyQuotaReader({ run = runProcess, resolve = resolveBinary, warn = console.error, now = Date.now } = {}) {
|
|
226
|
+
// credits: { remaining, at } from the last /credits answer that held a figure; askedAt: when it
|
|
227
|
+
// was last asked, answered or not, so an account without credits is not asked on every read
|
|
228
|
+
let stopped = null, credits = null, askedAt = null;
|
|
229
|
+
const stop = (why) => { stopped = why; warn(`[quota] agy: ${why}; Antigravity quota reads are off until the server restarts`); return null; };
|
|
230
|
+
// One command the CLI answers itself, or null: it did not start, or it was not shown free (the
|
|
231
|
+
// latch then says why, once). 30 s, as for `agy models`: both wait on the network, and a
|
|
232
|
+
// timeout turns reads off.
|
|
233
|
+
const ask = async (bin, command, timeoutMs) => {
|
|
234
|
+
let out;
|
|
235
|
+
try { out = (await run(bin, ['-p', command, '--output-format', 'json'], { timeoutMs, env: cleanEnv() })).stdout; } catch (e) {
|
|
236
|
+
if (e.code === 'TIMEOUT') return stop(`${command} gave no answer within ${timeoutMs} ms, so it may have reached a model`);
|
|
237
|
+
return null; // it did not start, or the server is shutting down
|
|
238
|
+
}
|
|
239
|
+
const obj = lastJson(out);
|
|
240
|
+
if (!agyReadWasFree(obj)) return stop(obj ? `${command} did not answer as a free command (a turn, tokens or a conversation)` : `${command} gave no JSON answer`);
|
|
241
|
+
return obj;
|
|
242
|
+
};
|
|
243
|
+
const read = async ({ timeoutMs = 30_000 } = {}) => {
|
|
244
|
+
if (stopped) return null;
|
|
245
|
+
let bin;
|
|
246
|
+
try { bin = resolve('agy'); } catch { return null; }
|
|
247
|
+
const usage = await ask(bin, '/usage', timeoutMs);
|
|
248
|
+
const q = usage?.status === 'SUCCESS' ? agyQuota(usage) : null; // ERROR: not signed in, or no network
|
|
249
|
+
if (!q) return null;
|
|
250
|
+
if (askedAt == null || now() - askedAt >= AGY_CREDITS_EVERY_MS) {
|
|
251
|
+
askedAt = now();
|
|
252
|
+
const answer = await ask(bin, '/credits', timeoutMs);
|
|
253
|
+
if (stopped) return null;
|
|
254
|
+
const remaining = agyCredits(answer);
|
|
255
|
+
if (remaining != null) credits = { remaining, at: now() };
|
|
256
|
+
}
|
|
257
|
+
return credits ? { ...q, credits } : q;
|
|
258
|
+
};
|
|
259
|
+
read.stopped = () => stopped;
|
|
260
|
+
return read;
|
|
261
|
+
}
|
|
262
|
+
export const readAgyQuota = agyQuotaReader();
|
|
263
|
+
|
|
264
|
+
function windowLabel(mins) {
|
|
265
|
+
if (!mins) return 'window';
|
|
266
|
+
if (mins % 1440 === 0) return `${mins / 1440}d`;
|
|
267
|
+
if (mins % 60 === 0) return `${mins / 60}h`;
|
|
268
|
+
return `${mins}m`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Ask the codex app-server for the account's rate limits. Spawns `codex app-server` on stdio,
|
|
272
|
+
// does the initialize handshake, reads one response, kills the child. Resolves null on any
|
|
273
|
+
// failure so the board degrades to the usage-page link.
|
|
274
|
+
// home: a named profile's CODEX_HOME, so each account's own limits are read.
|
|
275
|
+
export function readCodexQuota({ timeoutMs = 20_000, home = null } = {}) {
|
|
276
|
+
return new Promise((resolve) => {
|
|
277
|
+
let bin;
|
|
278
|
+
try { bin = resolveBinary('codex', ['@openai/codex', 'bin/codex.js']); } catch { return resolve(null); }
|
|
279
|
+
const child = spawn(bin.file, [...bin.prefix, 'app-server'], { windowsHide: true, stdio: ['pipe', 'pipe', 'ignore'], env: cleanEnv(home ? { env: 'CODEX_HOME', dir: home } : null) });
|
|
280
|
+
let buf = '', done = false;
|
|
281
|
+
const finish = (v) => {
|
|
282
|
+
if (done) return;
|
|
283
|
+
done = true; clearTimeout(timer);
|
|
284
|
+
if (process.platform === 'win32') { try { execFileSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore' }); } catch {} }
|
|
285
|
+
else { try { child.kill('SIGKILL'); } catch {} }
|
|
286
|
+
resolve(v);
|
|
287
|
+
};
|
|
288
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
289
|
+
const send = (o) => { try { child.stdin.write(JSON.stringify({ jsonrpc: '2.0', ...o }) + '\n'); } catch {} };
|
|
290
|
+
child.on('error', () => finish(null));
|
|
291
|
+
child.on('close', () => finish(null));
|
|
292
|
+
child.stdin.on('error', () => {});
|
|
293
|
+
child.stdout.setEncoding('utf8').on('data', (d) => {
|
|
294
|
+
buf += d;
|
|
295
|
+
let i;
|
|
296
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
297
|
+
const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1);
|
|
298
|
+
let msg; try { msg = JSON.parse(line); } catch { continue; }
|
|
299
|
+
if (msg.id === 1) { send({ method: 'initialized' }); send({ id: 2, method: 'account/rateLimits/read', params: null }); }
|
|
300
|
+
if (msg.id === 2) finish(msg.result ? codexQuota(msg.result) : null);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
send({ id: 1, method: 'initialize', params: { clientInfo: { name: 'roundtable', version: VERSION } } });
|
|
304
|
+
});
|
|
305
|
+
}
|