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.
Files changed (48) hide show
  1. package/README.md +1 -1
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +2 -2
  4. package/dist/src/cli/commands.js +21 -5
  5. package/dist/src/cli/format.js +7 -3
  6. package/dist/src/cli/json-schemas.js +14 -1
  7. package/dist/src/cli/main.js +4 -3
  8. package/dist/src/core/config.d.ts +27 -0
  9. package/dist/src/core/config.js +48 -1
  10. package/dist/src/core/handoff-enable.js +10 -5
  11. package/dist/src/core/launch.d.ts +5 -0
  12. package/dist/src/core/launch.js +14 -3
  13. package/dist/src/core/quota-fetch.d.ts +22 -0
  14. package/dist/src/core/quota-fetch.js +98 -0
  15. package/dist/src/core/quota-levels.d.ts +3 -2
  16. package/dist/src/core/quota-levels.js +8 -4
  17. package/dist/src/core/quota-store.d.ts +19 -0
  18. package/dist/src/core/quota-store.js +62 -0
  19. package/dist/src/core/skill-check.d.ts +2 -0
  20. package/dist/src/core/skill-check.js +20 -2
  21. package/dist/src/core/update.js +1 -15
  22. package/dist/src/families/grok/handoff-hook.d.ts +6 -0
  23. package/dist/src/families/grok/handoff-hook.js +45 -2
  24. package/dist/src/families/grok/handoff.d.ts +6 -1
  25. package/dist/src/families/grok/handoff.js +30 -13
  26. package/dist/src/families/grok/index.js +5 -0
  27. package/dist/src/families/grok/quota-log.d.ts +30 -0
  28. package/dist/src/families/grok/quota-log.js +140 -0
  29. package/dist/src/families/grok/quota.d.ts +5 -0
  30. package/dist/src/families/grok/quota.js +112 -0
  31. package/dist/src/panel/handler.d.ts +6 -2
  32. package/dist/src/panel/handler.js +60 -3
  33. package/dist/src/panel/i18n.d.ts +22 -5
  34. package/dist/src/panel/i18n.js +44 -10
  35. package/dist/src/panel/page-logic.d.ts +18 -2
  36. package/dist/src/panel/page-logic.js +53 -6
  37. package/dist/src/panel/page-script.js +275 -90
  38. package/dist/src/panel/page-styles.js +30 -10
  39. package/dist/src/runtime.d.ts +30 -2
  40. package/dist/src/runtime.js +64 -21
  41. package/dist/src/types.d.ts +49 -1
  42. package/package.json +2 -2
  43. package/schemas/config.json +46 -0
  44. package/schemas/quota.json +14 -1
  45. package/skills/sideby/SKILL.md +1 -1
  46. package/skills/sideby/references/diagnose.md +2 -1
  47. package/skills/sideby/references/handoff.md +1 -1
  48. 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: `update it with \`${SKILL_INSTALL}\`, so agents learn this version's commands`,
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
  });
@@ -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): no public Quota, so only a turn that failed at the limit starts a
2
- // Handoff. Leaf module. The wording comes from the grok 1.0.50 binary; check it again when Grok changes.
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
- /** The whole file setup writes; teardown removes it only while it is still exactly this. */
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). It has no public Quota, so it starts one only after a turn
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
- /** The whole file setup writes; teardown removes it only while it is still exactly this. */
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
- if (have !== null)
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: `--- /dev/null\n+++ ${shown}\n${want
160
- .trimEnd()
161
- .split('\n')
162
- .map((l) => `+${l}`)
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 that hits its usage limit can hand over.',
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>;