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
@@ -68,15 +68,45 @@ const HandoffSchema = Type.Object({
68
68
  description: 'Accounts (ref or short command) that may take over from an account of another organization.',
69
69
  })),
70
70
  families: Type.Optional(Type.Record(Type.String(), Type.Object({
71
- policy: Type.Optional(Type.Union([Type.Literal('pressure'), Type.Literal('order')])),
72
- order: Type.Optional(Type.Array(Type.String())),
71
+ policy: Type.Optional(Type.Union([Type.Literal('pressure'), Type.Literal('order')], {
72
+ description: '`pressure` picks the account with the most Quota left; `order` follows `order`.',
73
+ })),
74
+ order: Type.Optional(Type.Array(Type.String(), { description: 'Accounts (ref or short command) to try, in order.' })),
73
75
  }, { additionalProperties: false }), {
74
76
  description: 'Handoff Order per family the session starts in: `pressure` (default) or an `order` list.',
75
77
  })),
76
78
  }, { additionalProperties: false });
77
79
  export const ConfigSchema = Type.Object({
78
80
  $schema: Type.Optional(Type.String()),
79
- accounts: Type.Optional(Type.Record(Type.String(), Type.Object({ args: Type.Optional(Type.Array(Type.String())) }, { additionalProperties: false }), { description: 'Per-Account settings keyed by `<family>:<name>`.' })),
81
+ panel: Type.Optional(Type.Object({
82
+ theme: Type.Optional(Type.String({
83
+ enum: ['system', 'light', 'dark'],
84
+ description: 'Panel colours: follow the system (default), always light, or always dark.',
85
+ })),
86
+ language: Type.Optional(Type.String({
87
+ enum: ['auto', 'en', 'zh'],
88
+ description: "Panel language: the browser's (default), English, or Chinese.",
89
+ })),
90
+ }, { additionalProperties: false, description: 'How the Panel looks (spec §3.29).' })),
91
+ terminal: Type.Optional(Type.Object({
92
+ preferred: Type.Optional(Type.String({
93
+ enum: ['terminal', 'iterm2', 'ghostty', 'orca', 'wezterm', 'kitty', 'alacritty', 'custom'],
94
+ description: 'The terminal sideby opens for AI Tasks; without it sideby picks the one you use.',
95
+ })),
96
+ custom: Type.Optional(Type.String({
97
+ description: 'Your own launch command for `preferred: custom`, with `{cwd}` and `{cmd}` placeholders.',
98
+ })),
99
+ }, { additionalProperties: false, description: 'The terminal AI Tasks open in (spec §3.28).' })),
100
+ ask: Type.Optional(Type.Object({
101
+ account: Type.Optional(Type.String({
102
+ description: 'The account AI Tasks start (`<family>:<name>` or a short command); sideby picks one without it.',
103
+ })),
104
+ }, { additionalProperties: false, description: 'AI Tasks (spec §3.28).' })),
105
+ accounts: Type.Optional(Type.Record(Type.String(), Type.Object({
106
+ args: Type.Optional(Type.Array(Type.String(), {
107
+ description: 'Host arguments sideby starts the account with; `sideby config` and `sideby model` set only the model and effort ones.',
108
+ })),
109
+ }, { additionalProperties: false }), { description: 'Per-Account settings keyed by `<family>:<name>`.' })),
80
110
  aliases: Type.Optional(Type.Record(Type.String({ pattern: '^[A-Za-z_][A-Za-z0-9_-]*$' }), AliasSchema, {
81
111
  description: 'Short shell function names created by `sideby shell-init`, mapped to an Account ref or to `{ account, args }`.',
82
112
  })),
@@ -85,7 +115,9 @@ export const ConfigSchema = Type.Object({
85
115
  plugins: Type.Optional(Type.Record(Type.String(), Type.Record(Type.String(), Type.Unknown()), {
86
116
  description: 'Per-Plugin settings; `enabled: false` turns a Plugin off.',
87
117
  })),
88
- extraSharedItems: Type.Optional(Type.Record(Type.String(), Type.Array(SharedItemSchema))),
118
+ extraSharedItems: Type.Optional(Type.Record(Type.String(), Type.Array(SharedItemSchema), {
119
+ description: 'Per family, more items each account shares with the main account: `{ path, mode }` with a relative path.',
120
+ })),
89
121
  accountTitle: Type.Optional(Type.Boolean({
90
122
  description: 'Set the terminal title to `[<short command or account>]` when sideby starts an Account, and keep Hosts that allow it (Codex) from replacing it. Off by default.',
91
123
  })),
@@ -127,8 +159,8 @@ export const ConfigSchema = Type.Object({
127
159
  })),
128
160
  }, { additionalProperties: false, description: 'Quota that sideby asks for (spec §3.26, ADR-0025).' })),
129
161
  shellInitFile: Type.Optional(Type.Object({
130
- zsh: Type.Optional(Type.String({ minLength: 1 })),
131
- bash: Type.Optional(Type.String({ minLength: 1 })),
162
+ zsh: Type.Optional(Type.String({ minLength: 1, description: 'The file for zsh.' })),
163
+ bash: Type.Optional(Type.String({ minLength: 1, description: 'The file for bash.' })),
132
164
  }, {
133
165
  additionalProperties: false,
134
166
  description: 'Absolute or `~/` paths of files sideby rewrites with the `sideby shell-init <shell>` output whenever it adds an Account or an alias; source the file from your shell rc.',
@@ -191,8 +223,8 @@ async function configTarget(file) {
191
223
  return (await lstatOrNull(file))?.isSymbolicLink() ? ((await realpathOrNull(file)) ?? file) : file;
192
224
  }
193
225
  /**
194
- * Re-reads the config file, lets `edit` change one top-level `key` (sideby writes only `aliases`, `handoff`,
195
- * `accounts`, `update`, `browser`, `display` and `quota`, spec §3.10), and an `edit` that returns undefined removes it, and writes the result: every other key and their order kept (with `$schema`), 2-space JSON
226
+ * Re-reads the config file, lets `edit` change one top-level `key` (the keys sideby writes are listed in spec §3.10;
227
+ * `sideby config set|unset` may change any but plugin settings, §3.29), and an `edit` that returns undefined removes it, and writes the result: every other key and their order kept (with `$schema`), 2-space JSON
196
228
  * with a trailing newline, atomically, keeping the file's mode (0600 for a new file). Refuses, without writing, a
197
229
  * file that is not valid JSON or not a valid config, a result that is not a valid config, or a file that changed
198
230
  * while sideby wrote it. `edit` returns null to leave the file alone; `dryRun` returns the change without writing.
@@ -233,8 +265,18 @@ async function editConfigKey(file, key, retry, edit, opts = {}) {
233
265
  : { ...data, [key]: after };
234
266
  if (after === undefined)
235
267
  delete next[key];
236
- if (!Value.Check(ConfigSchema, next))
237
- throw new ConfigError(`the new ${key} would not match schemas/config.json`);
268
+ if (!Value.Check(ConfigSchema, next)) {
269
+ const first = [...Value.Errors(ConfigSchema, next)][0];
270
+ const where = (first?.instancePath ?? '').replace(/^\//, '').split('/').join('.') || key;
271
+ throw new ConfigError(`${where} ${first?.message ?? 'is invalid'}, so nothing was changed; \`sideby config get\` lists the keys and the values they take`, { code: 'bad-value' });
272
+ }
273
+ for (const alias of Object.keys(next.aliases ?? {})) {
274
+ const problem = aliasProblem(alias);
275
+ if (problem)
276
+ throw new ConfigError(`alias "${alias}" ${problem} (it becomes a shell function name); pick another name`, {
277
+ code: 'bad-value',
278
+ });
279
+ }
238
280
  const h = handoffSettings(next);
239
281
  if (h.prepareAt >= h.threshold)
240
282
  throw new ConfigError(`handoff.prepareAt must be lower than handoff.threshold; fix them in ${file} and ${retry} again`);
@@ -346,3 +388,229 @@ export async function removeConfigAlias(file, alias) {
346
388
  });
347
389
  return { status, aliases };
348
390
  }
391
+ export function editConfigTerminal(file, patch) {
392
+ return editConfigKey(file, 'terminal', 'run `sideby terminal`', (current) => {
393
+ const next = { ...current, ...patch };
394
+ for (const key of ['preferred', 'custom'])
395
+ if (next[key] === undefined)
396
+ delete next[key];
397
+ return Object.keys(next).length ? next : undefined;
398
+ });
399
+ }
400
+ export function editConfigAsk(file, account) {
401
+ return editConfigKey(file, 'ask', 'run `sideby ask`', () => (account ? { account } : undefined));
402
+ }
403
+ /**
404
+ * The Panel's theme and language (spec §3.29): a value sets the key, null removes it (back to the default), and an
405
+ * empty `panel` goes away with it (see `editConfigKey` for how the file is written).
406
+ */
407
+ export function editConfigPanel(file, patch, opts = {}) {
408
+ return editConfigKey(file, 'panel', 'change the setting', (current) => {
409
+ const next = { ...current };
410
+ for (const k of ['theme', 'language'])
411
+ if (patch[k] === null)
412
+ delete next[k];
413
+ else if (patch[k] !== undefined)
414
+ next[k] = patch[k];
415
+ return Object.keys(next).length ? next : undefined;
416
+ }, opts);
417
+ }
418
+ /** The name shown for a key the user picks, per record in the schema. */
419
+ const PLACEHOLDERS = {
420
+ accounts: '<family>:<name>',
421
+ aliases: '<short>',
422
+ plugins: '<plugin>',
423
+ extraSharedItems: '<family>',
424
+ 'handoff.families': '<family>',
425
+ 'display.accounts': '<family>',
426
+ 'display.pinned': '<family>',
427
+ 'quota.check': '<family>',
428
+ };
429
+ const BAD_SEGMENT = new Set(['__proto__', 'constructor', 'prototype']);
430
+ function takes(node) {
431
+ if (node.anyOf) {
432
+ const consts = node.anyOf.filter((n) => n.const !== undefined).map((n) => String(n.const));
433
+ if (consts.length === node.anyOf.length)
434
+ return `one of: ${consts.join(', ')}`;
435
+ return 'an account ref (`<family>:<name>`), or JSON {"account": …, "args": […]}';
436
+ }
437
+ if (node.enum)
438
+ return `one of: ${node.enum.join(', ')}`;
439
+ if (node.type === 'boolean')
440
+ return 'true or false';
441
+ if (node.type === 'integer')
442
+ return node.maximum !== undefined
443
+ ? `a whole number from ${node.minimum ?? 0} to ${node.maximum}`
444
+ : `a whole number, ${node.minimum ?? 0} or more`;
445
+ if (node.type === 'array')
446
+ return node.items?.type === 'string' ? 'a JSON list of text, such as ["a","b"]' : 'a JSON list';
447
+ if (node.type === 'string')
448
+ return 'text';
449
+ return 'a JSON object';
450
+ }
451
+ /** Every key `sideby config set` takes, with its meaning and current value, in schema order. */
452
+ export function settableKeys(config) {
453
+ const out = [];
454
+ const walk = (node, path, meaning) => {
455
+ const key = path.join('.');
456
+ const here = node.description ?? meaning;
457
+ if (node.properties) {
458
+ for (const [name, child] of Object.entries(node.properties))
459
+ if (name !== '$schema')
460
+ walk(child, [...path, name], here);
461
+ return;
462
+ }
463
+ const pattern = node.patternProperties && Object.values(node.patternProperties)[0];
464
+ if (pattern) {
465
+ const holder = PLACEHOLDERS[key] ?? '<name>';
466
+ if (key === 'plugins') {
467
+ walk({
468
+ type: 'boolean',
469
+ description: 'false turns the plugin off; its own settings are not changed here.',
470
+ }, [...path, holder, 'enabled'], here);
471
+ return;
472
+ }
473
+ if (pattern.properties)
474
+ return walk(pattern, [...path, holder], here);
475
+ return leaf(pattern, [...path, holder], here);
476
+ }
477
+ leaf(node, path, here);
478
+ };
479
+ const leaf = (node, path, meaning) => {
480
+ const key = path.join('.');
481
+ const item = { key, meaning: node.description ?? meaning, takes: takes(node) };
482
+ const holder = path.findIndex((p) => p.startsWith('<'));
483
+ if (holder < 0) {
484
+ const v = getIn(config, path);
485
+ if (v !== undefined)
486
+ item.value = v;
487
+ }
488
+ else {
489
+ const entries = [];
490
+ const parent = getIn(config, path.slice(0, holder));
491
+ if (parent && typeof parent === 'object')
492
+ for (const name of Object.keys(parent)) {
493
+ const concrete = [...path.slice(0, holder), name, ...path.slice(holder + 1)];
494
+ const v = getIn(config, concrete);
495
+ if (v !== undefined)
496
+ entries.push({ key: concrete.join('.'), value: v });
497
+ }
498
+ if (entries.length)
499
+ item.entries = entries;
500
+ }
501
+ out.push(item);
502
+ };
503
+ walk(ConfigSchema, [], '');
504
+ return out;
505
+ }
506
+ function getIn(value, path) {
507
+ let at = value;
508
+ for (const seg of path) {
509
+ if (!at || typeof at !== 'object' || Array.isArray(at) || !Object.hasOwn(at, seg))
510
+ return undefined;
511
+ at = at[seg];
512
+ }
513
+ return at;
514
+ }
515
+ /** Splits and checks a dot path; refuses what `config` may not read or change. */
516
+ export function configKeyPath(key) {
517
+ return describeConfigKey(key).path;
518
+ }
519
+ /** The path of `key`, what it does and the values it takes; refuses what `config` may not read or change. */
520
+ export function describeConfigKey(key) {
521
+ const path = key.split('.');
522
+ const list = '`sideby config get` lists the keys you can use';
523
+ if (!key || path.some((p) => !p || BAD_SEGMENT.has(p)))
524
+ throw new ConfigError(`"${key}" is not a config key; ${list}`, { code: 'bad-key' });
525
+ if (path[0] === '$schema')
526
+ throw new ConfigError(`$schema is for editors and is not a setting; ${list}`, { code: 'bad-key' });
527
+ if (path[0] === 'plugins' && !(path.length === 3 && path[2] === 'enabled'))
528
+ throw new ConfigError(`a plugin's own settings may hold secrets, so \`sideby config\` does not read or change them (only plugins.<plugin>.enabled); edit them in the config file`, { code: 'plugin-config' });
529
+ let node = ConfigSchema;
530
+ let meaning = '';
531
+ for (const seg of path) {
532
+ const n = node;
533
+ if (!n)
534
+ break;
535
+ const holder = n.anyOf ?? [n];
536
+ node = undefined;
537
+ for (const h of holder) {
538
+ if (h.properties?.[seg])
539
+ node = h.properties[seg];
540
+ else if (h.patternProperties) {
541
+ const [re, child] = Object.entries(h.patternProperties)[0];
542
+ if (new RegExp(re).test(seg))
543
+ node = child;
544
+ }
545
+ if (node)
546
+ break;
547
+ }
548
+ if (node?.description)
549
+ meaning = node.description;
550
+ }
551
+ if (!node)
552
+ throw new ConfigError(`"${key}" is not a config key; ${list}`, { code: 'bad-key' });
553
+ if (path[0] === 'plugins')
554
+ return { path, meaning: 'false turns the plugin off.', takes: 'true or false' };
555
+ return { path, meaning, takes: takes(node) };
556
+ }
557
+ /** A value from the command line: JSON when it parses (90, true, ["a"]), else the text itself. */
558
+ export function parseConfigValue(text) {
559
+ try {
560
+ return JSON.parse(text);
561
+ }
562
+ catch {
563
+ return text;
564
+ }
565
+ }
566
+ /** `sideby config get [key]`: the value at `key` (undefined when not set). */
567
+ export async function configGet(file, key) {
568
+ const path = configKeyPath(key);
569
+ return { key, value: getIn(await loadConfig(file), path) };
570
+ }
571
+ function setIn(value, path, next, key) {
572
+ const [seg, ...rest] = path;
573
+ if (seg === undefined)
574
+ return next;
575
+ if (value !== undefined && (typeof value !== 'object' || value === null || Array.isArray(value)))
576
+ throw new ConfigError(`${key} cannot be set inside a value that is not an object; set the part before it as a whole (JSON), or unset it first`, { code: 'bad-key' });
577
+ const obj = { ...value };
578
+ const child = setIn(obj[seg], rest, next, key);
579
+ if (child === undefined || (rest.length && isEmptyObject(child) && next === undefined))
580
+ delete obj[seg];
581
+ else
582
+ obj[seg] = child;
583
+ return obj;
584
+ }
585
+ const isEmptyObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) && Object.keys(v).length === 0;
586
+ /**
587
+ * `sideby config set|unset` (spec §3.29): sets `key` to `value`, or removes it when `value` is undefined (and parents
588
+ * left empty with it). The whole result must match the schema; `accounts.<ref>.args` takes only what `argsProblem`
589
+ * accepts (the model and effort arguments, as `sideby model`), and without `argsProblem` it is refused. Written as
590
+ * every other config change (see `editConfigKey`); `dryRun` shows the change without writing.
591
+ */
592
+ export async function editConfigPath(file, key, value, opts = {}) {
593
+ const path = configKeyPath(key);
594
+ if (path[0] === 'plugins' && value !== undefined && typeof value !== 'boolean')
595
+ throw new ConfigError(`${key} takes true or false`, { code: 'bad-value' });
596
+ const top = path[0];
597
+ const r = await editConfigKey(file, top, 'run the command', (current) => {
598
+ const next = setIn(current, path.slice(1), value, key);
599
+ const result = path.length === 1 ? next : isEmptyObject(next) ? undefined : next;
600
+ if (top === 'accounts')
601
+ checkAccountArgs(current, result, opts.argsProblem);
602
+ return result;
603
+ }, opts);
604
+ const inner = path.slice(1);
605
+ return { key, before: getIn(r.before, inner), after: getIn(r.after, inner), changed: r.changed };
606
+ }
607
+ function checkAccountArgs(before, after, argsProblem) {
608
+ for (const [ref, entry] of Object.entries(after ?? {})) {
609
+ const args = entry.args;
610
+ if (!args || JSON.stringify(args) === JSON.stringify(before?.[ref]?.args))
611
+ continue;
612
+ const problem = argsProblem ? argsProblem(ref, args) : 'sideby cannot check these arguments here';
613
+ if (problem)
614
+ throw new ConfigError(`accounts.${ref}.args: ${problem}. \`sideby config\` sets only the model and effort arguments, as \`sideby model ${ref}\` does; other host arguments go in a short command (\`sideby alias add <short> ${ref} -- <args>\`)`, { code: 'bad-value' });
615
+ }
616
+ }
@@ -39,18 +39,29 @@ export declare function sidebyBinProblem(env: Env, why: string): Promise<string
39
39
  * the Family's `selectVar` set for every Account but the Main Account.
40
40
  */
41
41
  export declare function accountEnv(family: FamilyDef, account: Account, baseEnv: Env): Env;
42
- export declare function prepareLaunch(opts: {
42
+ export interface LaunchCommandOptions {
43
43
  account: Account;
44
44
  family: FamilyDef;
45
- baseEnv: Env;
46
45
  accountArgs: readonly string[];
47
46
  userArgs: readonly string[];
48
47
  command: 'run' | 'login';
48
+ /** Config `accountTitle`: set the terminal title to `[<label>]` and keep the Host from replacing it. */
49
+ title?: boolean;
50
+ }
51
+ /**
52
+ * The Host and its arguments for a Launch before any `launch.before` hook: Family defaults, the title arguments, the
53
+ * Account's config arguments, then the user's. Pure: reads no file and runs no Plugin, so a preview can show it.
54
+ */
55
+ export declare function launchCommand(opts: LaunchCommandOptions): {
56
+ bin: string;
57
+ args: string[];
58
+ notice?: string;
59
+ };
60
+ export declare function prepareLaunch(opts: LaunchCommandOptions & {
61
+ baseEnv: Env;
49
62
  bus: HookBus;
50
63
  /** What the Host may show as the Account (`SIDEBY_LABEL`): the short command, else the ref. */
51
64
  label?: string;
52
- /** Config `accountTitle`: set the terminal title to `[<label>]` and keep the Host from replacing it. */
53
- title?: boolean;
54
65
  }): Promise<PreparedLaunch>;
55
66
  /**
56
67
  * Runs the Host with the terminal attached and returns sideby's exit code (spec §3.5):
@@ -72,6 +72,32 @@ export function accountEnv(family, account, baseEnv) {
72
72
  env[family.selectVar] = account.dir;
73
73
  return env;
74
74
  }
75
+ /**
76
+ * The Host and its arguments for a Launch before any `launch.before` hook: Family defaults, the title arguments, the
77
+ * Account's config arguments, then the user's. Pure: reads no file and runs no Plugin, so a preview can show it.
78
+ */
79
+ export function launchCommand(opts) {
80
+ const { account, family } = opts;
81
+ if (opts.command === 'login') {
82
+ // Family arguments still apply: Codex must store a non-main login in the Account's own file.
83
+ const base = family.defaultArgs?.(account, []) ?? [];
84
+ return family.login.args
85
+ ? { bin: family.bin, args: [...base, ...family.login.args] }
86
+ : { bin: family.bin, args: base, notice: family.login.hint };
87
+ }
88
+ const keep = opts.title && family.keepTitle && !opts.userArgs.some((a) => a.includes(family.keepTitle.key))
89
+ ? family.keepTitle.args
90
+ : [];
91
+ return {
92
+ bin: family.bin,
93
+ args: [
94
+ ...(family.defaultArgs?.(account, opts.userArgs) ?? []),
95
+ ...keep,
96
+ ...opts.accountArgs,
97
+ ...opts.userArgs,
98
+ ],
99
+ };
100
+ }
75
101
  export async function prepareLaunch(opts) {
76
102
  const { account, family } = opts;
77
103
  const env = accountEnv(family, account, opts.baseEnv);
@@ -84,29 +110,7 @@ export async function prepareLaunch(opts) {
84
110
  // inherited from another sideby Launch never leaks through.
85
111
  env.SIDEBY_ACCOUNT = account.ref;
86
112
  env.SIDEBY_LABEL = opts.label || account.ref;
87
- let args;
88
- let notice;
89
- if (opts.command === 'login') {
90
- // Family arguments still apply: Codex must store a non-main login in the Account's own file.
91
- const base = family.defaultArgs?.(account, []) ?? [];
92
- if (family.login.args)
93
- args = [...base, ...family.login.args];
94
- else {
95
- args = base;
96
- notice = family.login.hint;
97
- }
98
- }
99
- else {
100
- const keep = opts.title && family.keepTitle && !opts.userArgs.some((a) => a.includes(family.keepTitle.key))
101
- ? family.keepTitle.args
102
- : [];
103
- args = [
104
- ...(family.defaultArgs?.(account, opts.userArgs) ?? []),
105
- ...keep,
106
- ...opts.accountArgs,
107
- ...opts.userArgs,
108
- ];
109
- }
113
+ const { args, notice } = launchCommand(opts);
110
114
  await opts.bus.run('launch.before', family.id, {
111
115
  account,
112
116
  family,
@@ -1,4 +1,5 @@
1
1
  import type { Env } from '../types.ts';
2
+ import { type AutostartContext } from './autostart.ts';
2
3
  import type { Paths } from './paths.ts';
3
4
  import { type LocalBuild } from './version.ts';
4
5
  export interface LocalDirs {
@@ -49,8 +50,17 @@ export interface SwitchResult {
49
50
  /** True when it already ran `to`, and nothing changed. */
50
51
  unchanged?: boolean;
51
52
  restartedPanel: boolean;
53
+ /**
54
+ * Back on npm, `sideby update --yes` of the npm code brought the skill agents read to its version (true), or that
55
+ * failed (false); absent for a switch to the Local Build, which has no skill of its own to bring.
56
+ */
57
+ skillSynced?: boolean;
58
+ /** With `skillSync: 'background'`: that `sideby update --yes` started on its own and is not waited for. */
59
+ skillSyncStarted?: boolean;
52
60
  /** Another install held the lock; nothing ran. */
53
61
  busy?: boolean;
62
+ /** An existing login item (ADR-0029) now starts the sideby switched to; absent when there was none to change. */
63
+ refreshedAutostart?: boolean;
54
64
  }
55
65
  /**
56
66
  * Puts `to` behind the global `sideby`. `local` links the newest Local Build; `npm` restores the kept package or
@@ -60,4 +70,11 @@ export interface SwitchResult {
60
70
  export declare function switchLocal(paths: Paths, d: LocalDirs, to: LocalTarget, env: Env, opts?: {
61
71
  panelPort?: () => Promise<number | null>;
62
72
  onOutput?: (text: string) => void;
73
+ /**
74
+ * Back on npm, wait for the skill (a terminal shows its progress) or start it on its own: it fetches from the
75
+ * network and can take minutes, longer than a Portal waits for an answer (`background`, the Panel).
76
+ */
77
+ skillSync?: 'wait' | 'background';
78
+ /** Internal, for tests: parts of the machine the login item refresh uses. */
79
+ autostart?: Partial<AutostartContext>;
63
80
  }): Promise<SwitchResult>;
@@ -2,10 +2,11 @@
2
2
  // these; `sideby local` and the Panel's version popover switch between the newest Local Build and npm without the
3
3
  // checkout. Only an npm global install is handled: `<prefix>/lib/node_modules/sideby` is the folder every caller
4
4
  // resolves, a link to `<dev>/pkg` while a Local Build runs.
5
- import { lstat, mkdir, readFile, readlink, rename, rm, symlink } from 'node:fs/promises';
5
+ import { lstat, mkdir, readFile, readlink, realpath, rename, rm, symlink } from 'node:fs/promises';
6
6
  import { homedir } from 'node:os';
7
7
  import { dirname, join, relative } from 'node:path';
8
- import { compareVersions, fetchLatest, restartPanelByName, runProgram, takeInstallLock } from './update.js';
8
+ import { refreshAutostart } from './autostart.js';
9
+ import { compareVersions, fetchLatest, restartPanelByName, runProgram, startDetached, takeInstallLock, } from './update.js';
9
10
  import { parseLocalBuild } from './version.js';
10
11
  const BIN = 'dist/src/cli/main.js';
11
12
  export const pkgDir = (d) => join(d.dev, 'pkg');
@@ -122,6 +123,21 @@ export function switchBlocker(s, to) {
122
123
  return 'there is no local build yet; run `pnpm local` in the sideby checkout';
123
124
  return null;
124
125
  }
126
+ /**
127
+ * Points an existing login item at the sideby now behind the global command (spec §3.29): its entry, with this Node.
128
+ * Never creates one and never throws; undefined when there is no login item of sideby's, or the entry is missing.
129
+ */
130
+ async function refreshLoginItem(paths, d, ctx = {}) {
131
+ const entry = await realpath(join(globalPkg(d), BIN)).catch(() => null);
132
+ if (!entry)
133
+ return undefined;
134
+ const r = await refreshAutostart({
135
+ home: paths.home,
136
+ launcher: { node: process.execPath, entry },
137
+ ...ctx,
138
+ });
139
+ return r.state === 'on' || r.state === 'stale' ? r.rewritten : undefined;
140
+ }
125
141
  /**
126
142
  * Puts `to` behind the global `sideby`. `local` links the newest Local Build; `npm` restores the kept package or
127
143
  * installs `latest` (a failed install links the Local Build back, so `sideby` never goes missing). Afterwards a
@@ -183,7 +199,27 @@ export async function switchLocal(paths, d, to, env, opts = {}) {
183
199
  finally {
184
200
  await release().catch(() => { });
185
201
  }
202
+ // The login item records the sideby it starts, like `sideby update` keeps it pointing at the one in use.
203
+ const refreshed = await refreshLoginItem(paths, d, opts.autostart);
204
+ const login = refreshed === undefined ? {} : { refreshedAutostart: refreshed };
186
205
  const port = opts.panelPort ? await opts.panelPort().catch(() => null) : null;
187
206
  const restartedPanel = port !== null && (await restartPanelByName(env, port));
188
- return { ok: true, message, running: to, restartedPanel };
207
+ if (to === 'local')
208
+ return { ok: true, message, running: to, restartedPanel, ...login };
209
+ // The skill is part of the version (spec §3.23): the npm code updates it as `sideby update --yes` does for anyone,
210
+ // by name so the new code runs, after the lock above is free. A failure keeps the switch.
211
+ if (opts.skillSync === 'background')
212
+ return {
213
+ ok: true,
214
+ message,
215
+ running: to,
216
+ restartedPanel,
217
+ ...login,
218
+ skillSyncStarted: await startDetached('sideby', ['update', '--yes'], env),
219
+ };
220
+ const u = await runProgram('sideby', ['update', '--yes'], {
221
+ env,
222
+ ...(opts.onOutput ? { onOutput: opts.onOutput } : {}),
223
+ }).catch(() => ({ code: 1, output: '' }));
224
+ return { ok: true, message, running: to, restartedPanel, ...login, skillSynced: u.code === 0 };
189
225
  }
@@ -0,0 +1,78 @@
1
+ import { HookBus } from '../plugins/bus.ts';
2
+ import type { Account, Env, FamilyDef } from '../types.ts';
3
+ import { type PreparedLaunch } from './launch.ts';
4
+ /** How long `sideby try` waits for the Host (spec §3.28). The spawner enforces it. */
5
+ export declare const PROBE_TIMEOUT_MS = 60000;
6
+ /** Combined stdout and stderr kept after redaction, in bytes. */
7
+ export declare const PROBE_OUTPUT_MAX_BYTES = 2048;
8
+ /**
9
+ * What the injected spawner receives. It must start `bin` with `args` only, in `cwd`, with `env`,
10
+ * and with stdin ignored (not a terminal and not a pipe the Host can wait on). When `timeoutMs`
11
+ * elapses it stops the process and resolves with `timedOut: true`. Errors must not include `env`
12
+ * or anything read from a Secret File.
13
+ */
14
+ export interface ProbeSpawnRequest {
15
+ bin: string;
16
+ args: readonly string[];
17
+ env: Env;
18
+ cwd: string;
19
+ timeoutMs: number;
20
+ /** Always `ignore`: the probe is not interactive. */
21
+ stdin: 'ignore';
22
+ }
23
+ export interface ProbeSpawnResult {
24
+ /** Process exit code, or null when it timed out or the code could not be read. */
25
+ exitCode: number | null;
26
+ stdout: string;
27
+ stderr: string;
28
+ /** True when the spawner stopped the process because `timeoutMs` elapsed. */
29
+ timedOut: boolean;
30
+ }
31
+ export interface ProbeDeps {
32
+ family: FamilyDef;
33
+ /** The environment sideby itself is running in: the same `baseEnv` Launch copies. */
34
+ baseEnv: Env;
35
+ /** Working directory for the Host. The caller creates this temp directory. */
36
+ cwd: string;
37
+ spawn: (req: ProbeSpawnRequest) => Promise<ProbeSpawnResult>;
38
+ /** Account arguments from config, passed through Launch the same way `sideby run` does. */
39
+ accountArgs?: readonly string[];
40
+ /** What Launch would set as `SIDEBY_LABEL`. Defaults to the account ref. */
41
+ label?: string;
42
+ /** `launch.before` hooks. Pass the runtime bus so the probe sees the same environment as Launch. */
43
+ bus?: HookBus;
44
+ /**
45
+ * The Launch already prepared with `probe.args` as the user's arguments (the runtime's `prepareLaunch`), so its
46
+ * hooks and config arguments are applied once; `accountArgs`, `label` and `bus` are then unused.
47
+ */
48
+ launch?: PreparedLaunch;
49
+ /** Defaults to {@link PROBE_TIMEOUT_MS}. */
50
+ timeoutMs?: number;
51
+ }
52
+ export interface ProbeResult {
53
+ /** True when the Host exited 0 before the timeout. */
54
+ ok: boolean;
55
+ exitCode: number | null;
56
+ /**
57
+ * The first 2 KiB of stdout followed by stderr. Secret File values of length 4 or more are
58
+ * replaced with `***` before this cut, so a value that straddles the cut cannot leak.
59
+ */
60
+ output: string;
61
+ /** True when the spawner stopped the Host at the timeout. `ok` is then false. */
62
+ timedOut: boolean;
63
+ }
64
+ /**
65
+ * Runs the Family's probe the way Launch would start the Host: `prepareLaunch`'s environment and
66
+ * arguments (with `probe.args` as the user's arguments), the caller's temp directory as cwd, stdin ignored, and a 60s
67
+ * timeout the spawner enforces. Returns the exit code and the redacted output.
68
+ * Throws a UserError when the Family has no probe; the message says this tool cannot be tried
69
+ * and to start the account with `sideby run` instead.
70
+ */
71
+ export declare function runProbe(account: Account, deps: ProbeDeps): Promise<ProbeResult>;
72
+ /**
73
+ * The real spawner for {@link ProbeDeps.spawn}. The Host leads its own process group, so the timeout stops it and
74
+ * any child it started; the result comes at once, even while a child still holds the output pipes open.
75
+ */
76
+ export declare function spawnProbe(request: ProbeSpawnRequest): Promise<ProbeSpawnResult>;
77
+ /** Replaces each secret of length 4 or more. Longer values go first so one cannot hide inside another. */
78
+ export declare function redactSecrets(text: string, values: readonly string[]): string;