sideby 0.16.1 → 0.17.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 (75) hide show
  1. package/README.md +5 -3
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +16 -0
  4. package/dist/src/cli/commands.js +457 -5
  5. package/dist/src/cli/json-schemas.js +112 -0
  6. package/dist/src/cli/main.js +93 -3
  7. package/dist/src/core/ask.d.ts +150 -0
  8. package/dist/src/core/ask.js +365 -0
  9. package/dist/src/core/attachments.d.ts +50 -0
  10. package/dist/src/core/attachments.js +442 -0
  11. package/dist/src/core/autostart.d.ts +88 -0
  12. package/dist/src/core/autostart.js +256 -0
  13. package/dist/src/core/config.d.ts +97 -0
  14. package/dist/src/core/config.js +278 -10
  15. package/dist/src/core/launch.d.ts +15 -4
  16. package/dist/src/core/launch.js +27 -23
  17. package/dist/src/core/local-build.d.ts +5 -0
  18. package/dist/src/core/local-build.js +23 -3
  19. package/dist/src/core/probe.d.ts +78 -0
  20. package/dist/src/core/probe.js +127 -0
  21. package/dist/src/core/same-login.d.ts +9 -4
  22. package/dist/src/core/same-login.js +9 -4
  23. package/dist/src/core/secret-file.d.ts +26 -0
  24. package/dist/src/core/secret-file.js +126 -7
  25. package/dist/src/core/terminal.d.ts +93 -0
  26. package/dist/src/core/terminal.js +371 -0
  27. package/dist/src/core/update.d.ts +11 -0
  28. package/dist/src/core/update.js +38 -0
  29. package/dist/src/families/claude/index.js +3 -0
  30. package/dist/src/families/claude/quota-cache.d.ts +24 -7
  31. package/dist/src/families/claude/quota-cache.js +72 -21
  32. package/dist/src/families/codex/index.js +7 -0
  33. package/dist/src/families/codex/login-key.d.ts +8 -0
  34. package/dist/src/families/codex/login-key.js +33 -0
  35. package/dist/src/families/grok/index.js +3 -0
  36. package/dist/src/families/login-keys.d.ts +6 -0
  37. package/dist/src/families/login-keys.js +6 -0
  38. package/dist/src/families/pi/index.js +4 -0
  39. package/dist/src/panel/background.js +3 -2
  40. package/dist/src/panel/handler.d.ts +29 -2
  41. package/dist/src/panel/handler.js +393 -9
  42. package/dist/src/panel/i18n.d.ts +172 -2
  43. package/dist/src/panel/i18n.js +375 -35
  44. package/dist/src/panel/page-logic.d.ts +196 -0
  45. package/dist/src/panel/page-logic.js +377 -0
  46. package/dist/src/panel/page-script.js +947 -126
  47. package/dist/src/panel/page-styles.js +69 -19
  48. package/dist/src/panel/page.d.ts +5 -0
  49. package/dist/src/panel/page.js +2 -0
  50. package/dist/src/panel/server.d.ts +16 -2
  51. package/dist/src/panel/server.js +41 -13
  52. package/dist/src/plugins/check.d.ts +47 -0
  53. package/dist/src/plugins/check.js +544 -0
  54. package/dist/src/plugins/scaffold.d.ts +21 -0
  55. package/dist/src/plugins/scaffold.js +123 -0
  56. package/dist/src/quota/statusline-tap.js +1 -1
  57. package/dist/src/runtime.d.ts +10 -0
  58. package/dist/src/runtime.js +47 -16
  59. package/dist/src/types.d.ts +19 -0
  60. package/package.json +2 -2
  61. package/schemas/ask.json +180 -0
  62. package/schemas/autostart.json +165 -0
  63. package/schemas/config-get.json +132 -0
  64. package/schemas/config-set.json +107 -0
  65. package/schemas/config.json +73 -6
  66. package/schemas/key.json +93 -0
  67. package/schemas/plugins-check.json +113 -0
  68. package/schemas/plugins-new.json +87 -0
  69. package/schemas/terminal.json +152 -0
  70. package/schemas/try.json +109 -0
  71. package/schemas/update.json +4 -0
  72. package/skills/sideby/SKILL.md +12 -6
  73. package/skills/sideby/references/accounts.md +1 -1
  74. package/skills/sideby/references/ai-tasks.md +116 -0
  75. package/skills/sideby/references/panel.md +14 -0
@@ -2,9 +2,12 @@
2
2
  import { randomBytes, timingSafeEqual } from 'node:crypto';
3
3
  import { aliasesByAccount } from '../core/accounts.js';
4
4
  import { listAppProfiles, openAppProfile, removeAppProfile, } from '../core/app-profiles.js';
5
+ import { openLoginInTerminal, pickTaskAccount, previewAttachments, promptForTask, setCustomTerminal, setPreferredTerminal, startAiTask, terminals, tryAccount, } from '../core/ask.js';
6
+ import { MAX_ATTACHMENTS, MAX_UPLOAD_BYTES, removeStaged, stageUpload } from '../core/attachments.js';
7
+ import { applyAutostart, autostartStatus, planAutostart, } from '../core/autostart.js';
5
8
  import { FAVICON_SVG, ICON_SVG } from '../core/brand-mark.js';
6
9
  import { browserReport, openInBrowser, setBrowserSignIns, } from '../core/browser-profiles.js';
7
- import { aliasArgs, aliasRefs, editConfigUpdate, } from '../core/config.js';
10
+ import { aliasArgs, aliasRefs, editConfigPanel, editConfigTerminal, editConfigUpdate, } from '../core/config.js';
8
11
  import { openDesktopApp, withDesktopApps } from '../core/desktop-app.js';
9
12
  import { UserError } from '../core/errors.js';
10
13
  import { planHandoff } from '../core/handoff.js';
@@ -13,12 +16,15 @@ import { handoffOverview } from '../core/handoff-overview.js';
13
16
  import { configureHandoff, handoffStatus } from '../core/handoff-status.js';
14
17
  import { accountModel, setAccountModel } from '../core/model-choice.js';
15
18
  import { tildify } from '../core/paths.js';
19
+ import { SECRET_VAR_NAME, secretVarFor, setSecret } from '../core/secret-file.js';
16
20
  import { SKILL_INSTALL } from '../core/skill-check.js';
21
+ import { launchArgs, TERMINALS } from '../core/terminal.js';
17
22
  import { MIN_CALLS } from '../core/tuning.js';
18
23
  import * as update from '../core/update.js';
19
24
  import { buildId, localBuild, packageVersion } from '../core/version.js';
20
25
  import { HOOK_FAMILIES } from '../handoff/hook.js';
21
26
  import { renderPage } from './page.js';
27
+ import { AI_TASK_KINDS } from './page-logic.js';
22
28
  import { resolveTheme } from './theme.js';
23
29
  export const PANEL_VERSION = packageVersion();
24
30
  export const MAX_BODY_BYTES = 64 * 1024;
@@ -44,6 +50,59 @@ export function healthByAccount(history) {
44
50
  });
45
51
  return out;
46
52
  }
53
+ function keyFacts(def) {
54
+ let keyVar = null;
55
+ try {
56
+ keyVar = secretVarFor(def);
57
+ }
58
+ catch { }
59
+ return {
60
+ keyVar,
61
+ ...(def.secretFileMeansApi === false ? { keyAlways: true } : {}),
62
+ ...(def.probe ? { tryCommand: [def.bin, ...def.probe.args] } : {}),
63
+ };
64
+ }
65
+ let autostartOverride = {};
66
+ /**
67
+ * Internal, for tests: replaces parts of the machine the Panel's start-at-login switch uses (platform, launcher,
68
+ * `exec`, login home). Not part of the plugin or Portal contract.
69
+ */
70
+ export function bindPanelAutostart(ctx) {
71
+ const before = autostartOverride;
72
+ autostartOverride = { ...autostartOverride, ...ctx };
73
+ return () => {
74
+ autostartOverride = before;
75
+ };
76
+ }
77
+ const autostartCtx = (rt) => ({ home: rt.paths.home, ...autostartOverride });
78
+ /**
79
+ * The start-at-login state as the page shows it (spec §3.29): the agent's path and what it starts, with the home
80
+ * folder as `~`, and nothing else of the machine.
81
+ */
82
+ export function autostartView(s, home) {
83
+ const t = (p) => tildify(p, home);
84
+ return {
85
+ supported: s.supported,
86
+ ...(s.reason ? { reason: s.reason } : {}),
87
+ state: s.state,
88
+ path: t(s.path),
89
+ current: s.current,
90
+ ...(s.programArguments ? { programArguments: s.programArguments.map(t) } : {}),
91
+ ...(s.missing ? { missing: s.missing.map(t) } : {}),
92
+ };
93
+ }
94
+ function autostartPlanView(p, home) {
95
+ return {
96
+ action: p.action,
97
+ changed: p.changed,
98
+ path: tildify(p.path, home),
99
+ ...(p.programArguments ? { programArguments: p.programArguments.map((a) => tildify(a, home)) } : {}),
100
+ ...(p.blocked ? { blocked: p.blocked } : {}),
101
+ status: autostartView(p.status, home),
102
+ };
103
+ }
104
+ const PANEL_THEMES = ['system', 'light', 'dark'];
105
+ const PANEL_LANGUAGES = ['auto', 'en', 'zh'];
47
106
  // One Update Check at a time per process, started by a state read when one is due (the cache also keeps a failed
48
107
  // one from being tried again within the hour, though the page asks every 20 s).
49
108
  let checking = null;
@@ -312,9 +371,10 @@ async function buildState(rt, opts) {
312
371
  readOnly: opts.readOnly,
313
372
  generatedAt: ctx.now.toISOString(),
314
373
  families: families.map((f) => {
315
- const logo = rt.families.get(f.id)?.logo;
374
+ const def = rt.families.get(f.id);
375
+ const logo = def?.logo;
316
376
  const g = f.brokenBin ? { ...f, brokenBin: tildify(f.brokenBin, ctx.home) } : f;
317
- return logo ? { ...g, logo } : g;
377
+ return { ...g, ...(logo ? { logo } : {}), ...(def ? keyFacts(def) : {}) };
318
378
  }),
319
379
  accounts: list,
320
380
  pluginErrors: rt.pluginErrors.map((e) => ({ where: e.where, message: e.message })),
@@ -338,6 +398,7 @@ async function buildState(rt, opts) {
338
398
  ...(showBrowser ? { browser } : {}),
339
399
  display: rt.displayOrder(),
340
400
  ...(rt.quotaChecks().length ? { quotaChecks: rt.quotaChecks() } : {}),
401
+ ...(rt.config.panel && Object.keys(rt.config.panel).length ? { panel: rt.config.panel } : {}),
341
402
  };
342
403
  }
343
404
  /** The `display` of a /api/display-order body: null, or string lists only; anything else is refused. */
@@ -405,6 +466,102 @@ function handoffChange(body) {
405
466
  }
406
467
  return out;
407
468
  }
469
+ /** The terminals for Settings and the confirm area: installed paths stay on the server. */
470
+ async function terminalsView(rt) {
471
+ const r = await terminals(rt);
472
+ return {
473
+ installed: r.installed.map((t) => t.id),
474
+ preferred: r.preferred,
475
+ guess: r.guess,
476
+ custom: rt.config.terminal?.custom ?? null,
477
+ terminals: r.terminals.map((t) => ({ id: t.id, name: t.name, installed: t.installed })),
478
+ };
479
+ }
480
+ const AI_TASKS = AI_TASK_KINDS;
481
+ /** The `context` of an AI Task body (spec §3.28): only the strings and findings a Task Prompt shows. */
482
+ function askContext(body) {
483
+ const c = body.context;
484
+ if (c === undefined || c === null)
485
+ return undefined;
486
+ if (typeof c !== 'object' || Array.isArray(c))
487
+ throw new HttpError(400, '"context" must be an object');
488
+ const out = {};
489
+ const str = (v, what) => {
490
+ if (typeof v !== 'string')
491
+ throw new HttpError(400, `${what} must be a string`);
492
+ return v;
493
+ };
494
+ for (const [k, v] of Object.entries(c)) {
495
+ if (k === 'account' || k === 'family' || k === 'quotaReason' || k === 'version' || k === 'terminalCustom')
496
+ out[k] = str(v, `"context.${k}"`);
497
+ else if (k === 'terminals') {
498
+ if (!Array.isArray(v) || v.length > 50)
499
+ throw new HttpError(400, '"context.terminals" must be a list');
500
+ out.terminals = v.map((x) => str(x, 'each of "context.terminals"'));
501
+ }
502
+ else if (k === 'handoff') {
503
+ if (!Array.isArray(v) || v.length > 100)
504
+ throw new HttpError(400, '"context.handoff" must be a list');
505
+ out.handoff = v.map((x) => str(x, 'each of "context.handoff"'));
506
+ }
507
+ else if (k === 'findings') {
508
+ if (!Array.isArray(v) || v.length > 100)
509
+ throw new HttpError(400, '"context.findings" must be a list');
510
+ out.findings = v.map((f) => {
511
+ if (!f || typeof f !== 'object' || Array.isArray(f) || typeof f.message !== 'string')
512
+ throw new HttpError(400, 'each finding needs a "message"');
513
+ const one = {
514
+ message: f.message,
515
+ };
516
+ for (const key of ['code', 'account', 'item', 'hint'])
517
+ if (f[key] !== undefined && f[key] !== null)
518
+ one[key] = str(f[key], `"finding.${key}"`);
519
+ return one;
520
+ });
521
+ }
522
+ else
523
+ throw new HttpError(400, `unknown context "${k}"`);
524
+ }
525
+ return out;
526
+ }
527
+ /** Task, text, context, language and account of an /api/ask body; unknown tasks are refused. */
528
+ function askInput(body) {
529
+ const task = optString(body, 'task') ?? 'free';
530
+ if (!AI_TASKS.includes(task))
531
+ throw new HttpError(400, `"task" must be one of ${AI_TASKS.join(', ')}`);
532
+ const text = optString(body, 'text');
533
+ if (text && text.length > 8000)
534
+ throw new HttpError(400, '"text" is too long; keep it under 8000 characters');
535
+ const lang = optString(body, 'lang');
536
+ if (lang && lang !== 'en' && lang !== 'zh')
537
+ throw new HttpError(400, '"lang" must be "en" or "zh"');
538
+ const account = optString(body, 'account');
539
+ const terminal = optString(body, 'terminal');
540
+ if (terminal && !TERMINALS.some((t) => t.id === terminal))
541
+ throw new HttpError(400, 'unknown terminal; pick one from the list');
542
+ const context = askContext(body);
543
+ const uploads = body.attachments;
544
+ if (uploads !== undefined &&
545
+ !(Array.isArray(uploads) &&
546
+ uploads.length <= MAX_ATTACHMENTS &&
547
+ uploads.every((u) => typeof u === 'string' && /^[a-f0-9]{16}$/.test(u))))
548
+ throw new HttpError(400, `"attachments" must list at most ${MAX_ATTACHMENTS} upload ids`);
549
+ return {
550
+ ...(uploads?.length ? { uploads: uploads } : {}),
551
+ task,
552
+ ...(text ? { text } : {}),
553
+ ...(context ? { context } : {}),
554
+ ...(lang ? { lang } : {}),
555
+ ...(account ? { account } : {}),
556
+ ...(terminal ? { terminal } : {}),
557
+ };
558
+ }
559
+ /** A terminal open result as the page needs it: macOS refusing Automation gets its own flag (spec §3.28). */
560
+ function opened(r) {
561
+ return { ...r, automation: !r.opened && r.reason === 'automation-denied' };
562
+ }
563
+ /** The AI Task upload route; its body is a file, not a JSON object, so it sits outside the route table. */
564
+ const ATTACH_ROUTE = '/api/ask/attach';
408
565
  /** Runs write requests one at a time; a few may wait, the rest are told to retry. */
409
566
  function writeQueue() {
410
567
  let tail = Promise.resolve();
@@ -434,6 +591,120 @@ export function createPanelHandler(opts) {
434
591
  const serial = writeQueue();
435
592
  const getRuntime = typeof opts.runtime === 'function' ? opts.runtime : async () => opts.runtime;
436
593
  const routes = {
594
+ // AI Tasks (spec §3.28, ADR-0027). The prompt and the account it would use, read only: for the confirm area,
595
+ // the copy fallback and the read-only Panel.
596
+ // Frees a staged upload the person removed from the dialog, so it stops counting toward the staging limit.
597
+ '/api/ask/attach/remove': {
598
+ async run(body) {
599
+ const ids = body.ids;
600
+ if (!Array.isArray(ids) || ids.length > MAX_ATTACHMENTS || ids.some((x) => typeof x !== 'string'))
601
+ throw new HttpError(400, `"ids" must be a list of at most ${MAX_ATTACHMENTS} upload ids`);
602
+ const rt = await getRuntime();
603
+ return { removed: await removeStaged(rt.paths, ids) };
604
+ },
605
+ },
606
+ '/api/ask/prompt': {
607
+ writes: () => false,
608
+ queued: false,
609
+ async run(body) {
610
+ const input = askInput(body);
611
+ const rt = await getRuntime();
612
+ const { prompt, removedSecrets } = promptForTask(rt, input, await previewAttachments(rt, input));
613
+ const pick = await pickTaskAccount(rt, input);
614
+ return {
615
+ prompt,
616
+ removedSecrets,
617
+ account: 'account' in pick ? pick.account.ref : null,
618
+ ...('reason' in pick ? { reason: pick.reason } : {}),
619
+ };
620
+ },
621
+ },
622
+ // Writes the task file and opens the terminal. An account the person picked, API ones too, is kept as `ask.account`.
623
+ '/api/ask': {
624
+ async run(body) {
625
+ const input = askInput(body);
626
+ const rt = await getRuntime();
627
+ return opened(await startAiTask(rt, { ...input, remember: Boolean(input.account || input.terminal) }));
628
+ },
629
+ },
630
+ // Runs `sideby login <ref>` in the person's terminal; no AI is involved (spec §3.28 "登录").
631
+ '/api/login': {
632
+ async run(body) {
633
+ const account = optString(body, 'account');
634
+ if (!account)
635
+ throw new HttpError(400, '"account" is required');
636
+ return opened(await openLoginInTerminal(await getRuntime(), account));
637
+ },
638
+ },
639
+ // The key box: writes one variable of an API Account's proxy.env. The value is never returned or logged.
640
+ '/api/key': {
641
+ async run(body) {
642
+ const account = optString(body, 'account');
643
+ if (!account)
644
+ throw new HttpError(400, '"account" is required');
645
+ const value = body.value;
646
+ if (typeof value !== 'string' || !value.trim())
647
+ throw new HttpError(400, 'enter the key');
648
+ const name = optString(body, 'var');
649
+ if (name !== undefined && !SECRET_VAR_NAME.test(name))
650
+ throw new HttpError(400, 'use an uppercase variable name such as API_KEY');
651
+ const rt = await getRuntime();
652
+ const found = await rt.resolve(account);
653
+ const family = rt.families.get(found.family);
654
+ if (!family)
655
+ throw new HttpError(400, 'unknown family');
656
+ if (found.kind !== 'api' && family.secretFileMeansApi !== false)
657
+ throw new UserError('This is a subscription account; sign in instead, or create an API account in New account.');
658
+ const v = name ?? secretVarFor(family);
659
+ const { join } = await import('node:path');
660
+ const r = await setSecret(found.secretFile ?? join(found.dir, 'proxy.env'), v, value.trim());
661
+ return { account: found.ref, var: v, written: true, path: tildify(r.path, rt.paths.home) };
662
+ },
663
+ },
664
+ // `sideby try`: the page has said it spends a few tokens. It starts a program, so it is a write; it runs beside
665
+ // the queue because the Host may take up to a minute.
666
+ '/api/try': {
667
+ queued: false,
668
+ async run(body) {
669
+ const account = optString(body, 'account');
670
+ if (!account)
671
+ throw new HttpError(400, '"account" is required');
672
+ return tryAccount(await getRuntime(), account);
673
+ },
674
+ },
675
+ // Preferred Terminal and Custom terminal command (spec §3.28): `id` null or left out goes back to the guess;
676
+ // `custom` an empty string removes the template.
677
+ '/api/terminal': {
678
+ async run(body) {
679
+ const id = body.id === undefined ? undefined : optString(body, 'id');
680
+ if (id && !TERMINALS.some((t) => t.id === id))
681
+ throw new HttpError(400, 'unknown terminal; pick one from the list');
682
+ const rt = await getRuntime();
683
+ if (body.custom !== undefined) {
684
+ // Same rules as `sideby terminal --custom`: a saved command is also the one chosen; clearing it drops that choice.
685
+ const custom = optString(body, 'custom')?.trim();
686
+ if (custom) {
687
+ try {
688
+ await setCustomTerminal(rt, custom);
689
+ }
690
+ catch (err) {
691
+ throw new HttpError(400, err.message);
692
+ }
693
+ }
694
+ else {
695
+ const wasChosen = rt.config.terminal?.preferred === 'custom';
696
+ const r = await editConfigTerminal(rt.paths.configFile, {
697
+ custom: undefined,
698
+ ...(wasChosen ? { preferred: undefined } : {}),
699
+ });
700
+ rt.config.terminal = r.after;
701
+ }
702
+ }
703
+ if (body.id !== undefined || body.custom === undefined)
704
+ await setPreferredTerminal(rt, id);
705
+ return terminalsView(rt);
706
+ },
707
+ },
437
708
  '/api/doctor': {
438
709
  // Only the record of the check is written, and a read-only Panel skips that (persist: false).
439
710
  writes: () => false,
@@ -810,6 +1081,60 @@ export function createPanelHandler(opts) {
810
1081
  return { family, ...(await rt.setQuotaCheck(family, body.on)) };
811
1082
  },
812
1083
  },
1084
+ // The Panel's theme and language (spec §3.29): config `panel.theme` and `panel.language`; null removes one.
1085
+ '/api/panel/settings': {
1086
+ async run(body) {
1087
+ const patch = {};
1088
+ for (const [k, v] of Object.entries(body)) {
1089
+ if (k === 'theme') {
1090
+ if (v !== null && !PANEL_THEMES.some((x) => x === v))
1091
+ throw new HttpError(400, '"theme" must be "system", "light", "dark" or null');
1092
+ patch.theme = v;
1093
+ }
1094
+ else if (k === 'language') {
1095
+ if (v !== null && !PANEL_LANGUAGES.some((x) => x === v))
1096
+ throw new HttpError(400, '"language" must be "auto", "en", "zh" or null');
1097
+ patch.language = v;
1098
+ }
1099
+ else
1100
+ throw new HttpError(400, `unknown setting "${k}"`);
1101
+ }
1102
+ if (!Object.keys(patch).length)
1103
+ throw new HttpError(400, 'send "theme" or "language"');
1104
+ const rt = await getRuntime();
1105
+ const r = await editConfigPanel(rt.paths.configFile, patch);
1106
+ return { changed: r.changed, panel: r.after ?? {} };
1107
+ },
1108
+ },
1109
+ // Start at login (spec §3.29, ADR-0029). Without `confirm` the plan: the file and what it starts, nothing is
1110
+ // written. With it, a fresh plan runs only while it starts what the person saw (`programArguments`).
1111
+ '/api/autostart': {
1112
+ writes: (body) => body.confirm === true,
1113
+ async run(body) {
1114
+ if (typeof body.on !== 'boolean')
1115
+ throw new HttpError(400, '"on" must be true or false');
1116
+ const rt = await getRuntime();
1117
+ const ctx = autostartCtx(rt);
1118
+ const home = rt.paths.home;
1119
+ const plan = await planAutostart(ctx, body.on ? 'on' : 'off');
1120
+ const shown = autostartPlanView(plan, home);
1121
+ if (!optBool(body, 'confirm') || plan.blocked)
1122
+ return { plan: shown, applied: false };
1123
+ const saw = body.programArguments;
1124
+ const want = shown.programArguments ?? [];
1125
+ if (plan.action === 'on' &&
1126
+ (!Array.isArray(saw) || saw.length !== want.length || saw.some((x, i) => x !== want[i])))
1127
+ return { plan: shown, applied: false, changedSince: true };
1128
+ const r = await applyAutostart(ctx, plan);
1129
+ return {
1130
+ applied: true,
1131
+ changed: r.changed,
1132
+ loaded: r.loaded,
1133
+ ...(r.note ? { note: r.note.split(home).join('~') } : {}),
1134
+ status: autostartView(await autostartStatus(ctx), home),
1135
+ };
1136
+ },
1137
+ },
813
1138
  // Undo for the quota setup above; the Runtime turns a teardown that throws into `{ ok: false, message }`.
814
1139
  '/api/quota/teardown': {
815
1140
  async run(body) {
@@ -821,6 +1146,29 @@ export function createPanelHandler(opts) {
821
1146
  },
822
1147
  },
823
1148
  };
1149
+ // An AI Task attachment (spec §3.28 "附件"): the body is the file itself, streamed into the staging folder rather than
1150
+ // read into memory, with its own size limit. The type comes from its first bytes; `Content-Type` is not trusted.
1151
+ const receiveAttachment = async (req) => {
1152
+ const declared = Number(req.headers['content-length']);
1153
+ if (Number.isFinite(declared) && declared > MAX_UPLOAD_BYTES)
1154
+ throw new HttpError(413, 'This file is over 200 MB; attach a smaller one.');
1155
+ try {
1156
+ // Stopping early (a wrong type, too big) must not destroy the socket the answer goes out on.
1157
+ const { id, kind, bytes } = await stageUpload((await getRuntime()).paths, req.iterator({ destroyOnReturn: false }));
1158
+ return { id, kind, bytes };
1159
+ }
1160
+ catch (err) {
1161
+ const code = err.code;
1162
+ if (code === 'attachment-too-large')
1163
+ throw new HttpError(413, err.message);
1164
+ if (code === 'attachment-type')
1165
+ throw new HttpError(415, err.message);
1166
+ // The staging folder is full: the server's own limit, whatever the page allows.
1167
+ if (code === 'attachment-quota')
1168
+ throw new HttpError(507, err.message, code);
1169
+ throw err;
1170
+ }
1171
+ };
824
1172
  const handle = async (req, res) => {
825
1173
  let pathname;
826
1174
  try {
@@ -840,6 +1188,11 @@ export function createPanelHandler(opts) {
840
1188
  if (method === 'GET' || method === 'HEAD') {
841
1189
  if (sub === '/' || sub === '/index.html') {
842
1190
  const nonce = randomBytes(16).toString('base64');
1191
+ // The theme and language in config (spec §3.29), so the first paint has them; a config that cannot be
1192
+ // read leaves the defaults, and /api/state reports the problem.
1193
+ const panel = method === 'HEAD'
1194
+ ? undefined
1195
+ : await getRuntime().then((rt) => rt.config.panel, () => undefined);
843
1196
  res.writeHead(200, {
844
1197
  'content-type': 'text/html; charset=utf-8',
845
1198
  'cache-control': 'no-store',
@@ -858,7 +1211,19 @@ export function createPanelHandler(opts) {
858
1211
  });
859
1212
  res.end(method === 'HEAD'
860
1213
  ? undefined
861
- : renderPage({ basePath, token, nonce, version, build, local, readOnly, theme, lang, updates }));
1214
+ : renderPage({
1215
+ basePath,
1216
+ token,
1217
+ nonce,
1218
+ version,
1219
+ build,
1220
+ local,
1221
+ readOnly,
1222
+ theme,
1223
+ lang,
1224
+ updates,
1225
+ ...(panel ? { panel } : {}),
1226
+ }));
862
1227
  return true;
863
1228
  }
864
1229
  // The mark, for a Portal's tab and for this page's favicon (ADR-0006 amendment).
@@ -883,18 +1248,28 @@ export function createPanelHandler(opts) {
883
1248
  send(res, 200, { token });
884
1249
  return true;
885
1250
  }
1251
+ if (sub === '/api/terminals') {
1252
+ send(res, 200, await terminalsView(await getRuntime()));
1253
+ return true;
1254
+ }
1255
+ // Start at login (spec §3.29): its state only; turning it on or off is the POST of the same path.
1256
+ if (sub === '/api/autostart') {
1257
+ const rt = await getRuntime();
1258
+ send(res, 200, autostartView(await autostartStatus(autostartCtx(rt)), rt.paths.home));
1259
+ return true;
1260
+ }
886
1261
  if (sub === '/api/state') {
887
1262
  send(res, 200, await buildState(await getRuntime(), { version, build, readOnly, updates }));
888
1263
  return true;
889
1264
  }
890
- if (Object.hasOwn(routes, sub))
1265
+ if (Object.hasOwn(routes, sub) || sub === ATTACH_ROUTE)
891
1266
  throw new HttpError(405, 'use POST');
892
1267
  throw new HttpError(404, 'not found');
893
1268
  }
894
1269
  if (method !== 'POST')
895
1270
  throw new HttpError(405, 'method not allowed');
896
1271
  const route = Object.hasOwn(routes, sub) ? routes[sub] : undefined;
897
- if (!route)
1272
+ if (!route && sub !== ATTACH_ROUTE)
898
1273
  throw new HttpError(404, 'not found');
899
1274
  const origin = req.headers.origin;
900
1275
  if (!origin || origin.toLowerCase() !== `http://${host}`)
@@ -902,11 +1277,18 @@ export function createPanelHandler(opts) {
902
1277
  const given = req.headers['x-sideby-token'];
903
1278
  if (typeof given !== 'string' || !sameSecret(given, token))
904
1279
  throw new HttpError(403, 'missing or wrong x-sideby-token; reload the page', 'bad-token');
1280
+ if (!route) {
1281
+ // An upload writes a file: a read-only Panel refuses it before reading a byte.
1282
+ if (readOnly)
1283
+ throw new HttpError(403, 'this panel is read-only');
1284
+ send(res, 200, await receiveAttachment(req));
1285
+ return true;
1286
+ }
905
1287
  const body = (await readBody(req));
906
1288
  if (readOnly && (route.writes?.(body) ?? true))
907
1289
  throw new HttpError(403, 'this panel is read-only');
908
1290
  // Reads that record history (doctor) also run in this queue, so overlapping checks merge in order.
909
- send(res, 200, await serial(() => route.run(body)));
1291
+ send(res, 200, await (route.queued === false ? route.run(body) : serial(() => route.run(body))));
910
1292
  return true;
911
1293
  }
912
1294
  catch (err) {
@@ -916,8 +1298,10 @@ export function createPanelHandler(opts) {
916
1298
  }
917
1299
  if (err instanceof HttpError) {
918
1300
  const body = err.code ? { error: err.message, code: err.code } : { error: err.message };
919
- send(res, err.status, body, err.status === 413 ? { connection: 'close' } : {});
920
- if (err.status === 413)
1301
+ // A refused body (too big, or an upload stopped early) is left unread: close the connection after answering.
1302
+ const unread = err.status === 413 || (sub === ATTACH_ROUTE && !req.readableEnded);
1303
+ send(res, err.status, body, unread ? { connection: 'close' } : {});
1304
+ if (unread)
921
1305
  req.resume();
922
1306
  }
923
1307
  else if (err instanceof UserError) {