sideby 0.13.0 → 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 (78) hide show
  1. package/README.md +1 -1
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +3 -2
  4. package/dist/src/cli/commands.js +69 -8
  5. package/dist/src/cli/format.js +7 -3
  6. package/dist/src/cli/json-schemas.js +62 -2
  7. package/dist/src/cli/main.js +42 -4
  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 +15 -0
  20. package/dist/src/core/skill-check.js +49 -3
  21. package/dist/src/core/update.d.ts +45 -0
  22. package/dist/src/core/update.js +72 -1
  23. package/dist/src/families/grok/handoff-hook.d.ts +6 -0
  24. package/dist/src/families/grok/handoff-hook.js +45 -2
  25. package/dist/src/families/grok/handoff.d.ts +6 -1
  26. package/dist/src/families/grok/handoff.js +30 -13
  27. package/dist/src/families/grok/index.js +5 -0
  28. package/dist/src/families/grok/quota-log.d.ts +30 -0
  29. package/dist/src/families/grok/quota-log.js +140 -0
  30. package/dist/src/families/grok/quota.d.ts +5 -0
  31. package/dist/src/families/grok/quota.js +112 -0
  32. package/dist/src/panel/handler.d.ts +9 -3
  33. package/dist/src/panel/handler.js +65 -3
  34. package/dist/src/panel/i18n.d.ts +25 -5
  35. package/dist/src/panel/i18n.js +52 -12
  36. package/dist/src/panel/page-logic.d.ts +18 -2
  37. package/dist/src/panel/page-logic.js +53 -6
  38. package/dist/src/panel/page-script.js +286 -92
  39. package/dist/src/panel/page-styles.js +30 -10
  40. package/dist/src/runtime.d.ts +30 -2
  41. package/dist/src/runtime.js +64 -21
  42. package/dist/src/types.d.ts +49 -1
  43. package/package.json +2 -2
  44. package/schemas/alias.json +62 -0
  45. package/schemas/app-install.json +62 -0
  46. package/schemas/app-uninstall.json +62 -0
  47. package/schemas/browser-open.json +62 -0
  48. package/schemas/browser-switch.json +62 -0
  49. package/schemas/browser.json +62 -0
  50. package/schemas/config.json +46 -0
  51. package/schemas/desktop-remove.json +62 -0
  52. package/schemas/desktop.json +62 -0
  53. package/schemas/doctor.json +62 -0
  54. package/schemas/error.json +62 -0
  55. package/schemas/families.json +62 -0
  56. package/schemas/handoff-disable.json +62 -0
  57. package/schemas/handoff-enable.json +62 -0
  58. package/schemas/handoff-ready.json +62 -0
  59. package/schemas/handoff-setup.json +62 -0
  60. package/schemas/handoff-status.json +62 -0
  61. package/schemas/handoff-teardown.json +62 -0
  62. package/schemas/list.json +62 -0
  63. package/schemas/model.json +62 -0
  64. package/schemas/new.json +62 -0
  65. package/schemas/next.json +62 -0
  66. package/schemas/plugins.json +62 -0
  67. package/schemas/quota-setup.json +62 -0
  68. package/schemas/quota-teardown.json +62 -0
  69. package/schemas/quota.json +76 -1
  70. package/schemas/remote-control.json +62 -0
  71. package/schemas/tune-change.json +62 -0
  72. package/schemas/tune.json +62 -0
  73. package/schemas/update.json +107 -1
  74. package/skills/sideby/SKILL.md +4 -2
  75. package/skills/sideby/references/diagnose.md +3 -2
  76. package/skills/sideby/references/handoff.md +1 -1
  77. package/skills/sideby/references/quota.md +5 -1
  78. package/skills/sideby/references/update.md +16 -0
@@ -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;
@@ -92,8 +92,10 @@ export interface PanelUpdate {
92
92
  command?: string;
93
93
  canRun: boolean;
94
94
  releaseNotes?: string;
95
- /** The command that updates the sideby skill, which sideby never runs itself. */
95
+ /** The command that installs the sideby skill by hand. */
96
96
  skill: string;
97
+ /** The installed sideby skill, which an Update brings along (spec §3.23); absent when none is installed. */
98
+ skillUpdate?: import('../core/update.ts').SkillUpdatePlan;
97
99
  }
98
100
  export interface PanelState {
99
101
  schemaVersion: 1;
@@ -138,6 +140,10 @@ export interface PanelState {
138
140
  * Absent without Claude, Codex or Grok subscription Accounts. Nothing of the browser is read while `enabled` is false.
139
141
  */
140
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[];
141
147
  }
142
148
  /** The `handoff` map of the Panel state, from the statuses and Quota already read for the page. */
143
149
  export declare function handoffByFamily(rt: Runtime, accounts: readonly PanelAccount[], now: Date): Record<string, string>;
@@ -4,7 +4,7 @@ import { aliasesByAccount } from '../core/accounts.js';
4
4
  import { listAppProfiles, openAppProfile, removeAppProfile, } from '../core/app-profiles.js';
5
5
  import { FAVICON_SVG, ICON_SVG } from '../core/brand-mark.js';
6
6
  import { browserReport, openInBrowser, setBrowserSignIns, } from '../core/browser-profiles.js';
7
- import { aliasArgs, aliasRefs, editConfigUpdate } from '../core/config.js';
7
+ import { aliasArgs, aliasRefs, editConfigUpdate, } from '../core/config.js';
8
8
  import { openDesktopApp, withDesktopApps } from '../core/desktop-app.js';
9
9
  import { UserError } from '../core/errors.js';
10
10
  import { planHandoff } from '../core/handoff.js';
@@ -61,6 +61,9 @@ async function updateState(rt, readOnly) {
61
61
  });
62
62
  const method = await update.installMethod(rt.env);
63
63
  const plan = cache.latest ? update.shownPlan(update.planFrom(method, current, cache.latest, rt.env)) : null;
64
+ const skillUpdate = await update
65
+ .skillUpdatePlan(rt.paths.home, plan?.available ? plan.latest : current)
66
+ .catch(() => null);
64
67
  return {
65
68
  current,
66
69
  ...(cache.latest ? { latest: cache.latest } : {}),
@@ -72,6 +75,7 @@ async function updateState(rt, readOnly) {
72
75
  ...(plan ? { command: plan.command, releaseNotes: plan.releaseNotes } : {}),
73
76
  canRun: Boolean(plan?.canRun),
74
77
  skill: SKILL_INSTALL,
78
+ ...(skillUpdate ? { skillUpdate } : {}),
75
79
  };
76
80
  }
77
81
  /** The `handoff` map of the Panel state, from the statuses and Quota already read for the page. */
@@ -82,7 +86,7 @@ export function handoffByFamily(rt, accounts, now) {
82
86
  // Count logins, not directories: a Same Login group (spec §3.21) is one Quota, so it alone is no alternative.
83
87
  const subs = list.filter((a) => a.kind === 'subscription');
84
88
  const logins = subs.filter((a, i) => !subs.slice(0, i).some((b) => a.sameLogin?.includes(b.ref))).length;
85
- if (!f.readQuota || logins < 2)
89
+ if ((!f.readQuota && !f.fetchQuota) || logins < 2)
86
90
  continue;
87
91
  const plan = planHandoff(id, list.map((a) => ({
88
92
  ref: a.ref,
@@ -199,7 +203,8 @@ async function accountState(rt, account, families) {
199
203
  };
200
204
  const [status, quota, usage] = await Promise.all([
201
205
  guard(rt.status(account)),
202
- guard(rt.quota(account)),
206
+ // The kept answer of a Quota check shows now; a stale one is asked again behind it (spec §3.26).
207
+ guard(rt.quota(account, { fetch: 'background' })),
203
208
  guard(rt.usage(account)),
204
209
  ]);
205
210
  const fam = families.find((f) => f.id === account.family);
@@ -299,8 +304,36 @@ async function buildState(rt, opts) {
299
304
  },
300
305
  ...(upd ? { update: upd } : {}),
301
306
  ...(showBrowser ? { browser } : {}),
307
+ display: rt.displayOrder(),
308
+ ...(rt.quotaChecks().length ? { quotaChecks: rt.quotaChecks() } : {}),
302
309
  };
303
310
  }
311
+ /** The `display` of a /api/display-order body: null, or string lists only; anything else is refused. */
312
+ function displayBody(body) {
313
+ const d = body.display;
314
+ if (d === null)
315
+ return undefined;
316
+ const names = (v, what) => {
317
+ if (!Array.isArray(v) || !v.every((x) => typeof x === 'string' && x))
318
+ throw new HttpError(400, `${what} must be a list of names`);
319
+ return [...v];
320
+ };
321
+ if (!d || typeof d !== 'object' || Array.isArray(d))
322
+ throw new HttpError(400, '"display" must be an object or null');
323
+ const out = {};
324
+ for (const [k, v] of Object.entries(d)) {
325
+ if (k === 'families')
326
+ out.families = names(v, '"families"');
327
+ else if (k === 'accounts') {
328
+ if (!v || typeof v !== 'object' || Array.isArray(v))
329
+ throw new HttpError(400, '"accounts" must be an object');
330
+ out.accounts = Object.fromEntries(Object.entries(v).map(([f, list]) => [f, names(list, `"accounts.${f}"`)]));
331
+ }
332
+ else
333
+ throw new HttpError(400, `unknown key "${k}"`);
334
+ }
335
+ return out;
336
+ }
304
337
  /** The `change` of a /api/handoff/configure body, with every field checked; unknown fields are refused. */
305
338
  function handoffChange(body) {
306
339
  const c = body.change;
@@ -643,6 +676,7 @@ export function createPanelHandler(opts) {
643
676
  message: r.message,
644
677
  restartedPanel: r.restartedPanel,
645
678
  refreshedApp: r.refreshedApp,
679
+ ...(r.skillUpdated === undefined ? {} : { skillUpdated: r.skillUpdated }),
646
680
  };
647
681
  },
648
682
  },
@@ -666,6 +700,34 @@ export function createPanelHandler(opts) {
666
700
  };
667
701
  },
668
702
  },
703
+ // Display Order (spec §3.25): replaces config `display`, or removes it for null. The drag itself is the preview.
704
+ '/api/display-order': {
705
+ async run(body) {
706
+ const display = displayBody(body);
707
+ const rt = await getRuntime();
708
+ return { display: await rt.setDisplayOrder(display) };
709
+ },
710
+ },
711
+ // Asks every Family that fetches Quota again now, and waits (spec §3.26); the page reloads its state after.
712
+ '/api/quota/refresh': {
713
+ async run() {
714
+ const rt = await getRuntime();
715
+ await rt.refreshQuota();
716
+ return { ok: true };
717
+ },
718
+ },
719
+ // A Family's Quota check on or off: config `quota.check.<family>` (spec §3.26).
720
+ '/api/quota/check': {
721
+ async run(body) {
722
+ const family = optString(body, 'family');
723
+ if (!family)
724
+ throw new HttpError(400, '"family" is required');
725
+ if (typeof body.on !== 'boolean')
726
+ throw new HttpError(400, '"on" must be true or false');
727
+ const rt = await getRuntime();
728
+ return { family, ...(await rt.setQuotaCheck(family, body.on)) };
729
+ },
730
+ },
669
731
  // Undo for the quota setup above; the Runtime turns a teardown that throws into `{ ok: false, message }`.
670
732
  '/api/quota/teardown': {
671
733
  async run(body) {
@@ -20,6 +20,9 @@ declare const en: {
20
20
  'upd.after': string;
21
21
  'upd.cannot': string;
22
22
  'upd.skill': string;
23
+ 'upd.skillToo': string;
24
+ 'upd.skillDone': string;
25
+ 'upd.skillManaged': string;
23
26
  'upd.checksOff': string;
24
27
  'upd.latest': string;
25
28
  'upd.latestNow': string;
@@ -138,6 +141,26 @@ declare const en: {
138
141
  'sort.pressure': string;
139
142
  'sort.name': string;
140
143
  'sort.recent': string;
144
+ 'sort.manual': string;
145
+ 'order.handle': string;
146
+ 'order.moveFamily': string;
147
+ 'order.moveAccount': string;
148
+ 'order.position': string;
149
+ 'order.failed': string;
150
+ 'order.resetButton': string;
151
+ 'order.resetHint': string;
152
+ 'order.reset': string;
153
+ 'quota.fetchFailed': string;
154
+ 'quota.checkOff': string;
155
+ 'quota.checkOffHint': string;
156
+ 'quota.fetchedFrom': string;
157
+ 'quota.lastCheckFailed': string;
158
+ 'quota.refreshFailed': string;
159
+ 'quota.checkOnToast': string;
160
+ 'quota.checkOffToast': string;
161
+ 'quota.checkFailed': string;
162
+ 'set.quotaCheck': string;
163
+ 'set.quotaCheckHint': string;
141
164
  'tb.quota': string;
142
165
  'tb.used': string;
143
166
  'tb.left': string;
@@ -196,13 +219,10 @@ declare const en: {
196
219
  'quota.dataFrom': string;
197
220
  'quota.asOf': string;
198
221
  'quota.sharedFrom': string;
199
- 'quota.readFrom': string;
200
- 'login.count': string;
201
222
  'login.label': string;
223
+ 'login.shared': string;
224
+ 'login.sharedHint': string;
202
225
  'login.hint': string;
203
- 'login.sharedAbove': string;
204
- 'login.collapse': string;
205
- 'login.expand': string;
206
226
  'quota.noWindows': string;
207
227
  'quota.off': string;
208
228
  'quota.turnOnFamily': string;