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
@@ -0,0 +1,98 @@
1
+ import { keptQuota, quotaStoreFile, readQuotaStore, writeQuotaStore, } from './quota-store.js';
2
+ import { inCi } from './update.js';
3
+ export const FETCH_TIMEOUT_MS = 20_000;
4
+ /** How long a `background` read waits when nothing is kept yet, so a first Panel load still shows Quota. */
5
+ const FIRST_WAIT_MS = 4_000;
6
+ /** One fetch per Account file at a time, shared by every reader in this process. */
7
+ const inFlight = new Map();
8
+ /** Why fetching is off for `family`, or null when it is on (spec §3.26). */
9
+ export function quotaCheckOff(family, config, env) {
10
+ if (config.quota?.check?.[family] === false)
11
+ return `Quota checks are turned off for this tool (config quota.check.${family}); turn them on in the Panel's settings`;
12
+ if (inCi(env))
13
+ return 'Quota checks are off in CI';
14
+ return null;
15
+ }
16
+ const sleep = (ms) => new Promise((resolve) => {
17
+ setTimeout(() => resolve(null), ms).unref();
18
+ });
19
+ async function runFetch(fq, account, ctx, hostEnv, file) {
20
+ const kept = await readQuotaStore(file);
21
+ const ac = new AbortController();
22
+ const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
23
+ const aborted = new Promise((_, reject) => {
24
+ ac.signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
25
+ });
26
+ aborted.catch(() => { });
27
+ let answer;
28
+ let error;
29
+ try {
30
+ answer = await Promise.race([fq.fetch(account, { ...ctx, signal: ac.signal, hostEnv }), aborted]);
31
+ }
32
+ catch (err) {
33
+ error = ac.signal.aborted
34
+ ? `no answer within ${FETCH_TIMEOUT_MS / 1000} seconds; sideby asks again in ${fq.everyMinutes} minutes`
35
+ : (err?.message ?? String(err));
36
+ }
37
+ finally {
38
+ clearTimeout(timer);
39
+ if (!ac.signal.aborted)
40
+ ac.abort();
41
+ }
42
+ if (answer?.status === 'unavailable')
43
+ error = answer.detail ?? answer.reason;
44
+ const quota = answer?.status === 'ok' ? keptQuota(answer) : kept?.quota;
45
+ const entry = {
46
+ version: 1,
47
+ attemptedAt: new Date().toISOString(),
48
+ ...(quota ? { quota } : {}),
49
+ ...(error ? { error } : {}),
50
+ };
51
+ await writeQuotaStore(file, entry).catch(() => { });
52
+ return entry;
53
+ }
54
+ /** The kept answer for `account`, after asking again when `mode` and its age say so. */
55
+ export async function fetchedQuota(fq, account, ctx, hostEnv, mode) {
56
+ const file = quotaStoreFile(ctx.stateDir, account.family, account.name);
57
+ const kept = await readQuotaStore(file);
58
+ if (mode === 'never')
59
+ return kept;
60
+ const at = kept ? Date.parse(kept.attemptedAt) : Number.NaN;
61
+ const age = ctx.now.getTime() - at;
62
+ // A time in the future (a changed clock) counts as old, so it cannot stop every later fetch.
63
+ const stale = !kept || !Number.isFinite(at) || age < -60_000 || age >= fq.everyMinutes * 60_000;
64
+ if (mode !== 'force' && !stale)
65
+ return kept;
66
+ let run = inFlight.get(file);
67
+ if (!run) {
68
+ run = runFetch(fq, account, ctx, hostEnv, file).finally(() => inFlight.delete(file));
69
+ inFlight.set(file, run);
70
+ }
71
+ if (mode !== 'background')
72
+ return run;
73
+ run.catch(() => { });
74
+ if (kept)
75
+ return kept;
76
+ return Promise.race([run, sleep(FIRST_WAIT_MS)]).then((e) => e ?? kept);
77
+ }
78
+ /**
79
+ * What sideby shows from a Family that fetches Quota: the newer of the kept answer and the local reading; else why
80
+ * there is none (`off` is why fetching is off, or null).
81
+ */
82
+ export function shownQuota(local, kept, off) {
83
+ const fetched = kept?.quota
84
+ ? { ...kept.quota, fetched: true, ...(kept.error ? { fetchError: kept.error } : {}) }
85
+ : null;
86
+ const own = local?.status === 'ok' ? local : null;
87
+ if (fetched && own)
88
+ return Date.parse(own.observedAt) > Date.parse(fetched.observedAt) ? own : fetched;
89
+ if (fetched)
90
+ return fetched;
91
+ if (own)
92
+ return own;
93
+ if (off)
94
+ return { status: 'unavailable', reason: 'not-enabled', detail: off };
95
+ if (kept?.error)
96
+ return { status: 'unavailable', reason: 'fetch-failed', detail: kept.error };
97
+ return local ?? { status: 'unavailable', reason: 'no-session' };
98
+ }
@@ -13,7 +13,8 @@ export declare function quotaLevel(usedPercent: number): QuotaLevel;
13
13
  export declare function windowReset(resetsAt: string, now: Date): boolean;
14
14
  /**
15
15
  * Quota Pressure: the used percent of the fullest window that has not reset yet (0 when all have reset),
16
- * or null when the Account has no Quota. The Panel keeps its own copy (`quotaPressure` in page-logic.ts,
17
- * -1 for null) because page-logic helpers must be self-contained; a test keeps the two equal.
16
+ * or null when the Account has no Quota. A window with a `name` covers only some models and never counts (spec
17
+ * §3.26). The Panel keeps its own copy (`quotaPressure` in page-logic.ts, -1 for null) because page-logic helpers
18
+ * must be self-contained; a test keeps the two equal.
18
19
  */
19
20
  export declare function quotaPressure(quota: QuotaResult | undefined, now: Date): number | null;
@@ -20,14 +20,18 @@ export function windowReset(resetsAt, now) {
20
20
  }
21
21
  /**
22
22
  * Quota Pressure: the used percent of the fullest window that has not reset yet (0 when all have reset),
23
- * or null when the Account has no Quota. The Panel keeps its own copy (`quotaPressure` in page-logic.ts,
24
- * -1 for null) because page-logic helpers must be self-contained; a test keeps the two equal.
23
+ * or null when the Account has no Quota. A window with a `name` covers only some models and never counts (spec
24
+ * §3.26). The Panel keeps its own copy (`quotaPressure` in page-logic.ts, -1 for null) because page-logic helpers
25
+ * must be self-contained; a test keeps the two equal.
25
26
  */
26
27
  export function quotaPressure(quota, now) {
27
- if (quota?.status !== 'ok' || quota.windows.length === 0)
28
+ if (quota?.status !== 'ok')
29
+ return null;
30
+ const windows = quota.windows.filter((w) => !w.name);
31
+ if (windows.length === 0)
28
32
  return null;
29
33
  let max = 0;
30
- for (const w of quota.windows)
34
+ for (const w of windows)
31
35
  if (!windowReset(w.resetsAt, now))
32
36
  max = Math.max(max, Math.min(100, Number(w.usedPercent) || 0));
33
37
  return max;
@@ -0,0 +1,19 @@
1
+ import type { QuotaResult } from '../types.ts';
2
+ export type FetchedQuota = Extract<QuotaResult, {
3
+ status: 'ok';
4
+ }>;
5
+ export interface QuotaStoreEntry {
6
+ version: 1;
7
+ /** When the latest fetch finished, good or not; the next one waits `everyMinutes` from here. */
8
+ attemptedAt: string;
9
+ /** The latest good answer, kept while later fetches fail. */
10
+ quota?: FetchedQuota;
11
+ /** Why the latest fetch failed, when it did. */
12
+ error?: string;
13
+ }
14
+ export declare function quotaStoreFile(stateDir: string, family: string, name: string): string;
15
+ /** Only the fields a Quota reading may carry, so a plugin's extra fields never reach the file. */
16
+ export declare function keptQuota(q: FetchedQuota): FetchedQuota;
17
+ /** The kept answer, or null when there is none or the file is not one sideby wrote. */
18
+ export declare function readQuotaStore(file: string): Promise<QuotaStoreEntry | null>;
19
+ export declare function writeQuotaStore(file: string, entry: QuotaStoreEntry): Promise<void>;
@@ -0,0 +1,62 @@
1
+ // The kept answers of `FamilyDef.fetchQuota` (spec §3.26): one file per Account in sideby's state directory, with the
2
+ // Quota numbers, reset times and plan name only. The handoff hook reads it, so this module imports only Node
3
+ // built-ins and leaf modules (never the Runtime, a Family index or core/accounts.ts).
4
+ import { mkdir, readFile } from 'node:fs/promises';
5
+ import { dirname, join } from 'node:path';
6
+ import { writeFileAtomic } from './fs-safe.js';
7
+ export function quotaStoreFile(stateDir, family, name) {
8
+ return join(stateDir, 'quota', family, `${name}.fetched.json`);
9
+ }
10
+ const isWindow = (w) => {
11
+ const v = w;
12
+ return (typeof v === 'object' &&
13
+ v !== null &&
14
+ typeof v.label === 'string' &&
15
+ (v.name === undefined || typeof v.name === 'string') &&
16
+ typeof v.windowMinutes === 'number' &&
17
+ typeof v.usedPercent === 'number' &&
18
+ typeof v.resetsAt === 'string');
19
+ };
20
+ /** Only the fields a Quota reading may carry, so a plugin's extra fields never reach the file. */
21
+ export function keptQuota(q) {
22
+ return {
23
+ status: 'ok',
24
+ windows: q.windows.filter(isWindow).map((w) => ({
25
+ label: w.label,
26
+ ...(w.name ? { name: w.name } : {}),
27
+ windowMinutes: w.windowMinutes,
28
+ usedPercent: w.usedPercent,
29
+ resetsAt: w.resetsAt,
30
+ })),
31
+ observedAt: q.observedAt,
32
+ source: q.source,
33
+ ...(typeof q.plan === 'string' && q.plan ? { plan: q.plan } : {}),
34
+ };
35
+ }
36
+ /** The kept answer, or null when there is none or the file is not one sideby wrote. */
37
+ export async function readQuotaStore(file) {
38
+ let data;
39
+ try {
40
+ data = JSON.parse(await readFile(file, 'utf8'));
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ const e = data;
46
+ if (typeof e !== 'object' || e === null || e.version !== 1 || typeof e.attemptedAt !== 'string')
47
+ return null;
48
+ const q = e.quota;
49
+ const quota = q && q.status === 'ok' && Array.isArray(q.windows) && typeof q.observedAt === 'string'
50
+ ? keptQuota({ ...q, source: typeof q.source === 'string' ? q.source : '' })
51
+ : undefined;
52
+ return {
53
+ version: 1,
54
+ attemptedAt: e.attemptedAt,
55
+ ...(quota ? { quota } : {}),
56
+ ...(typeof e.error === 'string' ? { error: e.error } : {}),
57
+ };
58
+ }
59
+ export async function writeQuotaStore(file, entry) {
60
+ await mkdir(dirname(file), { recursive: true, mode: 0o700 });
61
+ await writeFileAtomic(file, `${JSON.stringify(entry)}\n`, 0o600);
62
+ }
@@ -1,7 +1,22 @@
1
1
  import type { FamilyDef, Finding } from '../types.ts';
2
2
  export declare const SKILL_INSTALL = "npx skills add Adonis0123/sideby -g";
3
+ /** The folder holding `dir` that is a git work tree, if any: a skill there is managed by that repository. */
4
+ export declare function gitRootOf(dir: string): Promise<string | null>;
3
5
  /** `metadata.version` from a SKILL.md frontmatter, or null when there is none. */
4
6
  export declare function skillVersionOf(text: string): string | null;
7
+ /**
8
+ * The versions of the sideby skills an agent reads on this machine (`null`: a skill without one): where
9
+ * `npx skills add -g` puts it (`~/.agents/skills`) and Claude Code's user skills. Read for a `--json` output's
10
+ * `_notice`, so it looks at two fixed files only; Doctor checks every Family's main directory.
11
+ */
12
+ export declare function installedSkillVersions(home: string): Promise<(string | null)[]>;
13
+ /** The same skills with the real folder each lives in (for `sideby update`, which may update them). */
14
+ export declare function installedSkills(home: string): Promise<{
15
+ dir: string;
16
+ version: string | null;
17
+ }[]>;
18
+ /** The command `sideby update` runs to update an installed sideby skill: it keeps the agents it was installed for. */
19
+ export declare const SKILL_UPDATE: readonly ['npx', '-y', 'skills', 'update', 'sideby', '-g', '-y'];
5
20
  /**
6
21
  * Checks, for each given Family, the sideby skill its Host reads: `<main>/skills/sideby`, and for Codex also
7
22
  * `~/.agents/skills/sideby` (where `npx skills add -g` installs and Codex reads user skills). A file is read
@@ -1,17 +1,59 @@
1
1
  // Doctor's look at an installed sideby Agent Skill (spec §3.11). Read-only: a skill written for another
2
2
  // sideby version teaches commands this CLI may not have, so a version mismatch is a warning with the
3
3
  // reinstall command. Not installed is not a problem.
4
- import { readFile, realpath } from 'node:fs/promises';
5
- import { join } from 'node:path';
4
+ import { readFile, realpath, stat } from 'node:fs/promises';
5
+ import { dirname, join } from 'node:path';
6
6
  import { mainDirOf } from './accounts.js';
7
7
  import { tildify } from './paths.js';
8
8
  export const SKILL_INSTALL = 'npx skills add Adonis0123/sideby -g';
9
+ /** The folder holding `dir` that is a git work tree, if any: a skill there is managed by that repository. */
10
+ export async function gitRootOf(dir) {
11
+ for (let d = dir;; d = dirname(d)) {
12
+ try {
13
+ await stat(join(d, '.git'));
14
+ return d;
15
+ }
16
+ catch {
17
+ // keep looking up
18
+ }
19
+ if (dirname(d) === d)
20
+ return null;
21
+ }
22
+ }
9
23
  /** `metadata.version` from a SKILL.md frontmatter, or null when there is none. */
10
24
  export function skillVersionOf(text) {
11
25
  const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1];
12
26
  const meta = front && /^metadata:[ \t]*\r?\n((?:[ \t]+.*(?:\r?\n|$))*)/m.exec(front)?.[1];
13
27
  return (meta && /^[ \t]+version:[ \t]*['"]?([^'"\s]+)/m.exec(meta)?.[1]) || null;
14
28
  }
29
+ /**
30
+ * The versions of the sideby skills an agent reads on this machine (`null`: a skill without one): where
31
+ * `npx skills add -g` puts it (`~/.agents/skills`) and Claude Code's user skills. Read for a `--json` output's
32
+ * `_notice`, so it looks at two fixed files only; Doctor checks every Family's main directory.
33
+ */
34
+ export async function installedSkillVersions(home) {
35
+ return (await installedSkills(home)).map((s) => s.version);
36
+ }
37
+ /** The same skills with the real folder each lives in (for `sideby update`, which may update them). */
38
+ export async function installedSkills(home) {
39
+ const seen = new Set();
40
+ const out = [];
41
+ for (const dir of [join(home, '.agents', 'skills'), join(home, '.claude', 'skills')]) {
42
+ try {
43
+ const real = await realpath(join(dir, 'sideby', 'SKILL.md'));
44
+ if (seen.has(real))
45
+ continue;
46
+ seen.add(real);
47
+ out.push({ dir: dirname(real), version: skillVersionOf(await readFile(real, 'utf8')) });
48
+ }
49
+ catch {
50
+ // not installed there
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+ /** The command `sideby update` runs to update an installed sideby skill: it keeps the agents it was installed for. */
56
+ export const SKILL_UPDATE = ['npx', '-y', 'skills', 'update', 'sideby', '-g', '-y'];
15
57
  /**
16
58
  * Checks, for each given Family, the sideby skill its Host reads: `<main>/skills/sideby`, and for Codex also
17
59
  * `~/.agents/skills/sideby` (where `npx skills add -g` installs and Codex reads user skills). A file is read
@@ -42,13 +84,17 @@ export async function checkInstalledSkills(home, families, version) {
42
84
  if (found === version)
43
85
  continue;
44
86
  const where = tildify(path, home);
87
+ // `sideby update` brings the skill along, except one a git repository owns (a dotfiles clone, say).
88
+ const repo = await gitRootOf(dirname(real));
45
89
  out.push({
46
90
  level: 'warn',
47
91
  account: `${family.id}:main`,
48
92
  item: where,
49
93
  code: 'skill.outdated',
50
94
  message: `the sideby skill at ${where} is ${found ? `for sideby ${found}` : 'from before skills had a version'}; this sideby is ${version}`,
51
- hint: `update it with \`${SKILL_INSTALL}\`, so agents learn this version's commands`,
95
+ hint: repo
96
+ ? `it lives in the git repository ${tildify(repo, home)}: update it where that repository comes from (\`npx skills update sideby -g\` there), so agents learn this version's commands`
97
+ : `run \`sideby update\`, which updates the skill too (or \`${SKILL_INSTALL}\` by hand), so agents learn this version's commands`,
52
98
  source: 'core',
53
99
  fixable: false,
54
100
  });
@@ -114,6 +114,27 @@ export declare function refreshUpdateCheck(paths: Paths, env: Env): Promise<Upda
114
114
  export declare function noticeLine(cache: UpdateCache, current: string, now: Date): string | null;
115
115
  /** What the CLI says once about the last Auto Update, or null. */
116
116
  export declare function autoUpdateLine(cache: UpdateCache): string | null;
117
+ /** The `_notice` of a `--json` output (spec §3.23): what an agent should mention once its task is done. */
118
+ export interface JsonNotice {
119
+ update?: {
120
+ current: string;
121
+ latest: string;
122
+ command: string;
123
+ message: string;
124
+ };
125
+ skill?: {
126
+ installed: string | null;
127
+ current: string;
128
+ command: string;
129
+ message: string;
130
+ };
131
+ }
132
+ /**
133
+ * The `_notice` for a `--json` output, from the cached Update Check and the versions of the installed sideby skills
134
+ * (`null` for one written before skills had a version); null when there is nothing to say or checks are off. It
135
+ * reads no network: an agent sees what the last check found, as a person sees the Update Notice.
136
+ */
137
+ export declare function jsonNotice(cache: UpdateCache, current: string, config: UpdateSettings, env: Env, skills: readonly (string | null)[], skillCommand: string): JsonNotice | null;
117
138
  /**
118
139
  * What the CLI does after a command (spec §3.23), decided from the cache: the lines to print (the Auto Update result,
119
140
  * then the Update Notice), the change that records them, and whether to start a background check. The caller prints,
@@ -153,6 +174,8 @@ export interface UpdatePlan {
153
174
  command: string;
154
175
  canRun: boolean;
155
176
  releaseNotes: string;
177
+ /** The installed sideby skill, which an Update brings along (absent when none is installed). */
178
+ skill?: SkillUpdatePlan;
156
179
  }
157
180
  export declare function planFrom(method: InstallMethod, current: string, latest: string, env?: Env): UpdatePlan;
158
181
  /** The Install Method as outputs show it: no folder of this machine. */
@@ -174,9 +197,28 @@ export declare function shownPlan(plan: UpdatePlan): {
174
197
  command: string;
175
198
  canRun: boolean;
176
199
  releaseNotes: string;
200
+ skillUpdate?: SkillUpdatePlan | undefined;
177
201
  };
202
+ /** What an Update does to the installed sideby skill (spec §3.23, ADR-0021 amendment 2026-10-17). */
203
+ export interface SkillUpdatePlan {
204
+ /** Versions of the installed skills (`null`: one without a version). */
205
+ installed: (string | null)[];
206
+ /** Some installed skill is for another version than the one sideby will run. */
207
+ outdated: boolean;
208
+ command: string;
209
+ /** False when sideby must not run it; `reason` says why and the user runs `command` where it belongs. */
210
+ canRun: boolean;
211
+ reason?: string;
212
+ }
213
+ /** The skill part of an Update toward `target`; null when no sideby skill is installed. Reads two files. */
214
+ export declare function skillUpdatePlan(home: string, target: string): Promise<SkillUpdatePlan | null>;
178
215
  /** Asks the registry now (whatever the switches say: the user asked) and returns what an Update would do. */
179
216
  export declare function planUpdate(paths: Paths, env: Env): Promise<UpdatePlan>;
217
+ /** Updates the installed sideby skill when the plan says it is outdated and sideby may; null when it did nothing. */
218
+ export declare function applySkillUpdate(plan: SkillUpdatePlan | undefined, env: Env, onOutput?: (text: string) => void): Promise<{
219
+ ok: boolean;
220
+ message: string;
221
+ } | null>;
180
222
  export interface UpdateResult {
181
223
  ok: boolean;
182
224
  /** Exit code of the install command; absent when it did not run. */
@@ -189,6 +231,9 @@ export interface UpdateResult {
189
231
  refreshedApp: boolean;
190
232
  /** Another Update held the lock; nothing ran. */
191
233
  busy?: boolean;
234
+ /** The installed sideby skill was updated too (true), or that failed (false); absent when it was not tried. */
235
+ skillUpdated?: boolean;
236
+ skillMessage?: string;
192
237
  }
193
238
  /**
194
239
  * Runs the Update the plan names, then starts a running background Panel again from the new code and, when the
@@ -6,6 +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 { gitRootOf, installedSkills, SKILL_INSTALL, SKILL_UPDATE } from './skill-check.js';
9
10
  import { localBuild, packageVersion, parseLocalBuild } from './version.js';
10
11
  export const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
11
12
  export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
@@ -351,6 +352,32 @@ export function autoUpdateLine(cache) {
351
352
  ? `sideby updated itself from ${a.from} to ${a.to} (${RELEASE_NOTES(a.to)})`
352
353
  : `sideby could not update itself to ${a.to}: ${a.message}`;
353
354
  }
355
+ /**
356
+ * The `_notice` for a `--json` output, from the cached Update Check and the versions of the installed sideby skills
357
+ * (`null` for one written before skills had a version); null when there is nothing to say or checks are off. It
358
+ * reads no network: an agent sees what the last check found, as a person sees the Update Notice.
359
+ */
360
+ export function jsonNotice(cache, current, config, env, skills, skillCommand) {
361
+ if (checksOff(env, config))
362
+ return null;
363
+ const out = {};
364
+ if (isNewer(cache.latest, current))
365
+ out.update = {
366
+ current,
367
+ latest: cache.latest,
368
+ command: 'sideby update',
369
+ message: `sideby ${cache.latest} is available (this is ${current}); \`sideby update\` shows what it runs and updates after --yes`,
370
+ };
371
+ const stale = skills.find((v) => v !== current);
372
+ if (stale !== undefined)
373
+ out.skill = {
374
+ installed: stale,
375
+ current,
376
+ command: skillCommand,
377
+ message: `the installed sideby skill is ${stale ? `for sideby ${stale}` : 'from before skills had a version'}; this sideby is ${current}. \`${skillCommand}\` updates it`,
378
+ };
379
+ return out.update || out.skill ? out : null;
380
+ }
354
381
  /**
355
382
  * What the CLI does after a command (spec §3.23), decided from the cache: the lines to print (the Auto Update result,
356
383
  * then the Update Notice), the change that records them, and whether to start a background check. The caller prints,
@@ -532,14 +559,55 @@ export function shownPlan(plan) {
532
559
  command: plan.command,
533
560
  canRun: plan.canRun,
534
561
  releaseNotes: plan.releaseNotes,
562
+ ...(plan.skill ? { skillUpdate: plan.skill } : {}),
563
+ };
564
+ }
565
+ /** The skill part of an Update toward `target`; null when no sideby skill is installed. Reads two files. */
566
+ export async function skillUpdatePlan(home, target) {
567
+ const skills = await installedSkills(home);
568
+ if (!skills.length)
569
+ return null;
570
+ const installed = skills.map((s) => s.version);
571
+ const plan = {
572
+ installed,
573
+ outdated: installed.some((v) => v !== target),
574
+ command: SKILL_UPDATE.join(' '),
575
+ canRun: true,
535
576
  };
577
+ for (const s of skills) {
578
+ const root = await gitRootOf(s.dir);
579
+ if (root) {
580
+ const at = root.startsWith(home) ? `~${root.slice(home.length)}` : root;
581
+ return {
582
+ ...plan,
583
+ command: SKILL_INSTALL,
584
+ canRun: false,
585
+ reason: `the skill is in the git repository ${at}, so sideby leaves it alone; update it where that repository comes from`,
586
+ };
587
+ }
588
+ }
589
+ return plan;
536
590
  }
537
591
  /** Asks the registry now (whatever the switches say: the user asked) and returns what an Update would do. */
538
592
  export async function planUpdate(paths, env) {
539
593
  const latest = await fetchLatest(env);
540
594
  const at = deps().now().toISOString();
541
595
  await updateUpdateCache(paths, { checkedAt: at, triedAt: at, latest }).catch(() => { });
542
- return planFrom(await installMethod(env), packageVersion(), latest, env);
596
+ const plan = planFrom(await installMethod(env), packageVersion(), latest, env);
597
+ const skill = await skillUpdatePlan(paths.home, plan.available ? latest : plan.current).catch(() => null);
598
+ return skill ? { ...plan, skill } : plan;
599
+ }
600
+ /** Updates the installed sideby skill when the plan says it is outdated and sideby may; null when it did nothing. */
601
+ export async function applySkillUpdate(plan, env, onOutput) {
602
+ if (!plan?.outdated || !plan.canRun)
603
+ return null;
604
+ const [file, ...args] = SKILL_UPDATE;
605
+ const r = await deps()
606
+ .exec(file, args, { env, ...(onOutput ? { onOutput } : {}) })
607
+ .catch((e) => ({ code: 1, output: e instanceof Error ? e.message : String(e) }));
608
+ return r.code === 0
609
+ ? { ok: true, message: 'updated the sideby skill' }
610
+ : { ok: false, message: `\`${plan.command}\` failed (exit ${r.code}); run it yourself to see why` };
543
611
  }
544
612
  /**
545
613
  * Runs the Update the plan names, then starts a running background Panel again from the new code and, when the
@@ -582,6 +650,8 @@ export async function applyUpdate(paths, plan, env, opts = {}) {
582
650
  // the new code writes it again. Only where one is installed; a failure here never fails the Update.
583
651
  const installed = await d.appInstalled(paths.home, env).catch(() => false);
584
652
  const refreshed = installed && (await d.refreshApp(env).catch(() => false));
653
+ // The skill teaches agents this version's commands, so it follows the CLI; a failure there keeps the Update.
654
+ const skill = await applySkillUpdate(plan.skill, env, opts.onOutput);
585
655
  return {
586
656
  ok: true,
587
657
  code: 0,
@@ -589,6 +659,7 @@ export async function applyUpdate(paths, plan, env, opts = {}) {
589
659
  message: `updated sideby from ${plan.current} to ${plan.latest}`,
590
660
  restartedPanel: restarted,
591
661
  refreshedApp: refreshed,
662
+ ...(skill ? { skillUpdated: skill.ok, skillMessage: skill.message } : {}),
592
663
  };
593
664
  }
594
665
  finally {
@@ -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;