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
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Grok Build's Quota (spec §3.26, ADR-0025): how its billing answer becomes a Quota reading, and the local copy Grok
|
|
2
|
+
// writes into `$GROK_HOME/logs/unified.jsonl` at every session start and finished turn. Other lines of that file carry
|
|
3
|
+
// the user id and the end of the bearer token, so only lines whose `msg` is exactly the billing one are parsed, and
|
|
4
|
+
// only the Quota fields and the plan name are kept. Leaf module: the handoff hook reads the log too.
|
|
5
|
+
import { open, stat } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { FileMemo } from '../../core/file-memo.js';
|
|
8
|
+
export const GROK_BILLING_MSG = 'billing: fetched credits config';
|
|
9
|
+
const WEEKLY = 'USAGE_PERIOD_TYPE_WEEKLY';
|
|
10
|
+
const WEEK_MINUTES = 7 * 24 * 60;
|
|
11
|
+
const CHUNK = 64 * 1024;
|
|
12
|
+
/** How far back the CLI and the Panel look (Grok halves the file at 5 MB) and how far the handoff hook does. */
|
|
13
|
+
export const LOG_READ_BYTES = 8 * 1024 * 1024;
|
|
14
|
+
export const HOOK_LOG_READ_BYTES = 512 * 1024;
|
|
15
|
+
export function grokLogFile(accountDir) {
|
|
16
|
+
return join(accountDir, 'logs', 'unified.jsonl');
|
|
17
|
+
}
|
|
18
|
+
const record = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) ? v : {};
|
|
19
|
+
const num = (v) => {
|
|
20
|
+
const n = typeof v === 'string' && v.trim() ? Number(v) : v;
|
|
21
|
+
return typeof n === 'number' && Number.isFinite(n) ? n : undefined;
|
|
22
|
+
};
|
|
23
|
+
const str = (v) => (typeof v === 'string' && v ? v : undefined);
|
|
24
|
+
/**
|
|
25
|
+
* A Quota reading from Grok's billing answer: `{ config, subscription_tier }` from `_x.ai/billing`, or the log line's
|
|
26
|
+
* `ctx` (`{ config, subscriptionTier }`). Null when it holds no usable window.
|
|
27
|
+
*/
|
|
28
|
+
export function grokQuota(answer, observedAt, source) {
|
|
29
|
+
const top = record(answer);
|
|
30
|
+
const config = record(top.config);
|
|
31
|
+
const period = record(config.currentPeriod);
|
|
32
|
+
const weekly = period.type === WEEKLY;
|
|
33
|
+
const end = Date.parse(str(period.end) ?? str(config.billingPeriodEnd) ?? '');
|
|
34
|
+
if (!Number.isFinite(end))
|
|
35
|
+
return null;
|
|
36
|
+
const start = Date.parse(str(period.start) ?? str(config.billingPeriodStart) ?? '');
|
|
37
|
+
let used = num(config.creditUsagePercent) ?? num(top.creditUsagePercent);
|
|
38
|
+
if (used === undefined) {
|
|
39
|
+
const spent = num(record(config.used).val);
|
|
40
|
+
const limit = num(record(config.monthlyLimit).val);
|
|
41
|
+
if (spent !== undefined && limit)
|
|
42
|
+
used = (spent / limit) * 100;
|
|
43
|
+
else if (weekly)
|
|
44
|
+
used = 0;
|
|
45
|
+
}
|
|
46
|
+
if (used === undefined)
|
|
47
|
+
return null;
|
|
48
|
+
let minutes = Number.isFinite(start) && end > start ? Math.round((end - start) / 60_000) : undefined;
|
|
49
|
+
if (weekly)
|
|
50
|
+
minutes = WEEK_MINUTES;
|
|
51
|
+
if (minutes === undefined)
|
|
52
|
+
return null;
|
|
53
|
+
const days = Math.max(1, Math.round(minutes / 1440));
|
|
54
|
+
const window = {
|
|
55
|
+
label: minutes === WEEK_MINUTES ? '7d' : `${days}d`,
|
|
56
|
+
windowMinutes: minutes,
|
|
57
|
+
usedPercent: Math.max(0, Math.min(100, used)),
|
|
58
|
+
resetsAt: new Date(end).toISOString(),
|
|
59
|
+
};
|
|
60
|
+
const plan = str(top.subscription_tier) ?? str(top.subscriptionTier);
|
|
61
|
+
return { status: 'ok', windows: [window], observedAt, source, ...(plan ? { plan } : {}) };
|
|
62
|
+
}
|
|
63
|
+
/** The Quota of one log line, or null for every line that is not Grok's billing line. */
|
|
64
|
+
function billingLine(line, source) {
|
|
65
|
+
// A cheap check first: most lines are never parsed.
|
|
66
|
+
if (!line.includes(GROK_BILLING_MSG))
|
|
67
|
+
return null;
|
|
68
|
+
let data;
|
|
69
|
+
try {
|
|
70
|
+
data = record(JSON.parse(line));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const ts = str(data.ts);
|
|
76
|
+
if (data.msg !== GROK_BILLING_MSG || !ts || !Number.isFinite(Date.parse(ts)))
|
|
77
|
+
return null;
|
|
78
|
+
const ctx = record(data.ctx);
|
|
79
|
+
return grokQuota({ config: ctx.config, subscriptionTier: ctx.subscriptionTier }, new Date(Date.parse(ts)).toISOString(), source);
|
|
80
|
+
}
|
|
81
|
+
/** The newest billing line in the last `maxBytes` of `file`, read backwards a chunk at a time; null when none. */
|
|
82
|
+
export async function lastGrokBilling(file, maxBytes, source = file) {
|
|
83
|
+
let fh;
|
|
84
|
+
try {
|
|
85
|
+
fh = await open(file, 'r');
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const size = (await fh.stat()).size;
|
|
92
|
+
let pos = size;
|
|
93
|
+
// The start of the earliest line read so far, which may continue in the chunk before it.
|
|
94
|
+
let head = Buffer.alloc(0);
|
|
95
|
+
while (pos > 0 && size - pos < maxBytes) {
|
|
96
|
+
const len = Math.min(CHUNK, pos);
|
|
97
|
+
pos -= len;
|
|
98
|
+
const buf = Buffer.alloc(len);
|
|
99
|
+
await fh.read(buf, 0, len, pos);
|
|
100
|
+
const chunk = Buffer.concat([buf, head]);
|
|
101
|
+
const cut = pos > 0 ? chunk.indexOf(0x0a) : -1;
|
|
102
|
+
head = cut >= 0 ? chunk.subarray(0, cut) : Buffer.alloc(0);
|
|
103
|
+
const lines = chunk
|
|
104
|
+
.subarray(cut + 1)
|
|
105
|
+
.toString('utf8')
|
|
106
|
+
.split('\n');
|
|
107
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
108
|
+
const q = billingLine(lines[i], source);
|
|
109
|
+
if (q)
|
|
110
|
+
return q;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
await fh.close();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** The log's reading per Account directory; the file is read again only after it changed. */
|
|
120
|
+
export const grokQuotaMemo = new FileMemo();
|
|
121
|
+
/** Grok's local Quota for one Account directory: its newest billing line. */
|
|
122
|
+
export async function readGrokLogQuota(accountDir) {
|
|
123
|
+
const file = grokLogFile(accountDir);
|
|
124
|
+
const none = {
|
|
125
|
+
status: 'unavailable',
|
|
126
|
+
reason: 'no-session',
|
|
127
|
+
detail: 'Grok has not reported the quota of this account yet',
|
|
128
|
+
};
|
|
129
|
+
let st;
|
|
130
|
+
try {
|
|
131
|
+
st = await stat(file);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return none;
|
|
135
|
+
}
|
|
136
|
+
const memo = grokQuotaMemo.pass(accountDir);
|
|
137
|
+
const q = await memo.get(file, st, () => lastGrokBilling(file, LOG_READ_BYTES, 'logs/unified.jsonl'));
|
|
138
|
+
memo.done();
|
|
139
|
+
return q ?? none;
|
|
140
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Account, QuotaFetch, QuotaFetchContext, QuotaResult } from '../../types.ts';
|
|
2
|
+
export declare const GROK_QUOTA_ARGS: readonly ['agent', '--no-leader', 'stdio'];
|
|
3
|
+
export declare const GROK_BILLING_METHOD = "_x.ai/billing";
|
|
4
|
+
export declare function fetchGrokQuota(account: Account, ctx: QuotaFetchContext): Promise<QuotaResult>;
|
|
5
|
+
export declare const grokQuotaFetch: QuotaFetch;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Asks the Grok CLI for an Account's Quota (spec §3.26, ADR-0025): `grok agent --no-leader stdio` answers the ACP
|
|
2
|
+
// extension method `_x.ai/billing` with the request it makes itself, using the login of the `GROK_HOME` it runs under.
|
|
3
|
+
// sideby never reads the token and never sends a request of its own; no session is started.
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { createInterface } from 'node:readline';
|
|
7
|
+
import { grokQuota } from './quota-log.js';
|
|
8
|
+
export const GROK_QUOTA_ARGS = ['agent', '--no-leader', 'stdio'];
|
|
9
|
+
export const GROK_BILLING_METHOD = '_x.ai/billing';
|
|
10
|
+
/** How long Grok may take to exit after its stdin closes before sideby ends it. */
|
|
11
|
+
const EXIT_GRACE_MS = 10_000;
|
|
12
|
+
/** How long to wait before asking again after a failed answer, while Grok refreshes an expired token. */
|
|
13
|
+
const RETRY_MS = 1500;
|
|
14
|
+
const METHOD_NOT_FOUND = -32601;
|
|
15
|
+
const AUTH_REQUIRED = -32000;
|
|
16
|
+
const failed = (detail) => ({ status: 'unavailable', reason: 'fetch-failed', detail });
|
|
17
|
+
/** Why an error answer gave no Quota, and what to do. Grok's own message is never repeated (only matched). */
|
|
18
|
+
function errorDetail(account, error) {
|
|
19
|
+
if (error.code === METHOD_NOT_FOUND)
|
|
20
|
+
return 'this Grok version cannot report the quota; update Grok, then refresh';
|
|
21
|
+
if (error.code === AUTH_REQUIRED) {
|
|
22
|
+
const text = `${typeof error.data === 'string' ? error.data : ''} ${typeof error.message === 'string' ? error.message : ''}`;
|
|
23
|
+
if (text.includes('grok.com'))
|
|
24
|
+
return 'this account is signed in with an API key, which has no subscription quota';
|
|
25
|
+
return `Grok is not signed in for this account; run \`sideby login ${account.ref}\``;
|
|
26
|
+
}
|
|
27
|
+
return `Grok could not get the quota from xAI (error ${typeof error.code === 'number' ? error.code : 'unknown'}); sideby asks again later, or start Grok in this account once`;
|
|
28
|
+
}
|
|
29
|
+
export async function fetchGrokQuota(account, ctx) {
|
|
30
|
+
if (ctx.signal.aborted)
|
|
31
|
+
return failed('the quota check was stopped');
|
|
32
|
+
const child = spawn('grok', [...GROK_QUOTA_ARGS], {
|
|
33
|
+
cwd: tmpdir(),
|
|
34
|
+
env: { ...ctx.hostEnv, GROK_DISABLE_AUTOUPDATER: '1' },
|
|
35
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
36
|
+
});
|
|
37
|
+
const stop = () => {
|
|
38
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
39
|
+
child.kill('SIGTERM');
|
|
40
|
+
};
|
|
41
|
+
const finish = () => {
|
|
42
|
+
// Grok exits by itself once its stdin closes; it is ended only when it does not.
|
|
43
|
+
child.stdin.end();
|
|
44
|
+
const timer = setTimeout(stop, EXIT_GRACE_MS);
|
|
45
|
+
timer.unref();
|
|
46
|
+
child.once('exit', () => clearTimeout(timer));
|
|
47
|
+
child.unref();
|
|
48
|
+
};
|
|
49
|
+
ctx.signal.addEventListener('abort', stop, { once: true });
|
|
50
|
+
try {
|
|
51
|
+
return await new Promise((resolve) => {
|
|
52
|
+
const lines = createInterface({ input: child.stdout });
|
|
53
|
+
let done = false;
|
|
54
|
+
const settle = (r) => {
|
|
55
|
+
if (done)
|
|
56
|
+
return;
|
|
57
|
+
done = true;
|
|
58
|
+
lines.close();
|
|
59
|
+
resolve(r);
|
|
60
|
+
};
|
|
61
|
+
child.on('error', (err) => settle(failed(err.code === 'ENOENT'
|
|
62
|
+
? 'Grok is not installed or not on PATH; install it, then refresh'
|
|
63
|
+
: `could not start Grok (${err.code ?? 'error'})`)));
|
|
64
|
+
// `close` comes after the last stdout line, so an answer written just before exiting still counts.
|
|
65
|
+
child.on('close', () => settle(failed('Grok exited without answering; sideby asks again later')));
|
|
66
|
+
ctx.signal.addEventListener('abort', () => settle(failed('the quota check was stopped')), {
|
|
67
|
+
once: true,
|
|
68
|
+
});
|
|
69
|
+
child.stdin.on('error', () => { });
|
|
70
|
+
let id = 1;
|
|
71
|
+
const ask = () => child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method: GROK_BILLING_METHOD, params: {} })}\n`);
|
|
72
|
+
lines.on('line', (line) => {
|
|
73
|
+
let msg;
|
|
74
|
+
try {
|
|
75
|
+
msg = JSON.parse(line);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (msg?.id !== id)
|
|
81
|
+
return;
|
|
82
|
+
if (msg.error && typeof msg.error === 'object') {
|
|
83
|
+
const error = msg.error;
|
|
84
|
+
// A login whose token just expired fails once: Grok sends the old token and refreshes it as it starts
|
|
85
|
+
// (seen 2026-10-17), so the same process is asked once more after a moment.
|
|
86
|
+
if (id === 1 && error.code !== METHOD_NOT_FOUND && error.code !== AUTH_REQUIRED) {
|
|
87
|
+
id = 2;
|
|
88
|
+
setTimeout(() => {
|
|
89
|
+
if (!done)
|
|
90
|
+
ask();
|
|
91
|
+
}, RETRY_MS).unref();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
settle(failed(errorDetail(account, error)));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const q = grokQuota(msg.result, ctx.now.toISOString(), 'grok agent');
|
|
98
|
+
settle(q ?? failed('Grok answered in a form sideby does not recognise; update sideby'));
|
|
99
|
+
});
|
|
100
|
+
ask();
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
ctx.signal.removeEventListener('abort', stop);
|
|
105
|
+
finish();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export const grokQuotaFetch = {
|
|
109
|
+
everyMinutes: 15,
|
|
110
|
+
summary: "Grok asks xAI for each account's weekly quota, at most every 15 minutes",
|
|
111
|
+
fetch: fetchGrokQuota,
|
|
112
|
+
};
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { type AppProfileStatus } from '../core/app-profiles.ts';
|
|
3
3
|
import { type BrowserReport } from '../core/browser-profiles.ts';
|
|
4
|
-
import { type HandoffSettings } from '../core/config.ts';
|
|
4
|
+
import { type DisplayConfig, type HandoffSettings } from '../core/config.ts';
|
|
5
5
|
import { type AccountModel } from '../core/model-choice.ts';
|
|
6
6
|
import type { RemoteControlGroup } from '../core/remote-control.ts';
|
|
7
7
|
import * as update from '../core/update.ts';
|
|
8
|
-
import type { AccountStatus, DoctorHistory, FamilyInfo, Runtime } from '../runtime.ts';
|
|
8
|
+
import type { AccountStatus, DoctorHistory, FamilyInfo, QuotaCheckInfo, Runtime } from '../runtime.ts';
|
|
9
9
|
import type { Account, FamilyLogo, QuotaResult, QuotaSetupPlan, UsageResult } from '../types.ts';
|
|
10
10
|
import { type PanelTheme } from './theme.ts';
|
|
11
11
|
export declare const PANEL_VERSION: string;
|
|
@@ -81,12 +81,15 @@ export interface PanelUpdate {
|
|
|
81
81
|
checkedAt?: string;
|
|
82
82
|
/** Why Update Checks are off; absent when they are on. */
|
|
83
83
|
checksOff?: string;
|
|
84
|
+
checksOffCode?: update.ChecksOffCode;
|
|
84
85
|
/** Config `update.auto`. */
|
|
85
86
|
auto: boolean;
|
|
86
87
|
method: {
|
|
87
88
|
kind: update.InstallKind;
|
|
88
89
|
canUpdate: boolean;
|
|
89
90
|
reason?: string;
|
|
91
|
+
reasonCode?: update.ReasonCode;
|
|
92
|
+
reasonAt?: string;
|
|
90
93
|
};
|
|
91
94
|
/** The command an Update runs, or the one to run by hand when `canRun` is false. */
|
|
92
95
|
command?: string;
|
|
@@ -94,7 +97,10 @@ export interface PanelUpdate {
|
|
|
94
97
|
releaseNotes?: string;
|
|
95
98
|
/** The command that installs the sideby skill by hand. */
|
|
96
99
|
skill: string;
|
|
97
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* The installed sideby skill, which an Update brings along (spec §3.23); absent when none is installed, and for a
|
|
102
|
+
* Local Build, which `pnpm local --off` brings back to npm together with the skill.
|
|
103
|
+
*/
|
|
98
104
|
skillUpdate?: import('../core/update.ts').SkillUpdatePlan;
|
|
99
105
|
}
|
|
100
106
|
export interface PanelState {
|
|
@@ -140,6 +146,10 @@ export interface PanelState {
|
|
|
140
146
|
* Absent without Claude, Codex or Grok subscription Accounts. Nothing of the browser is read while `enabled` is false.
|
|
141
147
|
*/
|
|
142
148
|
browser?: BrowserReport;
|
|
149
|
+
/** Display Order (spec §3.25): config `display` as it is, an empty object when the user never dragged. */
|
|
150
|
+
display: DisplayConfig;
|
|
151
|
+
/** Families that ask for Quota in a way that costs a request (spec §3.26), with their switch; absent when none. */
|
|
152
|
+
quotaChecks?: QuotaCheckInfo[];
|
|
143
153
|
}
|
|
144
154
|
/** The `handoff` map of the Panel state, from the statuses and Quota already read for the page. */
|
|
145
155
|
export declare function handoffByFamily(rt: Runtime, accounts: readonly PanelAccount[], now: Date): Record<string, string>;
|
|
@@ -4,7 +4,7 @@ import { aliasesByAccount } from '../core/accounts.js';
|
|
|
4
4
|
import { listAppProfiles, openAppProfile, removeAppProfile, } from '../core/app-profiles.js';
|
|
5
5
|
import { FAVICON_SVG, ICON_SVG } from '../core/brand-mark.js';
|
|
6
6
|
import { browserReport, openInBrowser, setBrowserSignIns, } from '../core/browser-profiles.js';
|
|
7
|
-
import { aliasArgs, aliasRefs, editConfigUpdate } from '../core/config.js';
|
|
7
|
+
import { aliasArgs, aliasRefs, editConfigUpdate, } from '../core/config.js';
|
|
8
8
|
import { openDesktopApp, withDesktopApps } from '../core/desktop-app.js';
|
|
9
9
|
import { UserError } from '../core/errors.js';
|
|
10
10
|
import { planHandoff } from '../core/handoff.js';
|
|
@@ -53,7 +53,7 @@ let checking = null;
|
|
|
53
53
|
*/
|
|
54
54
|
async function updateState(rt, readOnly) {
|
|
55
55
|
const current = packageVersion();
|
|
56
|
-
const off = update.
|
|
56
|
+
const off = update.checksOffWhy(rt.env, rt.config);
|
|
57
57
|
const cache = await update.readUpdateCache(rt.paths);
|
|
58
58
|
if (!off && !readOnly && !checking && update.checkDue(cache, new Date()))
|
|
59
59
|
checking = update.refreshUpdateCheck(rt.paths, rt.env).finally(() => {
|
|
@@ -61,15 +61,15 @@ async function updateState(rt, readOnly) {
|
|
|
61
61
|
});
|
|
62
62
|
const method = await update.installMethod(rt.env);
|
|
63
63
|
const plan = cache.latest ? update.shownPlan(update.planFrom(method, current, cache.latest, rt.env)) : null;
|
|
64
|
-
const skillUpdate =
|
|
65
|
-
|
|
66
|
-
.catch(() => null);
|
|
64
|
+
const skillUpdate = method.kind === 'local'
|
|
65
|
+
? null
|
|
66
|
+
: await update.skillUpdatePlan(rt.paths.home, plan?.available ? plan.latest : current).catch(() => null);
|
|
67
67
|
return {
|
|
68
68
|
current,
|
|
69
69
|
...(cache.latest ? { latest: cache.latest } : {}),
|
|
70
70
|
available: Boolean(plan?.available),
|
|
71
71
|
...(cache.checkedAt ? { checkedAt: cache.checkedAt } : {}),
|
|
72
|
-
...(off ? { checksOff: off } : {}),
|
|
72
|
+
...(off ? { checksOff: off.text, checksOffCode: off.code } : {}),
|
|
73
73
|
auto: rt.config.update?.auto === true,
|
|
74
74
|
method: update.shownMethod(method),
|
|
75
75
|
...(plan ? { command: plan.command, releaseNotes: plan.releaseNotes } : {}),
|
|
@@ -86,7 +86,7 @@ export function handoffByFamily(rt, accounts, now) {
|
|
|
86
86
|
// Count logins, not directories: a Same Login group (spec §3.21) is one Quota, so it alone is no alternative.
|
|
87
87
|
const subs = list.filter((a) => a.kind === 'subscription');
|
|
88
88
|
const logins = subs.filter((a, i) => !subs.slice(0, i).some((b) => a.sameLogin?.includes(b.ref))).length;
|
|
89
|
-
if (!f.readQuota || logins < 2)
|
|
89
|
+
if ((!f.readQuota && !f.fetchQuota) || logins < 2)
|
|
90
90
|
continue;
|
|
91
91
|
const plan = planHandoff(id, list.map((a) => ({
|
|
92
92
|
ref: a.ref,
|
|
@@ -203,7 +203,8 @@ async function accountState(rt, account, families) {
|
|
|
203
203
|
};
|
|
204
204
|
const [status, quota, usage] = await Promise.all([
|
|
205
205
|
guard(rt.status(account)),
|
|
206
|
-
|
|
206
|
+
// The kept answer of a Quota check shows now; a stale one is asked again behind it (spec §3.26).
|
|
207
|
+
guard(rt.quota(account, { fetch: 'background' })),
|
|
207
208
|
guard(rt.usage(account)),
|
|
208
209
|
]);
|
|
209
210
|
const fam = families.find((f) => f.id === account.family);
|
|
@@ -303,8 +304,41 @@ async function buildState(rt, opts) {
|
|
|
303
304
|
},
|
|
304
305
|
...(upd ? { update: upd } : {}),
|
|
305
306
|
...(showBrowser ? { browser } : {}),
|
|
307
|
+
display: rt.displayOrder(),
|
|
308
|
+
...(rt.quotaChecks().length ? { quotaChecks: rt.quotaChecks() } : {}),
|
|
306
309
|
};
|
|
307
310
|
}
|
|
311
|
+
/** The `display` of a /api/display-order body: null, or string lists only; anything else is refused. */
|
|
312
|
+
function displayBody(body) {
|
|
313
|
+
const d = body.display;
|
|
314
|
+
if (d === null)
|
|
315
|
+
return undefined;
|
|
316
|
+
const names = (v, what) => {
|
|
317
|
+
if (!Array.isArray(v) || !v.every((x) => typeof x === 'string' && x))
|
|
318
|
+
throw new HttpError(400, `${what} must be a list of names`);
|
|
319
|
+
return [...v];
|
|
320
|
+
};
|
|
321
|
+
if (!d || typeof d !== 'object' || Array.isArray(d))
|
|
322
|
+
throw new HttpError(400, '"display" must be an object or null');
|
|
323
|
+
const out = {};
|
|
324
|
+
for (const [k, v] of Object.entries(d)) {
|
|
325
|
+
if (k === 'families')
|
|
326
|
+
out.families = names(v, '"families"');
|
|
327
|
+
else if (k === 'accounts') {
|
|
328
|
+
if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
329
|
+
throw new HttpError(400, '"accounts" must be an object');
|
|
330
|
+
out.accounts = Object.fromEntries(Object.entries(v).map(([f, list]) => [f, names(list, `"accounts.${f}"`)]));
|
|
331
|
+
}
|
|
332
|
+
else if (k === 'pinned') {
|
|
333
|
+
if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
334
|
+
throw new HttpError(400, '"pinned" must be an object');
|
|
335
|
+
out.pinned = Object.fromEntries(Object.entries(v).map(([f, list]) => [f, names(list, `"pinned.${f}"`)]));
|
|
336
|
+
}
|
|
337
|
+
else
|
|
338
|
+
throw new HttpError(400, `unknown key "${k}"`);
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
308
342
|
/** The `change` of a /api/handoff/configure body, with every field checked; unknown fields are refused. */
|
|
309
343
|
function handoffChange(body) {
|
|
310
344
|
const c = body.change;
|
|
@@ -632,6 +666,17 @@ export function createPanelHandler(opts) {
|
|
|
632
666
|
const shown = update.shownPlan(plan);
|
|
633
667
|
if (!optBool(body, 'confirm'))
|
|
634
668
|
return { ...shown, updated: false };
|
|
669
|
+
// The CLI is current and the skill is behind: `skill: true` updates it alone, as `sideby update --yes` does.
|
|
670
|
+
if (optBool(body, 'skill')) {
|
|
671
|
+
if (plan.available || plan.method.kind === 'local')
|
|
672
|
+
return { ...shown, updated: false, changed: true };
|
|
673
|
+
const r = await update.applySkillOnly(rt.paths, plan.skill, rt.env);
|
|
674
|
+
if (!r)
|
|
675
|
+
return { ...shown, updated: false, changed: true };
|
|
676
|
+
if (!r.ok)
|
|
677
|
+
throw new UserError(r.message);
|
|
678
|
+
return { ...shown, updated: false, skillUpdated: true, message: r.message };
|
|
679
|
+
}
|
|
635
680
|
// A confirmation that runs nothing always says why: the registry names another version (or none newer) now.
|
|
636
681
|
if (body.version !== plan.latest || !plan.available)
|
|
637
682
|
return { ...shown, updated: false, changed: true };
|
|
@@ -671,6 +716,34 @@ export function createPanelHandler(opts) {
|
|
|
671
716
|
};
|
|
672
717
|
},
|
|
673
718
|
},
|
|
719
|
+
// Display Order (spec §3.25): replaces config `display`, or removes it for null. The drag itself is the preview.
|
|
720
|
+
'/api/display-order': {
|
|
721
|
+
async run(body) {
|
|
722
|
+
const display = displayBody(body);
|
|
723
|
+
const rt = await getRuntime();
|
|
724
|
+
return { display: await rt.setDisplayOrder(display) };
|
|
725
|
+
},
|
|
726
|
+
},
|
|
727
|
+
// Asks every Family that fetches Quota again now, and waits (spec §3.26); the page reloads its state after.
|
|
728
|
+
'/api/quota/refresh': {
|
|
729
|
+
async run() {
|
|
730
|
+
const rt = await getRuntime();
|
|
731
|
+
await rt.refreshQuota();
|
|
732
|
+
return { ok: true };
|
|
733
|
+
},
|
|
734
|
+
},
|
|
735
|
+
// A Family's Quota check on or off: config `quota.check.<family>` (spec §3.26).
|
|
736
|
+
'/api/quota/check': {
|
|
737
|
+
async run(body) {
|
|
738
|
+
const family = optString(body, 'family');
|
|
739
|
+
if (!family)
|
|
740
|
+
throw new HttpError(400, '"family" is required');
|
|
741
|
+
if (typeof body.on !== 'boolean')
|
|
742
|
+
throw new HttpError(400, '"on" must be true or false');
|
|
743
|
+
const rt = await getRuntime();
|
|
744
|
+
return { family, ...(await rt.setQuotaCheck(family, body.on)) };
|
|
745
|
+
},
|
|
746
|
+
},
|
|
674
747
|
// Undo for the quota setup above; the Runtime turns a teardown that throws into `{ ok: false, message }`.
|
|
675
748
|
'/api/quota/teardown': {
|
|
676
749
|
async run(body) {
|
package/dist/src/panel/i18n.d.ts
CHANGED
|
@@ -19,7 +19,6 @@ declare const en: {
|
|
|
19
19
|
'upd.runs': string;
|
|
20
20
|
'upd.after': string;
|
|
21
21
|
'upd.cannot': string;
|
|
22
|
-
'upd.skill': string;
|
|
23
22
|
'upd.skillToo': string;
|
|
24
23
|
'upd.skillDone': string;
|
|
25
24
|
'upd.skillManaged': string;
|
|
@@ -50,6 +49,29 @@ declare const en: {
|
|
|
50
49
|
'upd.done': string;
|
|
51
50
|
'upd.reloading': string;
|
|
52
51
|
'upd.restartHint': string;
|
|
52
|
+
'upd.skillLabel': string;
|
|
53
|
+
'upd.skillLater': string;
|
|
54
|
+
'upd.skillLabelLong': string;
|
|
55
|
+
'upd.skillBehind': string;
|
|
56
|
+
'upd.skillNoVer': string;
|
|
57
|
+
'upd.skillWhat': string;
|
|
58
|
+
'upd.skillNow': string;
|
|
59
|
+
'upd.skillDoneOnly': string;
|
|
60
|
+
'upd.skillChanged': string;
|
|
61
|
+
'upd.manual': string;
|
|
62
|
+
'upd.localAvail': string;
|
|
63
|
+
'upd.autoLocal': string;
|
|
64
|
+
'why.local-build': string;
|
|
65
|
+
'why.npx': string;
|
|
66
|
+
'why.volta': string;
|
|
67
|
+
'why.not-writable': string;
|
|
68
|
+
'why.project': string;
|
|
69
|
+
'why.source': string;
|
|
70
|
+
'why.skill-in-git': string;
|
|
71
|
+
'off.local-build': string;
|
|
72
|
+
'off.env': string;
|
|
73
|
+
'off.config': string;
|
|
74
|
+
'off.ci': string;
|
|
53
75
|
'acct.title': string;
|
|
54
76
|
'err.noAnswer': string;
|
|
55
77
|
'err.session': string;
|
|
@@ -141,6 +163,45 @@ declare const en: {
|
|
|
141
163
|
'sort.pressure': string;
|
|
142
164
|
'sort.name': string;
|
|
143
165
|
'sort.recent': string;
|
|
166
|
+
'sort.manual': string;
|
|
167
|
+
'order.handle': string;
|
|
168
|
+
'order.moveFamily': string;
|
|
169
|
+
'order.moveAccount': string;
|
|
170
|
+
'order.position': string;
|
|
171
|
+
'order.failed': string;
|
|
172
|
+
'sort.arrange': string;
|
|
173
|
+
'set.order': string;
|
|
174
|
+
'set.orderMine': string;
|
|
175
|
+
'set.orderDefault': string;
|
|
176
|
+
'order.resetButton': string;
|
|
177
|
+
'order.resetHint': string;
|
|
178
|
+
'order.reset': string;
|
|
179
|
+
'order.bar': string;
|
|
180
|
+
'order.done': string;
|
|
181
|
+
'order.saved': string;
|
|
182
|
+
'order.undo': string;
|
|
183
|
+
'order.undone': string;
|
|
184
|
+
'order.up': string;
|
|
185
|
+
'order.down': string;
|
|
186
|
+
'order.sameFamily': string;
|
|
187
|
+
'pin.pin': string;
|
|
188
|
+
'pin.unpin': string;
|
|
189
|
+
'pin.pinName': string;
|
|
190
|
+
'pin.unpinName': string;
|
|
191
|
+
'pin.full': string;
|
|
192
|
+
'pin.mark': string;
|
|
193
|
+
'pin.failed': string;
|
|
194
|
+
'quota.fetchFailed': string;
|
|
195
|
+
'quota.checkOff': string;
|
|
196
|
+
'quota.checkOffHint': string;
|
|
197
|
+
'quota.fetchedFrom': string;
|
|
198
|
+
'quota.lastCheckFailed': string;
|
|
199
|
+
'quota.refreshFailed': string;
|
|
200
|
+
'quota.checkOnToast': string;
|
|
201
|
+
'quota.checkOffToast': string;
|
|
202
|
+
'quota.checkFailed': string;
|
|
203
|
+
'set.quotaCheck': string;
|
|
204
|
+
'set.quotaCheckHint': string;
|
|
144
205
|
'tb.quota': string;
|
|
145
206
|
'tb.used': string;
|
|
146
207
|
'tb.left': string;
|
|
@@ -176,8 +237,10 @@ declare const en: {
|
|
|
176
237
|
'acct.signInHint': string;
|
|
177
238
|
'acct.signedOutLabel': string;
|
|
178
239
|
'acct.next': string;
|
|
179
|
-
'acct.
|
|
180
|
-
'acct.
|
|
240
|
+
'acct.nextWhy': string;
|
|
241
|
+
'acct.nextCopy': string;
|
|
242
|
+
'stat.needWho': string;
|
|
243
|
+
'stat.needWhoMore': string;
|
|
181
244
|
'acct.sameLogin': string;
|
|
182
245
|
'acct.sameLoginHint': string;
|
|
183
246
|
'acct.notInstalled': string;
|
|
@@ -199,13 +262,16 @@ declare const en: {
|
|
|
199
262
|
'quota.dataFrom': string;
|
|
200
263
|
'quota.asOf': string;
|
|
201
264
|
'quota.sharedFrom': string;
|
|
202
|
-
'quota.readFrom': string;
|
|
203
|
-
'login.count': string;
|
|
204
265
|
'login.label': string;
|
|
266
|
+
'login.dirs': string;
|
|
267
|
+
'login.explain': string;
|
|
268
|
+
'login.copyRun': string;
|
|
269
|
+
'login.showAs': string;
|
|
270
|
+
'login.showAsHint': string;
|
|
271
|
+
'login.shown': string;
|
|
272
|
+
'login.shownHint': string;
|
|
273
|
+
'login.section': string;
|
|
205
274
|
'login.hint': string;
|
|
206
|
-
'login.sharedAbove': string;
|
|
207
|
-
'login.collapse': string;
|
|
208
|
-
'login.expand': string;
|
|
209
275
|
'quota.noWindows': string;
|
|
210
276
|
'quota.off': string;
|
|
211
277
|
'quota.turnOnFamily': string;
|
|
@@ -226,6 +292,36 @@ declare const en: {
|
|
|
226
292
|
'col.account': string;
|
|
227
293
|
'col.used': string;
|
|
228
294
|
'col.left': string;
|
|
295
|
+
'finding.link.real-file': string;
|
|
296
|
+
'finding.symlink-forbidden': string;
|
|
297
|
+
'finding.link.other-target': string;
|
|
298
|
+
'finding.link.dangling': string;
|
|
299
|
+
'finding.missing': string;
|
|
300
|
+
'finding.copy.type-mismatch': string;
|
|
301
|
+
'finding.copy.main-empty': string;
|
|
302
|
+
'finding.copy.stale': string;
|
|
303
|
+
'finding.unsupported-type': string;
|
|
304
|
+
'finding.json-key.symlink': string;
|
|
305
|
+
'finding.json-key.main-unreadable': string;
|
|
306
|
+
'finding.json-key.main-not-object': string;
|
|
307
|
+
'finding.json-key.not-object': string;
|
|
308
|
+
'finding.json-key.would-remove': string;
|
|
309
|
+
'finding.json-key.drift': string;
|
|
310
|
+
'finding.credential.mode': string;
|
|
311
|
+
'finding.path.invalid': string;
|
|
312
|
+
'finding.local.is-link': string;
|
|
313
|
+
'finding.api.needs-own': string;
|
|
314
|
+
'finding.main.missing': string;
|
|
315
|
+
'finding.name.backup-like': string;
|
|
316
|
+
'finding.secret.not-file': string;
|
|
317
|
+
'finding.plugin.error': string;
|
|
318
|
+
'finding.extra-check.error': string;
|
|
319
|
+
'finding.fixHint': string;
|
|
320
|
+
'finding.raw': string;
|
|
321
|
+
'win.5h': string;
|
|
322
|
+
'win.7d': string;
|
|
323
|
+
'quota.resetsIn': string;
|
|
324
|
+
'quota.staleShort': string;
|
|
229
325
|
'col.tokens': string;
|
|
230
326
|
'col.last': string;
|
|
231
327
|
'col.health': string;
|
|
@@ -278,12 +374,44 @@ declare const en: {
|
|
|
278
374
|
'fix.off': string;
|
|
279
375
|
'fix.whyNoCheck': string;
|
|
280
376
|
'fix.whyManual': string;
|
|
377
|
+
'fix.whyButton': string;
|
|
378
|
+
'fix.copyAi': string;
|
|
379
|
+
'fix.copyAiTitle': string;
|
|
380
|
+
'fix.promptShown': string;
|
|
381
|
+
'fix.promptLead': string;
|
|
382
|
+
'fix.promptHint': string;
|
|
383
|
+
'fix.promptFixFirst': string;
|
|
384
|
+
'fix.promptCheck': string;
|
|
281
385
|
'fix.whyNothing': string;
|
|
282
386
|
'fix.result': string;
|
|
283
387
|
'fix.nothing': string;
|
|
284
388
|
'menu.title': string;
|
|
285
389
|
'menu.label': string;
|
|
286
390
|
'menu.copyRun': string;
|
|
391
|
+
'menu.groupOpen': string;
|
|
392
|
+
'ai.title': string;
|
|
393
|
+
'ai.installed': string;
|
|
394
|
+
'ai.notInstalled': string;
|
|
395
|
+
'ai.intro': string;
|
|
396
|
+
'ai.ready': string;
|
|
397
|
+
'ai.install': string;
|
|
398
|
+
'ai.examples': string;
|
|
399
|
+
'ai.copy': string;
|
|
400
|
+
'ai.ask.quota': string;
|
|
401
|
+
'ai.ask.new': string;
|
|
402
|
+
'ai.ask.pin': string;
|
|
403
|
+
'ai.ask.fix': string;
|
|
404
|
+
'ai.ask.handoff': string;
|
|
405
|
+
'ai.ask.rc': string;
|
|
406
|
+
'ai.orderHint': string;
|
|
407
|
+
'ai.emptyHint': string;
|
|
408
|
+
'ai.how': string;
|
|
409
|
+
'att.quota': string;
|
|
410
|
+
'att.fix': string;
|
|
411
|
+
'att.check': string;
|
|
412
|
+
'menu.groupCopy': string;
|
|
413
|
+
'menu.groupManage': string;
|
|
414
|
+
'row.copyRun': string;
|
|
287
415
|
'menu.copyAlias': string;
|
|
288
416
|
'menu.copyLogin': string;
|
|
289
417
|
'menu.copyDir': string;
|
|
@@ -452,6 +580,7 @@ declare const en: {
|
|
|
452
580
|
'tune.hostDefaultLong': string;
|
|
453
581
|
'tune.suggestedTag': string;
|
|
454
582
|
'tune.useThis': string;
|
|
583
|
+
'tune.custom': string;
|
|
455
584
|
'tune.working': string;
|
|
456
585
|
'tune.confirmTitle': string;
|
|
457
586
|
'tune.change': string;
|
|
@@ -476,6 +605,7 @@ declare const en: {
|
|
|
476
605
|
'tpage.idle': string;
|
|
477
606
|
'nf.title': string;
|
|
478
607
|
'nf.intro': string;
|
|
608
|
+
'nf.more': string;
|
|
479
609
|
'nf.tool': string;
|
|
480
610
|
'nf.accounts.one': string;
|
|
481
611
|
'nf.accounts.other': string;
|