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.
- package/README.md +1 -1
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +3 -2
- package/dist/src/cli/commands.js +69 -8
- package/dist/src/cli/format.js +7 -3
- package/dist/src/cli/json-schemas.js +62 -2
- package/dist/src/cli/main.js +42 -4
- package/dist/src/core/config.d.ts +27 -0
- package/dist/src/core/config.js +48 -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 +15 -0
- package/dist/src/core/skill-check.js +49 -3
- package/dist/src/core/update.d.ts +45 -0
- package/dist/src/core/update.js +72 -1
- 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 +9 -3
- package/dist/src/panel/handler.js +65 -3
- package/dist/src/panel/i18n.d.ts +25 -5
- package/dist/src/panel/i18n.js +52 -12
- package/dist/src/panel/page-logic.d.ts +18 -2
- package/dist/src/panel/page-logic.js +53 -6
- package/dist/src/panel/page-script.js +286 -92
- package/dist/src/panel/page-styles.js +30 -10
- package/dist/src/runtime.d.ts +30 -2
- package/dist/src/runtime.js +64 -21
- package/dist/src/types.d.ts +49 -1
- package/package.json +2 -2
- package/schemas/alias.json +62 -0
- package/schemas/app-install.json +62 -0
- package/schemas/app-uninstall.json +62 -0
- package/schemas/browser-open.json +62 -0
- package/schemas/browser-switch.json +62 -0
- package/schemas/browser.json +62 -0
- package/schemas/config.json +46 -0
- package/schemas/desktop-remove.json +62 -0
- package/schemas/desktop.json +62 -0
- package/schemas/doctor.json +62 -0
- package/schemas/error.json +62 -0
- package/schemas/families.json +62 -0
- package/schemas/handoff-disable.json +62 -0
- package/schemas/handoff-enable.json +62 -0
- package/schemas/handoff-ready.json +62 -0
- package/schemas/handoff-setup.json +62 -0
- package/schemas/handoff-status.json +62 -0
- package/schemas/handoff-teardown.json +62 -0
- package/schemas/list.json +62 -0
- package/schemas/model.json +62 -0
- package/schemas/new.json +62 -0
- package/schemas/next.json +62 -0
- package/schemas/plugins.json +62 -0
- package/schemas/quota-setup.json +62 -0
- package/schemas/quota-teardown.json +62 -0
- package/schemas/quota.json +76 -1
- package/schemas/remote-control.json +62 -0
- package/schemas/tune-change.json +62 -0
- package/schemas/tune.json +62 -0
- package/schemas/update.json +107 -1
- package/skills/sideby/SKILL.md +4 -2
- package/skills/sideby/references/diagnose.md +3 -2
- package/skills/sideby/references/handoff.md +1 -1
- package/skills/sideby/references/quota.md +5 -1
- 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.
|
|
17
|
-
* -1 for null) because page-logic helpers
|
|
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.
|
|
24
|
-
* -1 for null) because page-logic helpers
|
|
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'
|
|
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
|
|
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:
|
|
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
|
package/dist/src/core/update.js
CHANGED
|
@@ -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
|
-
|
|
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):
|
|
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;
|