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
@@ -197,7 +197,8 @@ code, .mono { font-family: var(--mono); font-size: 0.92em; }
197
197
  .next-why { margin: 0 0 10px; max-width: 280px; line-height: 1.5; }
198
198
  .next-cmd { display: block; margin-top: 8px; color: var(--muted); font-size: 11px; }
199
199
  /* A Same Login (spec §3.21, ADR-0026): one row; opened, a short line per directory. */
200
- .tag-login { font-weight: 400; }
200
+ .login-mark { display: inline-flex; flex: none; color: var(--muted); cursor: help; }
201
+ .login-mark .ic { width: 13px; height: 13px; }
201
202
  .login-detail { flex-direction: column; gap: 8px; }
202
203
  .login-detail .rd-note { margin: 0; }
203
204
  /* The directories share one set of columns (subgrid), so a wider cell in one line moves every line alike. */
@@ -220,9 +221,9 @@ code, .mono { font-family: var(--mono); font-size: 0.92em; }
220
221
  .arrange-tools { display: inline-flex; align-items: center; gap: 2px; }
221
222
  .arrange-tools .icon-btn { width: 28px; height: 28px; }
222
223
  .arrange-tools .icon-btn .ic { width: 14px; height: 14px; }
223
- .pin-btn { color: var(--faint); }
224
- .pin-btn[aria-pressed="true"] { color: var(--accent); }
225
- .pin-btn[aria-pressed="true"] .ic { fill: currentColor; }
224
+ /* One width for "Pin to top" and "Unpin", so the arrows line up down the list. */
225
+ .arrange-tools .pin-btn { min-width: 96px; margin-left: 6px; justify-content: center; }
226
+ .pin-btn[aria-disabled="true"] { color: var(--faint); cursor: not-allowed; }
226
227
  .drag-handle { width: 20px; height: 28px; color: var(--muted); cursor: grab; }
227
228
  .drag-handle:hover { color: var(--accent); }
228
229
  .drag-handle .ic { width: 14px; height: 14px; }
@@ -251,9 +252,12 @@ code, .mono { font-family: var(--mono); font-size: 0.92em; }
251
252
  .col-head > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
252
253
  .col-head .quota > span { overflow: hidden; text-overflow: ellipsis; }
253
254
  .ident { grid-area: id; display: flex; align-items: center; gap: 10px; min-width: 0; }
254
- .ident-text { min-width: 0; flex: 1; }
255
+ .ident-text { min-width: 0; flex: 1; position: relative; padding-left: 18px; }
256
+ /* The pin sits in the text column's own gutter, so the name and the email below it start at the same place. */
257
+ .ident-text .pin-slot, .ident-text .pin-mark { position: absolute; left: 0; top: 2px; margin: 0; }
255
258
  .ident-name { display: flex; align-items: center; gap: 6px; min-width: 0; flex-wrap: wrap; row-gap: 3px; }
256
259
  .ident-name .name { font-weight: var(--strong-weight); font-size: var(--font-size); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
260
+ .ident-name .alias { color: var(--muted); font-family: var(--mono); font-size: 12px; white-space: nowrap; }
257
261
  .ident-email { color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
258
262
  .email-btn, .model-btn { all: unset; cursor: pointer; border-radius: 4px; }
259
263
  .model-btn:hover { color: var(--text); text-decoration: underline dotted; }
@@ -263,11 +267,12 @@ code, .mono { font-family: var(--mono); font-size: 0.92em; }
263
267
  .br-list { list-style: none; margin: 0 0 12px; padding: 0; display: flex; flex-direction: column; gap: 6px; }
264
268
  .br-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--ctl-radius); }
265
269
  .email-btn:hover { color: var(--text); text-decoration: underline dotted; }
270
+ /* A button is laid out as one box, so the parent's ellipsis does not reach its text: it ends its own. */
271
+ .ident-email .email-btn { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; vertical-align: bottom; }
266
272
  .email-btn:focus-visible { outline: var(--focus-outline); outline-offset: 2px; }
267
- .ident-email:has(.br-btn) { display: flex; align-items: center; }
268
- .ident-email:has(.br-btn) > * { flex: none; }
269
- .ident-email:has(.br-btn) > .email-btn:not(.br-btn) { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
270
- .br-btn { display: inline-flex; align-items: center; gap: 3px; vertical-align: bottom; }
273
+ .br-btn { display: inline-flex; align-items: center; gap: 4px; color: var(--muted); font-size: 12px; max-width: 100%; }
274
+ .br-btn:hover { color: var(--text); text-decoration: underline dotted; }
275
+ .card > .rd-browser { flex-direction: row; align-items: center; gap: 8px; }
271
276
  .br-ico { width: 12px; height: 12px; flex: none; }
272
277
  .ident-email + .ident-sub { color: var(--faint); margin-top: 0; }
273
278
  .tools .tb-icon { width: var(--ctl-h); height: var(--ctl-h); border-color: var(--border-strong); background: var(--surface); }
@@ -282,19 +287,21 @@ code, .mono { font-family: var(--mono); font-size: 0.92em; }
282
287
  .row > .quota { grid-area: quota; }
283
288
  .row > .last { grid-area: last; }
284
289
  .row > .row-end { grid-area: more; display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
285
- /* The row's pin (spec §3.25): filled and always there on a pinned row; on the others it shows on hover or focus. */
286
290
  .row-run { width: 28px; height: 28px; color: var(--muted); }
287
291
  .row-run .ic { width: 15px; height: 15px; }
288
292
  .row-run:hover { color: var(--accent); }
289
- .row-pin { width: 28px; height: 28px; color: var(--faint); opacity: 0; transition: opacity .12s, color .12s; }
290
- .row-pin .ic { width: 15px; height: 15px; }
291
- .row:hover .row-pin, .card:hover .row-pin, .row-pin:focus-visible, .row-pin.is-on { opacity: 1; }
292
293
  .card-head > .row-end { display: flex; align-items: center; gap: 2px; margin-left: auto; }
293
- .row-pin:hover { color: var(--accent); }
294
- .row-pin.is-on { color: var(--accent); }
295
- .row-pin.is-on .ic { fill: currentColor; }
296
- .row-pin[aria-disabled="true"] { cursor: not-allowed; }
297
- @media (hover: none) { .row-pin { opacity: .6; } }
294
+ /* A pinned row's mark (spec §3.25, §3.27): a small muted outline pin right before the name, not a button. */
295
+ .pin-mark { display: inline-flex; flex: none; margin-right: -2px; color: var(--faint); }
296
+ .pin-mark .ic { width: 12px; height: 12px; }
297
+ /* The pin before the name (spec §3.27): always there when pinned; a fainter one on hover or focus in the row
298
+ otherwise. The slot keeps its width either way, so the name never moves. */
299
+ .pin-slot { display: inline-grid; place-items: center; flex: none; width: 16px; height: 16px; margin: 0 -4px 0 -2px; padding: 0; border: 0; border-radius: 4px; background: none; color: var(--faint); cursor: pointer; opacity: 0; }
300
+ .pin-slot .ic { width: 12px; height: 12px; }
301
+ .pin-slot.is-on { opacity: 1; }
302
+ .row:hover .pin-slot:not(.is-on), .row:focus-within .pin-slot:not(.is-on), .card:hover .pin-slot:not(.is-on), .card:focus-within .pin-slot:not(.is-on) { opacity: .5; }
303
+ .pin-slot:hover, .pin-slot:focus-visible { opacity: 1 !important; color: var(--accent); background: var(--hover); outline: none; }
304
+ .ctx-anchor { position: fixed; width: 0; height: 0; pointer-events: none; }
298
305
  .row-notes { grid-area: notes; display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
299
306
 
300
307
  /* Quota meters: percent and countdown over a bar colored by level (ok, warn at 60%, fail at 85% used). */
@@ -521,6 +528,31 @@ input[type=text]:hover:not(:disabled), input[type=number]:hover:not(:disabled),
521
528
  input[type=text]:focus, input[type=search]:focus, select:focus { border-color: var(--focus); }
522
529
  input[type=text]:disabled, select:disabled { background-color: var(--surface-2); color: var(--faint); cursor: not-allowed; }
523
530
  input.invalid { border-color: var(--fail); }
531
+ /* AI Tasks (spec §3.28): the text box, the key field, the example chips and the account and terminal pickers. */
532
+ input[type=password] { height: var(--field-h); border-radius: var(--ctl-radius); border: 1px solid var(--border-strong); background-color: var(--surface); color: var(--text); font: inherit; padding: 0 12px; width: 100%; }
533
+ textarea.ask-text { min-height: 64px; resize: vertical; border-radius: var(--ctl-radius); border: 1px solid var(--border-strong); background-color: var(--surface); color: var(--text); font: inherit; padding: 8px 12px; width: 100%; line-height: 1.45; }
534
+ input[type=password]:focus, textarea.ask-text:focus { border-color: var(--focus); }
535
+ textarea.ask-text:focus-visible, input[type=password]:focus-visible { outline: var(--focus-outline); outline-offset: 2px; box-shadow: var(--focus-ring); }
536
+ textarea::placeholder { color: var(--faint); }
537
+ .ask-chips { display: flex; flex-wrap: wrap; gap: 6px; }
538
+ .ask-chips .chip { font: inherit; font-size: var(--small); text-align: left; white-space: normal; }
539
+ .ask-attach-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; }
540
+ .ask-files-wrap { display: grid; gap: 6px; }
541
+ .ask-files-wrap:empty { display: none; }
542
+ .ask-files { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
543
+ .ask-file { position: relative; display: grid; gap: 2px; width: 104px; padding: 4px; border: 1px solid var(--border); border-radius: var(--ctl-radius); background: var(--surface); }
544
+ .ask-file.is-error { border-color: var(--fail); }
545
+ .ask-file-img, .ask-file-face { width: 94px; height: 64px; border-radius: calc(var(--ctl-radius) - 2px); }
546
+ .ask-file-img { object-fit: cover; display: block; }
547
+ .ask-file-face { display: grid; place-items: center; align-content: center; gap: 2px; background: var(--surface-2); color: var(--muted); }
548
+ .ask-file-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
549
+ .ask-file-x { position: absolute; top: 2px; right: 2px; width: 22px; height: 22px; background: var(--surface); }
550
+ .dialog.drop-on { outline: 2px dashed var(--focus); outline-offset: -6px; }
551
+ .ask-pick {display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; }
552
+ .ask-setup .link-btn { display: inline-flex; align-items: center; gap: 6px; }
553
+ .ask-setup .ic { width: 14px; height: 14px; }
554
+ .fam-other { background: transparent; color: inherit; font: inherit; text-align: left; border-style: dashed; }
555
+ .q-ask { margin-left: 6px; }
524
556
  /* The select draws its own chevron with two gradient strokes (the CSP allows no external images), so it follows the
525
557
  text color in both schemes and on hover. */
526
558
  select { --chevron: var(--muted); appearance: none; padding-right: 32px; cursor: pointer; text-overflow: ellipsis;
@@ -581,7 +613,7 @@ select:is(:hover, :focus) { --chevron: var(--text); }
581
613
  .dlg-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 18px; border-top: 1px solid var(--border); background: var(--surface-2); border-radius: 0 0 var(--radius) var(--radius); }
582
614
 
583
615
  /* Menu, tooltips, toasts */
584
- .menu { position: fixed; z-index: 40; min-width: 230px; max-width: min(380px, calc(100vw - 16px)); padding: 4px; background: var(--surface); border: 1px solid var(--border); border-radius: calc(var(--ctl-radius) + 2px); box-shadow: var(--float-shadow); }
616
+ .menu { position: fixed; z-index: 40; min-width: 230px; max-width: min(380px, calc(100vw - 16px)); padding: 4px; background: var(--surface); border: 1px solid var(--border); border-radius: calc(var(--ctl-radius) + 2px); box-shadow: var(--float-shadow); overflow-y: auto; overscroll-behavior: contain; }
585
617
  .menu-item { display: flex; align-items: center; gap: 10px; width: 100%; height: 32px; padding: 0 10px; border: 0; border-radius: calc(var(--ctl-radius) - 2px); background: none; color: var(--text); font: inherit; font-size: var(--control-size); text-align: left; cursor: pointer; }
586
618
  .menu-item .ic { flex: none; color: var(--muted); }
587
619
  .menu-item:hover:not(:disabled), .menu-item:focus-visible { background: var(--hover); outline: none; }
@@ -604,6 +636,24 @@ select:is(:hover, :focus) { --chevron: var(--text); }
604
636
  .set-hint { color: var(--faint); font-size: 12px; max-width: 260px; }
605
637
  .set-link { margin: 0 -8px; width: calc(100% + 16px); }
606
638
  .set-link .chip { margin-left: auto; }
639
+ /* Groups in the settings (spec §3.29): a small muted title, then its rows; a line between groups. */
640
+ .pop-settings { width: 340px; }
641
+ .set-group { display: flex; flex-direction: column; gap: 8px; }
642
+ .set-check .set-label { white-space: normal; }
643
+ .set-link .menu-label { flex: 1 1 auto; min-width: 0; white-space: normal; line-height: 1.3; }
644
+ .set-link { height: auto; min-height: 32px; padding-top: 6px; padding-bottom: 6px; }
645
+ .set-link .chip { flex: none; }
646
+ .set-group-title { color: var(--faint); font-size: 11px; font-weight: var(--strong-weight); letter-spacing: .02em; }
647
+ .popover .set-sep { margin: 0 -14px; }
648
+ .set-hint .link-btn { font-size: 12px; }
649
+ /* "More settings…" stays at the bottom of a popover that scrolls. */
650
+ .set-more { position: sticky; bottom: -12px; margin: 0 -14px -12px; width: calc(100% + 28px); height: 40px; padding: 0 16px 0 17px; border-top: 1px solid var(--border); border-radius: 0; background: var(--surface); }
651
+ .set-more:hover { background: var(--hover); }
652
+ .set-more .ic { margin-left: auto; }
653
+ .set-dialog { display: flex; flex-direction: column; gap: 12px; }
654
+ .set-dialog .set-hint { max-width: none; }
655
+ .set-dialog .set-link { margin: 0 -10px; width: calc(100% + 20px); }
656
+ .as-code { display: block; margin: -4px 0 4px; padding: 8px 10px; border-radius: var(--ctl-radius); background: var(--hover); font-family: var(--mono); font-size: 12px; overflow-wrap: anywhere; }
607
657
  .set-dot { background: var(--warn-bar); }
608
658
  .tip { display: inline-flex; position: relative; vertical-align: middle; }
609
659
  .tip-btn { display: inline-grid; place-items: center; width: 16px; height: 16px; padding: 0; border: 1px solid var(--border-strong); border-radius: 50%; background: var(--surface); color: var(--muted); font: 700 10px/1 var(--font); cursor: help; }
@@ -21,6 +21,11 @@ export interface PageOptions {
21
21
  lang?: 'auto' | 'en' | 'zh';
22
22
  /** createPanelHandler's `updates`: false hides everything about sideby updates. */
23
23
  updates?: boolean;
24
+ /** Config `panel` (spec §3.29): the theme and language saved for the Panel. */
25
+ panel?: {
26
+ theme?: string;
27
+ language?: string;
28
+ };
24
29
  }
25
30
  /** JSON that is safe inside a `<script>` element: no `</script>`, `<!--` or line separators survive. */
26
31
  export declare function jsonForScript(value: unknown): string;
@@ -42,6 +42,7 @@ export function renderPage(opts) {
42
42
  updates: opts.updates !== false,
43
43
  colorScheme: theme.colorScheme,
44
44
  lang,
45
+ panel: opts.panel ?? {},
45
46
  warn: QUOTA_WARN_PERCENT,
46
47
  fail: QUOTA_FAIL_PERCENT,
47
48
  usageDays: USAGE_DAYS,
@@ -74,6 +75,7 @@ export function renderPage(opts) {
74
75
  </div>
75
76
  <div class="top-actions">
76
77
  <button id="update" class="btn update-btn" type="button" hidden></button>
78
+ <button id="ask-ai" class="btn" type="button" aria-haspopup="dialog" aria-label="Let AI do it"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 9h12a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2zM12 5v4M9.5 13.5v1M14.5 13.5v1"/></svg><span class="hide-sm" data-i18n="ask.button">Let AI do it</span></button>
77
79
  <button id="settings" class="btn" type="button" aria-haspopup="dialog" aria-expanded="false"${lang === 'auto' ? '' : ' hidden'}><span data-i18n="set.button">Settings</span></button>
78
80
  <button id="refresh" class="btn" type="button" aria-label="Refresh"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 5.7"/><path d="M20 4v7h-7"/></svg><span class="hide-sm" data-i18n="top.refresh">Refresh</span></button>
79
81
  </div>
@@ -1,5 +1,12 @@
1
1
  import type { Runtime } from '../runtime.ts';
2
2
  export declare const DEFAULT_PANEL_PORT = 17420;
3
+ /**
4
+ * Whose Panel this is: a hash of the system user and HOME, so `sideby ui` reuses only its own user's Panel. Another
5
+ * user of the same Mac may run one on the same port; reusing it would show that user's accounts and start none of
6
+ * ours. A hash, so the health answer names no path. It only tells Panels apart; it is not authentication. The real
7
+ * path, so one HOME reached through a symlink gives one owner.
8
+ */
9
+ export declare function panelOwner(home?: string): string;
3
10
  export interface PanelServerOptions {
4
11
  runtimeFactory: () => Promise<Runtime>;
5
12
  port?: number;
@@ -11,6 +18,8 @@ export interface PanelServerOptions {
11
18
  * --stop` can tell this Panel from a reused pid or from another Panel before it sends a signal.
12
19
  */
13
20
  instance?: string;
21
+ /** panelOwner() of the HOME this Panel serves; defaults to this process's HOME. */
22
+ owner?: string;
14
23
  }
15
24
  export interface PanelServer {
16
25
  url: string;
@@ -22,11 +31,16 @@ export interface PanelIdentity {
22
31
  pid?: number;
23
32
  /** buildId() of the code serving it; absent from Panels older than this field. */
24
33
  build?: string;
34
+ /** panelOwner() of the user it serves; absent from Panels older than this field. */
35
+ owner?: string;
25
36
  }
26
37
  /** What the sideby Panel on this port says about itself, or undefined when none answers. */
27
38
  export declare function panelIdentity(port: number): Promise<PanelIdentity | undefined>;
28
- /** True when a sideby Panel answers on this port. */
29
- export declare function isSidebyPanel(port: number): Promise<boolean>;
39
+ /**
40
+ * True when a sideby Panel of this user answers on this port. One that names another owner belongs to another user
41
+ * and counts as a busy port; one too old to name an owner is taken as ours, as before.
42
+ */
43
+ export declare function isSidebyPanel(port: number, owner?: string): Promise<boolean>;
30
44
  /** Opens `url` in the default browser; returns false (and never throws) when that is not possible. */
31
45
  export declare function openBrowser(url: string): Promise<boolean>;
32
46
  export declare function startPanelServer(opts: PanelServerOptions): Promise<PanelServer>;
@@ -1,11 +1,34 @@
1
1
  // Standalone Panel server for `sideby ui`: loopback only, reuses a running Panel, shifts past busy ports.
2
2
  import { spawn } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
4
+ import { realpathSync } from 'node:fs';
3
5
  import { createServer } from 'node:http';
4
- import { buildId } from '../core/version.js';
6
+ import { homedir } from 'node:os';
7
+ import { resolve } from 'node:path';
8
+ import { buildId, packageVersion } from '../core/version.js';
5
9
  import { createPanelHandler } from './handler.js';
6
10
  export const DEFAULT_PANEL_PORT = 17420;
7
11
  const PORT_ATTEMPTS = 20;
8
12
  const LOOPBACK = '127.0.0.1';
13
+ /**
14
+ * Whose Panel this is: a hash of the system user and HOME, so `sideby ui` reuses only its own user's Panel. Another
15
+ * user of the same Mac may run one on the same port; reusing it would show that user's accounts and start none of
16
+ * ours. A hash, so the health answer names no path. It only tells Panels apart; it is not authentication. The real
17
+ * path, so one HOME reached through a symlink gives one owner.
18
+ */
19
+ export function panelOwner(home = process.env.HOME || homedir()) {
20
+ let dir = resolve(home);
21
+ try {
22
+ dir = realpathSync(dir);
23
+ }
24
+ catch {
25
+ // keep the resolved path when HOME does not exist
26
+ }
27
+ return createHash('sha256')
28
+ .update(`sideby-panel\0${process.getuid?.() ?? ''}\0${dir}`)
29
+ .digest('hex')
30
+ .slice(0, 16);
31
+ }
9
32
  function listen(server, port) {
10
33
  return new Promise((resolve, reject) => {
11
34
  const onError = (err) => {
@@ -37,15 +60,20 @@ export async function panelIdentity(port) {
37
60
  ...(typeof body.instance === 'string' ? { instance: body.instance } : {}),
38
61
  ...(Number.isInteger(body.pid) ? { pid: body.pid } : {}),
39
62
  ...(typeof body.build === 'string' ? { build: body.build } : {}),
63
+ ...(typeof body.owner === 'string' ? { owner: body.owner } : {}),
40
64
  };
41
65
  }
42
66
  catch {
43
67
  return undefined;
44
68
  }
45
69
  }
46
- /** True when a sideby Panel answers on this port. */
47
- export async function isSidebyPanel(port) {
48
- return (await panelIdentity(port)) !== undefined;
70
+ /**
71
+ * True when a sideby Panel of this user answers on this port. One that names another owner belongs to another user
72
+ * and counts as a busy port; one too old to name an owner is taken as ours, as before.
73
+ */
74
+ export async function isSidebyPanel(port, owner = panelOwner()) {
75
+ const id = await panelIdentity(port);
76
+ return id !== undefined && (id.owner === undefined || id.owner === owner);
49
77
  }
50
78
  /** Opens `url` in the default browser; returns false (and never throws) when that is not possible. */
51
79
  export function openBrowser(url) {
@@ -69,6 +97,9 @@ export function openBrowser(url) {
69
97
  export async function startPanelServer(opts) {
70
98
  const log = opts.log ?? ((s) => console.log(s));
71
99
  const first = opts.port ?? DEFAULT_PANEL_PORT;
100
+ const owner = opts.owner ?? panelOwner();
101
+ // /api/health always names app and version (ADR-0006), also when the caller did not pass one.
102
+ const version = opts.version ?? packageVersion();
72
103
  const announce = async (url, reused) => {
73
104
  log(reused ? `sideby panel is already running at ${url}` : `sideby panel: ${url}`);
74
105
  if (opts.open && !(await openBrowser(url)))
@@ -79,20 +110,17 @@ export async function startPanelServer(opts) {
79
110
  const handler = createPanelHandler({
80
111
  runtime: opts.runtimeFactory,
81
112
  allowedHosts: hosts,
82
- ...(opts.version ? { version: opts.version } : {}),
113
+ version,
83
114
  });
84
115
  const server = createServer((req, res) => {
85
- if (opts.instance &&
86
- req.method === 'GET' &&
87
- req.url === '/api/health' &&
88
- hosts.includes(req.headers.host ?? '')) {
116
+ if (req.method === 'GET' && req.url === '/api/health' && hosts.includes(req.headers.host ?? '')) {
89
117
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
90
118
  res.end(JSON.stringify({
91
119
  app: 'sideby',
92
- ...(opts.version ? { version: opts.version } : {}),
120
+ version,
93
121
  build: buildId(),
94
- instance: opts.instance,
95
- pid: process.pid,
122
+ owner,
123
+ ...(opts.instance ? { instance: opts.instance, pid: process.pid } : {}),
96
124
  }));
97
125
  return;
98
126
  }
@@ -124,7 +152,7 @@ export async function startPanelServer(opts) {
124
152
  }),
125
153
  };
126
154
  }
127
- if (await isSidebyPanel(port)) {
155
+ if (await isSidebyPanel(port, owner)) {
128
156
  await announce(url, true);
129
157
  return { url, reused: true, close: async () => { } };
130
158
  }
@@ -0,0 +1,47 @@
1
+ import type { Plugin } from '../types.ts';
2
+ export interface PluginProblem {
3
+ /** Stable machine code such as `shared-item.parent`. */
4
+ code: string;
5
+ message: string;
6
+ /** One sentence saying what to change. */
7
+ fix: string;
8
+ }
9
+ export interface PluginCheckResult {
10
+ ok: boolean;
11
+ /** The plugin's name, when its manifest or export could be read. */
12
+ plugin?: string;
13
+ /** Family ids the plugin registered. */
14
+ families: string[];
15
+ problems: PluginProblem[];
16
+ }
17
+ /**
18
+ * Checks the Family Plugin in `dir` (the folder holding `plugin.json`): manifest, trust (owner, modes), that it
19
+ * only `import type`s from sideby, and a leak scan for home paths, personal emails and token-shaped strings. When the
20
+ * loader would trust it, also its FamilyDef fields and a discover / launch / doctor round, run by {@link checkRound} in
21
+ * a child process with a throwaway HOME, killed after `timeoutMs` (20 s). An untrusted plugin is never copied,
22
+ * changed or run. Never throws for a broken plugin: every problem is in `problems`, each with a `fix`.
23
+ */
24
+ export declare function checkPlugin(opts: {
25
+ dir: string;
26
+ timeoutMs?: number;
27
+ }): Promise<PluginCheckResult>;
28
+ /**
29
+ * The child side of {@link runRound}: reads the job, runs {@link checkRound} and writes its report to fd 3, then
30
+ * exits so a timer or handle the plugin left open cannot keep it alive.
31
+ */
32
+ export declare function checkRoundMain(arg: string | undefined): Promise<void>;
33
+ /**
34
+ * `checkPlugin` for a Plugin that ships inside sideby: `dir` is its source folder, scanned for leaks. The same field
35
+ * checks and discover / launch / doctor round run; it loads as the only built-in.
36
+ */
37
+ export declare function checkBuiltinPlugin(opts: {
38
+ plugin: Plugin;
39
+ dir: string;
40
+ }): Promise<PluginCheckResult>;
41
+ /** What is missing or wrong in one FamilyDef, as written by the Plugin. */
42
+ export declare function defProblems(raw: unknown): PluginProblem[];
43
+ /**
44
+ * The plugin's sources against the rules that need no loading: value imports from sideby, TODOs left from the
45
+ * skeleton, and the leak scan. Messages give the file and line only, never the matched value.
46
+ */
47
+ export declare function scanSources(dir: string): Promise<PluginProblem[]>;