sideby 0.4.2 → 0.5.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/dist/build.json CHANGED
@@ -1 +1 @@
1
- {"id":"20261013T095821-360786"}
1
+ {"id":"20261014T061214-0f7370"}
@@ -0,0 +1,52 @@
1
+ import type { Runtime } from '../runtime.ts';
2
+ export interface EnableStep {
3
+ /** `config`: the `handoff` key; `quota`: the Family's quota display; `hook`: its hook file; `copy`: only that hook file, into the other Accounts of the Family. */
4
+ id: 'config' | 'quota' | 'hook' | 'copy';
5
+ family?: string;
6
+ /** The file the step changes; for `copy`, the file that is copied. */
7
+ file?: string;
8
+ diff?: string;
9
+ /** For `copy`: the Accounts that get the file. */
10
+ accounts?: string[];
11
+ }
12
+ export interface EnableManual {
13
+ /** `codex-trust`: each Codex account trusts the hooks once, in `/hooks`; `setup-blocked`: a setup that cannot be applied. */
14
+ id: 'codex-trust' | 'setup-blocked';
15
+ family?: string;
16
+ message?: string;
17
+ }
18
+ export interface EnablePlan {
19
+ /** Everything is already in place; nothing would be written. */
20
+ alreadyOn: boolean;
21
+ /** Why nothing can be done from here (the sideby on PATH does not answer hooks); a person has to fix it first. */
22
+ blocked?: string;
23
+ steps: EnableStep[];
24
+ manual: EnableManual[];
25
+ /** Same-Family hand-over as it will be once the plan is applied. */
26
+ sameFamily: boolean;
27
+ /** Names this plan; a confirmation that sends it back as `expect` applies only this plan. */
28
+ key: string;
29
+ }
30
+ export interface EnableResult {
31
+ plan: EnablePlan;
32
+ results: {
33
+ id: EnableStep['id'];
34
+ family?: string;
35
+ accounts?: string[];
36
+ ok: boolean;
37
+ message: string;
38
+ }[];
39
+ /** The plan is no longer the one that was shown (`expect`), so nothing was written; `plan` is the new one. */
40
+ changed?: boolean;
41
+ }
42
+ export declare function planHandoffEnable(rt: Runtime, opts?: {
43
+ sameFamily?: boolean;
44
+ }): Promise<EnablePlan>;
45
+ /**
46
+ * Applies the plan in an order where "on" means "working": the setups first, the config key last. Stops at the first
47
+ * failure. With `expect`, writes nothing unless the plan is still the one that was shown.
48
+ */
49
+ export declare function applyHandoffEnable(rt: Runtime, opts?: {
50
+ sameFamily?: boolean;
51
+ expect?: string;
52
+ }): Promise<EnableResult>;
@@ -0,0 +1,169 @@
1
+ // Turning Auto Handoff on in one step (spec §3.17 "页面与一键开启", ADR-0013): the Panel's button does what the CLI's
2
+ // `handoff enable`, `quota setup` and `handoff setup` do, shown first as one plan and applied together after one
3
+ // confirmation. Every step is an existing, reversible setup; nothing new is written.
4
+ import { createHash } from 'node:crypto';
5
+ import { lstat, mkdir, readFile, stat } from 'node:fs/promises';
6
+ import { dirname, isAbsolute, join, relative } from 'node:path';
7
+ import { realpathOrNull, writeFileAtomic } from './fs-safe.js';
8
+ import { configureHandoff, handoffStatus } from './handoff-status.js';
9
+ const keyOf = (p) => createHash('sha256')
10
+ .update(JSON.stringify([p.blocked ?? null, p.sameFamily, p.steps]))
11
+ .digest('hex')
12
+ .slice(0, 16);
13
+ /** Whether `rel` below `dir` goes through a link: a file seen through one is already there, or is not ours to change. */
14
+ async function throughLink(dir, rel) {
15
+ let at = dir;
16
+ for (const part of rel.split('/')) {
17
+ at = join(at, part);
18
+ const st = await lstat(at).catch(() => null);
19
+ if (!st)
20
+ return false;
21
+ if (st.isSymbolicLink())
22
+ return true;
23
+ }
24
+ return false;
25
+ }
26
+ /**
27
+ * The other Accounts of the Family that lack the hook file `file` (a path in the Main Account) at the same place in
28
+ * their own directory. Only that one file is ever written, and one that is already there is left as it is: an
29
+ * Account's other files are never touched.
30
+ */
31
+ async function hookCopies(rt, family, file) {
32
+ const accounts = (await rt.accounts()).filter((a) => a.family === family);
33
+ const main = accounts.find((a) => a.isMain);
34
+ if (!main)
35
+ return [];
36
+ const rel = relative(main.dir, file);
37
+ if (!rel || rel.startsWith('..') || isAbsolute(rel))
38
+ return [];
39
+ const mainReal = await realpathOrNull(main.dir);
40
+ const out = [];
41
+ for (const a of accounts) {
42
+ if (a.isMain || (await realpathOrNull(a.dir)) === mainReal || (await throughLink(a.dir, rel)))
43
+ continue;
44
+ const target = join(a.dir, rel);
45
+ // Whether the file is there, not whether it can be read: one that cannot be read is still not ours to replace.
46
+ const have = await lstat(target).then(() => true, () => false);
47
+ // A file that is already there is not ours to replace, whatever it says.
48
+ if (!have)
49
+ out.push({ ref: a.ref, target });
50
+ }
51
+ return out;
52
+ }
53
+ /** `sameFamily` left out keeps the current setting; Turn on from the Panel says it either way. */
54
+ const changeOf = (opts) => ({
55
+ auto: true,
56
+ ...(opts.sameFamily === undefined ? {} : { sameFamily: opts.sameFamily }),
57
+ });
58
+ export async function planHandoffEnable(rt, opts = {}) {
59
+ const st = await handoffStatus(rt);
60
+ if (st.sideby.problem) {
61
+ const plan = {
62
+ alreadyOn: false,
63
+ blocked: st.sideby.problem,
64
+ steps: [],
65
+ manual: [],
66
+ sameFamily: st.sameFamily,
67
+ };
68
+ return { ...plan, key: keyOf(plan) };
69
+ }
70
+ const steps = [];
71
+ const manual = [];
72
+ const cfg = await configureHandoff(rt, changeOf(opts), { apply: false });
73
+ if (cfg.changed)
74
+ steps.push({ id: 'config', file: cfg.file, diff: cfg.diff });
75
+ for (const f of st.families) {
76
+ if (!f.starts.length)
77
+ continue;
78
+ const def = rt.families.get(f.family);
79
+ if (def?.quotaSetup && f.starts.includes('quota')) {
80
+ const plan = await rt
81
+ .quotaSetup(f.family)
82
+ .plan()
83
+ .catch(() => null);
84
+ if (plan?.status === 'ready')
85
+ steps.push({ id: 'quota', family: f.family, file: plan.file, diff: plan.diff });
86
+ else if (plan?.status === 'blocked')
87
+ manual.push({ id: 'setup-blocked', family: f.family, message: plan.message });
88
+ }
89
+ if (def?.handoff?.hookSetup) {
90
+ const plan = await rt
91
+ .handoffSetup(f.family)
92
+ .plan()
93
+ .catch(() => null);
94
+ if (plan?.status === 'ready')
95
+ steps.push({ id: 'hook', family: f.family, file: plan.file, diff: plan.diff });
96
+ else if (plan?.status === 'blocked')
97
+ manual.push({ id: 'setup-blocked', family: f.family, message: plan.message });
98
+ if (plan?.status === 'ready' || plan?.status === 'enabled') {
99
+ const copies = await hookCopies(rt, f.family, plan.file);
100
+ if (copies.length)
101
+ steps.push({ id: 'copy', family: f.family, file: plan.file, accounts: copies.map((c) => c.ref) });
102
+ }
103
+ }
104
+ if (f.family === 'codex')
105
+ manual.push({ id: 'codex-trust', family: 'codex' });
106
+ }
107
+ const plan = { alreadyOn: steps.length === 0, steps, manual, sameFamily: cfg.settings.sameFamily };
108
+ return { ...plan, key: keyOf(plan) };
109
+ }
110
+ /**
111
+ * Applies the plan in an order where "on" means "working": the setups first, the config key last. Stops at the first
112
+ * failure. With `expect`, writes nothing unless the plan is still the one that was shown.
113
+ */
114
+ export async function applyHandoffEnable(rt, opts = {}) {
115
+ const plan = await planHandoffEnable(rt, opts);
116
+ if (opts.expect !== undefined && opts.expect !== plan.key)
117
+ return { plan, results: [], changed: true };
118
+ const results = [];
119
+ if (!plan.blocked) {
120
+ const order = ['quota', 'hook', 'copy', 'config'];
121
+ const todo = [...plan.steps].sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id));
122
+ for (const step of todo) {
123
+ let ok = true;
124
+ let message = '';
125
+ try {
126
+ if (step.id === 'quota' || step.id === 'hook') {
127
+ const setup = step.id === 'quota' ? rt.quotaSetup(step.family) : rt.handoffSetup(step.family);
128
+ const r = await setup.apply();
129
+ ok = r.status === 'enabled';
130
+ message = r.message;
131
+ }
132
+ else if (step.id === 'copy') {
133
+ const file = step.file;
134
+ const want = await readFile(file, 'utf8');
135
+ const mode = (await stat(file)).mode & 0o777;
136
+ const listed = new Set(step.accounts);
137
+ const copies = (await hookCopies(rt, step.family, file)).filter((c) => listed.has(c.ref));
138
+ for (const c of copies) {
139
+ await mkdir(dirname(c.target), { recursive: true });
140
+ await writeFileAtomic(c.target, want, mode);
141
+ }
142
+ const left = await hookCopies(rt, step.family, file);
143
+ ok = left.length === 0;
144
+ message = left.length
145
+ ? `${left.map((c) => c.ref).join(', ')} still lack ${file}; try again, and if it keeps failing, check that you may write to their hooks folder`
146
+ : '';
147
+ }
148
+ else {
149
+ const r = await configureHandoff(rt, changeOf(opts), { apply: true });
150
+ ok = r.applied || !r.changed;
151
+ }
152
+ }
153
+ catch (err) {
154
+ ok = false;
155
+ message = err.message;
156
+ }
157
+ results.push({
158
+ id: step.id,
159
+ ...(step.family ? { family: step.family } : {}),
160
+ ...(step.accounts ? { accounts: step.accounts } : {}),
161
+ ok,
162
+ message,
163
+ });
164
+ if (!ok)
165
+ break;
166
+ }
167
+ }
168
+ return { plan, results };
169
+ }
@@ -0,0 +1,26 @@
1
+ import type { Runtime } from '../runtime.ts';
2
+ import type { HandoffSettings } from './config.ts';
3
+ import { type HandoffTrigger } from './handoff-chain.ts';
4
+ import type { HandoffDecision } from './handoff-select.ts';
5
+ export interface RecentHandoff {
6
+ from: string;
7
+ to: string;
8
+ /** When the next account started. */
9
+ at: string;
10
+ trigger: HandoffTrigger | null;
11
+ briefSource: 'agent' | 'sideby' | 'user' | null;
12
+ }
13
+ export interface HandoffOverview {
14
+ settings: HandoffSettings;
15
+ /** Where each subscription account of a Family that can start a hand-over would send its work; empty while off. */
16
+ takeover: {
17
+ ref: string;
18
+ family: string;
19
+ starts: ('quota' | 'limit')[];
20
+ decision: HandoffDecision;
21
+ }[];
22
+ recent: RecentHandoff[];
23
+ }
24
+ /** The latest hand-overs, newest first: a hop that ended with a trigger and was followed by another hop. */
25
+ export declare function readRecentHandoffs(stateDir: string, limit?: number): Promise<RecentHandoff[]>;
26
+ export declare function handoffOverview(rt: Runtime): Promise<HandoffOverview>;
@@ -0,0 +1,63 @@
1
+ // What the Panel's Auto Handoff page shows (spec §3.17 "页面与一键开启"): where each account's work would go next and
2
+ // the last hand-overs. Reads only; from the chain files it takes the accounts, times and triggers, never a Brief.
3
+ import { readdir, stat } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { HOOK_FAMILIES } from '../handoff/hook.js';
6
+ import { FILES, readJsonFile } from './handoff-chain.js';
7
+ const SCAN_CHAINS = 40;
8
+ const RECENT = 5;
9
+ /** The latest hand-overs, newest first: a hop that ended with a trigger and was followed by another hop. */
10
+ export async function readRecentHandoffs(stateDir, limit = RECENT) {
11
+ const root = join(stateDir, 'handoffs');
12
+ const names = await readdir(root).catch(() => []);
13
+ const dirs = (await Promise.all(names.map(async (n) => ({ n, t: (await stat(join(root, n)).catch(() => null))?.mtimeMs ?? 0 }))))
14
+ .sort((a, b) => b.t - a.t)
15
+ .slice(0, SCAN_CHAINS);
16
+ const out = [];
17
+ for (const { n } of dirs) {
18
+ const chain = await readJsonFile(join(root, n, FILES.chain));
19
+ const hops = Array.isArray(chain?.hops) ? chain.hops : [];
20
+ for (let i = 0; i + 1 < hops.length; i++) {
21
+ const from = hops[i];
22
+ const to = hops[i + 1];
23
+ if (typeof from.ref !== 'string' ||
24
+ typeof to.ref !== 'string' ||
25
+ !Number.isFinite(Date.parse(to.startedAt)))
26
+ continue;
27
+ out.push({
28
+ from: from.ref,
29
+ to: to.ref,
30
+ at: to.startedAt,
31
+ trigger: from.trigger ?? null,
32
+ briefSource: from.briefSource ?? null,
33
+ });
34
+ }
35
+ }
36
+ return out.sort((a, b) => Date.parse(b.at) - Date.parse(a.at)).slice(0, limit);
37
+ }
38
+ export async function handoffOverview(rt) {
39
+ const settings = rt.handoffSettings();
40
+ const recent = await readRecentHandoffs(rt.paths.stateDir);
41
+ if (!settings.auto)
42
+ return { settings, takeover: [], recent };
43
+ const starters = new Set([...rt.families.values()]
44
+ .filter((f) => f.handoff?.starts.length && HOOK_FAMILIES.includes(f.id))
45
+ .map((f) => f.id));
46
+ const subs = (await rt.accounts()).filter((a) => starters.has(a.family) && a.kind === 'subscription');
47
+ const takeover = await Promise.all(subs.map(async (a) => {
48
+ let decision;
49
+ try {
50
+ decision = await rt.handoffDecision(a, [a.ref], { ahead: true });
51
+ }
52
+ catch (err) {
53
+ decision = { kind: 'none', reason: err.message };
54
+ }
55
+ return {
56
+ ref: a.ref,
57
+ family: a.family,
58
+ starts: [...(rt.families.get(a.family)?.handoff?.starts ?? [])],
59
+ decision,
60
+ };
61
+ }));
62
+ return { settings, takeover, recent };
63
+ }
@@ -213,7 +213,8 @@ opts = {}) {
213
213
  const writePlan = () => {
214
214
  pending = pending.then(async () => {
215
215
  try {
216
- const d = await rt.handoffDecision(account, used);
216
+ // Read by the hook once the threshold is crossed, so it answers for that moment (spec §3.17 "选号" 1).
217
+ const d = await rt.handoffDecision(account, used, { ahead: true });
217
218
  if (stopped)
218
219
  return;
219
220
  const plan = {
@@ -39,4 +39,6 @@ export declare function selectNext(input: {
39
39
  /** Accounts this chain already used, the origin included. */
40
40
  used: readonly string[];
41
41
  now: Date;
42
+ /** Decide now what happens when the origin's fullest window reaches the threshold (see `waitUntil`). */
43
+ ahead?: boolean;
42
44
  }): HandoffDecision;
@@ -9,13 +9,19 @@ export function sameOrganization(a, b) {
9
9
  return domain(a.email) === domain(b.email);
10
10
  return true;
11
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) {
12
+ /**
13
+ * The latest reset of the origin's full windows, when they all reset in time to wait. Full means at the threshold;
14
+ * `ahead` also counts the fullest window once it is past `prepareAt`, since it answers what happens when that window
15
+ * reaches the threshold (spec §3.17 "选号" 1: plan.json and the Panel, decided before the moment comes).
16
+ */
17
+ function waitUntil(origin, s, now, ahead) {
14
18
  if (origin.quota.status !== 'ok' || s.waitIfResetWithinMinutes <= 0)
15
19
  return undefined;
20
+ const open = origin.quota.windows.filter((w) => !windowReset(w.resetsAt, now));
21
+ const top = Math.max(0, ...open.map((w) => w.usedPercent));
16
22
  let last;
17
- for (const w of origin.quota.windows) {
18
- if (w.usedPercent < s.threshold || windowReset(w.resetsAt, now))
23
+ for (const w of open) {
24
+ if (w.usedPercent < s.threshold && !(ahead && w.usedPercent === top && top >= s.prepareAt))
19
25
  continue;
20
26
  const t = Date.parse(w.resetsAt);
21
27
  if (!Number.isFinite(t))
@@ -46,7 +52,7 @@ function stateOf(c, s, now) {
46
52
  }
47
53
  export function selectNext(input) {
48
54
  const { origin, settings: s, now } = input;
49
- const until = waitUntil(origin, s, now);
55
+ const until = waitUntil(origin, s, now, input.ahead === true);
50
56
  if (until)
51
57
  return { kind: 'wait', until };
52
58
  const refOf = (name) => input.aliases[name] ?? name;
@@ -12,6 +12,13 @@ export declare function cacheStateDir(env: Env): string;
12
12
  export declare function accountNameFromEnv(env: Env): string | null;
13
13
  /** Extracts the 5h and 7d windows from status line JSON; null when there is nothing usable. */
14
14
  export declare function parseRateLimits(input: string, observedAt: Date): ClaudeQuotaCache | null;
15
+ /**
16
+ * Merges a status line reading into the cached windows. Each session reports the Quota of its own last request, so an
17
+ * idle session of the same Account reports older numbers, sometimes of a window that has already reset: within one
18
+ * window the higher use wins, otherwise the later reset; a window the reading lacks is kept. Null when the reading
19
+ * only repeats older numbers, so nothing is written and `observedAt` keeps the time of the newer reading.
20
+ */
21
+ export declare function mergeQuotaCache(cached: readonly QuotaWindow[], next: ClaudeQuotaCache): ClaudeQuotaCache | null;
15
22
  /** Writes the cache atomically; creates the private parent directory first (writeFileAtomic does not). */
16
23
  export declare function writeQuotaCache(stateDir: string, name: string, cache: ClaudeQuotaCache): Promise<void>;
17
24
  export type CacheRead = {
@@ -64,6 +64,46 @@ export function parseRateLimits(input, observedAt) {
64
64
  }
65
65
  return windows.length ? { version: 1, observedAt: observedAt.toISOString(), windows } : null;
66
66
  }
67
+ /** Two readings belong to one window when their resets are less than a quarter of the window apart. */
68
+ function sameWindow(a, b) {
69
+ return Math.abs(Date.parse(a.resetsAt) - Date.parse(b.resetsAt)) < (a.windowMinutes * 60_000) / 4;
70
+ }
71
+ const sameReading = (a, b) => a.label === b.label &&
72
+ a.windowMinutes === b.windowMinutes &&
73
+ a.usedPercent === b.usedPercent &&
74
+ a.resetsAt === b.resetsAt;
75
+ const order = (label) => {
76
+ const i = WINDOWS.findIndex((w) => w.label === label);
77
+ return i < 0 ? WINDOWS.length : i;
78
+ };
79
+ /**
80
+ * Merges a status line reading into the cached windows. Each session reports the Quota of its own last request, so an
81
+ * idle session of the same Account reports older numbers, sometimes of a window that has already reset: within one
82
+ * window the higher use wins, otherwise the later reset; a window the reading lacks is kept. Null when the reading
83
+ * only repeats older numbers, so nothing is written and `observedAt` keeps the time of the newer reading.
84
+ */
85
+ export function mergeQuotaCache(cached, next) {
86
+ let older = false;
87
+ const windows = next.windows.map((w) => {
88
+ const c = cached.find((x) => x.label === w.label);
89
+ if (!c)
90
+ return w;
91
+ const keep = sameWindow(c, w)
92
+ ? c.usedPercent > w.usedPercent
93
+ : Date.parse(c.resetsAt) > Date.parse(w.resetsAt);
94
+ if (keep)
95
+ older = true;
96
+ return keep ? c : w;
97
+ });
98
+ for (const c of cached)
99
+ if (!windows.some((w) => w.label === c.label))
100
+ windows.push(c);
101
+ windows.sort((a, b) => order(a.label) - order(b.label));
102
+ const unchanged = windows.length === cached.length && windows.every((w) => cached.some((c) => sameReading(c, w)));
103
+ if (older && unchanged)
104
+ return null;
105
+ return { ...next, windows };
106
+ }
67
107
  /** Writes the cache atomically; creates the private parent directory first (writeFileAtomic does not). */
68
108
  export async function writeQuotaCache(stateDir, name, cache) {
69
109
  const file = claudeQuotaCacheFile(stateDir, name);
@@ -191,7 +191,7 @@ export const grokHookSetup = {
191
191
  await rmdir(join(ctx.home, '.grok', 'hooks')).catch(() => { });
192
192
  return {
193
193
  ok: true,
194
- message: `removed ${shown}; run \`sideby doctor grok --fix\` to remove the copies in other Grok accounts`,
194
+ message: `removed ${shown}; the copies in other Grok accounts (hooks/sideby-handoff.json) stay and do nothing outside a sideby hand-over; delete them yourself if you no longer want them`,
195
195
  };
196
196
  },
197
197
  };
@@ -9,7 +9,7 @@ const TEMPLATE = [
9
9
  ].join('; ');
10
10
  const RULES = 'Reference specs, ADRs, commits and diffs by path instead of repeating them, list the skills the next agent should use, and never include keys, passwords or personal data.';
11
11
  export function prepareMessage(brief, pressure) {
12
- return `sideby: this account's quota is at ${Math.round(pressure)}%, so another account may take over soon. Write a handoff brief to ${brief} now (${TEMPLATE}). ${RULES} Keep it current after each step, then go on with the task.`;
12
+ return `sideby: this account's quota is at ${Math.round(pressure)}%. Write a handoff brief to ${brief} now (${TEMPLATE}). ${RULES} Keep it current after each step, then go on with the task. Do not tell the user that the work will move to another account: sideby decides that and tells them itself.`;
13
13
  }
14
14
  export function thresholdMessage(brief, pressure) {
15
15
  return `sideby: this account's quota is at ${Math.round(pressure)}%. Finish the current step, update the handoff brief at ${brief} (${TEMPLATE}; ${RULES}), then end your turn. sideby will start the next account from that brief.`;
@@ -4,6 +4,8 @@ import { aliasesByAccount } from '../core/accounts.js';
4
4
  import { aliasArgs, aliasRefs } from '../core/config.js';
5
5
  import { UserError } from '../core/errors.js';
6
6
  import { planHandoff } from '../core/handoff.js';
7
+ import { applyHandoffEnable, planHandoffEnable } from '../core/handoff-enable.js';
8
+ import { handoffOverview } from '../core/handoff-overview.js';
7
9
  import { configureHandoff, handoffStatus } from '../core/handoff-status.js';
8
10
  import { tildify } from '../core/paths.js';
9
11
  import { MIN_CALLS } from '../core/tuning.js';
@@ -393,6 +395,34 @@ export function createPanelHandler(opts) {
393
395
  return { ...r, file: tildify(r.file, rt.paths.home) };
394
396
  },
395
397
  },
398
+ // The Auto Handoff page (spec §3.17): where work would go next and the last hand-overs. Reads only.
399
+ '/api/handoff/overview': {
400
+ writes: () => false,
401
+ async run() {
402
+ return handoffOverview(await getRuntime());
403
+ },
404
+ },
405
+ // Turning it on in one step (ADR-0013): without `confirm` only the plan; with it, every step in order.
406
+ '/api/handoff/enable': {
407
+ writes: (body) => body.confirm === true,
408
+ async run(body) {
409
+ // Left out keeps the current setting; true or false sets it.
410
+ const sameFamily = body.sameFamily === undefined ? undefined : optBool(body, 'sameFamily');
411
+ const rt = await getRuntime();
412
+ const home = rt.paths.home;
413
+ const show = (p) => ({
414
+ ...p,
415
+ steps: p.steps.map((s) => (s.file ? { ...s, file: tildify(s.file, home) } : s)),
416
+ });
417
+ const opts = sameFamily === undefined ? {} : { sameFamily };
418
+ if (!optBool(body, 'confirm'))
419
+ return show(await planHandoffEnable(rt, opts));
420
+ // `expect`: the key of the plan the person saw; a different plan now is shown again, not applied.
421
+ const expect = optString(body, 'expect');
422
+ const r = await applyHandoffEnable(rt, expect === undefined ? opts : { ...opts, expect });
423
+ return { ...r, plan: show(r.plan) };
424
+ },
425
+ },
396
426
  // Undo for the setup above; the Runtime turns a teardown that throws into `{ ok: false, message }`.
397
427
  '/api/quota/teardown': {
398
428
  async run(body) {
@@ -233,6 +233,54 @@ declare const en: {
233
233
  'menu.check': string;
234
234
  'menu.like': string;
235
235
  'menu.tune': string;
236
+ 'page.handoff': string;
237
+ 'hp.title': string;
238
+ 'hp.advanced': string;
239
+ 'hp.loading': string;
240
+ 'hp.intro': string;
241
+ 'hp.how1': string;
242
+ 'hp.how2': string;
243
+ 'hp.how3': string;
244
+ 'hp.noStarters': string;
245
+ 'hp.sameFamily': string;
246
+ 'hp.turnOn': string;
247
+ 'hp.turnOff': string;
248
+ 'hp.willDo': string;
249
+ 'hp.step.config': string;
250
+ 'hp.step.quota': string;
251
+ 'hp.step.hook': string;
252
+ 'hp.step.copy': string;
253
+ 'hp.showChange': string;
254
+ 'hp.sameFamilyOffNote': string;
255
+ 'hp.manual.codexTrust': string;
256
+ 'hp.blocked': string;
257
+ 'hp.confirmOn': string;
258
+ 'hp.applying': string;
259
+ 'hp.stepFailed': string;
260
+ 'hp.tryAgain': string;
261
+ 'hp.onNote': string;
262
+ 'hp.takeover': string;
263
+ 'hp.used': string;
264
+ 'hp.noQuota': string;
265
+ 'hp.goesTo': string;
266
+ 'hp.goesToLimit': string;
267
+ 'hp.waits': string;
268
+ 'hp.waitRule': string;
269
+ 'hp.planChanged': string;
270
+ 'hp.none': string;
271
+ 'hp.noneBack': string;
272
+ 'hp.noAccounts': string;
273
+ 'hp.recent': string;
274
+ 'hp.noneYet': string;
275
+ 'hp.why.threshold': string;
276
+ 'hp.why.limit': string;
277
+ 'hp.why.manual': string;
278
+ 'hp.missing': string;
279
+ 'hp.complete': string;
280
+ 'hp.onlySideby': string;
281
+ 'hp.sameConfirm': string;
282
+ 'hp.sameOn': string;
283
+ 'hp.sameOff': string;
236
284
  'page.accounts': string;
237
285
  'page.tuning': string;
238
286
  'tune.title': string;
@@ -235,6 +235,54 @@ const en = {
235
235
  'menu.check': 'Check this account',
236
236
  'menu.like': 'New account like this',
237
237
  'menu.tune': 'Context tuning',
238
+ 'page.handoff': 'Auto Handoff',
239
+ 'hp.title': 'Auto Handoff',
240
+ 'hp.advanced': 'Advanced settings',
241
+ 'hp.loading': 'Checking…',
242
+ 'hp.intro': 'When an account is about to run out of quota, your work moves to the next account by itself. You do not have to do anything.',
243
+ 'hp.how1': 'You keep working in a session you started with sideby (a short command such as cc001, or sideby run).',
244
+ 'hp.how2': 'When that account reaches {p}% of its quota, sideby has the AI write a short note about the task and ends the session.',
245
+ 'hp.how3': 'After a {s}-second countdown (Enter cancels), the next account starts in the same terminal and carries on from the note.',
246
+ 'hp.noStarters': 'None of your accounts can start a hand-over yet. Add a Claude, Codex or Grok account first.',
247
+ 'hp.sameFamily': 'Also switch between accounts of the same tool (for example cc001 to cc002)',
248
+ 'hp.turnOn': 'Turn on',
249
+ 'hp.turnOff': 'Turn off',
250
+ 'hp.willDo': 'To turn it on, sideby will:',
251
+ 'hp.step.config': 'Switch Auto Handoff on in sideby’s settings',
252
+ 'hp.step.quota': 'Turn on {host} quota display (hand-over needs it to know how much is used)',
253
+ 'hp.step.hook': 'Add sideby’s hand-over hook to {host}',
254
+ 'hp.step.copy': 'Copy the hook file into your other {host} accounts ({accounts}); nothing else in them changes',
255
+ 'hp.showChange': 'Show the change',
256
+ 'hp.sameFamilyOffNote': 'Accounts of the same tool will not switch between each other. You can turn that on later on this page.',
257
+ 'hp.manual.codexTrust': 'Codex asks once per account to trust sideby’s hooks. Type /hooks in Codex the first time and trust them; sideby reminds you then.',
258
+ 'hp.blocked': 'It cannot be turned on yet: {why}',
259
+ 'hp.confirmOn': 'Confirm and turn on',
260
+ 'hp.applying': 'Turning on…',
261
+ 'hp.stepFailed': 'Stopped at: {step}. {why}',
262
+ 'hp.tryAgain': 'Nothing after it was changed. Try again, or ask for help.',
263
+ 'hp.onNote': 'When an account reaches {p}% of its quota, its session moves to the next account by itself.',
264
+ 'hp.takeover': 'Where work goes next',
265
+ 'hp.used': '{p}% used',
266
+ 'hp.noQuota': 'quota unknown',
267
+ 'hp.goesTo': 'at {p}% it moves to {to}',
268
+ 'hp.goesToLimit': 'when it hits its usage limit it moves to {to}',
269
+ 'hp.waits': 'at the limit it waits: quota is back at {at}',
270
+ 'hp.waitRule': 'If its quota comes back within {w} minutes at that point, it waits instead of switching.',
271
+ 'hp.planChanged': 'Something changed since you looked, so sideby has not changed anything yet. This is what it would do now; confirm again.',
272
+ 'hp.none': 'no account can take over right now',
273
+ 'hp.noneBack': 'no account can take over now; {ref} is back at {at}',
274
+ 'hp.noAccounts': 'No subscription account can start a hand-over.',
275
+ 'hp.recent': 'Recent hand-overs',
276
+ 'hp.noneYet': 'None yet. The next time an account runs low you will see it here.',
277
+ 'hp.why.threshold': 'quota reached the hand-over level',
278
+ 'hp.why.limit': 'it hit its usage limit',
279
+ 'hp.why.manual': 'handed over on request',
280
+ 'hp.missing': 'One step left',
281
+ 'hp.complete': 'Finish setting up',
282
+ 'hp.onlySideby': 'Only sessions started with sideby hand over (a short command such as cc001, or sideby run). Typing claude directly does not.',
283
+ 'hp.sameConfirm': 'Switch it on',
284
+ 'hp.sameOn': 'Same-tool hand-over is on.',
285
+ 'hp.sameOff': 'Same-tool hand-over is off.',
238
286
  'page.accounts': 'Accounts',
239
287
  'page.tuning': 'Context tuning',
240
288
  'tune.title': 'Context tuning',
@@ -617,6 +665,54 @@ const zh = {
617
665
  'menu.check': '检查这个账号',
618
666
  'menu.like': '照此新建账号',
619
667
  'menu.tune': '上下文调优',
668
+ 'page.handoff': '自动交接',
669
+ 'hp.title': '自动交接',
670
+ 'hp.advanced': '高级设置',
671
+ 'hp.loading': '检查中…',
672
+ 'hp.intro': '账号额度快用完时,你的工作会自动换到下一个账号接着做,不用你操心。',
673
+ 'hp.how1': '你照常在用 sideby 启动的会话里工作(短命令如 cc001,或 sideby run)。',
674
+ 'hp.how2': '这个账号用到 {p}% 时,sideby 让 AI 写一份任务小结,然后结束这个会话。',
675
+ 'hp.how3': '倒计时 {s} 秒后(按回车可取消),下一个账号在同一个终端里按小结接着做。',
676
+ 'hp.noStarters': '你的账号里还没有能发起交接的。先添加一个 Claude、Codex 或 Grok 账号。',
677
+ 'hp.sameFamily': '同一个工具的账号之间也切换(例如 cc001 到 cc002)',
678
+ 'hp.turnOn': '一键开启',
679
+ 'hp.turnOff': '关闭',
680
+ 'hp.willDo': '开启时 sideby 会:',
681
+ 'hp.step.config': '在 sideby 的设置里打开自动交接',
682
+ 'hp.step.quota': '打开 {host} 的额度显示(交接要靠它知道用了多少)',
683
+ 'hp.step.hook': '给 {host} 装上 sideby 的交接钩子',
684
+ 'hp.step.copy': '把钩子文件复制到你的其他 {host} 账号({accounts}),它们的其他文件不动',
685
+ 'hp.showChange': '查看改动',
686
+ 'hp.sameFamilyOffNote': '同一个工具的账号之间不会互相切换,之后可以在本页打开。',
687
+ 'hp.manual.codexTrust': 'Codex 每个账号第一次要信任 sideby 的钩子:第一次启动时在 Codex 里输入 /hooks 并信任,到时 sideby 会提醒你。',
688
+ 'hp.blocked': '暂时不能开启:{why}',
689
+ 'hp.confirmOn': '确认开启',
690
+ 'hp.applying': '正在开启…',
691
+ 'hp.stepFailed': '停在这一步:{step}。{why}',
692
+ 'hp.tryAgain': '它之后的步骤没有执行。可以重试,或找人帮忙。',
693
+ 'hp.onNote': '任一账号用到 {p}% 时,它的会话会自动换到下一个账号。',
694
+ 'hp.takeover': '接下来会交给谁',
695
+ 'hp.used': '已用 {p}%',
696
+ 'hp.noQuota': '额度未知',
697
+ 'hp.goesTo': '到 {p}% 时交给 {to}',
698
+ 'hp.goesToLimit': '撞到用量上限时交给 {to}',
699
+ 'hp.waits': '到上限时会等待:{at} 额度恢复',
700
+ 'hp.waitRule': '如果那时离额度恢复不到 {w} 分钟,就原地等,不换号。',
701
+ 'hp.planChanged': '你查看之后设置有了变化,所以还没有改任何东西。下面是现在要做的改动,请再确认一次。',
702
+ 'hp.none': '现在没有能接手的账号',
703
+ 'hp.noneBack': '现在没有能接手的账号;{ref} 在 {at} 恢复',
704
+ 'hp.noAccounts': '没有能发起交接的订阅账号。',
705
+ 'hp.recent': '最近的交接',
706
+ 'hp.noneYet': '还没有。下次有账号额度快用完时,这里会出现记录。',
707
+ 'hp.why.threshold': '额度到了交接线',
708
+ 'hp.why.limit': '撞到了用量上限',
709
+ 'hp.why.manual': '按你的要求交接',
710
+ 'hp.missing': '还差一步',
711
+ 'hp.complete': '补全设置',
712
+ 'hp.onlySideby': '只有用 sideby 启动的会话才会交接(短命令如 cc001,或 sideby run)。直接敲 claude 启动的不会。',
713
+ 'hp.sameConfirm': '确认打开',
714
+ 'hp.sameOn': '已开启同工具交接。',
715
+ 'hp.sameOff': '已关闭同工具交接。',
620
716
  'page.accounts': '账号',
621
717
  'page.tuning': '上下文调优',
622
718
  'tune.title': '上下文调优',
@@ -227,6 +227,8 @@ const MAIN = String.raw `
227
227
  // tooltip, a focused field, or a text selection in the account list.
228
228
  function uiBusy() {
229
229
  if (menu.el || (dlg.el && dlg.el.open) || document.querySelector('.tip.is-open')) return true
230
+ // A plan on the Auto Handoff page, and the change a person opened to read, stay as they are.
231
+ if (!$('handoff-page').hidden && (hp.preview || hp.askSame || document.querySelector('#handoff-page details[open]'))) return true
230
232
  const el = document.activeElement
231
233
  if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable)) return true
232
234
  const sel = window.getSelection && window.getSelection()
@@ -894,7 +896,7 @@ const MAIN = String.raw `
894
896
  if (!id || (!id.email && !id.org)) return null
895
897
  const email = id.email ? emailText(id.email) : ''
896
898
  return h('div', { class: 'ident-email', title: [email, id.org].filter(Boolean).join(' · ') },
897
- dots([email ? h('span', { class: 'email' }, email) : null, id.org ? h('span', { class: 'org' }, id.org) : null]))
899
+ dots([email ? h('button', { class: 'email email-btn', type: 'button', title: t('menu.copyEmail'), 'aria-label': t('menu.copyEmail') + ' ' + email, onclick: (e) => { e.stopPropagation(); copyText(id.email) } }, email) : null, id.org ? h('span', { class: 'org' }, id.org) : null]))
898
900
  }
899
901
  function subLine(a) {
900
902
  const plan = a.quota && a.quota.status === 'ok' && a.quota.plan
@@ -1249,7 +1251,8 @@ const MAIN = String.raw `
1249
1251
  // What each card's size menu shows while the user is choosing, by group id; dropped once a change is made.
1250
1252
  const tuneSel = {}
1251
1253
  const tp = { data: null, loading: false, error: null, at: 0 }
1252
- let curPage = location.hash === '#tuning' ? 'tuning' : 'accounts'
1254
+ function pageFromHash() { return location.hash === '#tuning' ? 'tuning' : location.hash === '#handoff' ? 'handoff' : 'accounts' }
1255
+ let curPage = pageFromHash()
1253
1256
  function tuneSize(n) { return n === null || n === undefined ? t('tune.hostDefault') : tokens(n) }
1254
1257
  function tuneRedraw() {
1255
1258
  if (dlg.kind === 'tune') renderTune()
@@ -1442,25 +1445,37 @@ const MAIN = String.raw `
1442
1445
  function tuningAvailable() { return Boolean(S.state && S.state.families.some((f) => f.tuning)) }
1443
1446
  function setPage(next) {
1444
1447
  curPage = next
1445
- try { history.replaceState(null, '', next === 'tuning' ? '#tuning' : location.pathname + location.search) } catch {}
1448
+ try { history.replaceState(null, '', next === 'accounts' ? location.pathname + location.search : '#' + next) } catch {}
1446
1449
  renderPageChrome()
1447
1450
  }
1448
1451
  // The page tabs, and which sections show; runs with every render, so a reload or a language switch keeps them.
1449
1452
  function renderPageChrome() {
1450
1453
  const nav = $('pages')
1451
- const can = tuningAvailable()
1452
- if (!can && curPage === 'tuning' && S.state) curPage = 'accounts'
1453
- nav.hidden = !can
1454
- const tabs = [['accounts', t('page.accounts')], ['tuning', t('page.tuning')]]
1454
+ const canTune = tuningAvailable()
1455
+ const canHandoff = Boolean(ahState())
1456
+ if (S.state && ((curPage === 'tuning' && !canTune) || (curPage === 'handoff' && !canHandoff))) curPage = 'accounts'
1457
+ const tabs = [['accounts', t('page.accounts')]]
1458
+ if (canTune) tabs.push(['tuning', t('page.tuning')])
1459
+ if (canHandoff) tabs.push(['handoff', t('page.handoff')])
1460
+ nav.hidden = tabs.length < 2
1455
1461
  setKids(nav, h('div', { class: 'seg', role: 'presentation' }, tabs.map(([id, label]) =>
1456
1462
  h('button', { class: 'seg-btn', type: 'button', role: 'tab', 'aria-selected': curPage === id ? 'true' : 'false', onclick: () => setPage(id) }, label))))
1457
- const on = can && curPage === 'tuning'
1458
- for (const id of ['summary', 'accounts-page', 'checkup']) $(id).hidden = on
1459
- $('tuning-page').hidden = !on
1460
- if (on) {
1463
+ const away = curPage !== 'accounts' && tabs.some(([id]) => id === curPage)
1464
+ for (const id of ['summary', 'accounts-page', 'checkup']) $(id).hidden = away
1465
+ const onTune = away && curPage === 'tuning'
1466
+ const onHandoff = away && curPage === 'handoff'
1467
+ $('tuning-page').hidden = !onTune
1468
+ $('handoff-page').hidden = !onHandoff
1469
+ if (onTune) {
1461
1470
  if (!tp.data && !tp.loading && !tp.error) loadTuningPage()
1462
1471
  else renderTuningPage()
1463
1472
  }
1473
+ if (onHandoff) {
1474
+ // A change made elsewhere (Advanced settings, the CLI) makes what the page loaded out of date.
1475
+ const stale = hp.data && (hp.data.settings.auto !== ahState().settings.auto || hp.data.settings.sameFamily !== ahState().settings.sameFamily)
1476
+ if ((!hp.data || stale) && !hp.loading && !hp.error) loadHandoffPage()
1477
+ else renderHandoffPage()
1478
+ }
1464
1479
  }
1465
1480
  async function loadTuningPage() {
1466
1481
  tp.loading = true
@@ -1491,7 +1506,7 @@ const MAIN = String.raw `
1491
1506
  }
1492
1507
  setKids(box, kids)
1493
1508
  }
1494
- window.addEventListener('hashchange', () => { curPage = location.hash === '#tuning' ? 'tuning' : 'accounts'; renderPageChrome() })
1509
+ window.addEventListener('hashchange', () => { curPage = pageFromHash(); renderPageChrome() })
1495
1510
 
1496
1511
  // New account. The form is built once per opening so typing keeps focus; only its derived parts re-render.
1497
1512
  let nf = null
@@ -1964,6 +1979,192 @@ const MAIN = String.raw `
1964
1979
  if (ah.loading) loadHandoffStatus()
1965
1980
  }
1966
1981
 
1982
+ // ---------- the Auto Handoff page ----------
1983
+ // Off: one sentence and one button. On: where each account's work would go next, the last hand-overs and what is
1984
+ // still missing. Turning on is one plan shown first (hp.preview) and one confirmation; no commands to copy.
1985
+ const hp = { data: null, plan: null, loading: false, error: null, preview: null, previewing: false, applying: false, failed: null, changed: false, sameFamily: false, sameTouched: false, busyOff: false, askSame: false }
1986
+ async function loadHandoffPage() {
1987
+ hp.loading = true
1988
+ hp.error = null
1989
+ renderHandoffPage()
1990
+ try {
1991
+ // What is missing is only worth the check once it is on; while off the button shows the whole plan.
1992
+ const [data, plan] = await Promise.all([api('/api/handoff/overview', {}), ahState().settings.auto ? api('/api/handoff/enable', {}) : Promise.resolve(null)])
1993
+ hp.data = data
1994
+ hp.plan = plan
1995
+ } catch (e) { hp.error = e }
1996
+ hp.loading = false
1997
+ renderHandoffPage()
1998
+ }
1999
+ // Off, the box on this page decides; on, "finish setting up" keeps whatever is set.
2000
+ function hpBody() { return ahState().settings.auto ? {} : { sameFamily: hp.sameFamily } }
2001
+ async function hpPreview() {
2002
+ hp.previewing = true
2003
+ hp.failed = null
2004
+ hp.changed = false
2005
+ renderHandoffPage()
2006
+ try { hp.preview = await api('/api/handoff/enable', hpBody()) } catch (e) { hp.failed = { error: e } }
2007
+ hp.previewing = false
2008
+ renderHandoffPage()
2009
+ }
2010
+ async function hpApply() {
2011
+ hp.applying = true
2012
+ hp.failed = null
2013
+ renderHandoffPage()
2014
+ try {
2015
+ const r = await api('/api/handoff/enable', Object.assign(hpBody(), { confirm: true, expect: hp.preview && hp.preview.key }))
2016
+ const bad = r.results.find((x) => !x.ok)
2017
+ // A plan that changed since it was shown is shown again; one with nothing left to do means it is already on.
2018
+ hp.changed = Boolean(r.changed) && !r.plan.alreadyOn
2019
+ if (hp.changed || r.plan.blocked) hp.preview = r.plan
2020
+ else if (bad) hp.failed = { step: bad, plan: r.plan }
2021
+ else { hp.preview = null; toast(t('toast.ahOn')) }
2022
+ await load({ silent: true })
2023
+ await loadHandoffPage()
2024
+ } catch (e) { hp.failed = { error: e } }
2025
+ hp.applying = false
2026
+ renderHandoffPage()
2027
+ }
2028
+ async function hpChange(change, message) {
2029
+ hp.busyOff = true
2030
+ renderHandoffPage()
2031
+ try {
2032
+ await api('/api/handoff/configure', { change, confirm: true })
2033
+ hp.preview = null
2034
+ hp.askSame = false
2035
+ toast(message)
2036
+ await load({ silent: true })
2037
+ await loadHandoffPage()
2038
+ } catch (e) { hp.failed = { error: e } }
2039
+ hp.busyOff = false
2040
+ renderHandoffPage()
2041
+ }
2042
+ function hpStepTitle(step) {
2043
+ const host = step.family ? family(step.family).title : ''
2044
+ return t('hp.step.' + step.id, { host, accounts: (step.accounts || []).join(', ') })
2045
+ }
2046
+ function hpSteps(plan) {
2047
+ return h('ul', { class: 'hp-steps' }, plan.steps.map((step) => h('li', null, h('div', null,
2048
+ h('div', null, hpStepTitle(step)),
2049
+ step.diff ? h('details', { class: 'small' }, h('summary', null, t('hp.showChange')), h('p', { class: 'muted small' }, tx('ah.changes', { file: h('code', null, step.file) })), diffView(step.diff)) : null))))
2050
+ }
2051
+ function hpManual(plan) {
2052
+ return plan.manual.map((m) => h('p', { class: 'note' }, m.id === 'codex-trust' ? t('hp.manual.codexTrust') : (family(m.family).title + ': ' + (m.message || ''))))
2053
+ }
2054
+ function hpPreviewCard() {
2055
+ const p = hp.preview
2056
+ const kids = [h('h3', { class: 'small' }, t('hp.willDo'))]
2057
+ if (hp.changed) kids.push(h('p', { class: 'note note-warn', role: 'status' }, t('hp.planChanged')))
2058
+ if (p.blocked) kids.push(h('p', { class: 'note note-fail' }, t('hp.blocked', { why: p.blocked })), codeCopy('npm i -g sideby'))
2059
+ else {
2060
+ kids.push(hpSteps(p), ...hpManual(p))
2061
+ kids.push(p.sameFamily ? h('p', { class: 'note note-warn' }, t('ah.sameFamilyWarn')) : h('p', { class: 'muted small' }, t('hp.sameFamilyOffNote')))
2062
+ }
2063
+ const foot = [h('button', { class: 'btn', type: 'button', disabled: hp.applying, onclick: () => { hp.preview = null; hp.failed = null; hp.changed = false; renderHandoffPage() } }, t('btn.cancel'))]
2064
+ if (!p.blocked) foot.push(h('button', { class: 'btn btn-primary', type: 'button', disabled: hp.applying, 'aria-busy': hp.applying ? 'true' : null, onclick: hpApply }, hp.applying ? spinner() : null, hp.applying ? t('hp.applying') : t('hp.confirmOn')))
2065
+ kids.push(h('div', { class: 'row-actions' }, foot))
2066
+ return h('div', { class: 'ah-fam' }, kids)
2067
+ }
2068
+ function hpFailed() {
2069
+ const f = hp.failed
2070
+ if (!f) return null
2071
+ if (f.error) return errorNote(f.error, null)
2072
+ return h('p', { class: 'note note-fail', role: 'alert' }, t('hp.stepFailed', { step: hpStepTitle(f.step), why: f.step.message || '' }) + ' ' + t('hp.tryAgain'))
2073
+ }
2074
+ function hpDecision(d, starts) {
2075
+ const thr = ahState().settings.threshold
2076
+ if (d.kind === 'pick') return starts.includes('quota') ? t('hp.goesTo', { to: accountLabel(d.ref), p: thr }) : t('hp.goesToLimit', { to: accountLabel(d.ref) })
2077
+ if (d.kind === 'wait') return t('hp.waits', { at: clock(d.until) })
2078
+ return d.earliestReset ? t('hp.noneBack', { ref: accountLabel(d.earliestReset.ref), at: clock(d.earliestReset.at) }) : t('hp.none')
2079
+ }
2080
+ // The one case where reaching the threshold does not switch (spec §3.17 "选号" 1); none when waiting is off.
2081
+ function hpWaitRule(st) {
2082
+ const w = st.settings.waitIfResetWithinMinutes
2083
+ return w > 0 ? ' ' + t('hp.waitRule', { w }) : null
2084
+ }
2085
+ function hpRows(d) {
2086
+ if (!d.takeover.length) return h('p', { class: 'muted small' }, t('hp.noAccounts'))
2087
+ return h('ul', { class: 'hp-rows' }, d.takeover.map((r) => {
2088
+ const a = S.state.accounts.find((x) => x.ref === r.ref)
2089
+ const p = a ? quotaPressure(a, Date.now()) : -1
2090
+ return h('li', null,
2091
+ h('span', { class: 'title-logo' }, badge(r.family, 'sm'), h('b', { class: 'mono' }, accountLabel(r.ref))),
2092
+ h('span', { class: 'muted' }, p >= 0 ? t('hp.used', { p: Math.round(p) }) : t('hp.noQuota')),
2093
+ h('span', null, hpDecision(r.decision, r.starts)))
2094
+ }))
2095
+ }
2096
+ function hpRecent(d) {
2097
+ if (!d.recent.length) return h('p', { class: 'muted small' }, t('hp.noneYet'))
2098
+ return h('ul', { class: 'hp-rows' }, d.recent.map((r) => h('li', null,
2099
+ h('span', { class: 'mono' }, accountLabel(r.from) + ' → ' + accountLabel(r.to)),
2100
+ h('span', { class: 'muted' }, clock(r.at) + ' · ' + ago(r.at)),
2101
+ h('span', { class: 'muted' }, r.trigger ? t('hp.why.' + r.trigger) : ''))))
2102
+ }
2103
+ function hpSameFamilyBox(checked, onchange) {
2104
+ const input = h('input', { type: 'checkbox', disabled: BOOT.readOnly || hp.busyOff || hp.applying })
2105
+ input.checked = checked
2106
+ input.addEventListener('change', () => onchange(input.checked))
2107
+ return h('div', { class: 'check-row' }, h('label', { class: 'check' }, input, h('span', null, h('b', null, t('hp.sameFamily')), h('span', { class: 'muted small' }, t('ah.sameFamilyWarn')))))
2108
+ }
2109
+ function renderHandoffPage() {
2110
+ const box = $('handoff-page')
2111
+ if (box.hidden || !S.state) return
2112
+ const st = ahState()
2113
+ if (!st) return
2114
+ const kids = [h('div', { class: 'section-head' },
2115
+ h('h2', { id: 'h-handoff' }, t('hp.title')),
2116
+ h('div', { class: 'row-actions' },
2117
+ h('button', { class: 'btn btn-sm', type: 'button', onclick: openHandoff }, t('hp.advanced')),
2118
+ h('button', { class: 'btn btn-sm', type: 'button', disabled: hp.loading, onclick: loadHandoffPage }, hp.loading ? spinner() : icon('refresh'), t('top.refresh'))))]
2119
+ const on = st.settings.auto
2120
+ if (hp.loading && !hp.data) kids.push(h('p', { class: 'muted' }, spinner(), ' ', t('hp.loading')))
2121
+ if (hp.error) kids.push(errorNote(hp.error, loadHandoffPage))
2122
+ if (BOOT.readOnly) kids.push(h('p', { class: 'note' }, t('banner.readOnly')))
2123
+ if (!on) {
2124
+ kids.push(h('p', null, t('hp.intro')))
2125
+ kids.push(h('ol', { class: 'hp-how' }, h('li', null, t('hp.how1')), h('li', null, t('hp.how2', { p: st.settings.threshold }), hpWaitRule(st)), h('li', null, t('hp.how3', { s: st.settings.countdownSeconds }))))
2126
+ if (!S.state.accounts.some((a) => a.kind !== 'api' && st.starters.includes(a.family))) kids.push(h('p', { class: 'note' }, t('hp.noStarters')))
2127
+ else {
2128
+ if (!hp.preview) {
2129
+ // Until the person touches the box it shows what is set now, so turning on again keeps an earlier choice.
2130
+ if (!hp.sameTouched) hp.sameFamily = st.settings.sameFamily
2131
+ kids.push(hpSameFamilyBox(hp.sameFamily, (v) => { hp.sameFamily = v; hp.sameTouched = true }))
2132
+ kids.push(h('div', { class: 'row-actions' }, h('button', { class: 'btn btn-primary', type: 'button', disabled: BOOT.readOnly || hp.previewing, 'aria-busy': hp.previewing ? 'true' : null, onclick: hpPreview }, hp.previewing ? spinner() : null, t('hp.turnOn'))))
2133
+ } else kids.push(hpPreviewCard())
2134
+ }
2135
+ } else {
2136
+ kids.push(h('p', null, h('span', { class: 'chip chip-ok' }, t('ah.on')), ' ', t('hp.onNote', { p: st.settings.threshold }), hpWaitRule(st)))
2137
+ const d = hp.data
2138
+ if (d) {
2139
+ kids.push(h('h3', { class: 'small' }, t('hp.takeover')), hpRows(d))
2140
+ kids.push(h('h3', { class: 'small' }, t('hp.recent')), hpRecent(d))
2141
+ }
2142
+ const plan = hp.plan
2143
+ if (plan && (plan.blocked || plan.steps.length)) {
2144
+ kids.push(h('h3', { class: 'small' }, t('hp.missing')))
2145
+ if (!hp.preview) {
2146
+ if (plan.blocked) kids.push(h('p', { class: 'note note-fail' }, t('hp.blocked', { why: plan.blocked })), codeCopy('npm i -g sideby'))
2147
+ else {
2148
+ kids.push(hpSteps(plan))
2149
+ kids.push(h('div', { class: 'row-actions' }, h('button', { class: 'btn btn-primary', type: 'button', disabled: BOOT.readOnly || hp.previewing, onclick: hpPreview }, hp.previewing ? spinner() : null, t('hp.complete'))))
2150
+ }
2151
+ } else kids.push(hpPreviewCard())
2152
+ } else if (plan) kids.push(...hpManual(plan))
2153
+ kids.push(h('p', { class: 'muted small' }, t('hp.onlySideby')))
2154
+ kids.push(hpSameFamilyBox(st.settings.sameFamily, (v) => {
2155
+ if (v) { hp.askSame = true; renderHandoffPage() } else { hp.askSame = false; hpChange({ sameFamily: false }, t('hp.sameOff')) }
2156
+ }))
2157
+ if (hp.askSame && !st.settings.sameFamily) kids.push(h('div', { class: 'ah-fam' },
2158
+ h('p', { class: 'note note-warn' }, t('ah.sameFamilyWarn')),
2159
+ h('div', { class: 'row-actions' },
2160
+ h('button', { class: 'btn', type: 'button', disabled: hp.busyOff, onclick: () => { hp.askSame = false; renderHandoffPage() } }, t('btn.cancel')),
2161
+ h('button', { class: 'btn btn-primary', type: 'button', disabled: BOOT.readOnly || hp.busyOff, onclick: () => { hp.askSame = false; hpChange({ sameFamily: true }, t('hp.sameOn')) } }, t('hp.sameConfirm')))))
2162
+ kids.push(h('div', { class: 'row-actions' }, h('button', { class: 'btn', type: 'button', disabled: BOOT.readOnly || hp.busyOff, onclick: () => hpChange({ auto: false }, t('toast.ahOff')) }, hp.busyOff ? spinner() : null, t('hp.turnOff'))))
2163
+ }
2164
+ kids.push(hpFailed())
2165
+ setKids(box, kids)
2166
+ }
2167
+
1967
2168
  // ---------- banners ----------
1968
2169
  function renderBanners() {
1969
2170
  const out = []
@@ -1976,7 +2177,7 @@ const MAIN = String.raw `
1976
2177
  if (hint) out.push(h('div', { class: 'banner', role: 'status' },
1977
2178
  h('div', null, t('ah.hint', { ref: hint.ref, p: hint.pressure })),
1978
2179
  h('div', { class: 'banner-actions' },
1979
- h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: openHandoff }, t('ah.setUp')),
2180
+ h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: () => setPage('handoff') }, t('ah.setUp')),
1980
2181
  h('button', { class: 'btn btn-sm', type: 'button', onclick: () => { prefs.ahHintAt = Date.now(); savePrefs(); renderBanners() } }, t('ah.dismiss')))))
1981
2182
  const errs = S.state.pluginErrors
1982
2183
  if (errs.length) out.push(h('div', { class: 'banner banner-warn', role: 'status' }, h('div', null,
@@ -2061,7 +2262,7 @@ const MAIN = String.raw `
2061
2262
  // ---------- start ----------
2062
2263
  $('ver').textContent = BOOT.version ? 'v' + BOOT.version : ''
2063
2264
  $('refresh').addEventListener('click', () => load({ check: true, announce: true }))
2064
- $('handoff').addEventListener('click', openHandoff)
2265
+ $('handoff').addEventListener('click', () => setPage('handoff'))
2065
2266
  $('lang').addEventListener('click', () => {
2066
2267
  if (LANG_FIXED) return
2067
2268
  lang = lang === 'zh' ? 'en' : 'zh'
@@ -167,6 +167,9 @@ code, .mono { font-family: var(--mono); font-size: 0.92em; }
167
167
  .ident-name { display: flex; align-items: center; gap: 6px; min-width: 0; flex-wrap: wrap; row-gap: 3px; }
168
168
  .ident-name .name { font-weight: var(--strong-weight); font-size: var(--font-size); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
169
169
  .ident-email { color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
170
+ .email-btn { all: unset; cursor: pointer; border-radius: 4px; }
171
+ .email-btn:hover { color: var(--text); text-decoration: underline dotted; }
172
+ .email-btn:focus-visible { outline: var(--focus-outline); outline-offset: 2px; }
170
173
  .ident-email + .ident-sub { color: var(--faint); margin-top: 0; }
171
174
  .tools .tb-icon { width: var(--ctl-h); height: var(--ctl-h); border-color: var(--border-strong); background: var(--surface); }
172
175
  .tools .tb-icon:hover { background: var(--hover); }
@@ -335,6 +338,15 @@ a.btn:hover { text-decoration: none; }
335
338
  .banner-warn { background: var(--warn-soft); border-color: transparent; color: var(--warn); }
336
339
  .banner ul { margin: 6px 0 0; padding-left: 18px; }
337
340
  .banner-actions { display: flex; gap: 8px; flex: none; }
341
+ .hp-how { margin: 0; padding-left: 20px; display: flex; flex-direction: column; gap: 4px; }
342
+ .hp-steps { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
343
+ .hp-steps li { display: flex; gap: 8px; }
344
+ .hp-steps li::before { content: '•'; color: var(--muted); }
345
+ .hp-steps li > div { flex: 1; min-width: 0; }
346
+ .hp-rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
347
+ .hp-rows li { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 14px; padding: 8px 0; border-bottom: 1px solid var(--border); }
348
+ .hp-rows li:last-child { border-bottom: 0; }
349
+ #handoff-page > * + * { margin-top: 12px; }
338
350
  .ah-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; align-items: end; }
339
351
  .tune-model { border: 1px solid var(--border); border-radius: calc(var(--radius) - 4px); padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }
340
352
  .tune-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
@@ -76,6 +76,7 @@ export function renderPage(opts) {
76
76
  <div id="banners"></div>
77
77
  <nav id="pages" class="pages" role="tablist" hidden></nav>
78
78
  <section id="summary" class="summary" aria-busy="true">${SKELETON_STAT.repeat(4)}</section>
79
+ <section id="handoff-page" class="block" aria-labelledby="h-handoff" hidden></section>
79
80
  <section id="tuning-page" class="block" aria-labelledby="h-tuning" hidden></section>
80
81
  <section id="accounts-page" class="block" aria-labelledby="h-accounts">
81
82
  <div class="section-head acct-head"><h2 id="h-accounts"><span data-i18n="acct.title">Accounts</span><span id="acct-count" class="muted small"></span></h2></div>
@@ -4,7 +4,7 @@
4
4
  // Nothing about the cache may change what the original command receives, prints or returns.
5
5
  import { spawn } from 'node:child_process';
6
6
  import { constants } from 'node:os';
7
- import { accountNameFromEnv, cacheStateDir, parseRateLimits, writeQuotaCache, } from '../families/claude/quota-cache.js';
7
+ import { accountNameFromEnv, cacheStateDir, mergeQuotaCache, parseRateLimits, readQuotaCache, writeQuotaCache, } from '../families/claude/quota-cache.js';
8
8
  /** Command the wrapper starts with; `quota setup claude` detects an existing wrapper by this prefix. */
9
9
  export const TAP_COMMAND = 'sideby statusline-tap';
10
10
  export const ORIG_FLAG = '--orig-b64';
@@ -88,7 +88,18 @@ export async function runStatuslineTap(argv, env, stdin, stdout, stderr = proces
88
88
  const name = accountNameFromEnv(env);
89
89
  if (!cache || !name)
90
90
  return;
91
- await writeQuotaCache(cacheStateDir(env), name, cache);
91
+ // Several sessions of one Account share this cache; an idle one must not put back older numbers.
92
+ const stateDir = cacheStateDir(env);
93
+ const prev = await readQuotaCache(stateDir, name).catch(() => null);
94
+ const merged = prev?.status === 'ok' ? mergeQuotaCache(prev.windows, cache) : cache;
95
+ if (!merged) {
96
+ // Older numbers: write nothing, and show what the cache already knows.
97
+ if (prev?.status === 'ok')
98
+ cache = { ...cache, observedAt: prev.observedAt, windows: prev.windows };
99
+ return;
100
+ }
101
+ cache = merged;
102
+ await writeQuotaCache(stateDir, name, merged);
92
103
  }
93
104
  catch {
94
105
  // Caching is best effort.
@@ -218,9 +218,12 @@ export interface Runtime {
218
218
  handoffSettings(): HandoffSettings;
219
219
  /**
220
220
  * Where an Auto Handoff from `origin` would go now (spec §3.17 "选号"): every Account of every Family is a
221
- * candidate; `used` lists the chain's Accounts so far.
221
+ * candidate; `used` lists the chain's Accounts so far. `ahead` decides early what happens once the origin's fullest
222
+ * window reaches the threshold, as plan.json and the Panel need.
222
223
  */
223
- handoffDecision(origin: Account, used: readonly string[]): Promise<HandoffDecision>;
224
+ handoffDecision(origin: Account, used: readonly string[], opts?: {
225
+ ahead?: boolean;
226
+ }): Promise<HandoffDecision>;
224
227
  readContext(): ReadContext;
225
228
  }
226
229
  export declare class UnknownFamilyError extends UserError {
@@ -561,7 +561,7 @@ export async function createRuntime(opts = {}) {
561
561
  return planHandoff(f.id, candidates, readContext().now, o);
562
562
  },
563
563
  handoffSettings: () => handoffSettings(config),
564
- async handoffDecision(origin, used) {
564
+ async handoffDecision(origin, used, opts = {}) {
565
565
  const candidates = await Promise.all((await accounts()).map(async (a) => {
566
566
  const f = familyOf(a.family);
567
567
  const [installed, login, quota, identity] = await Promise.all([
@@ -601,6 +601,7 @@ export async function createRuntime(opts = {}) {
601
601
  aliases: aliasRefs(config.aliases),
602
602
  used,
603
603
  now: now(),
604
+ ...(opts.ahead ? { ahead: true } : {}),
604
605
  });
605
606
  },
606
607
  readContext,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sideby",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Run work, personal and API-key accounts at the same time, without their configs drifting apart.",
5
5
  "license": "MIT",
6
6
  "author": "Adonis0123 (https://github.com/Adonis0123)",
@@ -2,7 +2,7 @@
2
2
  name: sideby
3
3
  description: Use when the user runs, or wants to run, several AI coding accounts (Claude Code, Codex, Grok Build, pi) side by side. Covers installing sideby and setting up a new account ("install sideby", "set up another Claude account", "add cc008", "配置一个 cc00x", "再加一个 Codex 账号"), explaining what accounts share and what stays separate ("what do my accounts share?", "这些账号共享了什么"), showing which account a session is ("show [cc001] in the status line", "加上账号标识"), seeing accounts, quota and usage, moving on when one hits its 5-hour or weekly limit, adding a short command (alias), and repairing drifted account directories (skills, hooks, rules or settings not shared, wrong symlinks, credential file permissions). Drives the `sideby` CLI with `--json`; the agent does every step except browser sign-in, filling in secrets and starting interactive sessions, which it hands to the user as one command.
4
4
  metadata:
5
- version: "0.4.2"
5
+ version: "0.5.0"
6
6
  requires:
7
7
  bins: [sideby, node]
8
8
  cliHelp: sideby --help
@@ -39,7 +39,7 @@ Use this when the user says an account hit its 5-hour or weekly limit ("usage li
39
39
 
40
40
  With config `"handoff": { "auto": true }`, a session sideby started hands its task to the next account when quota runs low; see the guide's Auto Handoff section for the settings. If you are in such a session (the environment has `SIDEBY_HANDOFF_RUN`):
41
41
 
42
- - When sideby tells you the quota is at 80%, write the handoff brief at the path it names and keep it current: the goal, what is done, the remaining steps, key decisions and why, the files that matter. Reference specs, ADRs, commits and diffs by path; list the skills the next agent should use; never include keys, passwords or personal data.
42
+ - When sideby tells you the quota is at 80%, write the handoff brief at the path it names and keep it current (do not tell the user that another account will take over; sideby decides that and says so itself): the goal, what is done, the remaining steps, key decisions and why, the files that matter. Reference specs, ADRs, commits and diffs by path; list the skills the next agent should use; never include keys, passwords or personal data.
43
43
  - When it says the quota is at 95%, finish the current step, update the brief, and end your turn. sideby starts the next account from it.
44
44
  - When the user asks to hand over now, update the brief, run `sideby handoff ready` (add `--brief <file>` when the user's own handoff tool wrote one), and end your turn.
45
45
 
@@ -51,7 +51,7 @@ With config `"handoff": { "auto": true }`, a session sideby started hands its ta
51
51
  4. Work through `nextSteps` in order. `sideby quota setup claude` and `sideby handoff setup grok` also exit 10 first: ask before adding `--yes`. `npm i -g sideby` installs or upgrades sideby on PATH; ask first. Codex needs the user to trust sideby's hooks once per account (`/hooks` in Codex); you cannot do that for them.
52
52
  5. Run `sideby handoff status --json` again and report which families are `ready`.
53
53
 
54
- To turn it off: `sideby handoff disable --json`, then `--yes` after the user agrees; the other settings stay. The panel has the same settings under **Auto Handoff** in its top bar.
54
+ To turn it off: `sideby handoff disable --json`, then `--yes` after the user agrees; the other settings stay. The panel's **Auto Handoff** page does all of this in one click (turn on, with one confirmation; turn off) and shows where each account's work goes next; for a user who is not in a terminal, point them there instead of walking through the commands.
55
55
 
56
56
  ## Picking an account for a subtask
57
57