sideby 0.3.0 → 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.
Files changed (115) hide show
  1. package/README.md +27 -10
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +8 -1
  4. package/dist/src/cli/commands.js +330 -9
  5. package/dist/src/cli/format.d.ts +3 -1
  6. package/dist/src/cli/format.js +25 -0
  7. package/dist/src/cli/json-schemas.js +113 -0
  8. package/dist/src/cli/main.d.ts +5 -0
  9. package/dist/src/cli/main.js +40 -8
  10. package/dist/src/core/config.d.ts +40 -0
  11. package/dist/src/core/config.js +83 -11
  12. package/dist/src/core/file-memo.d.ts +23 -0
  13. package/dist/src/core/file-memo.js +37 -0
  14. package/dist/src/core/handoff-brief.d.ts +17 -0
  15. package/dist/src/core/handoff-brief.js +75 -0
  16. package/dist/src/core/handoff-chain.d.ts +74 -0
  17. package/dist/src/core/handoff-chain.js +74 -0
  18. package/dist/src/core/handoff-doctor.d.ts +13 -0
  19. package/dist/src/core/handoff-doctor.js +35 -0
  20. package/dist/src/core/handoff-run.d.ts +31 -0
  21. package/dist/src/core/handoff-run.js +346 -0
  22. package/dist/src/core/handoff-select.d.ts +42 -0
  23. package/dist/src/core/handoff-select.js +88 -0
  24. package/dist/src/core/handoff-status.d.ts +66 -0
  25. package/dist/src/core/handoff-status.js +198 -0
  26. package/dist/src/core/launch.d.ts +15 -0
  27. package/dist/src/core/launch.js +25 -2
  28. package/dist/src/core/memo-store.d.ts +20 -0
  29. package/dist/src/core/memo-store.js +92 -0
  30. package/dist/src/core/resume.d.ts +7 -2
  31. package/dist/src/core/resume.js +22 -9
  32. package/dist/src/core/tuning-apply.d.ts +51 -0
  33. package/dist/src/core/tuning-apply.js +237 -0
  34. package/dist/src/core/tuning.d.ts +98 -0
  35. package/dist/src/core/tuning.js +176 -0
  36. package/dist/src/families/claude/brief.d.ts +5 -0
  37. package/dist/src/families/claude/brief.js +63 -0
  38. package/dist/src/families/claude/handoff-hook.d.ts +2 -0
  39. package/dist/src/families/claude/handoff-hook.js +31 -0
  40. package/dist/src/families/claude/handoff.d.ts +5 -0
  41. package/dist/src/families/claude/handoff.js +115 -0
  42. package/dist/src/families/claude/index.js +8 -1
  43. package/dist/src/families/claude/tuning.d.ts +22 -0
  44. package/dist/src/families/claude/tuning.js +187 -0
  45. package/dist/src/families/claude/usage.d.ts +1 -0
  46. package/dist/src/families/claude/usage.js +2 -2
  47. package/dist/src/families/codex/brief.d.ts +5 -0
  48. package/dist/src/families/codex/brief.js +49 -0
  49. package/dist/src/families/codex/handoff-hook.d.ts +2 -0
  50. package/dist/src/families/codex/handoff-hook.js +16 -0
  51. package/dist/src/families/codex/handoff.d.ts +5 -0
  52. package/dist/src/families/codex/handoff.js +121 -0
  53. package/dist/src/families/codex/index.js +4 -0
  54. package/dist/src/families/codex/rate-limits.d.ts +16 -0
  55. package/dist/src/families/codex/rate-limits.js +92 -0
  56. package/dist/src/families/codex/rollout.d.ts +1 -2
  57. package/dist/src/families/codex/rollout.js +4 -48
  58. package/dist/src/families/codex/tuning.d.ts +16 -0
  59. package/dist/src/families/codex/tuning.js +145 -0
  60. package/dist/src/families/grok/brief.d.ts +5 -0
  61. package/dist/src/families/grok/brief.js +74 -0
  62. package/dist/src/families/grok/handoff-hook.d.ts +4 -0
  63. package/dist/src/families/grok/handoff-hook.js +26 -0
  64. package/dist/src/families/grok/handoff.d.ts +47 -0
  65. package/dist/src/families/grok/handoff.js +219 -0
  66. package/dist/src/families/grok/index.js +2 -0
  67. package/dist/src/families/pi/handoff.d.ts +2 -0
  68. package/dist/src/families/pi/handoff.js +13 -0
  69. package/dist/src/families/pi/index.js +2 -0
  70. package/dist/src/families/shared/brief.d.ts +24 -0
  71. package/dist/src/families/shared/brief.js +42 -0
  72. package/dist/src/families/shared/handoff-args.d.ts +18 -0
  73. package/dist/src/families/shared/handoff-args.js +57 -0
  74. package/dist/src/families/shared/sessions.d.ts +4 -0
  75. package/dist/src/families/shared/sessions.js +19 -0
  76. package/dist/src/families/shared/toml.d.ts +5 -0
  77. package/dist/src/families/shared/toml.js +30 -0
  78. package/dist/src/handoff/hook.d.ts +16 -0
  79. package/dist/src/handoff/hook.js +166 -0
  80. package/dist/src/handoff/messages.d.ts +7 -0
  81. package/dist/src/handoff/messages.js +27 -0
  82. package/dist/src/handoff/reader.d.ts +11 -0
  83. package/dist/src/handoff/reader.js +9 -0
  84. package/dist/src/panel/handler.d.ts +6 -0
  85. package/dist/src/panel/handler.js +110 -0
  86. package/dist/src/panel/i18n.d.ts +96 -0
  87. package/dist/src/panel/i18n.js +192 -0
  88. package/dist/src/panel/page-logic.d.ts +49 -0
  89. package/dist/src/panel/page-logic.js +67 -0
  90. package/dist/src/panel/page-script.js +454 -4
  91. package/dist/src/panel/page-styles.js +28 -2
  92. package/dist/src/panel/page.js +4 -1
  93. package/dist/src/quota/claude-statusline.js +4 -7
  94. package/dist/src/runtime.d.ts +40 -3
  95. package/dist/src/runtime.js +210 -41
  96. package/dist/src/types.d.ts +93 -0
  97. package/dist/testing/index.d.ts +5 -0
  98. package/dist/testing/index.js +1 -0
  99. package/package.json +16 -4
  100. package/schemas/config.json +73 -0
  101. package/schemas/families.json +5 -1
  102. package/schemas/handoff-disable.json +106 -0
  103. package/schemas/handoff-enable.json +106 -0
  104. package/schemas/handoff-ready.json +23 -0
  105. package/schemas/handoff-setup.json +60 -0
  106. package/schemas/handoff-status.json +137 -0
  107. package/schemas/handoff-teardown.json +30 -0
  108. package/schemas/list.json +5 -1
  109. package/schemas/tune-change.json +108 -0
  110. package/schemas/tune.json +243 -0
  111. package/skills/sideby/SKILL.md +5 -3
  112. package/skills/sideby/references/badge.md +1 -1
  113. package/skills/sideby/references/diagnose.md +4 -0
  114. package/skills/sideby/references/handoff.md +24 -2
  115. 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 {
@@ -15,6 +16,12 @@ export interface PreparedLaunch {
15
16
  export type HostLaunch = Pick<PreparedLaunch, 'family' | 'bin' | 'args' | 'env' | 'title'>;
16
17
  /** Finds an executable on PATH; returns null when the Host is not installed. */
17
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>;
18
25
  /**
19
26
  * Builds the Host's environment and arguments (spec §3.5):
20
27
  * copy the current env, clear Hijack Variables, merge the Secret File, set the select variable,
@@ -41,3 +48,11 @@ export declare function prepareLaunch(opts: {
41
48
  * default action so Ctrl-Z suspends sideby together with the Host and `fg` resumes both.
42
49
  */
43
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
+ };
@@ -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,
@@ -91,11 +106,18 @@ export async function prepareLaunch(opts) {
91
106
  * default action so Ctrl-Z suspends sideby together with the Host and `fg` resumes both.
92
107
  */
93
108
  export function runHost(p) {
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) {
94
116
  // OSC 0 sets the terminal's tab and window title; only on a terminal, never into a pipe.
95
117
  if (p.title && process.stdout.isTTY)
96
118
  process.stdout.write(`\x1b]0;${p.title}\x07`);
97
- return new Promise((resolve) => {
98
- const child = spawn(p.bin, p.args, { stdio: 'inherit', env: p.env });
119
+ const child = spawn(p.bin, p.args, { stdio: 'inherit', env: p.env });
120
+ const exited = new Promise((resolve) => {
99
121
  const ignore = () => { };
100
122
  const forwarded = new Set();
101
123
  const forward = (sig) => () => {
@@ -132,4 +154,5 @@ export function runHost(p) {
132
154
  resolve(code ?? 1);
133
155
  });
134
156
  });
157
+ return { child, exited };
135
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 no Account holds it. */
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
- /** Asks every Account of the Family for the session and picks the one that wrote it last. */
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>;
@@ -1,15 +1,28 @@
1
- /** Asks every Account of the Family for the session and picks the one that wrote it last. */
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 written = await Promise.all(own.map((a) => family.sessionWrittenAt(a, sessionId).catch(() => undefined)));
8
- let best;
9
- own.forEach((account, i) => {
10
- const at = written[i];
11
- if (at !== undefined && (!best || at > best.at))
12
- best = { account, at };
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
  }
@@ -0,0 +1,51 @@
1
+ import type { Account, ReadContext } from '../types.ts';
2
+ export declare const TUNE_KEY = "autoCompactWindow";
3
+ export declare const MIN_WINDOW = 10000;
4
+ export declare const MAX_WINDOW = 2000000;
5
+ export interface TuneApplyPlan {
6
+ /** `ready`: will write; `unchanged`: already that value; `blocked`: `reason` says why and what to do. */
7
+ status: 'ready' | 'unchanged' | 'blocked';
8
+ /** The real file that will be written. */
9
+ file: string;
10
+ /** The Account's `settings.json` is a link to `file`. */
11
+ link: boolean;
12
+ /** Refs of the Accounts whose `settings.json` is `file`, the planned Account first. */
13
+ affected: string[];
14
+ /** The value in the file now, or null when the key is not there. */
15
+ current: number | null;
16
+ /** The size to set, or null to go back to the default (the key is removed). */
17
+ next: number | null;
18
+ /** The file does not exist yet and will be created. */
19
+ creates: boolean;
20
+ reason?: string;
21
+ }
22
+ export interface TuneUndoRecord {
23
+ file: string;
24
+ /** The value before sideby wrote, or null when the key was not there. */
25
+ previous: number | null;
26
+ /** The value written; null when the default was restored (the key removed). */
27
+ applied: number | null;
28
+ /** sideby created the file. */
29
+ created: boolean;
30
+ at: string;
31
+ }
32
+ /** What applying `value` to the Account would do. Writes nothing. */
33
+ export declare function planTuneApply(account: Account, value: number | null, accounts: readonly Account[]): Promise<TuneApplyPlan>;
34
+ export interface TuneChange {
35
+ ok: boolean;
36
+ message: string;
37
+ plan: TuneApplyPlan;
38
+ }
39
+ /** Plans, and writes only when the plan is `ready`. */
40
+ export declare function applyTune(account: Account, value: number | null, accounts: readonly Account[], ctx: ReadContext): Promise<TuneChange>;
41
+ /** What could be undone for the Account: the record, only while the key still holds the value sideby wrote. */
42
+ export declare function tuneUndoInfo(account: Account, ctx: ReadContext): Promise<TuneUndoRecord | null>;
43
+ /** Puts the key back as it was, only while it still holds what sideby wrote. */
44
+ export declare function undoTune(account: Account, ctx: ReadContext): Promise<{
45
+ ok: boolean;
46
+ message: string;
47
+ }>;
48
+ /** The real file an Account's setting lives in (canonical, so Accounts sharing it get the same answer). */
49
+ export declare function settingsFileOf(account: Account): Promise<string>;
50
+ /** The size in the Account's settings file now, or null when there is none (the default). Reads only that key. */
51
+ export declare function currentTuneValue(account: Account): Promise<number | null>;