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.
- package/README.md +2 -1
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +4 -2
- package/dist/src/cli/commands.js +194 -11
- package/dist/src/cli/format.js +7 -3
- package/dist/src/cli/json-schemas.js +104 -3
- package/dist/src/cli/main.js +27 -7
- package/dist/src/core/config.d.ts +28 -0
- package/dist/src/core/config.js +54 -1
- package/dist/src/core/handoff-enable.js +10 -5
- package/dist/src/core/launch.d.ts +5 -0
- package/dist/src/core/launch.js +14 -3
- package/dist/src/core/quota-fetch.d.ts +22 -0
- package/dist/src/core/quota-fetch.js +98 -0
- package/dist/src/core/quota-levels.d.ts +3 -2
- package/dist/src/core/quota-levels.js +8 -4
- package/dist/src/core/quota-store.d.ts +19 -0
- package/dist/src/core/quota-store.js +62 -0
- package/dist/src/core/skill-check.d.ts +16 -0
- package/dist/src/core/skill-check.js +47 -3
- package/dist/src/core/update.d.ts +67 -1
- package/dist/src/core/update.js +147 -37
- package/dist/src/families/grok/handoff-hook.d.ts +6 -0
- package/dist/src/families/grok/handoff-hook.js +45 -2
- package/dist/src/families/grok/handoff.d.ts +6 -1
- package/dist/src/families/grok/handoff.js +30 -13
- package/dist/src/families/grok/index.js +5 -0
- package/dist/src/families/grok/quota-log.d.ts +30 -0
- package/dist/src/families/grok/quota-log.js +140 -0
- package/dist/src/families/grok/quota.d.ts +5 -0
- package/dist/src/families/grok/quota.js +112 -0
- package/dist/src/panel/handler.d.ts +13 -3
- package/dist/src/panel/handler.js +81 -8
- package/dist/src/panel/i18n.d.ts +138 -8
- package/dist/src/panel/i18n.js +301 -41
- package/dist/src/panel/page-logic.d.ts +59 -2
- package/dist/src/panel/page-logic.js +99 -6
- package/dist/src/panel/page-script.js +769 -175
- package/dist/src/panel/page-styles.js +83 -15
- package/dist/src/panel/page.js +3 -0
- package/dist/src/runtime.d.ts +32 -2
- package/dist/src/runtime.js +64 -21
- package/dist/src/types.d.ts +49 -1
- package/package.json +3 -2
- package/schemas/alias.json +1 -1
- package/schemas/app-install.json +1 -1
- package/schemas/app-uninstall.json +1 -1
- package/schemas/browser-open.json +1 -1
- package/schemas/browser-switch.json +1 -1
- package/schemas/browser.json +1 -1
- package/schemas/config.json +60 -0
- package/schemas/desktop-remove.json +1 -1
- package/schemas/desktop.json +1 -1
- package/schemas/doctor.json +1 -1
- package/schemas/error.json +1 -1
- package/schemas/families.json +1 -1
- package/schemas/handoff-disable.json +1 -1
- package/schemas/handoff-enable.json +1 -1
- package/schemas/handoff-ready.json +1 -1
- package/schemas/handoff-setup.json +1 -1
- package/schemas/handoff-status.json +282 -1
- package/schemas/handoff-teardown.json +1 -1
- package/schemas/list.json +6 -1
- package/schemas/model.json +1 -1
- package/schemas/new.json +1 -1
- package/schemas/next.json +1 -1
- package/schemas/order.json +148 -0
- package/schemas/pin.json +126 -0
- package/schemas/plugins.json +1 -1
- package/schemas/quota-check.json +88 -0
- package/schemas/quota-setup.json +1 -1
- package/schemas/quota-teardown.json +1 -1
- package/schemas/quota.json +15 -2
- package/schemas/remote-control.json +1 -1
- package/schemas/tune-change.json +1 -1
- package/schemas/tune.json +1 -1
- package/schemas/unpin.json +126 -0
- package/schemas/update-auto.json +84 -0
- package/schemas/update.json +79 -1
- package/skills/sideby/SKILL.md +7 -3
- package/skills/sideby/references/diagnose.md +2 -1
- package/skills/sideby/references/handoff.md +9 -1
- package/skills/sideby/references/panel.md +28 -0
- package/skills/sideby/references/quota.md +11 -1
- package/skills/sideby/references/update.md +6 -2
package/dist/src/core/update.js
CHANGED
|
@@ -6,7 +6,7 @@ import { access, constants, mkdir, open, readdir, readFile, realpath, rm, stat }
|
|
|
6
6
|
import { dirname, join, sep } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { writeFileAtomic } from './fs-safe.js';
|
|
9
|
-
import { installedSkills,
|
|
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
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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 {
|
|
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', {
|
|
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
|
-
? {
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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):
|
|
2
|
-
//
|
|
1
|
+
// Grok Build for the handoff hook (spec §3.17, §3.26): Quota Pressure from the kept answer of its CLI and the billing
|
|
2
|
+
// lines it writes to its log, and a turn that failed at the limit. Leaf module: the hook runs on every tool call. The
|
|
3
|
+
// wording comes from the grok 1.0.50 binary; check it again when Grok changes.
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { basename, join, resolve } from 'node:path';
|
|
6
|
+
import { ACCOUNT_NAME } from '../../core/account-name.js';
|
|
7
|
+
import { resolvePaths } from '../../core/paths.js';
|
|
8
|
+
import { quotaPressure } from '../../core/quota-levels.js';
|
|
9
|
+
import { quotaStoreFile, readQuotaStore } from '../../core/quota-store.js';
|
|
3
10
|
import { field } from '../../handoff/reader.js';
|
|
11
|
+
import { grokLogFile, HOOK_LOG_READ_BYTES, lastGrokBilling } from './quota-log.js';
|
|
12
|
+
const MAIN_DIR = '.grok';
|
|
13
|
+
const ACCOUNT_PREFIX = '.grok-';
|
|
14
|
+
/** The Account the hook runs for, from `GROK_HOME`: unset or `~/.grok` is `main`, `.grok-<name>` is `<name>`. */
|
|
15
|
+
export function grokAccountFromEnv(env) {
|
|
16
|
+
const home = env.HOME || homedir();
|
|
17
|
+
const dir = env.GROK_HOME;
|
|
18
|
+
if (!dir || resolve(dir) === join(home, MAIN_DIR))
|
|
19
|
+
return { name: 'main', dir: join(home, MAIN_DIR) };
|
|
20
|
+
const base = basename(dir);
|
|
21
|
+
if (!base.startsWith(ACCOUNT_PREFIX))
|
|
22
|
+
return null;
|
|
23
|
+
const name = base.slice(ACCOUNT_PREFIX.length);
|
|
24
|
+
return name !== 'main' && ACCOUNT_NAME.test(name) ? { name, dir } : null;
|
|
25
|
+
}
|
|
4
26
|
/** Substrings that mean the usage pool or credits are used up. `StopFailure.error` has no class of its own for it. */
|
|
5
27
|
export const GROK_USED_UP = [
|
|
6
28
|
'You hit your weekly limit',
|
|
@@ -15,6 +37,27 @@ export const GROK_USED_UP = [
|
|
|
15
37
|
'status 402',
|
|
16
38
|
];
|
|
17
39
|
export const grokHookReader = {
|
|
40
|
+
async pressure(_input, env, now) {
|
|
41
|
+
const account = grokAccountFromEnv(env);
|
|
42
|
+
if (!account)
|
|
43
|
+
return null;
|
|
44
|
+
try {
|
|
45
|
+
const [log, kept] = await Promise.all([
|
|
46
|
+
lastGrokBilling(grokLogFile(account.dir), HOOK_LOG_READ_BYTES),
|
|
47
|
+
readQuotaStore(quotaStoreFile(resolvePaths(env).stateDir, 'grok', account.name)),
|
|
48
|
+
]);
|
|
49
|
+
const fetched = kept?.quota ?? null;
|
|
50
|
+
const newer = log && fetched
|
|
51
|
+
? Date.parse(log.observedAt) > Date.parse(fetched.observedAt)
|
|
52
|
+
? log
|
|
53
|
+
: fetched
|
|
54
|
+
: (log ?? fetched);
|
|
55
|
+
return newer ? quotaPressure(newer, now) : null;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
},
|
|
18
61
|
limitHit(input) {
|
|
19
62
|
if (field(input, 'subagentType'))
|
|
20
63
|
return false;
|
|
@@ -41,7 +41,12 @@ export declare function grokEligibility(policy: ReturnType<typeof grokPolicy>, a
|
|
|
41
41
|
start: boolean;
|
|
42
42
|
receive: boolean;
|
|
43
43
|
};
|
|
44
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* The file sideby 0.13 and earlier wrote: no `PostToolUse`, since Grok had no Quota to prepare a Handoff from then.
|
|
46
|
+
* Setup replaces it, teardown removes it (spec §3.26).
|
|
47
|
+
*/
|
|
48
|
+
export declare const GROK_HOOK_FILE_V1: string;
|
|
49
|
+
/** The whole file setup writes; teardown removes it only while it is still exactly this (or `GROK_HOOK_FILE_V1`). */
|
|
45
50
|
export declare function grokHookFileText(): string;
|
|
46
51
|
export declare const grokHookSetup: QuotaSetup;
|
|
47
52
|
export declare const grokHandoff: FamilyHandoff;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// How Grok Build takes part in a Handoff (spec §3.17).
|
|
2
|
-
// failed at the limit. Interactive `grok` has no launch option for hooks: `sideby handoff setup grok` adds
|
|
1
|
+
// How Grok Build takes part in a Handoff (spec §3.17). Since its Quota comes from its own CLI (spec §3.26), it starts
|
|
2
|
+
// one at the Handoff Threshold, and still after a turn failed at the limit. Interactive `grok` has no launch option for hooks: `sideby handoff setup grok` adds
|
|
3
3
|
// `hooks/sideby-handoff.json` to the Main Account and Doctor copies it to the others (verified 2026-10-09).
|
|
4
4
|
// The hook inherits Grok's sandbox, so the profile decides whether a run may start or receive.
|
|
5
5
|
import { mkdir, readFile, rm, rmdir } from 'node:fs/promises';
|
|
@@ -124,10 +124,22 @@ export function grokEligibility(policy, args, home) {
|
|
|
124
124
|
};
|
|
125
125
|
}
|
|
126
126
|
const hookCommand = (event) => `sideby handoff-hook grok ${event}`;
|
|
127
|
-
/**
|
|
127
|
+
/**
|
|
128
|
+
* The file sideby 0.13 and earlier wrote: no `PostToolUse`, since Grok had no Quota to prepare a Handoff from then.
|
|
129
|
+
* Setup replaces it, teardown removes it (spec §3.26).
|
|
130
|
+
*/
|
|
131
|
+
export const GROK_HOOK_FILE_V1 = `${JSON.stringify({
|
|
132
|
+
hooks: {
|
|
133
|
+
Stop: [{ hooks: [{ type: 'command', command: hookCommand('Stop') }] }],
|
|
134
|
+
StopFailure: [{ hooks: [{ type: 'command', command: hookCommand('StopFailure'), timeout: 60 }] }],
|
|
135
|
+
},
|
|
136
|
+
}, null, 2)}\n`;
|
|
137
|
+
/** The whole file setup writes; teardown removes it only while it is still exactly this (or `GROK_HOOK_FILE_V1`). */
|
|
128
138
|
export function grokHookFileText() {
|
|
129
139
|
const hooks = {
|
|
130
140
|
hooks: {
|
|
141
|
+
// Quota Pressure after each tool call, to prepare and start a Handoff before the limit (spec §3.26).
|
|
142
|
+
PostToolUse: [{ hooks: [{ type: 'command', command: hookCommand('PostToolUse') }] }],
|
|
131
143
|
Stop: [{ hooks: [{ type: 'command', command: hookCommand('Stop') }] }],
|
|
132
144
|
// Observation hooks get 5 seconds by default; putting a Brief together may take longer.
|
|
133
145
|
StopFailure: [{ hooks: [{ type: 'command', command: hookCommand('StopFailure'), timeout: 60 }] }],
|
|
@@ -143,7 +155,8 @@ async function plan(ctx) {
|
|
|
143
155
|
const have = await textOrNull(file);
|
|
144
156
|
if (have === want)
|
|
145
157
|
return { status: 'enabled', file, diff: '', message: `already set up in ${shown}` };
|
|
146
|
-
|
|
158
|
+
const upgrade = have === GROK_HOOK_FILE_V1;
|
|
159
|
+
if (have !== null && !upgrade)
|
|
147
160
|
return {
|
|
148
161
|
status: 'blocked',
|
|
149
162
|
file,
|
|
@@ -153,19 +166,22 @@ async function plan(ctx) {
|
|
|
153
166
|
const problem = await sidebyBinProblem(ctx.env, 'Grok runs `sideby handoff-hook` when a turn ends');
|
|
154
167
|
if (problem)
|
|
155
168
|
return { status: 'blocked', file, diff: '', message: problem };
|
|
169
|
+
const lines = (text, mark) => text
|
|
170
|
+
.trimEnd()
|
|
171
|
+
.split('\n')
|
|
172
|
+
.map((l) => `${mark}${l}`)
|
|
173
|
+
.join('\n');
|
|
156
174
|
return {
|
|
157
175
|
status: 'ready',
|
|
158
176
|
file,
|
|
159
|
-
diff:
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
.join('\n')}\n`,
|
|
164
|
-
message: `adds ${shown} so Grok tells sideby when a turn hits the usage limit; then run \`sideby doctor grok --fix\` to copy it to every Grok account; undo with \`sideby handoff teardown grok\``,
|
|
177
|
+
diff: upgrade
|
|
178
|
+
? `--- ${shown}\n+++ ${shown}\n${lines(have, '-')}\n${lines(want, '+')}\n`
|
|
179
|
+
: `--- /dev/null\n+++ ${shown}\n${lines(want, '+')}\n`,
|
|
180
|
+
message: `${upgrade ? 'updates' : 'adds'} ${shown} so Grok tells sideby how full its quota is after each tool call and when a turn hits the usage limit; then run \`sideby doctor grok --fix\` to copy it to every Grok account; undo with \`sideby handoff teardown grok\``,
|
|
165
181
|
};
|
|
166
182
|
}
|
|
167
183
|
export const grokHookSetup = {
|
|
168
|
-
summary: 'Adds one sideby hook file to ~/.grok/hooks so a Grok session
|
|
184
|
+
summary: 'Adds one sideby hook file to ~/.grok/hooks so a Grok session can hand over when its quota runs low or hits the limit.',
|
|
169
185
|
plan,
|
|
170
186
|
async apply(ctx) {
|
|
171
187
|
const p = await plan(ctx);
|
|
@@ -175,13 +191,14 @@ export const grokHookSetup = {
|
|
|
175
191
|
await writeFileAtomic(p.file, grokHookFileText(), 0o644);
|
|
176
192
|
return { ...p, status: 'enabled' };
|
|
177
193
|
},
|
|
194
|
+
earlierTexts: [GROK_HOOK_FILE_V1],
|
|
178
195
|
async teardown(ctx) {
|
|
179
196
|
const file = hookFile(ctx);
|
|
180
197
|
const shown = tildify(file, ctx.home);
|
|
181
198
|
const have = await textOrNull(file);
|
|
182
199
|
if (have === null)
|
|
183
200
|
return { ok: true, message: `${shown} is not there; nothing to undo` };
|
|
184
|
-
if (sha256(have) !== sha256(grokHookFileText()))
|
|
201
|
+
if (sha256(have) !== sha256(grokHookFileText()) && have !== GROK_HOOK_FILE_V1)
|
|
185
202
|
return {
|
|
186
203
|
ok: false,
|
|
187
204
|
message: `${shown} changed since setup; delete it yourself if you no longer want it`,
|
|
@@ -196,7 +213,7 @@ export const grokHookSetup = {
|
|
|
196
213
|
},
|
|
197
214
|
};
|
|
198
215
|
export const grokHandoff = {
|
|
199
|
-
starts: ['limit'],
|
|
216
|
+
starts: ['quota', 'limit'],
|
|
200
217
|
promptArgs: promptAfterDashes,
|
|
201
218
|
continueArgs: (args) => carryArgs(args, GROK_ARGS),
|
|
202
219
|
resumeArgs: (id) => ['--resume', id],
|
|
@@ -5,6 +5,8 @@ import { BUILTIN_LOGOS } from '../logos.js';
|
|
|
5
5
|
import { groupedEntryWrittenAt, resumeFlagSession } from '../shared/sessions.js';
|
|
6
6
|
import { topLevelTomlString } from '../shared/toml.js';
|
|
7
7
|
import { grokHandoff } from './handoff.js';
|
|
8
|
+
import { grokQuotaFetch } from './quota.js';
|
|
9
|
+
import { readGrokLogQuota } from './quota-log.js';
|
|
8
10
|
// Grok's sandbox rejects these paths when they are symlinks; doctor quotes the Host's own words.
|
|
9
11
|
const HOOKS_DIR_MESSAGE = 'Grok hooks directory has wrong type (expected real directory)';
|
|
10
12
|
const REAL_FILE_MESSAGE = 'Grok hooks-paths registry has wrong type (expected real file)';
|
|
@@ -71,6 +73,9 @@ export const grokFamily = {
|
|
|
71
73
|
},
|
|
72
74
|
model: readModel,
|
|
73
75
|
identity: grokIdentity,
|
|
76
|
+
// Quota (spec §3.26, ADR-0025): Grok's own billing lines in its log, and its CLI asked at most every 15 minutes.
|
|
77
|
+
readQuota: (account) => readGrokLogQuota(account.dir),
|
|
78
|
+
fetchQuota: grokQuotaFetch,
|
|
74
79
|
resumedSession: resumeFlagSession,
|
|
75
80
|
handoff: grokHandoff,
|
|
76
81
|
// One directory per session, grouped by working directory: sessions/<encoded cwd>/<id>/.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { FileMemo } from '../../core/file-memo.ts';
|
|
2
|
+
import type { QuotaResult, QuotaWindow } from '../../types.ts';
|
|
3
|
+
export type GrokQuota = Extract<QuotaResult, {
|
|
4
|
+
status: 'ok';
|
|
5
|
+
}>;
|
|
6
|
+
export declare const GROK_BILLING_MSG = "billing: fetched credits config";
|
|
7
|
+
/** How far back the CLI and the Panel look (Grok halves the file at 5 MB) and how far the handoff hook does. */
|
|
8
|
+
export declare const LOG_READ_BYTES: number;
|
|
9
|
+
export declare const HOOK_LOG_READ_BYTES: number;
|
|
10
|
+
export declare function grokLogFile(accountDir: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* A Quota reading from Grok's billing answer: `{ config, subscription_tier }` from `_x.ai/billing`, or the log line's
|
|
13
|
+
* `ctx` (`{ config, subscriptionTier }`). Null when it holds no usable window.
|
|
14
|
+
*/
|
|
15
|
+
export declare function grokQuota(answer: unknown, observedAt: string, source: string): GrokQuota | null;
|
|
16
|
+
/** The newest billing line in the last `maxBytes` of `file`, read backwards a chunk at a time; null when none. */
|
|
17
|
+
export declare function lastGrokBilling(file: string, maxBytes: number, source?: string): Promise<GrokQuota | null>;
|
|
18
|
+
/** The log's reading per Account directory; the file is read again only after it changed. */
|
|
19
|
+
export declare const grokQuotaMemo: FileMemo<{
|
|
20
|
+
status: 'ok';
|
|
21
|
+
windows: QuotaWindow[];
|
|
22
|
+
observedAt: string;
|
|
23
|
+
source: string;
|
|
24
|
+
plan?: string;
|
|
25
|
+
from?: string;
|
|
26
|
+
fetched?: boolean;
|
|
27
|
+
fetchError?: string;
|
|
28
|
+
} | null>;
|
|
29
|
+
/** Grok's local Quota for one Account directory: its newest billing line. */
|
|
30
|
+
export declare function readGrokLogQuota(accountDir: string): Promise<QuotaResult>;
|