sideby 0.16.0 → 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 (77) 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 +460 -5
  5. package/dist/src/cli/json-schemas.js +115 -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 +17 -0
  18. package/dist/src/core/local-build.js +39 -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 +17 -0
  28. package/dist/src/core/update.js +63 -1
  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 +396 -9
  42. package/dist/src/panel/i18n.d.ts +175 -2
  43. package/dist/src/panel/i18n.js +381 -35
  44. package/dist/src/panel/page-logic.d.ts +214 -0
  45. package/dist/src/panel/page-logic.js +394 -0
  46. package/dist/src/panel/page-script.js +984 -134
  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/local.json +4 -0
  68. package/schemas/plugins-check.json +113 -0
  69. package/schemas/plugins-new.json +87 -0
  70. package/schemas/terminal.json +152 -0
  71. package/schemas/try.json +109 -0
  72. package/schemas/update.json +4 -0
  73. package/skills/sideby/SKILL.md +12 -6
  74. package/skills/sideby/references/accounts.md +1 -1
  75. package/skills/sideby/references/ai-tasks.md +116 -0
  76. package/skills/sideby/references/panel.md +14 -0
  77. package/skills/sideby/references/update.md +1 -1
@@ -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,
@@ -745,8 +1016,10 @@ export function createPanelHandler(opts) {
745
1016
  if (!optBool(body, 'confirm'))
746
1017
  return { ...(await lb.localStatus(d)), to, switched: false };
747
1018
  const { backgroundPanelPort } = await import('./background.js');
1019
+ // The skill fetch can outlast what a Portal waits for: it runs on its own, and the answer comes at once.
748
1020
  const r = await lb.switchLocal(rt.paths, d, to, rt.env, {
749
1021
  panelPort: () => backgroundPanelPort(rt.paths),
1022
+ skillSync: 'background',
750
1023
  });
751
1024
  if (!r.ok)
752
1025
  throw new UserError(r.message);
@@ -756,6 +1029,7 @@ export function createPanelHandler(opts) {
756
1029
  switched: !r.unchanged,
757
1030
  message: r.message,
758
1031
  restartedPanel: r.restartedPanel,
1032
+ ...(r.skillSyncStarted === undefined ? {} : { skillSyncStarted: r.skillSyncStarted }),
759
1033
  };
760
1034
  },
761
1035
  },
@@ -807,6 +1081,60 @@ export function createPanelHandler(opts) {
807
1081
  return { family, ...(await rt.setQuotaCheck(family, body.on)) };
808
1082
  },
809
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
+ },
810
1138
  // Undo for the quota setup above; the Runtime turns a teardown that throws into `{ ok: false, message }`.
811
1139
  '/api/quota/teardown': {
812
1140
  async run(body) {
@@ -818,6 +1146,29 @@ export function createPanelHandler(opts) {
818
1146
  },
819
1147
  },
820
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
+ };
821
1172
  const handle = async (req, res) => {
822
1173
  let pathname;
823
1174
  try {
@@ -837,6 +1188,11 @@ export function createPanelHandler(opts) {
837
1188
  if (method === 'GET' || method === 'HEAD') {
838
1189
  if (sub === '/' || sub === '/index.html') {
839
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);
840
1196
  res.writeHead(200, {
841
1197
  'content-type': 'text/html; charset=utf-8',
842
1198
  'cache-control': 'no-store',
@@ -855,7 +1211,19 @@ export function createPanelHandler(opts) {
855
1211
  });
856
1212
  res.end(method === 'HEAD'
857
1213
  ? undefined
858
- : 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
+ }));
859
1227
  return true;
860
1228
  }
861
1229
  // The mark, for a Portal's tab and for this page's favicon (ADR-0006 amendment).
@@ -880,18 +1248,28 @@ export function createPanelHandler(opts) {
880
1248
  send(res, 200, { token });
881
1249
  return true;
882
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
+ }
883
1261
  if (sub === '/api/state') {
884
1262
  send(res, 200, await buildState(await getRuntime(), { version, build, readOnly, updates }));
885
1263
  return true;
886
1264
  }
887
- if (Object.hasOwn(routes, sub))
1265
+ if (Object.hasOwn(routes, sub) || sub === ATTACH_ROUTE)
888
1266
  throw new HttpError(405, 'use POST');
889
1267
  throw new HttpError(404, 'not found');
890
1268
  }
891
1269
  if (method !== 'POST')
892
1270
  throw new HttpError(405, 'method not allowed');
893
1271
  const route = Object.hasOwn(routes, sub) ? routes[sub] : undefined;
894
- if (!route)
1272
+ if (!route && sub !== ATTACH_ROUTE)
895
1273
  throw new HttpError(404, 'not found');
896
1274
  const origin = req.headers.origin;
897
1275
  if (!origin || origin.toLowerCase() !== `http://${host}`)
@@ -899,11 +1277,18 @@ export function createPanelHandler(opts) {
899
1277
  const given = req.headers['x-sideby-token'];
900
1278
  if (typeof given !== 'string' || !sameSecret(given, token))
901
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
+ }
902
1287
  const body = (await readBody(req));
903
1288
  if (readOnly && (route.writes?.(body) ?? true))
904
1289
  throw new HttpError(403, 'this panel is read-only');
905
1290
  // Reads that record history (doctor) also run in this queue, so overlapping checks merge in order.
906
- send(res, 200, await serial(() => route.run(body)));
1291
+ send(res, 200, await (route.queued === false ? route.run(body) : serial(() => route.run(body))));
907
1292
  return true;
908
1293
  }
909
1294
  catch (err) {
@@ -913,8 +1298,10 @@ export function createPanelHandler(opts) {
913
1298
  }
914
1299
  if (err instanceof HttpError) {
915
1300
  const body = err.code ? { error: err.message, code: err.code } : { error: err.message };
916
- send(res, err.status, body, err.status === 413 ? { connection: 'close' } : {});
917
- 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)
918
1305
  req.resume();
919
1306
  }
920
1307
  else if (err instanceof UserError) {