sideby 0.2.2 → 0.4.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 +27 -10
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +8 -1
- package/dist/src/cli/commands.js +330 -9
- package/dist/src/cli/format.d.ts +3 -1
- package/dist/src/cli/format.js +25 -0
- package/dist/src/cli/json-schemas.js +113 -0
- package/dist/src/cli/main.d.ts +5 -0
- package/dist/src/cli/main.js +40 -8
- package/dist/src/core/config.d.ts +41 -0
- package/dist/src/core/config.js +86 -11
- package/dist/src/core/file-memo.d.ts +23 -0
- package/dist/src/core/file-memo.js +37 -0
- package/dist/src/core/handoff-brief.d.ts +17 -0
- package/dist/src/core/handoff-brief.js +75 -0
- package/dist/src/core/handoff-chain.d.ts +74 -0
- package/dist/src/core/handoff-chain.js +74 -0
- package/dist/src/core/handoff-doctor.d.ts +13 -0
- package/dist/src/core/handoff-doctor.js +35 -0
- package/dist/src/core/handoff-run.d.ts +31 -0
- package/dist/src/core/handoff-run.js +346 -0
- package/dist/src/core/handoff-select.d.ts +42 -0
- package/dist/src/core/handoff-select.js +88 -0
- package/dist/src/core/handoff-status.d.ts +66 -0
- package/dist/src/core/handoff-status.js +198 -0
- package/dist/src/core/launch.d.ts +22 -1
- package/dist/src/core/launch.js +54 -4
- package/dist/src/core/memo-store.d.ts +20 -0
- package/dist/src/core/memo-store.js +92 -0
- package/dist/src/core/resume.d.ts +7 -2
- package/dist/src/core/resume.js +22 -9
- package/dist/src/core/tuning-apply.d.ts +51 -0
- package/dist/src/core/tuning-apply.js +237 -0
- package/dist/src/core/tuning.d.ts +98 -0
- package/dist/src/core/tuning.js +176 -0
- package/dist/src/families/claude/brief.d.ts +5 -0
- package/dist/src/families/claude/brief.js +63 -0
- package/dist/src/families/claude/handoff-hook.d.ts +2 -0
- package/dist/src/families/claude/handoff-hook.js +31 -0
- package/dist/src/families/claude/handoff.d.ts +5 -0
- package/dist/src/families/claude/handoff.js +115 -0
- package/dist/src/families/claude/index.js +8 -1
- package/dist/src/families/claude/tuning.d.ts +22 -0
- package/dist/src/families/claude/tuning.js +187 -0
- package/dist/src/families/claude/usage.d.ts +1 -0
- package/dist/src/families/claude/usage.js +2 -2
- package/dist/src/families/codex/brief.d.ts +5 -0
- package/dist/src/families/codex/brief.js +49 -0
- package/dist/src/families/codex/handoff-hook.d.ts +2 -0
- package/dist/src/families/codex/handoff-hook.js +16 -0
- package/dist/src/families/codex/handoff.d.ts +5 -0
- package/dist/src/families/codex/handoff.js +121 -0
- package/dist/src/families/codex/index.js +6 -0
- package/dist/src/families/codex/rate-limits.d.ts +16 -0
- package/dist/src/families/codex/rate-limits.js +92 -0
- package/dist/src/families/codex/rollout.d.ts +1 -2
- package/dist/src/families/codex/rollout.js +4 -48
- package/dist/src/families/codex/tuning.d.ts +16 -0
- package/dist/src/families/codex/tuning.js +145 -0
- package/dist/src/families/grok/brief.d.ts +5 -0
- package/dist/src/families/grok/brief.js +74 -0
- package/dist/src/families/grok/handoff-hook.d.ts +4 -0
- package/dist/src/families/grok/handoff-hook.js +26 -0
- package/dist/src/families/grok/handoff.d.ts +47 -0
- package/dist/src/families/grok/handoff.js +219 -0
- package/dist/src/families/grok/index.js +2 -0
- package/dist/src/families/pi/handoff.d.ts +2 -0
- package/dist/src/families/pi/handoff.js +13 -0
- package/dist/src/families/pi/index.js +2 -0
- package/dist/src/families/shared/brief.d.ts +24 -0
- package/dist/src/families/shared/brief.js +42 -0
- package/dist/src/families/shared/handoff-args.d.ts +18 -0
- package/dist/src/families/shared/handoff-args.js +57 -0
- package/dist/src/families/shared/sessions.d.ts +4 -0
- package/dist/src/families/shared/sessions.js +19 -0
- package/dist/src/families/shared/toml.d.ts +5 -0
- package/dist/src/families/shared/toml.js +30 -0
- package/dist/src/handoff/hook.d.ts +16 -0
- package/dist/src/handoff/hook.js +166 -0
- package/dist/src/handoff/messages.d.ts +7 -0
- package/dist/src/handoff/messages.js +27 -0
- package/dist/src/handoff/reader.d.ts +11 -0
- package/dist/src/handoff/reader.js +9 -0
- package/dist/src/panel/handler.d.ts +6 -0
- package/dist/src/panel/handler.js +110 -0
- package/dist/src/panel/i18n.d.ts +96 -0
- package/dist/src/panel/i18n.js +192 -0
- package/dist/src/panel/page-logic.d.ts +49 -0
- package/dist/src/panel/page-logic.js +67 -0
- package/dist/src/panel/page-script.js +454 -4
- package/dist/src/panel/page-styles.js +28 -2
- package/dist/src/panel/page.js +4 -1
- package/dist/src/quota/claude-statusline.js +4 -7
- package/dist/src/runtime.d.ts +40 -3
- package/dist/src/runtime.js +226 -43
- package/dist/src/types.d.ts +101 -0
- package/dist/testing/index.d.ts +5 -0
- package/dist/testing/index.js +1 -0
- package/package.json +16 -4
- package/schemas/config.json +77 -0
- package/schemas/families.json +5 -1
- package/schemas/handoff-disable.json +106 -0
- package/schemas/handoff-enable.json +106 -0
- package/schemas/handoff-ready.json +23 -0
- package/schemas/handoff-setup.json +60 -0
- package/schemas/handoff-status.json +137 -0
- package/schemas/handoff-teardown.json +30 -0
- package/schemas/list.json +5 -1
- package/schemas/tune-change.json +108 -0
- package/schemas/tune.json +243 -0
- package/skills/sideby/SKILL.md +7 -4
- package/skills/sideby/references/badge.md +31 -0
- package/skills/sideby/references/diagnose.md +4 -0
- package/skills/sideby/references/handoff.md +24 -2
- package/skills/sideby/references/setup.md +3 -2
- package/README.zh-CN.md +0 -112
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { planHandoff } from './handoff.js';
|
|
2
|
+
import { windowReset } from './quota-levels.js';
|
|
3
|
+
const domain = (email) => email.slice(email.lastIndexOf('@') + 1).toLowerCase();
|
|
4
|
+
/** Organizations when both have one, else email domains when both have one, else the same. */
|
|
5
|
+
export function sameOrganization(a, b) {
|
|
6
|
+
if (a?.org && b?.org)
|
|
7
|
+
return a.org.toLowerCase() === b.org.toLowerCase();
|
|
8
|
+
if (a?.email && b?.email)
|
|
9
|
+
return domain(a.email) === domain(b.email);
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
/** The latest reset of the origin's windows at or above the threshold, when they all reset in time to wait. */
|
|
13
|
+
function waitUntil(origin, s, now) {
|
|
14
|
+
if (origin.quota.status !== 'ok' || s.waitIfResetWithinMinutes <= 0)
|
|
15
|
+
return undefined;
|
|
16
|
+
let last;
|
|
17
|
+
for (const w of origin.quota.windows) {
|
|
18
|
+
if (w.usedPercent < s.threshold || windowReset(w.resetsAt, now))
|
|
19
|
+
continue;
|
|
20
|
+
const t = Date.parse(w.resetsAt);
|
|
21
|
+
if (!Number.isFinite(t))
|
|
22
|
+
return undefined;
|
|
23
|
+
last = Math.max(last ?? t, t);
|
|
24
|
+
}
|
|
25
|
+
if (last === undefined || last - now.getTime() > s.waitIfResetWithinMinutes * 60_000)
|
|
26
|
+
return undefined;
|
|
27
|
+
return new Date(last).toISOString();
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* One candidate's §3.13 state; API accounts are pickable here, as the last resort. An Account already at the user's
|
|
31
|
+
* threshold counts as full even below §3.13's 85%, or it would hand over again on its first tool call.
|
|
32
|
+
*/
|
|
33
|
+
function stateOf(c, s, now) {
|
|
34
|
+
const e = planHandoff(c.family, [c], now, { includeApi: true }).accounts[0];
|
|
35
|
+
if (e.state !== 'ready' || e.pressure === null || e.pressure < s.threshold || c.quota.status !== 'ok')
|
|
36
|
+
return e;
|
|
37
|
+
const resets = c.quota.windows
|
|
38
|
+
.filter((w) => w.usedPercent >= s.threshold && !windowReset(w.resetsAt, now))
|
|
39
|
+
.map((w) => Date.parse(w.resetsAt))
|
|
40
|
+
.filter(Number.isFinite);
|
|
41
|
+
return {
|
|
42
|
+
...e,
|
|
43
|
+
state: 'full',
|
|
44
|
+
...(resets.length ? { resetsAt: new Date(Math.max(...resets)).toISOString() } : {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export function selectNext(input) {
|
|
48
|
+
const { origin, settings: s, now } = input;
|
|
49
|
+
const until = waitUntil(origin, s, now);
|
|
50
|
+
if (until)
|
|
51
|
+
return { kind: 'wait', until };
|
|
52
|
+
const refOf = (name) => input.aliases[name] ?? name;
|
|
53
|
+
const allowed = new Set(s.crossOrganization.map(refOf));
|
|
54
|
+
const used = new Set([origin.ref, ...input.used]);
|
|
55
|
+
const byRef = new Map(input.candidates.map((c) => [c.ref, c]));
|
|
56
|
+
const family = s.families[origin.family];
|
|
57
|
+
let list;
|
|
58
|
+
if (family?.policy === 'order') {
|
|
59
|
+
list = family.order.map(refOf).flatMap((r) => (byRef.has(r) ? [byRef.get(r)] : []));
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
// The Family by §3.13's ranking, then other Families' API accounts.
|
|
63
|
+
const own = input.candidates.filter((c) => c.family === origin.family);
|
|
64
|
+
const ranked = planHandoff(origin.family, own, now, { includeApi: true }).accounts.map((e) => byRef.get(e.ref));
|
|
65
|
+
list = [...ranked, ...input.candidates.filter((c) => c.family !== origin.family && c.kind === 'api')];
|
|
66
|
+
}
|
|
67
|
+
const eligible = list.filter((c) => !used.has(c.ref) &&
|
|
68
|
+
c.installed &&
|
|
69
|
+
c.receive &&
|
|
70
|
+
// An API account pays per request, so it gets past no vendor's limit: sameFamily does not apply to it.
|
|
71
|
+
(s.sameFamily || c.family !== origin.family || c.kind === 'api') &&
|
|
72
|
+
(allowed.has(c.ref) || sameOrganization(origin.identity, c.identity)));
|
|
73
|
+
const states = eligible.map((c) => ({ c, e: stateOf(c, s, now) }));
|
|
74
|
+
const first = states.find(({ e }) => e.state === 'ready' || e.state === 'unknown') ??
|
|
75
|
+
states.find(({ e }) => e.state === 'api');
|
|
76
|
+
if (first)
|
|
77
|
+
return { kind: 'pick', ref: first.c.ref };
|
|
78
|
+
const full = states
|
|
79
|
+
.filter(({ e }) => e.state === 'full' && e.resetsAt)
|
|
80
|
+
.sort((a, b) => Date.parse(a.e.resetsAt) - Date.parse(b.e.resetsAt))[0];
|
|
81
|
+
return {
|
|
82
|
+
kind: 'none',
|
|
83
|
+
reason: eligible.length
|
|
84
|
+
? 'every account that may take over is full or signed out'
|
|
85
|
+
: 'no account may take over',
|
|
86
|
+
...(full ? { earliestReset: { ref: full.c.ref, at: full.e.resetsAt } } : {}),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Runtime } from '../runtime.ts';
|
|
2
|
+
import { type HandoffSettings } from './config.ts';
|
|
3
|
+
export interface HandoffFamilyStatus {
|
|
4
|
+
family: string;
|
|
5
|
+
/** What the Family can start; empty when it can only take over. */
|
|
6
|
+
starts: ('quota' | 'limit')[];
|
|
7
|
+
/** Whether sessions of this Family can start a Handoff now. */
|
|
8
|
+
ready: boolean;
|
|
9
|
+
/** What keeps it from starting one; each has a matching entry in `nextSteps`. */
|
|
10
|
+
problems: string[];
|
|
11
|
+
/** Worth knowing, but not in the way. */
|
|
12
|
+
notes: string[];
|
|
13
|
+
order: {
|
|
14
|
+
policy: 'pressure' | 'order';
|
|
15
|
+
order: string[];
|
|
16
|
+
unknown: string[];
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export interface HandoffStatus {
|
|
20
|
+
auto: boolean;
|
|
21
|
+
sameFamily: boolean;
|
|
22
|
+
prepareAt: number;
|
|
23
|
+
threshold: number;
|
|
24
|
+
sideby: {
|
|
25
|
+
ok: boolean;
|
|
26
|
+
problem?: string;
|
|
27
|
+
};
|
|
28
|
+
families: HandoffFamilyStatus[];
|
|
29
|
+
/** Commands still to run, in order. Setup commands show their change first (exit 10). */
|
|
30
|
+
nextSteps: string[];
|
|
31
|
+
}
|
|
32
|
+
export declare function handoffStatus(rt: Runtime): Promise<HandoffStatus>;
|
|
33
|
+
/** One `handoff` change; fields left out keep their value. The Panel and the CLI send the same shape. */
|
|
34
|
+
export interface HandoffChange {
|
|
35
|
+
auto?: boolean;
|
|
36
|
+
sameFamily?: boolean;
|
|
37
|
+
prepareAt?: number;
|
|
38
|
+
threshold?: number;
|
|
39
|
+
waitIfResetWithinMinutes?: number;
|
|
40
|
+
countdownSeconds?: number;
|
|
41
|
+
/** Handoff Orders by Family; an empty list sends that Family back to ranking by quota. */
|
|
42
|
+
orders?: {
|
|
43
|
+
family: string;
|
|
44
|
+
order: string[];
|
|
45
|
+
}[];
|
|
46
|
+
}
|
|
47
|
+
/** A line diff of two small texts: unchanged lines start with two spaces, removed with `- `, added with `+ `. */
|
|
48
|
+
export declare function lineDiff(before: string, after: string): string;
|
|
49
|
+
/**
|
|
50
|
+
* Shows, or with `apply` writes, the `handoff` change (spec §3.17). Every other setting stays. Names in `order`
|
|
51
|
+
* must resolve to an Account.
|
|
52
|
+
*/
|
|
53
|
+
export declare function configureHandoff(rt: Runtime, change: HandoffChange, opts: {
|
|
54
|
+
apply: boolean;
|
|
55
|
+
}): Promise<{
|
|
56
|
+
changed: boolean;
|
|
57
|
+
applied: boolean;
|
|
58
|
+
file: string;
|
|
59
|
+
diff: string;
|
|
60
|
+
settings: HandoffSettings;
|
|
61
|
+
}>;
|
|
62
|
+
/**
|
|
63
|
+
* After a session ends with Auto Handoff off: one line pointing at it when the Account's quota passed the threshold
|
|
64
|
+
* and its Family could start a Handoff; at most once a day. Null when there is nothing to say.
|
|
65
|
+
*/
|
|
66
|
+
export declare function handoffHint(rt: Runtime, ref: string): Promise<string | null>;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// `sideby handoff status|enable|disable` (spec §3.17 "交给 AI 开启"): what Auto Handoff still needs on this machine,
|
|
2
|
+
// and the one config change that turns it on or off. Status only reads; enable and disable write the `handoff` key.
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { HOOK_FAMILIES } from '../handoff/hook.js';
|
|
5
|
+
import { editConfigHandoff, handoffSettings } from './config.js';
|
|
6
|
+
import { UserError } from './errors.js';
|
|
7
|
+
import { ensureChainDir, readJsonFile, writeJsonFile } from './handoff-chain.js';
|
|
8
|
+
import { hookEntryWorks } from './handoff-run.js';
|
|
9
|
+
import { sidebyBinProblem } from './launch.js';
|
|
10
|
+
import { quotaPressure } from './quota-levels.js';
|
|
11
|
+
export async function handoffStatus(rt) {
|
|
12
|
+
const s = rt.handoffSettings();
|
|
13
|
+
const accounts = await rt.accounts();
|
|
14
|
+
const aliases = Object.fromEntries(Object.entries(rt.config.aliases ?? {}).map(([k, v]) => [k, typeof v === 'string' ? v : v.account]));
|
|
15
|
+
const refs = new Set(accounts.map((a) => a.ref));
|
|
16
|
+
const binProblem = (await sidebyBinProblem(rt.env, 'the Host runs `sideby handoff-hook` for Auto Handoff')) ??
|
|
17
|
+
((await hookEntryWorks(rt.env))
|
|
18
|
+
? undefined
|
|
19
|
+
: 'the sideby on PATH is older than this one and has no `handoff-hook`; upgrade it with `npm i -g sideby`');
|
|
20
|
+
const steps = [];
|
|
21
|
+
if (!s.auto)
|
|
22
|
+
steps.push('sideby handoff enable');
|
|
23
|
+
if (binProblem)
|
|
24
|
+
steps.push('npm i -g sideby');
|
|
25
|
+
const families = [];
|
|
26
|
+
for (const f of rt.families.values()) {
|
|
27
|
+
if (!accounts.some((a) => a.family === f.id))
|
|
28
|
+
continue;
|
|
29
|
+
const builtIn = HOOK_FAMILIES.includes(f.id);
|
|
30
|
+
const starts = f.handoff && builtIn ? [...f.handoff.starts] : [];
|
|
31
|
+
const problems = [];
|
|
32
|
+
const notes = [];
|
|
33
|
+
if (!f.handoff)
|
|
34
|
+
notes.push(`${f.title} takes no part in Auto Handoff`);
|
|
35
|
+
else if (!starts.length)
|
|
36
|
+
notes.push(builtIn || !f.handoff.starts.length
|
|
37
|
+
? `${f.title} can only take over, never start a handoff`
|
|
38
|
+
: `${f.title} comes from a plugin, so it can only take over for now`);
|
|
39
|
+
if (starts.includes('quota') && f.quotaSetup) {
|
|
40
|
+
const plan = await rt
|
|
41
|
+
.quotaSetup(f.id)
|
|
42
|
+
.plan()
|
|
43
|
+
.catch(() => null);
|
|
44
|
+
if (plan?.status !== 'enabled') {
|
|
45
|
+
problems.push(`${f.title} reports no quota to sideby yet`);
|
|
46
|
+
steps.push(`sideby quota setup ${f.id}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (starts.length && f.handoff?.hookSetup) {
|
|
50
|
+
const plan = await rt
|
|
51
|
+
.handoffSetup(f.id)
|
|
52
|
+
.plan()
|
|
53
|
+
.catch(() => null);
|
|
54
|
+
if (plan?.status !== 'enabled') {
|
|
55
|
+
problems.push(`${f.title} has no sideby hook file yet`);
|
|
56
|
+
steps.push(`sideby handoff setup ${f.id}`, `sideby doctor ${f.id} --fix`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (f.id === 'codex' && starts.length)
|
|
60
|
+
notes.push('each Codex account asks once to trust sideby’s hooks: type /hooks in Codex and trust them');
|
|
61
|
+
if (f.id === 'grok' && starts.length)
|
|
62
|
+
notes.push('the `workspace` and `read-only` sandboxes can only take over; `strict` and custom ones take no part');
|
|
63
|
+
const fam = s.families[f.id] ?? { policy: 'pressure', order: [] };
|
|
64
|
+
const unknown = fam.order.filter((n) => !refs.has(aliases[n] ?? n));
|
|
65
|
+
if (fam.policy === 'order' && fam.order.length === 0) {
|
|
66
|
+
problems.push(`handoff.families.${f.id} has policy "order" but lists no account`);
|
|
67
|
+
steps.push(`sideby handoff enable --order ${f.id}=<account,account>`);
|
|
68
|
+
}
|
|
69
|
+
if (unknown.length)
|
|
70
|
+
notes.push(`order names ${unknown.join(', ')}, which sideby cannot find; they are skipped`);
|
|
71
|
+
families.push({
|
|
72
|
+
family: f.id,
|
|
73
|
+
starts,
|
|
74
|
+
ready: s.auto && !binProblem && starts.length > 0 && problems.length === 0,
|
|
75
|
+
problems,
|
|
76
|
+
notes,
|
|
77
|
+
order: { policy: fam.policy, order: fam.order, unknown },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
auto: s.auto,
|
|
82
|
+
sameFamily: s.sameFamily,
|
|
83
|
+
prepareAt: s.prepareAt,
|
|
84
|
+
threshold: s.threshold,
|
|
85
|
+
sideby: binProblem ? { ok: false, problem: binProblem } : { ok: true },
|
|
86
|
+
families,
|
|
87
|
+
nextSteps: steps,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** A line diff of two small texts: unchanged lines start with two spaces, removed with `- `, added with `+ `. */
|
|
91
|
+
export function lineDiff(before, after) {
|
|
92
|
+
const a = before.split('\n');
|
|
93
|
+
const b = after.split('\n');
|
|
94
|
+
// Longest common subsequence table; the settings block is a few dozen lines at most.
|
|
95
|
+
const lcs = a.map(() => new Array(b.length + 1).fill(0));
|
|
96
|
+
lcs.push(new Array(b.length + 1).fill(0));
|
|
97
|
+
for (let i = a.length - 1; i >= 0; i--)
|
|
98
|
+
for (let j = b.length - 1; j >= 0; j--)
|
|
99
|
+
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
100
|
+
const out = [];
|
|
101
|
+
let i = 0;
|
|
102
|
+
let j = 0;
|
|
103
|
+
while (i < a.length || j < b.length) {
|
|
104
|
+
if (i < a.length && j < b.length && a[i] === b[j]) {
|
|
105
|
+
out.push(` ${a[i]}`);
|
|
106
|
+
i++;
|
|
107
|
+
j++;
|
|
108
|
+
}
|
|
109
|
+
else if (i < a.length && (j === b.length || lcs[i + 1][j] >= lcs[i][j + 1])) {
|
|
110
|
+
out.push(`- ${a[i]}`);
|
|
111
|
+
i++;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
out.push(`+ ${b[j]}`);
|
|
115
|
+
j++;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return out.join('\n');
|
|
119
|
+
}
|
|
120
|
+
const pretty = (v) => JSON.stringify(v ?? {}, null, 2);
|
|
121
|
+
/**
|
|
122
|
+
* Shows, or with `apply` writes, the `handoff` change (spec §3.17). Every other setting stays. Names in `order`
|
|
123
|
+
* must resolve to an Account.
|
|
124
|
+
*/
|
|
125
|
+
export async function configureHandoff(rt, change, opts) {
|
|
126
|
+
const known = [...(await rt.accounts()).map((a) => a.ref), ...Object.keys(rt.config.aliases ?? {})];
|
|
127
|
+
for (const { family, order } of change.orders ?? []) {
|
|
128
|
+
if (!rt.families.has(family))
|
|
129
|
+
throw new UserError(`unknown family "${family}"; available: ${[...rt.families.keys()].join(', ')}`);
|
|
130
|
+
const missing = [];
|
|
131
|
+
for (const name of order)
|
|
132
|
+
await rt.resolve(name).catch(() => missing.push(name));
|
|
133
|
+
if (missing.length)
|
|
134
|
+
throw new UserError(`${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} not an account or short command; use one of: ${known.join(', ')}`, { code: 'unknown-account' });
|
|
135
|
+
}
|
|
136
|
+
const edit = (current) => {
|
|
137
|
+
const next = { ...current };
|
|
138
|
+
for (const k of [
|
|
139
|
+
'auto',
|
|
140
|
+
'sameFamily',
|
|
141
|
+
'prepareAt',
|
|
142
|
+
'threshold',
|
|
143
|
+
'waitIfResetWithinMinutes',
|
|
144
|
+
'countdownSeconds',
|
|
145
|
+
])
|
|
146
|
+
if (change[k] !== undefined)
|
|
147
|
+
next[k] = change[k];
|
|
148
|
+
if (change.orders?.length) {
|
|
149
|
+
const families = { ...(current.families ?? {}) };
|
|
150
|
+
for (const { family, order } of change.orders)
|
|
151
|
+
if (order.length)
|
|
152
|
+
families[family] = { policy: 'order', order: [...order] };
|
|
153
|
+
else
|
|
154
|
+
delete families[family];
|
|
155
|
+
next.families = families;
|
|
156
|
+
}
|
|
157
|
+
return next;
|
|
158
|
+
};
|
|
159
|
+
const file = rt.paths.configFile;
|
|
160
|
+
const r = await editConfigHandoff(file, edit, { dryRun: !opts.apply });
|
|
161
|
+
return {
|
|
162
|
+
changed: r.changed,
|
|
163
|
+
applied: r.changed && opts.apply,
|
|
164
|
+
file,
|
|
165
|
+
diff: r.changed
|
|
166
|
+
? `--- ${join('sideby', 'config.json')} (handoff)\n${lineDiff(pretty(r.before), pretty(r.after))}\n`
|
|
167
|
+
: '',
|
|
168
|
+
settings: handoffSettings({ handoff: r.after }),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const HINT_EVERY_MS = 24 * 60 * 60 * 1000;
|
|
172
|
+
/**
|
|
173
|
+
* After a session ends with Auto Handoff off: one line pointing at it when the Account's quota passed the threshold
|
|
174
|
+
* and its Family could start a Handoff; at most once a day. Null when there is nothing to say.
|
|
175
|
+
*/
|
|
176
|
+
export async function handoffHint(rt, ref) {
|
|
177
|
+
const s = rt.handoffSettings();
|
|
178
|
+
if (s.auto)
|
|
179
|
+
return null;
|
|
180
|
+
try {
|
|
181
|
+
const account = await rt.resolve(ref);
|
|
182
|
+
if (!rt.families.get(account.family)?.handoff?.starts.length || !HOOK_FAMILIES.includes(account.family))
|
|
183
|
+
return null;
|
|
184
|
+
const pressure = quotaPressure(await rt.quota(account), new Date());
|
|
185
|
+
if (pressure === null || pressure < s.threshold)
|
|
186
|
+
return null;
|
|
187
|
+
const file = join(rt.paths.stateDir, 'handoff', 'hint.json');
|
|
188
|
+
const last = (await readJsonFile(file))?.at;
|
|
189
|
+
if (last && Date.now() - Date.parse(last) < HINT_EVERY_MS)
|
|
190
|
+
return null;
|
|
191
|
+
await ensureChainDir(join(rt.paths.stateDir, 'handoff'));
|
|
192
|
+
await writeJsonFile(file, { at: new Date().toISOString() });
|
|
193
|
+
return `${account.ref} is at ${Math.round(pressure)}%. Sessions can hand their task to another account by themselves when quota runs low (off by default): see \`sideby handoff status\`.`;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ChildProcess } from 'node:child_process';
|
|
1
2
|
import type { HookBus } from '../plugins/bus.ts';
|
|
2
3
|
import type { Account, Env, FamilyDef } from '../types.ts';
|
|
3
4
|
export interface PreparedLaunch {
|
|
@@ -8,11 +9,19 @@ export interface PreparedLaunch {
|
|
|
8
9
|
env: Env;
|
|
9
10
|
/** Printed before launching when sign-in happens inside the Host. */
|
|
10
11
|
notice?: string;
|
|
12
|
+
/** Terminal title set before the Host starts (config `accountTitle`), for example `[codex002]`. */
|
|
13
|
+
title?: string;
|
|
11
14
|
}
|
|
12
15
|
/** What `runHost` needs: a prepared Account launch, or the Host run unchanged (`sideby resume`). */
|
|
13
|
-
export type HostLaunch = Pick<PreparedLaunch, 'family' | 'bin' | 'args' | 'env'>;
|
|
16
|
+
export type HostLaunch = Pick<PreparedLaunch, 'family' | 'bin' | 'args' | 'env' | 'title'>;
|
|
14
17
|
/** Finds an executable on PATH; returns null when the Host is not installed. */
|
|
15
18
|
export declare function which(bin: string, env: Env): Promise<string | null>;
|
|
19
|
+
/**
|
|
20
|
+
* Why a Host could not run `sideby` by name, or null when it can: something the Host runs on its own (the status line
|
|
21
|
+
* tap, the handoff hook) needs sideby installed on PATH, not the npx cache, which can disappear. `why` completes
|
|
22
|
+
* "sideby is not on PATH, and …".
|
|
23
|
+
*/
|
|
24
|
+
export declare function sidebyBinProblem(env: Env, why: string): Promise<string | null>;
|
|
16
25
|
/**
|
|
17
26
|
* Builds the Host's environment and arguments (spec §3.5):
|
|
18
27
|
* copy the current env, clear Hijack Variables, merge the Secret File, set the select variable,
|
|
@@ -26,6 +35,10 @@ export declare function prepareLaunch(opts: {
|
|
|
26
35
|
userArgs: readonly string[];
|
|
27
36
|
command: 'run' | 'login';
|
|
28
37
|
bus: HookBus;
|
|
38
|
+
/** What the Host may show as the Account (`SIDEBY_LABEL`): the short command, else the ref. */
|
|
39
|
+
label?: string;
|
|
40
|
+
/** Config `accountTitle`: set the terminal title to `[<label>]` and keep the Host from replacing it. */
|
|
41
|
+
title?: boolean;
|
|
29
42
|
}): Promise<PreparedLaunch>;
|
|
30
43
|
/**
|
|
31
44
|
* Runs the Host with the terminal attached and returns sideby's exit code (spec §3.5):
|
|
@@ -35,3 +48,11 @@ export declare function prepareLaunch(opts: {
|
|
|
35
48
|
* default action so Ctrl-Z suspends sideby together with the Host and `fg` resumes both.
|
|
36
49
|
*/
|
|
37
50
|
export declare function runHost(p: HostLaunch): Promise<number>;
|
|
51
|
+
/**
|
|
52
|
+
* `runHost` with the child process exposed, so the Auto Handoff (spec §3.17) can end the Host and start the next
|
|
53
|
+
* Account in the same terminal. Same signal handling, exit codes and title as `runHost`.
|
|
54
|
+
*/
|
|
55
|
+
export declare function spawnHost(p: HostLaunch): {
|
|
56
|
+
child: ChildProcess;
|
|
57
|
+
exited: Promise<number>;
|
|
58
|
+
};
|
package/dist/src/core/launch.js
CHANGED
|
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { access, constants } from 'node:fs/promises';
|
|
3
3
|
import { constants as osConstants } from 'node:os';
|
|
4
4
|
import { delimiter, join } from 'node:path';
|
|
5
|
+
import { realpathOrNull } from './fs-safe.js';
|
|
5
6
|
import { readSecretFile } from './secret-file.js';
|
|
6
7
|
/** Finds an executable on PATH; returns null when the Host is not installed. */
|
|
7
8
|
export async function which(bin, env) {
|
|
@@ -19,6 +20,20 @@ export async function which(bin, env) {
|
|
|
19
20
|
}
|
|
20
21
|
return null;
|
|
21
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Why a Host could not run `sideby` by name, or null when it can: something the Host runs on its own (the status line
|
|
25
|
+
* tap, the handoff hook) needs sideby installed on PATH, not the npx cache, which can disappear. `why` completes
|
|
26
|
+
* "sideby is not on PATH, and …".
|
|
27
|
+
*/
|
|
28
|
+
export async function sidebyBinProblem(env, why) {
|
|
29
|
+
const bin = await which('sideby', env);
|
|
30
|
+
if (!bin)
|
|
31
|
+
return `sideby is not on PATH, and ${why}; install it with \`npm i -g sideby\` and retry`;
|
|
32
|
+
const real = (await realpathOrNull(bin)) ?? bin;
|
|
33
|
+
if (bin.includes('/_npx/') || real.includes('/_npx/'))
|
|
34
|
+
return `sideby on PATH comes from the npx cache (${bin}), which can disappear; install it with \`npm i -g sideby\` and retry`;
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
22
37
|
/**
|
|
23
38
|
* Builds the Host's environment and arguments (spec §3.5):
|
|
24
39
|
* copy the current env, clear Hijack Variables, merge the Secret File, set the select variable,
|
|
@@ -34,6 +49,10 @@ export async function prepareLaunch(opts) {
|
|
|
34
49
|
Object.assign(env, await readSecretFile(account.secretFile, opts.baseEnv));
|
|
35
50
|
if (!account.isMain)
|
|
36
51
|
env[family.selectVar] = account.dir;
|
|
52
|
+
// Which Account this is, for a status line, prompt or hook to show (spec §3.5); set for every Launch, so a value
|
|
53
|
+
// inherited from another sideby Launch never leaks through.
|
|
54
|
+
env.SIDEBY_ACCOUNT = account.ref;
|
|
55
|
+
env.SIDEBY_LABEL = opts.label || account.ref;
|
|
37
56
|
let args;
|
|
38
57
|
let notice;
|
|
39
58
|
if (opts.command === 'login') {
|
|
@@ -47,7 +66,15 @@ export async function prepareLaunch(opts) {
|
|
|
47
66
|
}
|
|
48
67
|
}
|
|
49
68
|
else {
|
|
50
|
-
|
|
69
|
+
const keep = opts.title && family.keepTitle && !opts.userArgs.some((a) => a.includes(family.keepTitle.key))
|
|
70
|
+
? family.keepTitle.args
|
|
71
|
+
: [];
|
|
72
|
+
args = [
|
|
73
|
+
...(family.defaultArgs?.(account, opts.userArgs) ?? []),
|
|
74
|
+
...keep,
|
|
75
|
+
...opts.accountArgs,
|
|
76
|
+
...opts.userArgs,
|
|
77
|
+
];
|
|
51
78
|
}
|
|
52
79
|
await opts.bus.run('launch.before', family.id, {
|
|
53
80
|
account,
|
|
@@ -57,7 +84,19 @@ export async function prepareLaunch(opts) {
|
|
|
57
84
|
config: {},
|
|
58
85
|
command: opts.command,
|
|
59
86
|
});
|
|
60
|
-
|
|
87
|
+
// Control characters would end the escape sequence early; labels are shell-safe names, so this is a guard only.
|
|
88
|
+
const title = opts.title
|
|
89
|
+
? [...`[${env.SIDEBY_LABEL ?? account.ref}]`].filter((ch) => ch >= ' ' && ch !== '\x7f').join('')
|
|
90
|
+
: undefined;
|
|
91
|
+
return {
|
|
92
|
+
account,
|
|
93
|
+
family,
|
|
94
|
+
bin: family.bin,
|
|
95
|
+
args,
|
|
96
|
+
env,
|
|
97
|
+
...(notice ? { notice } : {}),
|
|
98
|
+
...(title ? { title } : {}),
|
|
99
|
+
};
|
|
61
100
|
}
|
|
62
101
|
/**
|
|
63
102
|
* Runs the Host with the terminal attached and returns sideby's exit code (spec §3.5):
|
|
@@ -67,8 +106,18 @@ export async function prepareLaunch(opts) {
|
|
|
67
106
|
* default action so Ctrl-Z suspends sideby together with the Host and `fg` resumes both.
|
|
68
107
|
*/
|
|
69
108
|
export function runHost(p) {
|
|
70
|
-
return
|
|
71
|
-
|
|
109
|
+
return spawnHost(p).exited;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* `runHost` with the child process exposed, so the Auto Handoff (spec §3.17) can end the Host and start the next
|
|
113
|
+
* Account in the same terminal. Same signal handling, exit codes and title as `runHost`.
|
|
114
|
+
*/
|
|
115
|
+
export function spawnHost(p) {
|
|
116
|
+
// OSC 0 sets the terminal's tab and window title; only on a terminal, never into a pipe.
|
|
117
|
+
if (p.title && process.stdout.isTTY)
|
|
118
|
+
process.stdout.write(`\x1b]0;${p.title}\x07`);
|
|
119
|
+
const child = spawn(p.bin, p.args, { stdio: 'inherit', env: p.env });
|
|
120
|
+
const exited = new Promise((resolve) => {
|
|
72
121
|
const ignore = () => { };
|
|
73
122
|
const forwarded = new Set();
|
|
74
123
|
const forward = (sig) => () => {
|
|
@@ -105,4 +154,5 @@ export function runHost(p) {
|
|
|
105
154
|
resolve(code ?? 1);
|
|
106
155
|
});
|
|
107
156
|
});
|
|
157
|
+
return { child, exited };
|
|
108
158
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** At most one write per this many milliseconds; a cache a minute old only means re-parsing the newest logs. */
|
|
2
|
+
export declare const SAVE_INTERVAL_MS = 60000;
|
|
3
|
+
/** A write waits this long after the last read, so one Panel refresh over every Account is written as a whole. */
|
|
4
|
+
export declare const SAVE_DELAY_MS = 2000;
|
|
5
|
+
export declare function memoCachePath(stateDir: string): string;
|
|
6
|
+
export interface MemoStore {
|
|
7
|
+
/** Loads the cache file once; later calls return the same promise. Never rejects. */
|
|
8
|
+
ready(): Promise<void>;
|
|
9
|
+
/**
|
|
10
|
+
* Schedules a write SAVE_DELAY_MS after the last call, and no sooner than SAVE_INTERVAL_MS after the previous
|
|
11
|
+
* write. The timer does not keep the process alive, so a short CLI run simply exits without writing.
|
|
12
|
+
*/
|
|
13
|
+
saveSoon(): void;
|
|
14
|
+
/** Writes the cache now if a memo changed. Never rejects. */
|
|
15
|
+
save(): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
/** One store per state directory and process. */
|
|
18
|
+
export declare function memoStore(stateDir: string, now?: () => number, delayMs?: number): MemoStore;
|
|
19
|
+
/** Forgets the per-directory stores (for tests). */
|
|
20
|
+
export declare function resetMemoStores(): void;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Keeps the persisted FileMemos (core/file-memo.ts) in `<stateDir>/cache/file-memo.json`, so a new process does not
|
|
2
|
+
// parse every session log again: the Panel worker starts cold after each restart, reinstall or language switch,
|
|
3
|
+
// and parsing the logs of a busy HOME takes seconds, while reading this file back takes milliseconds.
|
|
4
|
+
//
|
|
5
|
+
// The file holds token counts, session ids and log paths, never a credential; it is written atomically with mode
|
|
6
|
+
// 600. Anything unexpected in it (another format, a parse error, a memo version that changed) is ignored and the
|
|
7
|
+
// logs are parsed again, which gives the same results.
|
|
8
|
+
import { mkdir, readFile } from 'node:fs/promises';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { persistedMemos } from './file-memo.js';
|
|
11
|
+
import { writeFileAtomic } from './fs-safe.js';
|
|
12
|
+
const FORMAT = 1;
|
|
13
|
+
/** At most one write per this many milliseconds; a cache a minute old only means re-parsing the newest logs. */
|
|
14
|
+
export const SAVE_INTERVAL_MS = 60_000;
|
|
15
|
+
/** A write waits this long after the last read, so one Panel refresh over every Account is written as a whole. */
|
|
16
|
+
export const SAVE_DELAY_MS = 2_000;
|
|
17
|
+
export function memoCachePath(stateDir) {
|
|
18
|
+
return join(stateDir, 'cache', 'file-memo.json');
|
|
19
|
+
}
|
|
20
|
+
const stores = new Map();
|
|
21
|
+
/** One store per state directory and process. */
|
|
22
|
+
export function memoStore(stateDir, now = Date.now, delayMs = SAVE_DELAY_MS) {
|
|
23
|
+
const known = stores.get(stateDir);
|
|
24
|
+
if (known)
|
|
25
|
+
return known;
|
|
26
|
+
const file = memoCachePath(stateDir);
|
|
27
|
+
let loading;
|
|
28
|
+
let writing;
|
|
29
|
+
let lastWrite = Number.NEGATIVE_INFINITY;
|
|
30
|
+
let timer;
|
|
31
|
+
const load = async () => {
|
|
32
|
+
try {
|
|
33
|
+
const data = JSON.parse(await readFile(file, 'utf8'));
|
|
34
|
+
if (data?.format !== FORMAT || !data.memos || typeof data.memos !== 'object')
|
|
35
|
+
return;
|
|
36
|
+
for (const [name, memo] of persistedMemos) {
|
|
37
|
+
const saved = data.memos[name];
|
|
38
|
+
if (saved && saved.version === memo.persist?.version && saved.data && typeof saved.data === 'object')
|
|
39
|
+
memo.restore(saved.data);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// No cache yet, or an unreadable one: the logs are parsed as before.
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const write = async () => {
|
|
47
|
+
if (![...persistedMemos.values()].some((m) => m.dirty))
|
|
48
|
+
return;
|
|
49
|
+
lastWrite = now();
|
|
50
|
+
const out = { format: FORMAT, memos: {} };
|
|
51
|
+
for (const [name, memo] of persistedMemos)
|
|
52
|
+
out.memos[name] = { version: memo.persist?.version ?? 0, data: memo.dump() };
|
|
53
|
+
try {
|
|
54
|
+
await mkdir(join(stateDir, 'cache'), { recursive: true, mode: 0o700 });
|
|
55
|
+
await writeFileAtomic(file, JSON.stringify(out));
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Best effort: a cache that could not be written only means a slower next start.
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const store = {
|
|
62
|
+
ready: () => {
|
|
63
|
+
loading ??= load();
|
|
64
|
+
return loading;
|
|
65
|
+
},
|
|
66
|
+
async save() {
|
|
67
|
+
await store.ready();
|
|
68
|
+
while (writing)
|
|
69
|
+
await writing;
|
|
70
|
+
writing = write().finally(() => {
|
|
71
|
+
writing = undefined;
|
|
72
|
+
});
|
|
73
|
+
await writing;
|
|
74
|
+
},
|
|
75
|
+
saveSoon() {
|
|
76
|
+
if (timer)
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
const wait = Math.max(delayMs, lastWrite + SAVE_INTERVAL_MS - now());
|
|
79
|
+
timer = setTimeout(() => {
|
|
80
|
+
timer = undefined;
|
|
81
|
+
void store.save();
|
|
82
|
+
}, wait);
|
|
83
|
+
timer.unref?.();
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
stores.set(stateDir, store);
|
|
87
|
+
return store;
|
|
88
|
+
}
|
|
89
|
+
/** Forgets the per-directory stores (for tests). */
|
|
90
|
+
export function resetMemoStores() {
|
|
91
|
+
stores.clear();
|
|
92
|
+
}
|
|
@@ -2,8 +2,13 @@ import type { Account, FamilyDef } from '../types.ts';
|
|
|
2
2
|
export interface ResumeRoute {
|
|
3
3
|
/** The session the Host arguments resume by id; absent when they resume none. */
|
|
4
4
|
sessionId?: string;
|
|
5
|
-
/** The Account that last wrote that session; absent when
|
|
5
|
+
/** The Account that last wrote that session, or else the newest that started it; absent when none did. */
|
|
6
6
|
account?: Account;
|
|
7
|
+
/** Set when `account` only started the session: the Host never saved it, so there is nothing to resume. */
|
|
8
|
+
neverSaved?: true;
|
|
7
9
|
}
|
|
8
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Asks every Account of the Family for the session and picks the one that wrote it last. When none holds it, picks
|
|
12
|
+
* the one that started it last, if the Family can tell (ADR-0009).
|
|
13
|
+
*/
|
|
9
14
|
export declare function findResumeRoute(family: FamilyDef, accounts: readonly Account[], args: readonly string[]): Promise<ResumeRoute>;
|
package/dist/src/core/resume.js
CHANGED
|
@@ -1,15 +1,28 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** The Account whose `at` is newest, skipping those without one. */
|
|
2
|
+
async function newest(accounts, at) {
|
|
3
|
+
const times = await Promise.all(accounts.map((a) => at(a).catch(() => undefined)));
|
|
4
|
+
let best;
|
|
5
|
+
accounts.forEach((account, i) => {
|
|
6
|
+
const t = times[i];
|
|
7
|
+
if (t !== undefined && (!best || t > best.at))
|
|
8
|
+
best = { account, at: t };
|
|
9
|
+
});
|
|
10
|
+
return best?.account;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Asks every Account of the Family for the session and picks the one that wrote it last. When none holds it, picks
|
|
14
|
+
* the one that started it last, if the Family can tell (ADR-0009).
|
|
15
|
+
*/
|
|
2
16
|
export async function findResumeRoute(family, accounts, args) {
|
|
3
17
|
const sessionId = family.resumedSession?.(args);
|
|
4
18
|
if (!sessionId || !family.sessionWrittenAt)
|
|
5
19
|
return {};
|
|
6
20
|
const own = accounts.filter((a) => a.family === family.id);
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
return best ? { sessionId, account: best.account } : { sessionId };
|
|
21
|
+
const holder = await newest(own, (a) => family.sessionWrittenAt(a, sessionId));
|
|
22
|
+
if (holder)
|
|
23
|
+
return { sessionId, account: holder };
|
|
24
|
+
const starter = family.sessionStartedAt
|
|
25
|
+
? await newest(own, (a) => family.sessionStartedAt(a, sessionId))
|
|
26
|
+
: undefined;
|
|
27
|
+
return starter ? { sessionId, account: starter, neverSaved: true } : { sessionId };
|
|
15
28
|
}
|