sideby 0.13.1 → 0.15.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 (85) hide show
  1. package/README.md +2 -1
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +4 -2
  4. package/dist/src/cli/commands.js +194 -11
  5. package/dist/src/cli/format.js +7 -3
  6. package/dist/src/cli/json-schemas.js +104 -3
  7. package/dist/src/cli/main.js +27 -7
  8. package/dist/src/core/config.d.ts +28 -0
  9. package/dist/src/core/config.js +54 -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 +16 -0
  20. package/dist/src/core/skill-check.js +47 -3
  21. package/dist/src/core/update.d.ts +67 -1
  22. package/dist/src/core/update.js +147 -37
  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 +13 -3
  33. package/dist/src/panel/handler.js +81 -8
  34. package/dist/src/panel/i18n.d.ts +138 -8
  35. package/dist/src/panel/i18n.js +301 -41
  36. package/dist/src/panel/page-logic.d.ts +59 -2
  37. package/dist/src/panel/page-logic.js +99 -6
  38. package/dist/src/panel/page-script.js +769 -175
  39. package/dist/src/panel/page-styles.js +83 -15
  40. package/dist/src/panel/page.js +3 -0
  41. package/dist/src/runtime.d.ts +32 -2
  42. package/dist/src/runtime.js +64 -21
  43. package/dist/src/types.d.ts +49 -1
  44. package/package.json +3 -2
  45. package/schemas/alias.json +1 -1
  46. package/schemas/app-install.json +1 -1
  47. package/schemas/app-uninstall.json +1 -1
  48. package/schemas/browser-open.json +1 -1
  49. package/schemas/browser-switch.json +1 -1
  50. package/schemas/browser.json +1 -1
  51. package/schemas/config.json +60 -0
  52. package/schemas/desktop-remove.json +1 -1
  53. package/schemas/desktop.json +1 -1
  54. package/schemas/doctor.json +1 -1
  55. package/schemas/error.json +1 -1
  56. package/schemas/families.json +1 -1
  57. package/schemas/handoff-disable.json +1 -1
  58. package/schemas/handoff-enable.json +1 -1
  59. package/schemas/handoff-ready.json +1 -1
  60. package/schemas/handoff-setup.json +1 -1
  61. package/schemas/handoff-status.json +282 -1
  62. package/schemas/handoff-teardown.json +1 -1
  63. package/schemas/list.json +6 -1
  64. package/schemas/model.json +1 -1
  65. package/schemas/new.json +1 -1
  66. package/schemas/next.json +1 -1
  67. package/schemas/order.json +148 -0
  68. package/schemas/pin.json +126 -0
  69. package/schemas/plugins.json +1 -1
  70. package/schemas/quota-check.json +88 -0
  71. package/schemas/quota-setup.json +1 -1
  72. package/schemas/quota-teardown.json +1 -1
  73. package/schemas/quota.json +15 -2
  74. package/schemas/remote-control.json +1 -1
  75. package/schemas/tune-change.json +1 -1
  76. package/schemas/tune.json +1 -1
  77. package/schemas/unpin.json +126 -0
  78. package/schemas/update-auto.json +84 -0
  79. package/schemas/update.json +79 -1
  80. package/skills/sideby/SKILL.md +7 -3
  81. package/skills/sideby/references/diagnose.md +2 -1
  82. package/skills/sideby/references/handoff.md +9 -1
  83. package/skills/sideby/references/panel.md +28 -0
  84. package/skills/sideby/references/quota.md +11 -1
  85. package/skills/sideby/references/update.md +6 -2
@@ -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 { installedSkills, SKILL_UPDATE, SKILL_UPDATE_SHOWN, skillOwnerRepo } 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;
@@ -151,21 +151,25 @@ export function sameLine(from, to) {
151
151
  const set = (v) => v !== undefined && v !== '' && v !== '0' && v.toLowerCase() !== 'false';
152
152
  /** True in CI, where a notice is noise and a check is a request nobody reads. */
153
153
  export const inCi = (env) => set(env.CI) || set(env.CONTINUOUS_INTEGRATION) || set(env.BUILD_NUMBER);
154
- /** Why Update Checks are off, or null when they are on (spec §3.23). */
155
- export function checksOff(env, config, local = localBuild() !== null) {
154
+ /** Why Update Checks are off, with a code for the Panel, or null when they are on (spec §3.23). */
155
+ export function checksOffWhy(env, config, local = localBuild() !== null) {
156
156
  // A Local Build (ADR-0024) follows the checkout: a notice or an Auto Update would only replace it with npm's.
157
157
  if (local)
158
- return 'this is a local build (pnpm local)';
158
+ return { code: 'local-build', text: 'this is a local build (pnpm local)' };
159
159
  if (set(env.SIDEBY_NO_UPDATE_NOTIFIER))
160
- return 'SIDEBY_NO_UPDATE_NOTIFIER is set';
160
+ return { code: 'env', text: 'SIDEBY_NO_UPDATE_NOTIFIER is set' };
161
161
  if (set(env.NO_UPDATE_NOTIFIER))
162
- return 'NO_UPDATE_NOTIFIER is set';
162
+ return { code: 'env', text: 'NO_UPDATE_NOTIFIER is set' };
163
163
  if (config.update?.check === false)
164
- return 'config update.check is false';
164
+ return { code: 'config', text: 'config update.check is false' };
165
165
  if (inCi(env))
166
- return 'running in CI';
166
+ return { code: 'ci', text: 'running in CI' };
167
167
  return null;
168
168
  }
169
+ /** Why Update Checks are off, or null when they are on (spec §3.23). */
170
+ export function checksOff(env, config, local = localBuild() !== null) {
171
+ return checksOffWhy(env, config, local)?.text ?? null;
172
+ }
169
173
  // ---- registry
170
174
  /** The registry checks and installs use: `SIDEBY_UPDATE_REGISTRY` (http or https only), else npm's. */
171
175
  function registryOf(env) {
@@ -217,6 +221,18 @@ export async function readUpdateCache(paths) {
217
221
  message: a.message,
218
222
  ...(a.shown ? { shown: true } : {}),
219
223
  };
224
+ const sn = d.skillNotified;
225
+ if (sn && str(sn.version) && str(sn.at))
226
+ out.skillNotified = { version: sn.version, at: sn.at };
227
+ const sa = d.skillAuto;
228
+ if (sa && str(sa.to) && str(sa.at) && typeof sa.ok === 'boolean' && str(sa.message))
229
+ out.skillAuto = {
230
+ to: sa.to,
231
+ at: sa.at,
232
+ ok: sa.ok,
233
+ message: sa.message,
234
+ ...(sa.shown ? { shown: true } : {}),
235
+ };
220
236
  return out;
221
237
  }
222
238
  catch {
@@ -352,6 +368,44 @@ export function autoUpdateLine(cache) {
352
368
  ? `sideby updated itself from ${a.from} to ${a.to} (${RELEASE_NOTES(a.to)})`
353
369
  : `sideby could not update itself to ${a.to}: ${a.message}`;
354
370
  }
371
+ /** What the CLI says once about the skill Auto Update brought up to date alone, or null. */
372
+ export function skillAutoLine(cache) {
373
+ const a = cache.skillAuto;
374
+ if (!a || a.shown)
375
+ return null;
376
+ return a.ok
377
+ ? `sideby updated the sideby skill agents read to ${a.to}`
378
+ : `sideby could not update the sideby skill to ${a.to}: ${a.message}`;
379
+ }
380
+ /** The skill part of a `SkillUpdatePlan` toward the running version, as `afterCommandPlan` takes it. */
381
+ export const skillBehind = (plan, current) => plan?.outdated
382
+ ? { installed: plan.installed.find((v) => v !== current) ?? null, canRun: plan.canRun }
383
+ : null;
384
+ /**
385
+ * The line about a skill behind this version the CLI may print now (spec §3.23), or null: once a day per CLI version,
386
+ * and never while Auto Update is on (the background check brings the skill along) and sideby may update it.
387
+ */
388
+ export function skillNoticeLine(cache, current, config, skill, now) {
389
+ if (!skill || (config.update?.auto === true && skill.canRun))
390
+ return null;
391
+ const n = cache.skillNotified;
392
+ if (n && n.version === current && age(n.at, now) < NOTICE_INTERVAL_MS)
393
+ return null;
394
+ return `the sideby skill agents read is for ${skill.installed ?? 'an older sideby'} (this sideby is ${current}) · run \`sideby update\``;
395
+ }
396
+ /**
397
+ * Auto Update is on, the CLI is the newest version the cache knows, and the skill is behind it where sideby may
398
+ * update it, not tried for this version yet (or a failed try a day ago). A newer CLI waiting is left to the Update
399
+ * of the CLI: the skills tool fetches the default branch, which would put the skill ahead of this CLI.
400
+ */
401
+ export function skillAutoPending(cache, current, config, skill, now) {
402
+ if (config.update?.auto !== true || !skill?.canRun || isNewer(cache.latest, current))
403
+ return false;
404
+ const a = cache.skillAuto;
405
+ if (!a || a.to !== current)
406
+ return true;
407
+ return !a.ok && !(age(a.at, now) < AUTO_RETRY_MS);
408
+ }
355
409
  /**
356
410
  * The `_notice` for a `--json` output, from the cached Update Check and the versions of the installed sideby skills
357
411
  * (`null` for one written before skills had a version); null when there is nothing to say or checks are off. It
@@ -383,27 +437,40 @@ export function jsonNotice(cache, current, config, env, skills, skillCommand) {
383
437
  * then the Update Notice), the change that records them, and whether to start a background check. The caller prints,
384
438
  * writes `record` with `updateUpdateCache` and starts the check; nothing here touches the machine.
385
439
  */
386
- export function afterCommandPlan(cache, current, config, env, now) {
440
+ export function afterCommandPlan(cache, current, config, env, now, skill = null) {
387
441
  if (checksOff(env, config))
388
442
  return { lines: [], record: null, startCheck: false };
389
443
  const lines = [];
390
444
  const auto = autoUpdateLine(cache);
445
+ const skillAuto = skillAutoLine(cache);
391
446
  const notice = noticeLine(cache, current, now);
392
- if (auto)
393
- lines.push(auto);
394
- if (notice)
395
- lines.push(notice);
447
+ // A newer CLI's notice already leads to `sideby update`, which brings the skill along.
448
+ const skillNotice = notice ? null : skillNoticeLine(cache, current, config, skill, now);
449
+ for (const l of [auto, skillAuto, notice, skillNotice])
450
+ if (l)
451
+ lines.push(l);
396
452
  const shownAuto = cache.auto;
453
+ const shownSkillAuto = cache.skillAuto;
397
454
  const record = lines.length
398
455
  ? (cur) => ({
399
456
  // Mark only the Auto Update that was printed; a newer one written meanwhile stays to be said.
400
457
  ...(auto && cur.auto && cur.auto.at === shownAuto?.at && cur.auto.to === shownAuto.to
401
458
  ? { auto: { ...cur.auto, shown: true } }
402
459
  : {}),
460
+ ...(skillAuto && cur.skillAuto && cur.skillAuto.at === shownSkillAuto?.at
461
+ ? { skillAuto: { ...cur.skillAuto, shown: true } }
462
+ : {}),
403
463
  ...(notice ? { notified: { version: cache.latest, at: now.toISOString() } } : {}),
464
+ ...(skillNotice ? { skillNotified: { version: current, at: now.toISOString() } } : {}),
404
465
  })
405
466
  : null;
406
- return { lines, record, startCheck: checkDue(cache, now) || autoPending(cache, current, config, now) };
467
+ return {
468
+ lines,
469
+ record,
470
+ startCheck: checkDue(cache, now) ||
471
+ autoPending(cache, current, config, now) ||
472
+ skillAutoPending(cache, current, config, skill, now),
473
+ };
407
474
  }
408
475
  // ---- Install Method
409
476
  /** Where the running copy comes from (spec §3.23 table); reads only paths. */
@@ -421,16 +488,20 @@ export async function installMethod(env) {
421
488
  if (parseLocalBuild(JSON.parse(await readFile(join(packageDir, 'dist', 'build.json'), 'utf8'))))
422
489
  return at('local', {
423
490
  reason: 'it is a local build; run `pnpm local --off` in the sideby checkout to go back to npm',
491
+ reasonCode: 'local-build',
424
492
  });
425
493
  }
426
494
  catch {
427
495
  // no build file: not a Local Build
428
496
  }
429
497
  if (p.includes('/_npx/'))
430
- return at('npx', { reason: 'it runs from the npx cache, which fetches a version per call' });
498
+ return at('npx', {
499
+ reason: 'it runs from the npx cache, which fetches a version per call',
500
+ reasonCode: 'npx',
501
+ });
431
502
  // Volta keeps a global package under its own npm prefix; installing there behind its back breaks its records.
432
503
  if (p.includes('/.volta/'))
433
- return at('volta', { reason: 'Volta manages this install' });
504
+ return at('volta', { reason: 'Volta manages this install', reasonCode: 'volta' });
434
505
  const npm = /^(.*)\/lib\/node_modules\/sideby$/.exec(p);
435
506
  if (npm) {
436
507
  const prefix = npm[1];
@@ -442,7 +513,11 @@ export async function installMethod(env) {
442
513
  prefix,
443
514
  canUpdate: !blocked.length,
444
515
  ...(blocked.length
445
- ? { reason: `${blocked.join(' and ')} ${blocked.length > 1 ? 'are' : 'is'} not writable by you` }
516
+ ? {
517
+ reason: `${blocked.join(' and ')} ${blocked.length > 1 ? 'are' : 'is'} not writable by you`,
518
+ reasonCode: 'not-writable',
519
+ reasonAt: blocked.join(', '),
520
+ }
446
521
  : {}),
447
522
  });
448
523
  }
@@ -452,8 +527,8 @@ export async function installMethod(env) {
452
527
  if (p.includes('/.bun/install/global/'))
453
528
  return at('bun', { canUpdate: true });
454
529
  if (p.includes('/node_modules/'))
455
- return at('project', { reason: 'it is a dependency of a project' });
456
- return at('source', { reason: 'it runs from a source checkout' });
530
+ return at('project', { reason: 'it is a dependency of a project', reasonCode: 'project' });
531
+ return at('source', { reason: 'it runs from a source checkout', reasonCode: 'source' });
457
532
  }
458
533
  /**
459
534
  * The command an Update runs (or that the user runs) for `version`; `run` is set only when sideby may run it. With
@@ -548,6 +623,8 @@ export const shownMethod = (m) => ({
548
623
  kind: m.kind,
549
624
  canUpdate: m.canUpdate,
550
625
  ...(m.reason ? { reason: m.reason } : {}),
626
+ ...(m.reasonCode ? { reasonCode: m.reasonCode } : {}),
627
+ ...(m.reasonAt ? { reasonAt: m.reasonAt } : {}),
551
628
  });
552
629
  /** The plan as `sideby update --json` and the Panel show it (schemas/update.json). */
553
630
  export function shownPlan(plan) {
@@ -562,20 +639,6 @@ export function shownPlan(plan) {
562
639
  ...(plan.skill ? { skillUpdate: plan.skill } : {}),
563
640
  };
564
641
  }
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
642
  /** The skill part of an Update toward `target`; null when no sideby skill is installed. Reads two files. */
580
643
  export async function skillUpdatePlan(home, target) {
581
644
  const skills = await installedSkills(home);
@@ -589,14 +652,17 @@ export async function skillUpdatePlan(home, target) {
589
652
  canRun: true,
590
653
  };
591
654
  for (const s of skills) {
592
- const root = await gitRootOf(s.dir);
655
+ // A git repository owns the skill unless the skills tool records installing it there (spec §3.23).
656
+ const root = await skillOwnerRepo(s.dir);
593
657
  if (root) {
594
658
  const at = root.startsWith(home) ? `~${root.slice(home.length)}` : root;
595
659
  return {
596
660
  ...plan,
597
- command: SKILL_INSTALL,
661
+ command: SKILL_UPDATE_SHOWN,
598
662
  canRun: false,
599
663
  reason: `the skill is in the git repository ${at}, so sideby leaves it alone; update it where that repository comes from`,
664
+ reasonCode: 'skill-in-git',
665
+ reasonAt: at,
600
666
  };
601
667
  }
602
668
  }
@@ -623,6 +689,27 @@ export async function applySkillUpdate(plan, env, onOutput) {
623
689
  ? { ok: true, message: 'updated the sideby skill' }
624
690
  : { ok: false, message: `\`${plan.command}\` failed (exit ${r.code}); run it yourself to see why` };
625
691
  }
692
+ /**
693
+ * Updates only the sideby skill, for a CLI that is already current (`sideby update`, the Panel, Auto Update). Holds
694
+ * the same lock as an Update, so a skill update never runs beside one; null when there was nothing to do.
695
+ */
696
+ export async function applySkillOnly(paths, plan, env, onOutput) {
697
+ if (!plan?.outdated || !plan.canRun)
698
+ return null;
699
+ const release = await takeLock(paths, 'update-install.lock').catch(() => null);
700
+ if (!release)
701
+ return {
702
+ ok: false,
703
+ busy: true,
704
+ message: 'another sideby update is running; wait for it to finish, then run `sideby update` again',
705
+ };
706
+ try {
707
+ return await applySkillUpdate(plan, env, onOutput);
708
+ }
709
+ finally {
710
+ await release().catch(() => { });
711
+ }
712
+ }
626
713
  /**
627
714
  * Runs the Update the plan names, then starts a running background Panel again from the new code and, when the
628
715
  * desktop app is installed, runs `sideby app install` again from the new code. The caller has
@@ -703,6 +790,22 @@ export async function autoUpdateBlocker(paths, plan) {
703
790
  return `a session is running (pid ${running.join(', ')})`;
704
791
  return null;
705
792
  }
793
+ /**
794
+ * The skill half of Auto Update (spec §3.23): with the CLI current, a skill behind it is updated alone and the result
795
+ * recorded once per version, so a failure waits a day.
796
+ */
797
+ async function skillAutoSync(paths, env, config, cache, current, now) {
798
+ const plan = await skillUpdatePlan(paths.home, current).catch(() => null);
799
+ if (!skillAutoPending(cache, current, config, skillBehind(plan, current), now))
800
+ return;
801
+ const r = await applySkillOnly(paths, plan ?? undefined, env);
802
+ // A manual Update holding the lock is not a failure; the next command looks again.
803
+ if (!r || r.busy)
804
+ return;
805
+ await updateUpdateCache(paths, {
806
+ skillAuto: { to: current, at: deps().now().toISOString(), ok: r.ok, message: r.message },
807
+ });
808
+ }
706
809
  /**
707
810
  * Body of the detached `sideby update-check`: an Update Check when one is due, then an Auto Update when config
708
811
  * `update.auto` is on and nothing blocks it. Only one runs at a time; it never throws.
@@ -719,9 +822,16 @@ export async function backgroundCheck(paths, env, config, panelPort) {
719
822
  if (checkDue(cache, now))
720
823
  cache = await refreshUpdateCheck(paths, env);
721
824
  const current = packageVersion();
722
- if (!autoPending(cache, current, config, now))
825
+ if (!autoPending(cache, current, config, now)) {
826
+ await skillAutoSync(paths, env, config, cache, current, now);
723
827
  return;
724
- const plan = planFrom(await installMethod(env), current, cache.latest, env);
828
+ }
829
+ // The skill teaches agents the new version's commands, so an Auto Update carries it as `sideby update` does.
830
+ const skill = await skillUpdatePlan(paths.home, cache.latest).catch(() => null);
831
+ const plan = {
832
+ ...planFrom(await installMethod(env), current, cache.latest, env),
833
+ ...(skill ? { skill } : {}),
834
+ };
725
835
  await updateUpdateCache(paths, { autoTriedAt: now.toISOString() });
726
836
  // A blocker only the user can lift is said once and looked at again a day later, like a failed install; a
727
837
  // running session only delays it (the next look is within the hour).
@@ -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>;