sideby 0.13.1 → 0.14.0
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 +1 -1
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +2 -2
- package/dist/src/cli/commands.js +21 -5
- package/dist/src/cli/format.js +7 -3
- package/dist/src/cli/json-schemas.js +14 -1
- package/dist/src/cli/main.js +4 -3
- package/dist/src/core/config.d.ts +27 -0
- package/dist/src/core/config.js +48 -1
- package/dist/src/core/handoff-enable.js +10 -5
- package/dist/src/core/launch.d.ts +5 -0
- package/dist/src/core/launch.js +14 -3
- package/dist/src/core/quota-fetch.d.ts +22 -0
- package/dist/src/core/quota-fetch.js +98 -0
- package/dist/src/core/quota-levels.d.ts +3 -2
- package/dist/src/core/quota-levels.js +8 -4
- package/dist/src/core/quota-store.d.ts +19 -0
- package/dist/src/core/quota-store.js +62 -0
- package/dist/src/core/skill-check.d.ts +2 -0
- package/dist/src/core/skill-check.js +20 -2
- package/dist/src/core/update.js +1 -15
- package/dist/src/families/grok/handoff-hook.d.ts +6 -0
- package/dist/src/families/grok/handoff-hook.js +45 -2
- package/dist/src/families/grok/handoff.d.ts +6 -1
- package/dist/src/families/grok/handoff.js +30 -13
- package/dist/src/families/grok/index.js +5 -0
- package/dist/src/families/grok/quota-log.d.ts +30 -0
- package/dist/src/families/grok/quota-log.js +140 -0
- package/dist/src/families/grok/quota.d.ts +5 -0
- package/dist/src/families/grok/quota.js +112 -0
- package/dist/src/panel/handler.d.ts +6 -2
- package/dist/src/panel/handler.js +60 -3
- package/dist/src/panel/i18n.d.ts +22 -5
- package/dist/src/panel/i18n.js +44 -10
- package/dist/src/panel/page-logic.d.ts +18 -2
- package/dist/src/panel/page-logic.js +53 -6
- package/dist/src/panel/page-script.js +275 -90
- package/dist/src/panel/page-styles.js +30 -10
- package/dist/src/runtime.d.ts +30 -2
- package/dist/src/runtime.js +64 -21
- package/dist/src/types.d.ts +49 -1
- package/package.json +2 -2
- package/schemas/config.json +46 -0
- package/schemas/quota.json +14 -1
- package/skills/sideby/SKILL.md +1 -1
- package/skills/sideby/references/diagnose.md +2 -1
- package/skills/sideby/references/handoff.md +1 -1
- package/skills/sideby/references/quota.md +5 -1
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
// Doctor's look at an installed sideby Agent Skill (spec §3.11). Read-only: a skill written for another
|
|
2
2
|
// sideby version teaches commands this CLI may not have, so a version mismatch is a warning with the
|
|
3
3
|
// reinstall command. Not installed is not a problem.
|
|
4
|
-
import { readFile, realpath } from 'node:fs/promises';
|
|
4
|
+
import { readFile, realpath, stat } from 'node:fs/promises';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
6
6
|
import { mainDirOf } from './accounts.js';
|
|
7
7
|
import { tildify } from './paths.js';
|
|
8
8
|
export const SKILL_INSTALL = 'npx skills add Adonis0123/sideby -g';
|
|
9
|
+
/** The folder holding `dir` that is a git work tree, if any: a skill there is managed by that repository. */
|
|
10
|
+
export async function gitRootOf(dir) {
|
|
11
|
+
for (let d = dir;; d = dirname(d)) {
|
|
12
|
+
try {
|
|
13
|
+
await stat(join(d, '.git'));
|
|
14
|
+
return d;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// keep looking up
|
|
18
|
+
}
|
|
19
|
+
if (dirname(d) === d)
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
9
23
|
/** `metadata.version` from a SKILL.md frontmatter, or null when there is none. */
|
|
10
24
|
export function skillVersionOf(text) {
|
|
11
25
|
const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1];
|
|
@@ -70,13 +84,17 @@ export async function checkInstalledSkills(home, families, version) {
|
|
|
70
84
|
if (found === version)
|
|
71
85
|
continue;
|
|
72
86
|
const where = tildify(path, home);
|
|
87
|
+
// `sideby update` brings the skill along, except one a git repository owns (a dotfiles clone, say).
|
|
88
|
+
const repo = await gitRootOf(dirname(real));
|
|
73
89
|
out.push({
|
|
74
90
|
level: 'warn',
|
|
75
91
|
account: `${family.id}:main`,
|
|
76
92
|
item: where,
|
|
77
93
|
code: 'skill.outdated',
|
|
78
94
|
message: `the sideby skill at ${where} is ${found ? `for sideby ${found}` : 'from before skills had a version'}; this sideby is ${version}`,
|
|
79
|
-
hint:
|
|
95
|
+
hint: repo
|
|
96
|
+
? `it lives in the git repository ${tildify(repo, home)}: update it where that repository comes from (\`npx skills update sideby -g\` there), so agents learn this version's commands`
|
|
97
|
+
: `run \`sideby update\`, which updates the skill too (or \`${SKILL_INSTALL}\` by hand), so agents learn this version's commands`,
|
|
80
98
|
source: 'core',
|
|
81
99
|
fixable: false,
|
|
82
100
|
});
|
package/dist/src/core/update.js
CHANGED
|
@@ -6,7 +6,7 @@ import { access, constants, mkdir, open, readdir, readFile, realpath, rm, stat }
|
|
|
6
6
|
import { dirname, join, sep } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { writeFileAtomic } from './fs-safe.js';
|
|
9
|
-
import { installedSkills, SKILL_INSTALL, SKILL_UPDATE } from './skill-check.js';
|
|
9
|
+
import { gitRootOf, installedSkills, SKILL_INSTALL, SKILL_UPDATE } from './skill-check.js';
|
|
10
10
|
import { localBuild, packageVersion, parseLocalBuild } from './version.js';
|
|
11
11
|
export const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
|
|
12
12
|
export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
@@ -562,20 +562,6 @@ export function shownPlan(plan) {
|
|
|
562
562
|
...(plan.skill ? { skillUpdate: plan.skill } : {}),
|
|
563
563
|
};
|
|
564
564
|
}
|
|
565
|
-
/** The folder holding `dir` that is a git work tree, if any: a skill there is managed by that repository. */
|
|
566
|
-
async function gitRootOf(dir) {
|
|
567
|
-
for (let d = dir;; d = dirname(d)) {
|
|
568
|
-
try {
|
|
569
|
-
await stat(join(d, '.git'));
|
|
570
|
-
return d;
|
|
571
|
-
}
|
|
572
|
-
catch {
|
|
573
|
-
// keep looking up
|
|
574
|
-
}
|
|
575
|
-
if (dirname(d) === d)
|
|
576
|
-
return null;
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
565
|
/** The skill part of an Update toward `target`; null when no sideby skill is installed. Reads two files. */
|
|
580
566
|
export async function skillUpdatePlan(home, target) {
|
|
581
567
|
const skills = await installedSkills(home);
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { type HookReader } from '../../handoff/reader.ts';
|
|
2
|
+
import type { Env } from '../../types.ts';
|
|
3
|
+
/** The Account the hook runs for, from `GROK_HOME`: unset or `~/.grok` is `main`, `.grok-<name>` is `<name>`. */
|
|
4
|
+
export declare function grokAccountFromEnv(env: Env): {
|
|
5
|
+
name: string;
|
|
6
|
+
dir: string;
|
|
7
|
+
} | null;
|
|
2
8
|
/** Substrings that mean the usage pool or credits are used up. `StopFailure.error` has no class of its own for it. */
|
|
3
9
|
export declare const GROK_USED_UP: string[];
|
|
4
10
|
export declare const grokHookReader: HookReader;
|
|
@@ -1,6 +1,28 @@
|
|
|
1
|
-
// Grok Build for the handoff hook (spec §3.17):
|
|
2
|
-
//
|
|
1
|
+
// Grok Build for the handoff hook (spec §3.17, §3.26): Quota Pressure from the kept answer of its CLI and the billing
|
|
2
|
+
// lines it writes to its log, and a turn that failed at the limit. Leaf module: the hook runs on every tool call. The
|
|
3
|
+
// wording comes from the grok 1.0.50 binary; check it again when Grok changes.
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { basename, join, resolve } from 'node:path';
|
|
6
|
+
import { ACCOUNT_NAME } from '../../core/account-name.js';
|
|
7
|
+
import { resolvePaths } from '../../core/paths.js';
|
|
8
|
+
import { quotaPressure } from '../../core/quota-levels.js';
|
|
9
|
+
import { quotaStoreFile, readQuotaStore } from '../../core/quota-store.js';
|
|
3
10
|
import { field } from '../../handoff/reader.js';
|
|
11
|
+
import { grokLogFile, HOOK_LOG_READ_BYTES, lastGrokBilling } from './quota-log.js';
|
|
12
|
+
const MAIN_DIR = '.grok';
|
|
13
|
+
const ACCOUNT_PREFIX = '.grok-';
|
|
14
|
+
/** The Account the hook runs for, from `GROK_HOME`: unset or `~/.grok` is `main`, `.grok-<name>` is `<name>`. */
|
|
15
|
+
export function grokAccountFromEnv(env) {
|
|
16
|
+
const home = env.HOME || homedir();
|
|
17
|
+
const dir = env.GROK_HOME;
|
|
18
|
+
if (!dir || resolve(dir) === join(home, MAIN_DIR))
|
|
19
|
+
return { name: 'main', dir: join(home, MAIN_DIR) };
|
|
20
|
+
const base = basename(dir);
|
|
21
|
+
if (!base.startsWith(ACCOUNT_PREFIX))
|
|
22
|
+
return null;
|
|
23
|
+
const name = base.slice(ACCOUNT_PREFIX.length);
|
|
24
|
+
return name !== 'main' && ACCOUNT_NAME.test(name) ? { name, dir } : null;
|
|
25
|
+
}
|
|
4
26
|
/** Substrings that mean the usage pool or credits are used up. `StopFailure.error` has no class of its own for it. */
|
|
5
27
|
export const GROK_USED_UP = [
|
|
6
28
|
'You hit your weekly limit',
|
|
@@ -15,6 +37,27 @@ export const GROK_USED_UP = [
|
|
|
15
37
|
'status 402',
|
|
16
38
|
];
|
|
17
39
|
export const grokHookReader = {
|
|
40
|
+
async pressure(_input, env, now) {
|
|
41
|
+
const account = grokAccountFromEnv(env);
|
|
42
|
+
if (!account)
|
|
43
|
+
return null;
|
|
44
|
+
try {
|
|
45
|
+
const [log, kept] = await Promise.all([
|
|
46
|
+
lastGrokBilling(grokLogFile(account.dir), HOOK_LOG_READ_BYTES),
|
|
47
|
+
readQuotaStore(quotaStoreFile(resolvePaths(env).stateDir, 'grok', account.name)),
|
|
48
|
+
]);
|
|
49
|
+
const fetched = kept?.quota ?? null;
|
|
50
|
+
const newer = log && fetched
|
|
51
|
+
? Date.parse(log.observedAt) > Date.parse(fetched.observedAt)
|
|
52
|
+
? log
|
|
53
|
+
: fetched
|
|
54
|
+
: (log ?? fetched);
|
|
55
|
+
return newer ? quotaPressure(newer, now) : null;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
},
|
|
18
61
|
limitHit(input) {
|
|
19
62
|
if (field(input, 'subagentType'))
|
|
20
63
|
return false;
|
|
@@ -41,7 +41,12 @@ export declare function grokEligibility(policy: ReturnType<typeof grokPolicy>, a
|
|
|
41
41
|
start: boolean;
|
|
42
42
|
receive: boolean;
|
|
43
43
|
};
|
|
44
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* The file sideby 0.13 and earlier wrote: no `PostToolUse`, since Grok had no Quota to prepare a Handoff from then.
|
|
46
|
+
* Setup replaces it, teardown removes it (spec §3.26).
|
|
47
|
+
*/
|
|
48
|
+
export declare const GROK_HOOK_FILE_V1: string;
|
|
49
|
+
/** The whole file setup writes; teardown removes it only while it is still exactly this (or `GROK_HOOK_FILE_V1`). */
|
|
45
50
|
export declare function grokHookFileText(): string;
|
|
46
51
|
export declare const grokHookSetup: QuotaSetup;
|
|
47
52
|
export declare const grokHandoff: FamilyHandoff;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// How Grok Build takes part in a Handoff (spec §3.17).
|
|
2
|
-
// failed at the limit. Interactive `grok` has no launch option for hooks: `sideby handoff setup grok` adds
|
|
1
|
+
// How Grok Build takes part in a Handoff (spec §3.17). Since its Quota comes from its own CLI (spec §3.26), it starts
|
|
2
|
+
// one at the Handoff Threshold, and still after a turn failed at the limit. Interactive `grok` has no launch option for hooks: `sideby handoff setup grok` adds
|
|
3
3
|
// `hooks/sideby-handoff.json` to the Main Account and Doctor copies it to the others (verified 2026-10-09).
|
|
4
4
|
// The hook inherits Grok's sandbox, so the profile decides whether a run may start or receive.
|
|
5
5
|
import { mkdir, readFile, rm, rmdir } from 'node:fs/promises';
|
|
@@ -124,10 +124,22 @@ export function grokEligibility(policy, args, home) {
|
|
|
124
124
|
};
|
|
125
125
|
}
|
|
126
126
|
const hookCommand = (event) => `sideby handoff-hook grok ${event}`;
|
|
127
|
-
/**
|
|
127
|
+
/**
|
|
128
|
+
* The file sideby 0.13 and earlier wrote: no `PostToolUse`, since Grok had no Quota to prepare a Handoff from then.
|
|
129
|
+
* Setup replaces it, teardown removes it (spec §3.26).
|
|
130
|
+
*/
|
|
131
|
+
export const GROK_HOOK_FILE_V1 = `${JSON.stringify({
|
|
132
|
+
hooks: {
|
|
133
|
+
Stop: [{ hooks: [{ type: 'command', command: hookCommand('Stop') }] }],
|
|
134
|
+
StopFailure: [{ hooks: [{ type: 'command', command: hookCommand('StopFailure'), timeout: 60 }] }],
|
|
135
|
+
},
|
|
136
|
+
}, null, 2)}\n`;
|
|
137
|
+
/** The whole file setup writes; teardown removes it only while it is still exactly this (or `GROK_HOOK_FILE_V1`). */
|
|
128
138
|
export function grokHookFileText() {
|
|
129
139
|
const hooks = {
|
|
130
140
|
hooks: {
|
|
141
|
+
// Quota Pressure after each tool call, to prepare and start a Handoff before the limit (spec §3.26).
|
|
142
|
+
PostToolUse: [{ hooks: [{ type: 'command', command: hookCommand('PostToolUse') }] }],
|
|
131
143
|
Stop: [{ hooks: [{ type: 'command', command: hookCommand('Stop') }] }],
|
|
132
144
|
// Observation hooks get 5 seconds by default; putting a Brief together may take longer.
|
|
133
145
|
StopFailure: [{ hooks: [{ type: 'command', command: hookCommand('StopFailure'), timeout: 60 }] }],
|
|
@@ -143,7 +155,8 @@ async function plan(ctx) {
|
|
|
143
155
|
const have = await textOrNull(file);
|
|
144
156
|
if (have === want)
|
|
145
157
|
return { status: 'enabled', file, diff: '', message: `already set up in ${shown}` };
|
|
146
|
-
|
|
158
|
+
const upgrade = have === GROK_HOOK_FILE_V1;
|
|
159
|
+
if (have !== null && !upgrade)
|
|
147
160
|
return {
|
|
148
161
|
status: 'blocked',
|
|
149
162
|
file,
|
|
@@ -153,19 +166,22 @@ async function plan(ctx) {
|
|
|
153
166
|
const problem = await sidebyBinProblem(ctx.env, 'Grok runs `sideby handoff-hook` when a turn ends');
|
|
154
167
|
if (problem)
|
|
155
168
|
return { status: 'blocked', file, diff: '', message: problem };
|
|
169
|
+
const lines = (text, mark) => text
|
|
170
|
+
.trimEnd()
|
|
171
|
+
.split('\n')
|
|
172
|
+
.map((l) => `${mark}${l}`)
|
|
173
|
+
.join('\n');
|
|
156
174
|
return {
|
|
157
175
|
status: 'ready',
|
|
158
176
|
file,
|
|
159
|
-
diff:
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
.join('\n')}\n`,
|
|
164
|
-
message: `adds ${shown} so Grok tells sideby when a turn hits the usage limit; then run \`sideby doctor grok --fix\` to copy it to every Grok account; undo with \`sideby handoff teardown grok\``,
|
|
177
|
+
diff: upgrade
|
|
178
|
+
? `--- ${shown}\n+++ ${shown}\n${lines(have, '-')}\n${lines(want, '+')}\n`
|
|
179
|
+
: `--- /dev/null\n+++ ${shown}\n${lines(want, '+')}\n`,
|
|
180
|
+
message: `${upgrade ? 'updates' : 'adds'} ${shown} so Grok tells sideby how full its quota is after each tool call and when a turn hits the usage limit; then run \`sideby doctor grok --fix\` to copy it to every Grok account; undo with \`sideby handoff teardown grok\``,
|
|
165
181
|
};
|
|
166
182
|
}
|
|
167
183
|
export const grokHookSetup = {
|
|
168
|
-
summary: 'Adds one sideby hook file to ~/.grok/hooks so a Grok session
|
|
184
|
+
summary: 'Adds one sideby hook file to ~/.grok/hooks so a Grok session can hand over when its quota runs low or hits the limit.',
|
|
169
185
|
plan,
|
|
170
186
|
async apply(ctx) {
|
|
171
187
|
const p = await plan(ctx);
|
|
@@ -175,13 +191,14 @@ export const grokHookSetup = {
|
|
|
175
191
|
await writeFileAtomic(p.file, grokHookFileText(), 0o644);
|
|
176
192
|
return { ...p, status: 'enabled' };
|
|
177
193
|
},
|
|
194
|
+
earlierTexts: [GROK_HOOK_FILE_V1],
|
|
178
195
|
async teardown(ctx) {
|
|
179
196
|
const file = hookFile(ctx);
|
|
180
197
|
const shown = tildify(file, ctx.home);
|
|
181
198
|
const have = await textOrNull(file);
|
|
182
199
|
if (have === null)
|
|
183
200
|
return { ok: true, message: `${shown} is not there; nothing to undo` };
|
|
184
|
-
if (sha256(have) !== sha256(grokHookFileText()))
|
|
201
|
+
if (sha256(have) !== sha256(grokHookFileText()) && have !== GROK_HOOK_FILE_V1)
|
|
185
202
|
return {
|
|
186
203
|
ok: false,
|
|
187
204
|
message: `${shown} changed since setup; delete it yourself if you no longer want it`,
|
|
@@ -196,7 +213,7 @@ export const grokHookSetup = {
|
|
|
196
213
|
},
|
|
197
214
|
};
|
|
198
215
|
export const grokHandoff = {
|
|
199
|
-
starts: ['limit'],
|
|
216
|
+
starts: ['quota', 'limit'],
|
|
200
217
|
promptArgs: promptAfterDashes,
|
|
201
218
|
continueArgs: (args) => carryArgs(args, GROK_ARGS),
|
|
202
219
|
resumeArgs: (id) => ['--resume', id],
|
|
@@ -5,6 +5,8 @@ import { BUILTIN_LOGOS } from '../logos.js';
|
|
|
5
5
|
import { groupedEntryWrittenAt, resumeFlagSession } from '../shared/sessions.js';
|
|
6
6
|
import { topLevelTomlString } from '../shared/toml.js';
|
|
7
7
|
import { grokHandoff } from './handoff.js';
|
|
8
|
+
import { grokQuotaFetch } from './quota.js';
|
|
9
|
+
import { readGrokLogQuota } from './quota-log.js';
|
|
8
10
|
// Grok's sandbox rejects these paths when they are symlinks; doctor quotes the Host's own words.
|
|
9
11
|
const HOOKS_DIR_MESSAGE = 'Grok hooks directory has wrong type (expected real directory)';
|
|
10
12
|
const REAL_FILE_MESSAGE = 'Grok hooks-paths registry has wrong type (expected real file)';
|
|
@@ -71,6 +73,9 @@ export const grokFamily = {
|
|
|
71
73
|
},
|
|
72
74
|
model: readModel,
|
|
73
75
|
identity: grokIdentity,
|
|
76
|
+
// Quota (spec §3.26, ADR-0025): Grok's own billing lines in its log, and its CLI asked at most every 15 minutes.
|
|
77
|
+
readQuota: (account) => readGrokLogQuota(account.dir),
|
|
78
|
+
fetchQuota: grokQuotaFetch,
|
|
74
79
|
resumedSession: resumeFlagSession,
|
|
75
80
|
handoff: grokHandoff,
|
|
76
81
|
// One directory per session, grouped by working directory: sessions/<encoded cwd>/<id>/.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { FileMemo } from '../../core/file-memo.ts';
|
|
2
|
+
import type { QuotaResult, QuotaWindow } from '../../types.ts';
|
|
3
|
+
export type GrokQuota = Extract<QuotaResult, {
|
|
4
|
+
status: 'ok';
|
|
5
|
+
}>;
|
|
6
|
+
export declare const GROK_BILLING_MSG = "billing: fetched credits config";
|
|
7
|
+
/** How far back the CLI and the Panel look (Grok halves the file at 5 MB) and how far the handoff hook does. */
|
|
8
|
+
export declare const LOG_READ_BYTES: number;
|
|
9
|
+
export declare const HOOK_LOG_READ_BYTES: number;
|
|
10
|
+
export declare function grokLogFile(accountDir: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* A Quota reading from Grok's billing answer: `{ config, subscription_tier }` from `_x.ai/billing`, or the log line's
|
|
13
|
+
* `ctx` (`{ config, subscriptionTier }`). Null when it holds no usable window.
|
|
14
|
+
*/
|
|
15
|
+
export declare function grokQuota(answer: unknown, observedAt: string, source: string): GrokQuota | null;
|
|
16
|
+
/** The newest billing line in the last `maxBytes` of `file`, read backwards a chunk at a time; null when none. */
|
|
17
|
+
export declare function lastGrokBilling(file: string, maxBytes: number, source?: string): Promise<GrokQuota | null>;
|
|
18
|
+
/** The log's reading per Account directory; the file is read again only after it changed. */
|
|
19
|
+
export declare const grokQuotaMemo: FileMemo<{
|
|
20
|
+
status: 'ok';
|
|
21
|
+
windows: QuotaWindow[];
|
|
22
|
+
observedAt: string;
|
|
23
|
+
source: string;
|
|
24
|
+
plan?: string;
|
|
25
|
+
from?: string;
|
|
26
|
+
fetched?: boolean;
|
|
27
|
+
fetchError?: string;
|
|
28
|
+
} | null>;
|
|
29
|
+
/** Grok's local Quota for one Account directory: its newest billing line. */
|
|
30
|
+
export declare function readGrokLogQuota(accountDir: string): Promise<QuotaResult>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Grok Build's Quota (spec §3.26, ADR-0025): how its billing answer becomes a Quota reading, and the local copy Grok
|
|
2
|
+
// writes into `$GROK_HOME/logs/unified.jsonl` at every session start and finished turn. Other lines of that file carry
|
|
3
|
+
// the user id and the end of the bearer token, so only lines whose `msg` is exactly the billing one are parsed, and
|
|
4
|
+
// only the Quota fields and the plan name are kept. Leaf module: the handoff hook reads the log too.
|
|
5
|
+
import { open, stat } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { FileMemo } from '../../core/file-memo.js';
|
|
8
|
+
export const GROK_BILLING_MSG = 'billing: fetched credits config';
|
|
9
|
+
const WEEKLY = 'USAGE_PERIOD_TYPE_WEEKLY';
|
|
10
|
+
const WEEK_MINUTES = 7 * 24 * 60;
|
|
11
|
+
const CHUNK = 64 * 1024;
|
|
12
|
+
/** How far back the CLI and the Panel look (Grok halves the file at 5 MB) and how far the handoff hook does. */
|
|
13
|
+
export const LOG_READ_BYTES = 8 * 1024 * 1024;
|
|
14
|
+
export const HOOK_LOG_READ_BYTES = 512 * 1024;
|
|
15
|
+
export function grokLogFile(accountDir) {
|
|
16
|
+
return join(accountDir, 'logs', 'unified.jsonl');
|
|
17
|
+
}
|
|
18
|
+
const record = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) ? v : {};
|
|
19
|
+
const num = (v) => {
|
|
20
|
+
const n = typeof v === 'string' && v.trim() ? Number(v) : v;
|
|
21
|
+
return typeof n === 'number' && Number.isFinite(n) ? n : undefined;
|
|
22
|
+
};
|
|
23
|
+
const str = (v) => (typeof v === 'string' && v ? v : undefined);
|
|
24
|
+
/**
|
|
25
|
+
* A Quota reading from Grok's billing answer: `{ config, subscription_tier }` from `_x.ai/billing`, or the log line's
|
|
26
|
+
* `ctx` (`{ config, subscriptionTier }`). Null when it holds no usable window.
|
|
27
|
+
*/
|
|
28
|
+
export function grokQuota(answer, observedAt, source) {
|
|
29
|
+
const top = record(answer);
|
|
30
|
+
const config = record(top.config);
|
|
31
|
+
const period = record(config.currentPeriod);
|
|
32
|
+
const weekly = period.type === WEEKLY;
|
|
33
|
+
const end = Date.parse(str(period.end) ?? str(config.billingPeriodEnd) ?? '');
|
|
34
|
+
if (!Number.isFinite(end))
|
|
35
|
+
return null;
|
|
36
|
+
const start = Date.parse(str(period.start) ?? str(config.billingPeriodStart) ?? '');
|
|
37
|
+
let used = num(config.creditUsagePercent) ?? num(top.creditUsagePercent);
|
|
38
|
+
if (used === undefined) {
|
|
39
|
+
const spent = num(record(config.used).val);
|
|
40
|
+
const limit = num(record(config.monthlyLimit).val);
|
|
41
|
+
if (spent !== undefined && limit)
|
|
42
|
+
used = (spent / limit) * 100;
|
|
43
|
+
else if (weekly)
|
|
44
|
+
used = 0;
|
|
45
|
+
}
|
|
46
|
+
if (used === undefined)
|
|
47
|
+
return null;
|
|
48
|
+
let minutes = Number.isFinite(start) && end > start ? Math.round((end - start) / 60_000) : undefined;
|
|
49
|
+
if (weekly)
|
|
50
|
+
minutes = WEEK_MINUTES;
|
|
51
|
+
if (minutes === undefined)
|
|
52
|
+
return null;
|
|
53
|
+
const days = Math.max(1, Math.round(minutes / 1440));
|
|
54
|
+
const window = {
|
|
55
|
+
label: minutes === WEEK_MINUTES ? '7d' : `${days}d`,
|
|
56
|
+
windowMinutes: minutes,
|
|
57
|
+
usedPercent: Math.max(0, Math.min(100, used)),
|
|
58
|
+
resetsAt: new Date(end).toISOString(),
|
|
59
|
+
};
|
|
60
|
+
const plan = str(top.subscription_tier) ?? str(top.subscriptionTier);
|
|
61
|
+
return { status: 'ok', windows: [window], observedAt, source, ...(plan ? { plan } : {}) };
|
|
62
|
+
}
|
|
63
|
+
/** The Quota of one log line, or null for every line that is not Grok's billing line. */
|
|
64
|
+
function billingLine(line, source) {
|
|
65
|
+
// A cheap check first: most lines are never parsed.
|
|
66
|
+
if (!line.includes(GROK_BILLING_MSG))
|
|
67
|
+
return null;
|
|
68
|
+
let data;
|
|
69
|
+
try {
|
|
70
|
+
data = record(JSON.parse(line));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const ts = str(data.ts);
|
|
76
|
+
if (data.msg !== GROK_BILLING_MSG || !ts || !Number.isFinite(Date.parse(ts)))
|
|
77
|
+
return null;
|
|
78
|
+
const ctx = record(data.ctx);
|
|
79
|
+
return grokQuota({ config: ctx.config, subscriptionTier: ctx.subscriptionTier }, new Date(Date.parse(ts)).toISOString(), source);
|
|
80
|
+
}
|
|
81
|
+
/** The newest billing line in the last `maxBytes` of `file`, read backwards a chunk at a time; null when none. */
|
|
82
|
+
export async function lastGrokBilling(file, maxBytes, source = file) {
|
|
83
|
+
let fh;
|
|
84
|
+
try {
|
|
85
|
+
fh = await open(file, 'r');
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const size = (await fh.stat()).size;
|
|
92
|
+
let pos = size;
|
|
93
|
+
// The start of the earliest line read so far, which may continue in the chunk before it.
|
|
94
|
+
let head = Buffer.alloc(0);
|
|
95
|
+
while (pos > 0 && size - pos < maxBytes) {
|
|
96
|
+
const len = Math.min(CHUNK, pos);
|
|
97
|
+
pos -= len;
|
|
98
|
+
const buf = Buffer.alloc(len);
|
|
99
|
+
await fh.read(buf, 0, len, pos);
|
|
100
|
+
const chunk = Buffer.concat([buf, head]);
|
|
101
|
+
const cut = pos > 0 ? chunk.indexOf(0x0a) : -1;
|
|
102
|
+
head = cut >= 0 ? chunk.subarray(0, cut) : Buffer.alloc(0);
|
|
103
|
+
const lines = chunk
|
|
104
|
+
.subarray(cut + 1)
|
|
105
|
+
.toString('utf8')
|
|
106
|
+
.split('\n');
|
|
107
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
108
|
+
const q = billingLine(lines[i], source);
|
|
109
|
+
if (q)
|
|
110
|
+
return q;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
await fh.close();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** The log's reading per Account directory; the file is read again only after it changed. */
|
|
120
|
+
export const grokQuotaMemo = new FileMemo();
|
|
121
|
+
/** Grok's local Quota for one Account directory: its newest billing line. */
|
|
122
|
+
export async function readGrokLogQuota(accountDir) {
|
|
123
|
+
const file = grokLogFile(accountDir);
|
|
124
|
+
const none = {
|
|
125
|
+
status: 'unavailable',
|
|
126
|
+
reason: 'no-session',
|
|
127
|
+
detail: 'Grok has not reported the quota of this account yet',
|
|
128
|
+
};
|
|
129
|
+
let st;
|
|
130
|
+
try {
|
|
131
|
+
st = await stat(file);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return none;
|
|
135
|
+
}
|
|
136
|
+
const memo = grokQuotaMemo.pass(accountDir);
|
|
137
|
+
const q = await memo.get(file, st, () => lastGrokBilling(file, LOG_READ_BYTES, 'logs/unified.jsonl'));
|
|
138
|
+
memo.done();
|
|
139
|
+
return q ?? none;
|
|
140
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Account, QuotaFetch, QuotaFetchContext, QuotaResult } from '../../types.ts';
|
|
2
|
+
export declare const GROK_QUOTA_ARGS: readonly ['agent', '--no-leader', 'stdio'];
|
|
3
|
+
export declare const GROK_BILLING_METHOD = "_x.ai/billing";
|
|
4
|
+
export declare function fetchGrokQuota(account: Account, ctx: QuotaFetchContext): Promise<QuotaResult>;
|
|
5
|
+
export declare const grokQuotaFetch: QuotaFetch;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Asks the Grok CLI for an Account's Quota (spec §3.26, ADR-0025): `grok agent --no-leader stdio` answers the ACP
|
|
2
|
+
// extension method `_x.ai/billing` with the request it makes itself, using the login of the `GROK_HOME` it runs under.
|
|
3
|
+
// sideby never reads the token and never sends a request of its own; no session is started.
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { createInterface } from 'node:readline';
|
|
7
|
+
import { grokQuota } from './quota-log.js';
|
|
8
|
+
export const GROK_QUOTA_ARGS = ['agent', '--no-leader', 'stdio'];
|
|
9
|
+
export const GROK_BILLING_METHOD = '_x.ai/billing';
|
|
10
|
+
/** How long Grok may take to exit after its stdin closes before sideby ends it. */
|
|
11
|
+
const EXIT_GRACE_MS = 10_000;
|
|
12
|
+
/** How long to wait before asking again after a failed answer, while Grok refreshes an expired token. */
|
|
13
|
+
const RETRY_MS = 1500;
|
|
14
|
+
const METHOD_NOT_FOUND = -32601;
|
|
15
|
+
const AUTH_REQUIRED = -32000;
|
|
16
|
+
const failed = (detail) => ({ status: 'unavailable', reason: 'fetch-failed', detail });
|
|
17
|
+
/** Why an error answer gave no Quota, and what to do. Grok's own message is never repeated (only matched). */
|
|
18
|
+
function errorDetail(account, error) {
|
|
19
|
+
if (error.code === METHOD_NOT_FOUND)
|
|
20
|
+
return 'this Grok version cannot report the quota; update Grok, then refresh';
|
|
21
|
+
if (error.code === AUTH_REQUIRED) {
|
|
22
|
+
const text = `${typeof error.data === 'string' ? error.data : ''} ${typeof error.message === 'string' ? error.message : ''}`;
|
|
23
|
+
if (text.includes('grok.com'))
|
|
24
|
+
return 'this account is signed in with an API key, which has no subscription quota';
|
|
25
|
+
return `Grok is not signed in for this account; run \`sideby login ${account.ref}\``;
|
|
26
|
+
}
|
|
27
|
+
return `Grok could not get the quota from xAI (error ${typeof error.code === 'number' ? error.code : 'unknown'}); sideby asks again later, or start Grok in this account once`;
|
|
28
|
+
}
|
|
29
|
+
export async function fetchGrokQuota(account, ctx) {
|
|
30
|
+
if (ctx.signal.aborted)
|
|
31
|
+
return failed('the quota check was stopped');
|
|
32
|
+
const child = spawn('grok', [...GROK_QUOTA_ARGS], {
|
|
33
|
+
cwd: tmpdir(),
|
|
34
|
+
env: { ...ctx.hostEnv, GROK_DISABLE_AUTOUPDATER: '1' },
|
|
35
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
36
|
+
});
|
|
37
|
+
const stop = () => {
|
|
38
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
39
|
+
child.kill('SIGTERM');
|
|
40
|
+
};
|
|
41
|
+
const finish = () => {
|
|
42
|
+
// Grok exits by itself once its stdin closes; it is ended only when it does not.
|
|
43
|
+
child.stdin.end();
|
|
44
|
+
const timer = setTimeout(stop, EXIT_GRACE_MS);
|
|
45
|
+
timer.unref();
|
|
46
|
+
child.once('exit', () => clearTimeout(timer));
|
|
47
|
+
child.unref();
|
|
48
|
+
};
|
|
49
|
+
ctx.signal.addEventListener('abort', stop, { once: true });
|
|
50
|
+
try {
|
|
51
|
+
return await new Promise((resolve) => {
|
|
52
|
+
const lines = createInterface({ input: child.stdout });
|
|
53
|
+
let done = false;
|
|
54
|
+
const settle = (r) => {
|
|
55
|
+
if (done)
|
|
56
|
+
return;
|
|
57
|
+
done = true;
|
|
58
|
+
lines.close();
|
|
59
|
+
resolve(r);
|
|
60
|
+
};
|
|
61
|
+
child.on('error', (err) => settle(failed(err.code === 'ENOENT'
|
|
62
|
+
? 'Grok is not installed or not on PATH; install it, then refresh'
|
|
63
|
+
: `could not start Grok (${err.code ?? 'error'})`)));
|
|
64
|
+
// `close` comes after the last stdout line, so an answer written just before exiting still counts.
|
|
65
|
+
child.on('close', () => settle(failed('Grok exited without answering; sideby asks again later')));
|
|
66
|
+
ctx.signal.addEventListener('abort', () => settle(failed('the quota check was stopped')), {
|
|
67
|
+
once: true,
|
|
68
|
+
});
|
|
69
|
+
child.stdin.on('error', () => { });
|
|
70
|
+
let id = 1;
|
|
71
|
+
const ask = () => child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method: GROK_BILLING_METHOD, params: {} })}\n`);
|
|
72
|
+
lines.on('line', (line) => {
|
|
73
|
+
let msg;
|
|
74
|
+
try {
|
|
75
|
+
msg = JSON.parse(line);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (msg?.id !== id)
|
|
81
|
+
return;
|
|
82
|
+
if (msg.error && typeof msg.error === 'object') {
|
|
83
|
+
const error = msg.error;
|
|
84
|
+
// A login whose token just expired fails once: Grok sends the old token and refreshes it as it starts
|
|
85
|
+
// (seen 2026-10-17), so the same process is asked once more after a moment.
|
|
86
|
+
if (id === 1 && error.code !== METHOD_NOT_FOUND && error.code !== AUTH_REQUIRED) {
|
|
87
|
+
id = 2;
|
|
88
|
+
setTimeout(() => {
|
|
89
|
+
if (!done)
|
|
90
|
+
ask();
|
|
91
|
+
}, RETRY_MS).unref();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
settle(failed(errorDetail(account, error)));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const q = grokQuota(msg.result, ctx.now.toISOString(), 'grok agent');
|
|
98
|
+
settle(q ?? failed('Grok answered in a form sideby does not recognise; update sideby'));
|
|
99
|
+
});
|
|
100
|
+
ask();
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
ctx.signal.removeEventListener('abort', stop);
|
|
105
|
+
finish();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export const grokQuotaFetch = {
|
|
109
|
+
everyMinutes: 15,
|
|
110
|
+
summary: "Grok asks xAI for each account's weekly quota, at most every 15 minutes",
|
|
111
|
+
fetch: fetchGrokQuota,
|
|
112
|
+
};
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { type AppProfileStatus } from '../core/app-profiles.ts';
|
|
3
3
|
import { type BrowserReport } from '../core/browser-profiles.ts';
|
|
4
|
-
import { type HandoffSettings } from '../core/config.ts';
|
|
4
|
+
import { type DisplayConfig, type HandoffSettings } from '../core/config.ts';
|
|
5
5
|
import { type AccountModel } from '../core/model-choice.ts';
|
|
6
6
|
import type { RemoteControlGroup } from '../core/remote-control.ts';
|
|
7
7
|
import * as update from '../core/update.ts';
|
|
8
|
-
import type { AccountStatus, DoctorHistory, FamilyInfo, Runtime } from '../runtime.ts';
|
|
8
|
+
import type { AccountStatus, DoctorHistory, FamilyInfo, QuotaCheckInfo, Runtime } from '../runtime.ts';
|
|
9
9
|
import type { Account, FamilyLogo, QuotaResult, QuotaSetupPlan, UsageResult } from '../types.ts';
|
|
10
10
|
import { type PanelTheme } from './theme.ts';
|
|
11
11
|
export declare const PANEL_VERSION: string;
|
|
@@ -140,6 +140,10 @@ export interface PanelState {
|
|
|
140
140
|
* Absent without Claude, Codex or Grok subscription Accounts. Nothing of the browser is read while `enabled` is false.
|
|
141
141
|
*/
|
|
142
142
|
browser?: BrowserReport;
|
|
143
|
+
/** Display Order (spec §3.25): config `display` as it is, an empty object when the user never dragged. */
|
|
144
|
+
display: DisplayConfig;
|
|
145
|
+
/** Families that ask for Quota in a way that costs a request (spec §3.26), with their switch; absent when none. */
|
|
146
|
+
quotaChecks?: QuotaCheckInfo[];
|
|
143
147
|
}
|
|
144
148
|
/** The `handoff` map of the Panel state, from the statuses and Quota already read for the page. */
|
|
145
149
|
export declare function handoffByFamily(rt: Runtime, accounts: readonly PanelAccount[], now: Date): Record<string, string>;
|