sideby 0.3.0 → 0.4.1

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 +332 -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 +114 -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 +63 -0
  33. package/dist/src/core/tuning-apply.js +264 -0
  34. package/dist/src/core/tuning.d.ts +113 -0
  35. package/dist/src/core/tuning.js +192 -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 +203 -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 +98 -0
  87. package/dist/src/panel/i18n.js +196 -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 +461 -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 +212 -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 +126 -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,166 @@
1
+ // Entry for `sideby handoff-hook <family> <event>` (spec §3.17). A Host runs it on PostToolUse, Stop and
2
+ // StopFailure, so it imports only Node built-ins and leaf modules: never the Runtime, a Family index or
3
+ // core/accounts.ts. It never fails the Host: any problem means no output and exit 0.
4
+ import { rm, stat } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+ import { FILES, HANDOFF_ENV, readJsonFile, writeJsonFile, } from '../core/handoff-chain.js';
7
+ import { claudeHookReader } from '../families/claude/handoff-hook.js';
8
+ import { codexHookReader } from '../families/codex/handoff-hook.js';
9
+ import { grokHookReader } from '../families/grok/handoff-hook.js';
10
+ import { endTurnMessage, prepareMessage, stopMessage, thresholdMessage } from './messages.js';
11
+ import { field } from './reader.js';
12
+ export const HOOK_COMMAND = 'sideby handoff-hook';
13
+ const READERS = {
14
+ claude: claudeHookReader,
15
+ codex: codexHookReader,
16
+ grok: grokHookReader,
17
+ };
18
+ /** Families whose hooks this entry understands; only they can start a Handoff. */
19
+ export const HOOK_FAMILIES = Object.keys(READERS);
20
+ const EVENTS = new Set(['PostToolUse', 'Stop', 'StopFailure']);
21
+ async function readAll(stream) {
22
+ const chunks = [];
23
+ try {
24
+ for await (const c of stream)
25
+ chunks.push(typeof c === 'string' ? Buffer.from(c) : Buffer.from(c));
26
+ }
27
+ catch {
28
+ // keep what arrived
29
+ }
30
+ return Buffer.concat(chunks).toString('utf8');
31
+ }
32
+ const contextOutput = (text) => ({
33
+ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: text },
34
+ });
35
+ const blockOutput = (reason) => ({ decision: 'block', reason });
36
+ async function mtimeMs(path) {
37
+ try {
38
+ return (await stat(path)).mtimeMs;
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ /** What one hook call decided: output for the Host, and files to write. Separate from I/O for the tests. */
45
+ export async function decide(opts) {
46
+ const { family, event, input, env, now } = opts;
47
+ const runId = env[HANDOFF_ENV.run];
48
+ const dir = env[HANDOFF_ENV.dir];
49
+ const reader = READERS[family];
50
+ // Not a run sideby started for a Handoff, or another Family's hook (Grok also runs Claude's settings hooks).
51
+ if (!runId || !dir || env[HANDOFF_ENV.family] !== family || !reader)
52
+ return {};
53
+ const plan = await readJsonFile(join(dir, FILES.plan));
54
+ const usable = plan && plan.runId === runId ? plan : null;
55
+ const runFile = join(dir, FILES.run(runId));
56
+ const state = (await readJsonFile(runFile)) ?? {};
57
+ const sessionId = field(input, 'session_id', 'sessionId');
58
+ const transcriptPath = field(input, 'transcript_path', 'transcriptPath');
59
+ const session = {
60
+ ...(sessionId ? { sessionId } : {}),
61
+ ...(transcriptPath ? { transcriptPath } : {}),
62
+ };
63
+ const save = (next) => writeJsonFile(runFile, { ...next, ...session });
64
+ const readyFile = join(dir, FILES.ready);
65
+ const ready = async (r) => {
66
+ if (await readJsonFile(readyFile))
67
+ return;
68
+ await writeJsonFile(readyFile, { runId, at: now.toISOString(), ...session, ...r });
69
+ };
70
+ const threshold = usable?.threshold ?? 95;
71
+ const pressure = reader.pressure ? await reader.pressure(input, env, now) : null;
72
+ if (event === 'StopFailure') {
73
+ if (reader.limitHit?.(input, pressure, threshold))
74
+ await ready({ trigger: 'limit' });
75
+ return {};
76
+ }
77
+ if (event === 'Stop') {
78
+ // Grok also fires Stop when the session ends; only a finished turn counts.
79
+ const reason = field(input, 'reason');
80
+ if (reason && reason !== 'end_turn')
81
+ return {};
82
+ const request = await readJsonFile(join(dir, FILES.request));
83
+ if (request && request.runId === runId) {
84
+ await ready({ ...request, trigger: 'manual' });
85
+ await rm(join(dir, FILES.request), { force: true });
86
+ return {};
87
+ }
88
+ if (!usable)
89
+ return {};
90
+ if (state.thresholdSentAt && usable.agentBrief === false) {
91
+ await ready({ trigger: 'threshold' });
92
+ return {};
93
+ }
94
+ if (state.thresholdSentAt) {
95
+ const written = await mtimeMs(usable.brief);
96
+ if (written !== null && written > Date.parse(state.thresholdSentAt))
97
+ await ready({ trigger: 'threshold', brief: usable.brief, briefSource: 'agent' });
98
+ else if (!state.stopBlocked && input.stop_hook_active !== true && input.stopHookActive !== true) {
99
+ await save({ ...state, stopBlocked: true });
100
+ return { output: blockOutput(stopMessage(usable.brief)) };
101
+ }
102
+ else
103
+ await ready({ trigger: 'threshold' });
104
+ return {};
105
+ }
106
+ // A turn without tool calls: the threshold is first seen here.
107
+ if (pressure !== null && pressure >= threshold && usable.decision === 'pick') {
108
+ if (usable.agentBrief === false) {
109
+ await ready({ trigger: 'threshold' });
110
+ return {};
111
+ }
112
+ await save({ ...state, thresholdSentAt: now.toISOString(), stopBlocked: true });
113
+ return { output: blockOutput(thresholdMessage(usable.brief, pressure)) };
114
+ }
115
+ return {};
116
+ }
117
+ // PostToolUse
118
+ if (!usable || pressure === null)
119
+ return {};
120
+ if (pressure >= usable.threshold && usable.decision === 'pick' && !state.thresholdSentAt) {
121
+ await save({ ...state, thresholdSentAt: now.toISOString() });
122
+ const text = usable.agentBrief === false ? endTurnMessage(pressure) : thresholdMessage(usable.brief, pressure);
123
+ return { output: contextOutput(text) };
124
+ }
125
+ if (usable.agentBrief === false)
126
+ return {};
127
+ if (pressure >= usable.prepareAt && !state.prepareSentAt && !state.thresholdSentAt) {
128
+ await save({ ...state, prepareSentAt: now.toISOString() });
129
+ return { output: contextOutput(prepareMessage(usable.brief, pressure)) };
130
+ }
131
+ return {};
132
+ }
133
+ export async function runHandoffHook(argv, env, stdin, stdout) {
134
+ // `sideby run` asks this first, so an older sideby on PATH without the hook entry is caught before any hook runs.
135
+ if (argv[0] === '--check') {
136
+ stdout.write('ok\n');
137
+ return 0;
138
+ }
139
+ try {
140
+ const [family, event] = argv;
141
+ if (!family || !event || !EVENTS.has(event) || !env[HANDOFF_ENV.run])
142
+ return 0;
143
+ let input;
144
+ try {
145
+ input = JSON.parse(await readAll(stdin));
146
+ }
147
+ catch {
148
+ return 0;
149
+ }
150
+ if (input === null || typeof input !== 'object' || Array.isArray(input))
151
+ return 0;
152
+ const r = await decide({
153
+ family,
154
+ event: event,
155
+ input: input,
156
+ env,
157
+ now: new Date(),
158
+ });
159
+ if (r.output)
160
+ stdout.write(`${JSON.stringify(r.output)}\n`);
161
+ }
162
+ catch {
163
+ // never fail the Host
164
+ }
165
+ return 0;
166
+ }
@@ -0,0 +1,7 @@
1
+ export declare function prepareMessage(brief: string, pressure: number): string;
2
+ export declare function thresholdMessage(brief: string, pressure: number): string;
3
+ /** At the threshold when the session cannot write a Brief: sideby puts one together from its records. */
4
+ export declare function endTurnMessage(pressure: number): string;
5
+ export declare function stopMessage(brief: string): string;
6
+ /** The next session's first prompt: the Brief's path only, never its text (it would show in a process list). */
7
+ export declare function firstPrompt(fromRef: string, brief: string): string;
@@ -0,0 +1,27 @@
1
+ // Text the handoff hook puts in front of the model, and the first prompt of the next session (spec §3.17).
2
+ // English, since Hosts and models read it; complete in itself, so it works without any skill installed.
3
+ const TEMPLATE = [
4
+ 'the goal of the task',
5
+ 'what is done',
6
+ 'the remaining steps',
7
+ 'key decisions and why',
8
+ 'the files that matter',
9
+ ].join('; ');
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
+ 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.`;
13
+ }
14
+ export function thresholdMessage(brief, pressure) {
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.`;
16
+ }
17
+ /** At the threshold when the session cannot write a Brief: sideby puts one together from its records. */
18
+ export function endTurnMessage(pressure) {
19
+ return `sideby: this account's quota is at ${Math.round(pressure)}%. Finish the current step and end your turn; sideby will start the next account and tell it what you did.`;
20
+ }
21
+ export function stopMessage(brief) {
22
+ return `sideby: before you stop, update the handoff brief at ${brief} (${TEMPLATE}; ${RULES}), then end your turn.`;
23
+ }
24
+ /** The next session's first prompt: the Brief's path only, never its text (it would show in a process list). */
25
+ export function firstPrompt(fromRef, brief) {
26
+ return `This task was handed over from ${fromRef} by sideby because its quota ran low. Read ${brief} first, then continue the task it describes.`;
27
+ }
@@ -0,0 +1,11 @@
1
+ import type { Env } from '../types.ts';
2
+ /** The hook's stdin, parsed: field names differ per Host (snake_case for Claude and Codex, camelCase for Grok). */
3
+ export type HookInput = Record<string, unknown>;
4
+ export interface HookReader {
5
+ /** Quota Pressure in percent now, or null when unknown. Families without a Quota source leave it out. */
6
+ pressure?(input: HookInput, env: Env, now: Date): Promise<number | null>;
7
+ /** Whether a `StopFailure` means the Quota is used up, not a transient rate limit or an overloaded server. */
8
+ limitHit?(input: HookInput, pressure: number | null, threshold: number): boolean;
9
+ }
10
+ /** The first string field among `names`, so one reader accepts both spellings. */
11
+ export declare function field(input: HookInput, ...names: string[]): string | undefined;
@@ -0,0 +1,9 @@
1
+ /** The first string field among `names`, so one reader accepts both spellings. */
2
+ export function field(input, ...names) {
3
+ for (const n of names) {
4
+ const v = input[n];
5
+ if (typeof v === 'string')
6
+ return v;
7
+ }
8
+ return undefined;
9
+ }
@@ -1,4 +1,5 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { type HandoffSettings } from '../core/config.ts';
2
3
  import type { AccountStatus, DoctorHistory, FamilyInfo, Runtime } from '../runtime.ts';
3
4
  import type { Account, FamilyLogo, QuotaResult, QuotaSetupPlan, UsageResult } from '../types.ts';
4
5
  import { type PanelTheme } from './theme.ts';
@@ -85,6 +86,11 @@ export interface PanelState {
85
86
  * at least two subscription Accounts, some Quota data and a pick; API Accounts are left out as in the CLI default.
86
87
  */
87
88
  handoff?: Record<string, string>;
89
+ /** Auto Handoff settings (spec §3.17) and the Families that can start one; readiness comes from /api/handoff/status. */
90
+ autoHandoff: {
91
+ settings: HandoffSettings;
92
+ starters: string[];
93
+ };
88
94
  }
89
95
  /** The `handoff` map of the Panel state, from the statuses and Quota already read for the page. */
90
96
  export declare function handoffByFamily(rt: Runtime, accounts: readonly PanelAccount[], now: Date): Record<string, string>;
@@ -4,7 +4,11 @@ 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 { configureHandoff, handoffStatus } from '../core/handoff-status.js';
8
+ import { tildify } from '../core/paths.js';
9
+ import { MIN_CALLS } from '../core/tuning.js';
7
10
  import { buildId, packageVersion } from '../core/version.js';
11
+ import { HOOK_FAMILIES } from '../handoff/hook.js';
8
12
  import { renderPage } from './page.js';
9
13
  import { resolveTheme } from './theme.js';
10
14
  export const PANEL_VERSION = packageVersion();
@@ -212,8 +216,48 @@ async function buildState(rt, opts) {
212
216
  ...(history ? { history } : {}),
213
217
  ...(rt.config.aliases ? { configAliases: aliasRefs(rt.config.aliases) } : {}),
214
218
  ...(Object.keys(handoff).length ? { handoff } : {}),
219
+ autoHandoff: {
220
+ settings: rt.handoffSettings(),
221
+ starters: [...rt.families.values()]
222
+ .filter((f) => f.handoff?.starts.length && HOOK_FAMILIES.includes(f.id))
223
+ .map((f) => f.id),
224
+ },
215
225
  };
216
226
  }
227
+ /** The `change` of a /api/handoff/configure body, with every field checked; unknown fields are refused. */
228
+ function handoffChange(body) {
229
+ const c = body.change;
230
+ if (!c || typeof c !== 'object' || Array.isArray(c))
231
+ throw new HttpError(400, '"change" must be an object');
232
+ const out = {};
233
+ for (const [k, v] of Object.entries(c)) {
234
+ if (k === 'auto' || k === 'sameFamily') {
235
+ if (typeof v !== 'boolean')
236
+ throw new HttpError(400, `"${k}" must be true or false`);
237
+ out[k] = v;
238
+ }
239
+ else if (k === 'prepareAt' ||
240
+ k === 'threshold' ||
241
+ k === 'waitIfResetWithinMinutes' ||
242
+ k === 'countdownSeconds') {
243
+ if (!Number.isInteger(v) || v < 0)
244
+ throw new HttpError(400, `"${k}" must be a whole number`);
245
+ out[k] = v;
246
+ }
247
+ else if (k === 'orders') {
248
+ if (!Array.isArray(v) ||
249
+ !v.every((o) => o &&
250
+ typeof o.family === 'string' &&
251
+ Array.isArray(o.order) &&
252
+ o.order.every((n) => typeof n === 'string')))
253
+ throw new HttpError(400, '"orders" must be a list of { family, order: [account] }');
254
+ out.orders = v.map((o) => ({ family: o.family, order: [...o.order] }));
255
+ }
256
+ else
257
+ throw new HttpError(400, `unknown setting "${k}"`);
258
+ }
259
+ return out;
260
+ }
217
261
  /** Runs write requests one at a time; a few may wait, the rest are told to retry. */
218
262
  function writeQueue() {
219
263
  let tail = Promise.resolve();
@@ -283,6 +327,72 @@ export function createPanelHandler(opts) {
283
327
  return { family, summary: setup.summary, plan };
284
328
  },
285
329
  },
330
+ // Context tuning (spec §3.18): replays recent local sessions; reads only, so a read-only Panel allows it.
331
+ // The setting lives in a settings file that Accounts often share, so the answer comes grouped by file: one
332
+ // suggestion and one thing to apply per group, with every Account's details alongside.
333
+ '/api/tuning': {
334
+ writes: () => false,
335
+ async run(body) {
336
+ const account = optString(body, 'account');
337
+ const rt = await getRuntime();
338
+ const home = rt.paths.home;
339
+ const { accounts, groups } = await rt.tuneGroups(account ? await rt.resolve(account) : undefined);
340
+ return {
341
+ minCalls: MIN_CALLS,
342
+ accounts,
343
+ groups: groups.map((g) => ({ ...g, id: g.file, file: tildify(g.file, home) })),
344
+ };
345
+ },
346
+ },
347
+ // Using a suggestion writes one key of the Account's settings (ADR-0012): without `confirm` it only shows the plan.
348
+ '/api/tuning/apply': {
349
+ writes: (body) => body.confirm === true,
350
+ async run(body) {
351
+ const account = optString(body, 'account');
352
+ if (!account)
353
+ throw new HttpError(400, '"account" is required');
354
+ const value = body.value;
355
+ if (typeof value !== 'number' && value !== null)
356
+ throw new HttpError(400, '"value" must be a number of tokens, or null for the default');
357
+ const rt = await getRuntime();
358
+ const found = await rt.resolve(account);
359
+ const home = rt.paths.home;
360
+ const show = (p) => ({ ...p, file: tildify(p.file, home) });
361
+ if (!optBool(body, 'confirm')) {
362
+ const plan = await rt.tuneApplyPlan(found, value);
363
+ return { ref: found.ref, applied: false, plan: show(plan), message: plan.reason ?? '' };
364
+ }
365
+ const r = await rt.tuneApply(found, value);
366
+ return { ref: found.ref, applied: r.ok, plan: show(r.plan), message: r.message };
367
+ },
368
+ },
369
+ '/api/tuning/undo': {
370
+ async run(body) {
371
+ const account = optString(body, 'account');
372
+ if (!account)
373
+ throw new HttpError(400, '"account" is required');
374
+ const rt = await getRuntime();
375
+ const found = await rt.resolve(account);
376
+ const r = await rt.tuneUndo(found);
377
+ return { ref: found.ref, undone: r.ok, message: r.message };
378
+ },
379
+ },
380
+ // Auto Handoff (spec §3.17): readiness reads only; configure shows the config change unless `confirm`.
381
+ '/api/handoff/status': {
382
+ writes: () => false,
383
+ async run() {
384
+ return handoffStatus(await getRuntime());
385
+ },
386
+ },
387
+ '/api/handoff/configure': {
388
+ writes: (body) => body.confirm === true,
389
+ async run(body) {
390
+ const change = handoffChange(body);
391
+ const rt = await getRuntime();
392
+ const r = await configureHandoff(rt, change, { apply: optBool(body, 'confirm') });
393
+ return { ...r, file: tildify(r.file, rt.paths.home) };
394
+ },
395
+ },
286
396
  // Undo for the setup above; the Runtime turns a teardown that throws into `{ ok: false, message }`.
287
397
  '/api/quota/teardown': {
288
398
  async run(body) {
@@ -87,6 +87,12 @@ declare const en: {
87
87
  'need.quota-high.other': string;
88
88
  'need.health-warn.one': string;
89
89
  'need.health-warn.other': string;
90
+ 'why.error': string;
91
+ 'why.health-fail': string;
92
+ 'why.signed-out': string;
93
+ 'why.not-installed': string;
94
+ 'why.quota-high': string;
95
+ 'why.health-warn': string;
90
96
  'tb.search': string;
91
97
  'tb.searchPh': string;
92
98
  'tb.family': string;
@@ -117,6 +123,7 @@ declare const en: {
117
123
  'group.toggle': string;
118
124
  'group.attention.one': string;
119
125
  'group.attention.other': string;
126
+ 'group.attentionShow': string;
120
127
  'acct.alias': string;
121
128
  'acct.aliasArgs': string;
122
129
  'acct.api': string;
@@ -130,6 +137,7 @@ declare const en: {
130
137
  'acct.notInstalled': string;
131
138
  'acct.readError': string;
132
139
  'acct.installHost': string;
140
+ 'acct.nearLimit': string;
133
141
  'acct.installIt': string;
134
142
  'quota.pctUsed': string;
135
143
  'quota.pctLeft': string;
@@ -224,6 +232,57 @@ declare const en: {
224
232
  'menu.copyEmail': string;
225
233
  'menu.check': string;
226
234
  'menu.like': string;
235
+ 'menu.tune': string;
236
+ 'page.accounts': string;
237
+ 'page.tuning': string;
238
+ 'tune.title': string;
239
+ 'tune.intro': string;
240
+ 'tune.loading': string;
241
+ 'tune.none': string;
242
+ 'tune.noSource': string;
243
+ 'tune.unreadable': string;
244
+ 'tune.calls': string;
245
+ 'tune.now': string;
246
+ 'tune.hostDefault': string;
247
+ 'tune.best': string;
248
+ 'tune.saved': string;
249
+ 'tune.compactions.one': string;
250
+ 'tune.compactions.other': string;
251
+ 'tune.noCompactions': string;
252
+ 'tune.groupShared': string;
253
+ 'tune.basis': string;
254
+ 'tune.outOfRange': string;
255
+ 'tune.overrides': string;
256
+ 'tune.groupNone': string;
257
+ 'tune.details': string;
258
+ 'tune.detailsObserved': string;
259
+ 'tune.use': string;
260
+ 'tune.setting': string;
261
+ 'tune.hostDefaultLong': string;
262
+ 'tune.suggestedTag': string;
263
+ 'tune.useThis': string;
264
+ 'tune.working': string;
265
+ 'tune.confirmTitle': string;
266
+ 'tune.change': string;
267
+ 'tune.file': string;
268
+ 'tune.willCreate': string;
269
+ 'tune.shared': string;
270
+ 'tune.newSessions': string;
271
+ 'tune.confirm': string;
272
+ 'tune.done': string;
273
+ 'tune.already': string;
274
+ 'tune.applied': string;
275
+ 'tune.undo': string;
276
+ 'tune.undone': string;
277
+ 'tune.reason.already-best': string;
278
+ 'tune.reason.insufficient-calls': string;
279
+ 'tune.reason.no-compactions': string;
280
+ 'tune.reason.no-optimum': string;
281
+ 'tune.reason.unknown-model': string;
282
+ 'tune.reason.observe-only': string;
283
+ 'tpage.title': string;
284
+ 'tpage.none': string;
285
+ 'tpage.idle': string;
227
286
  'nf.title': string;
228
287
  'nf.intro': string;
229
288
  'nf.tool': string;
@@ -264,6 +323,45 @@ declare const en: {
264
323
  'nf.aliasNotAdded': string;
265
324
  'nf.shellFileUpdated': string;
266
325
  'nf.shellFileSame': string;
326
+ 'ah.button': string;
327
+ 'ah.on': string;
328
+ 'ah.off': string;
329
+ 'ah.title': string;
330
+ 'ah.intro': string;
331
+ 'ah.auto': string;
332
+ 'ah.sameFamily': string;
333
+ 'ah.sameFamilyWarn': string;
334
+ 'ah.prepareAt': string;
335
+ 'ah.threshold': string;
336
+ 'ah.wait': string;
337
+ 'ah.countdown': string;
338
+ 'ah.badLevels': string;
339
+ 'ah.order': string;
340
+ 'ah.byQuota': string;
341
+ 'ah.myOrder': string;
342
+ 'ah.byQuotaHint': string;
343
+ 'ah.add': string;
344
+ 'ah.up': string;
345
+ 'ah.down': string;
346
+ 'ah.remove': string;
347
+ 'ah.emptyOrder': string;
348
+ 'ah.readiness': string;
349
+ 'ah.checking': string;
350
+ 'ah.ready': string;
351
+ 'ah.notYet': string;
352
+ 'ah.takesOver': string;
353
+ 'ah.steps': string;
354
+ 'ah.review': string;
355
+ 'ah.apply': string;
356
+ 'ah.applying': string;
357
+ 'ah.nothing': string;
358
+ 'ah.saved': string;
359
+ 'ah.changes': string;
360
+ 'ah.hint': string;
361
+ 'ah.setUp': string;
362
+ 'ah.dismiss': string;
363
+ 'toast.ahOn': string;
364
+ 'toast.ahOff': string;
267
365
  'qs.title': string;
268
366
  'qs.preparing': string;
269
367
  'qs.changes': string;