sideby 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +111 -2
  3. package/README.zh-CN.md +112 -0
  4. package/dist/build.json +1 -0
  5. package/dist/src/cli/args.d.ts +13 -0
  6. package/dist/src/cli/args.js +38 -0
  7. package/dist/src/cli/commands.d.ts +50 -0
  8. package/dist/src/cli/commands.js +566 -0
  9. package/dist/src/cli/format.d.ts +16 -0
  10. package/dist/src/cli/format.js +80 -0
  11. package/dist/src/cli/json-schemas.d.ts +2 -0
  12. package/dist/src/cli/json-schemas.js +259 -0
  13. package/dist/src/cli/main.d.ts +2 -0
  14. package/dist/src/cli/main.js +276 -0
  15. package/dist/src/cli/schemas.d.ts +1 -0
  16. package/dist/src/cli/schemas.js +16 -0
  17. package/dist/src/core/account-name.d.ts +2 -0
  18. package/dist/src/core/account-name.js +2 -0
  19. package/dist/src/core/accounts.d.ts +20 -0
  20. package/dist/src/core/accounts.js +131 -0
  21. package/dist/src/core/config.d.ts +61 -0
  22. package/dist/src/core/config.js +184 -0
  23. package/dist/src/core/create.d.ts +30 -0
  24. package/dist/src/core/create.js +126 -0
  25. package/dist/src/core/doctor.d.ts +41 -0
  26. package/dist/src/core/doctor.js +171 -0
  27. package/dist/src/core/errors.d.ts +12 -0
  28. package/dist/src/core/errors.js +14 -0
  29. package/dist/src/core/file-memo.d.ts +24 -0
  30. package/dist/src/core/file-memo.js +41 -0
  31. package/dist/src/core/fs-safe.d.ts +38 -0
  32. package/dist/src/core/fs-safe.js +205 -0
  33. package/dist/src/core/handoff.d.ts +41 -0
  34. package/dist/src/core/handoff.js +54 -0
  35. package/dist/src/core/identity.d.ts +23 -0
  36. package/dist/src/core/identity.js +79 -0
  37. package/dist/src/core/last-doctor.d.ts +36 -0
  38. package/dist/src/core/last-doctor.js +69 -0
  39. package/dist/src/core/launch.d.ts +37 -0
  40. package/dist/src/core/launch.js +108 -0
  41. package/dist/src/core/login-path.d.ts +23 -0
  42. package/dist/src/core/login-path.js +96 -0
  43. package/dist/src/core/paths.d.ts +13 -0
  44. package/dist/src/core/paths.js +32 -0
  45. package/dist/src/core/quota-levels.d.ts +19 -0
  46. package/dist/src/core/quota-levels.js +34 -0
  47. package/dist/src/core/resume.d.ts +9 -0
  48. package/dist/src/core/resume.js +15 -0
  49. package/dist/src/core/secret-file.d.ts +30 -0
  50. package/dist/src/core/secret-file.js +116 -0
  51. package/dist/src/core/share-meaning.d.ts +6 -0
  52. package/dist/src/core/share-meaning.js +32 -0
  53. package/dist/src/core/share-mode-names.d.ts +1 -0
  54. package/dist/src/core/share-mode-names.js +10 -0
  55. package/dist/src/core/share-modes.d.ts +24 -0
  56. package/dist/src/core/share-modes.js +405 -0
  57. package/dist/src/core/shell-init.d.ts +31 -0
  58. package/dist/src/core/shell-init.js +84 -0
  59. package/dist/src/core/skill-check.d.ts +11 -0
  60. package/dist/src/core/skill-check.js +57 -0
  61. package/dist/src/core/usage-days.d.ts +15 -0
  62. package/dist/src/core/usage-days.js +33 -0
  63. package/dist/src/core/version.d.ts +9 -0
  64. package/dist/src/core/version.js +46 -0
  65. package/dist/src/desktop/app.d.ts +60 -0
  66. package/dist/src/desktop/app.js +316 -0
  67. package/dist/src/desktop/icon.d.ts +16 -0
  68. package/dist/src/desktop/icon.js +174 -0
  69. package/dist/src/families/claude/index.d.ts +10 -0
  70. package/dist/src/families/claude/index.js +127 -0
  71. package/dist/src/families/claude/layout.d.ts +6 -0
  72. package/dist/src/families/claude/layout.js +5 -0
  73. package/dist/src/families/claude/quota-cache.d.ts +31 -0
  74. package/dist/src/families/claude/quota-cache.js +111 -0
  75. package/dist/src/families/claude/quota.d.ts +3 -0
  76. package/dist/src/families/claude/quota.js +53 -0
  77. package/dist/src/families/claude/usage.d.ts +22 -0
  78. package/dist/src/families/claude/usage.js +146 -0
  79. package/dist/src/families/codex/index.d.ts +5 -0
  80. package/dist/src/families/codex/index.js +70 -0
  81. package/dist/src/families/codex/rollout.d.ts +67 -0
  82. package/dist/src/families/codex/rollout.js +298 -0
  83. package/dist/src/families/grok/index.d.ts +8 -0
  84. package/dist/src/families/grok/index.js +82 -0
  85. package/dist/src/families/index.d.ts +3 -0
  86. package/dist/src/families/index.js +40 -0
  87. package/dist/src/families/logos.d.ts +21 -0
  88. package/dist/src/families/logos.js +26 -0
  89. package/dist/src/families/pi/index.d.ts +3 -0
  90. package/dist/src/families/pi/index.js +54 -0
  91. package/dist/src/families/shared/sessions.d.ts +9 -0
  92. package/dist/src/families/shared/sessions.js +70 -0
  93. package/dist/src/families/shared/toml.d.ts +5 -0
  94. package/dist/src/families/shared/toml.js +16 -0
  95. package/dist/src/index.d.ts +5 -0
  96. package/dist/src/index.js +4 -0
  97. package/dist/src/panel/background.d.ts +62 -0
  98. package/dist/src/panel/background.js +274 -0
  99. package/dist/src/panel/handler.d.ts +91 -0
  100. package/dist/src/panel/handler.js +396 -0
  101. package/dist/src/panel/i18n.d.ts +287 -0
  102. package/dist/src/panel/i18n.js +570 -0
  103. package/dist/src/panel/page-logic.d.ts +154 -0
  104. package/dist/src/panel/page-logic.js +326 -0
  105. package/dist/src/panel/page-script.d.ts +2 -0
  106. package/dist/src/panel/page-script.js +1633 -0
  107. package/dist/src/panel/page-styles.d.ts +7 -0
  108. package/dist/src/panel/page-styles.js +514 -0
  109. package/dist/src/panel/page.d.ts +20 -0
  110. package/dist/src/panel/page.js +96 -0
  111. package/dist/src/panel/server.d.ts +32 -0
  112. package/dist/src/panel/server.js +133 -0
  113. package/dist/src/panel/theme.d.ts +116 -0
  114. package/dist/src/panel/theme.js +132 -0
  115. package/dist/src/plugins/account-script.d.ts +4 -0
  116. package/dist/src/plugins/account-script.js +66 -0
  117. package/dist/src/plugins/bus.d.ts +33 -0
  118. package/dist/src/plugins/bus.js +84 -0
  119. package/dist/src/plugins/loader.d.ts +58 -0
  120. package/dist/src/plugins/loader.js +250 -0
  121. package/dist/src/quota/claude-statusline.d.ts +28 -0
  122. package/dist/src/quota/claude-statusline.js +301 -0
  123. package/dist/src/quota/statusline-tap.d.ts +11 -0
  124. package/dist/src/quota/statusline-tap.js +104 -0
  125. package/dist/src/runtime.d.ts +195 -0
  126. package/dist/src/runtime.js +424 -0
  127. package/dist/src/types.d.ts +285 -0
  128. package/dist/src/types.js +3 -0
  129. package/dist/testing/demo.d.ts +8 -0
  130. package/dist/testing/demo.js +50 -0
  131. package/dist/testing/index.d.ts +36 -0
  132. package/dist/testing/index.js +118 -0
  133. package/package.json +66 -4
  134. package/schemas/alias.json +102 -0
  135. package/schemas/app-install.json +74 -0
  136. package/schemas/app-uninstall.json +54 -0
  137. package/schemas/config.json +170 -0
  138. package/schemas/doctor.json +229 -0
  139. package/schemas/error.json +25 -0
  140. package/schemas/families.json +205 -0
  141. package/schemas/list.json +177 -0
  142. package/schemas/new.json +199 -0
  143. package/schemas/next.json +127 -0
  144. package/schemas/plugins.json +74 -0
  145. package/schemas/quota-setup.json +60 -0
  146. package/schemas/quota-teardown.json +30 -0
  147. package/schemas/quota.json +203 -0
  148. package/skills/sideby/SKILL.md +61 -0
  149. package/skills/sideby/references/accounts.md +31 -0
  150. package/skills/sideby/references/diagnose.md +47 -0
  151. package/skills/sideby/references/handoff.md +41 -0
  152. package/skills/sideby/references/quota.md +14 -0
  153. package/skills/sideby/references/setup.md +31 -0
  154. package/skills/sideby/references/sharing.md +17 -0
@@ -0,0 +1,40 @@
1
+ import { packageVersion } from '../core/version.js';
2
+ import { accountScript } from '../plugins/account-script.js';
3
+ import { claudePlugin } from './claude/index.js';
4
+ import { codexPlugin } from './codex/index.js';
5
+ import { grokPlugin } from './grok/index.js';
6
+ import { piPlugin } from './pi/index.js';
7
+ const VERSION = packageVersion();
8
+ /** Built-in Plugins in load order. Families register here; `account-script` is off unless enabled. */
9
+ export const BUILTIN_PLUGINS = [
10
+ {
11
+ plugin: claudePlugin,
12
+ version: VERSION,
13
+ description: 'Claude Code family (~/.claude, CLAUDE_CONFIG_DIR)',
14
+ defaultEnabled: true,
15
+ },
16
+ {
17
+ plugin: codexPlugin,
18
+ version: VERSION,
19
+ description: 'Codex family (~/.codex, CODEX_HOME)',
20
+ defaultEnabled: true,
21
+ },
22
+ {
23
+ plugin: grokPlugin,
24
+ version: VERSION,
25
+ description: 'Grok Build family (~/.grok, GROK_HOME)',
26
+ defaultEnabled: true,
27
+ },
28
+ {
29
+ plugin: piPlugin,
30
+ version: VERSION,
31
+ description: 'pi family (~/.pi/agent, PI_CODING_AGENT_DIR)',
32
+ defaultEnabled: true,
33
+ },
34
+ {
35
+ plugin: accountScript,
36
+ version: VERSION,
37
+ description: 'Run <account>/sideby-before-launch before each launch',
38
+ defaultEnabled: false,
39
+ },
40
+ ];
@@ -0,0 +1,21 @@
1
+ /** Logos of the built-in Families, by Family id; each Family sets its own as `logo`. */
2
+ export declare const BUILTIN_LOGOS: {
3
+ claude: {
4
+ title: string;
5
+ color: string;
6
+ path: string;
7
+ };
8
+ codex: {
9
+ title: string;
10
+ path: string;
11
+ };
12
+ grok: {
13
+ title: string;
14
+ fillRule: "evenodd";
15
+ path: string;
16
+ };
17
+ pi: {
18
+ title: string;
19
+ path: string;
20
+ };
21
+ };
@@ -0,0 +1,26 @@
1
+ // Built-in Family marks for the Panel (FamilyDef.logo): 24x24 single-path SVG icons, drawn inline because the page CSP
2
+ // allows no external images.
3
+ // Sources: Claude, OpenAI and pi from Simple Icons (CC0 1.0, https://simpleicons.org; OpenAI from v15.0.0, the last release
4
+ // that ships it; pi is the mark from https://pi.dev). Grok from LobeHub Icons (MIT, Copyright (c) 2023 LobeHub,
5
+ // https://github.com/lobehub/lobe-icons). The marks are trademarks of their owners and only identify each Host.
6
+ /** Logos of the built-in Families, by Family id; each Family sets its own as `logo`. */
7
+ export const BUILTIN_LOGOS = {
8
+ claude: {
9
+ title: 'Claude',
10
+ color: '#D97757',
11
+ path: 'm4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z',
12
+ },
13
+ codex: {
14
+ title: 'OpenAI',
15
+ path: 'M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z',
16
+ },
17
+ grok: {
18
+ title: 'Grok',
19
+ fillRule: 'evenodd',
20
+ path: 'M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815',
21
+ },
22
+ pi: {
23
+ title: 'pi',
24
+ path: 'M0 0v24h6v-6h6v-6H6V6h6v6h6V0Zm18 12v12h6V12Z',
25
+ },
26
+ };
@@ -0,0 +1,3 @@
1
+ import type { FamilyDef, Plugin } from '../../types.ts';
2
+ export declare const piFamily: FamilyDef;
3
+ export declare const piPlugin: Plugin;
@@ -0,0 +1,54 @@
1
+ // Built-in Family Plugin for pi (spec §3.4, checked against pi 1.0.2). pi keeps each Account one level
2
+ // deeper (`~/.pi-<name>/agent`) and signs in inside the app, so there is no login command.
3
+ import { readFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { lstatOrNull } from '../../core/fs-safe.js';
6
+ import { BUILTIN_LOGOS } from '../logos.js';
7
+ /** `auth.json` exists after `/login`; its contents are never read. */
8
+ async function loginState(account) {
9
+ return (await lstatOrNull(join(account.dir, 'auth.json'))) ? 'logged-in' : 'logged-out';
10
+ }
11
+ async function model(account) {
12
+ try {
13
+ const s = JSON.parse(await readFile(join(account.dir, 'settings.json'), 'utf8'));
14
+ const provider = typeof s?.defaultProvider === 'string' ? s.defaultProvider : '';
15
+ const id = typeof s?.defaultModel === 'string' ? s.defaultModel : '';
16
+ if (!id)
17
+ return undefined;
18
+ return provider ? `${provider}/${id}` : id;
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ }
24
+ export const piFamily = {
25
+ id: 'pi',
26
+ title: 'pi',
27
+ logo: BUILTIN_LOGOS.pi,
28
+ bin: 'pi',
29
+ installUrl: 'https://github.com/earendil-works/pi',
30
+ selectVar: 'PI_CODING_AGENT_DIR',
31
+ secretFileMeansApi: false,
32
+ layout: { main: '.pi/agent', account: '.pi-<name>/agent' },
33
+ hijackVars: [
34
+ 'ANTHROPIC_API_KEY',
35
+ 'ANTHROPIC_AUTH_TOKEN',
36
+ 'ANTHROPIC_BASE_URL',
37
+ 'OPENAI_API_KEY',
38
+ 'OPENAI_BASE_URL',
39
+ ],
40
+ apiVars: ['ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL', 'OPENAI_API_KEY', 'OPENAI_BASE_URL'],
41
+ sharedItems: [
42
+ { path: 'AGENTS.md', mode: 'link' },
43
+ { path: 'auth.json', mode: 'info', credential: true },
44
+ ],
45
+ login: { args: null, hint: 'pi signs in inside the app: start it and type /login' },
46
+ loginState,
47
+ model,
48
+ };
49
+ export const piPlugin = {
50
+ name: 'pi',
51
+ register(api) {
52
+ api.family(piFamily);
53
+ },
54
+ };
@@ -0,0 +1,9 @@
1
+ export declare const isSessionId: (s: string | undefined) => s is string;
2
+ /** The id after `--resume <id>`, `--resume=<id>` or `-r <id>`; Claude Code and Grok Build share this form. */
3
+ export declare function resumeFlagSession(args: readonly string[]): string | undefined;
4
+ /** The first UUID after the `resume` subcommand (`codex resume [options] <id>`). */
5
+ export declare function resumeSubcommandSession(args: readonly string[]): string | undefined;
6
+ /** `<dir>/*` + `/<entry>`: one level of grouping directories (Claude projects, Grok working directories). */
7
+ export declare function groupedEntryWrittenAt(dir: string, entry: string, type: 'file' | 'dir'): Promise<number | undefined>;
8
+ /** A file anywhere under `dir` whose name ends with `suffix` (Codex `sessions/YYYY/MM/DD/rollout-…-<id>.jsonl`). */
9
+ export declare function nestedFileWrittenAt(dir: string, suffix: string): Promise<number | undefined>;
@@ -0,0 +1,70 @@
1
+ // Reading which session a Host command resumes, and whether an Account holds it (ADR-0007).
2
+ import { readdir, stat } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5
+ export const isSessionId = (s) => s !== undefined && SESSION_ID.test(s);
6
+ /** Arguments before the first `--`, the ones the Host reads as its own options. */
7
+ const options = (args) => {
8
+ const end = args.indexOf('--');
9
+ return end === -1 ? args : args.slice(0, end);
10
+ };
11
+ /** The id after `--resume <id>`, `--resume=<id>` or `-r <id>`; Claude Code and Grok Build share this form. */
12
+ export function resumeFlagSession(args) {
13
+ const opts = options(args);
14
+ for (let i = 0; i < opts.length; i++) {
15
+ const a = opts[i];
16
+ const id = a.startsWith('--resume=')
17
+ ? a.slice('--resume='.length)
18
+ : a === '--resume' || a === '-r'
19
+ ? opts[i + 1]
20
+ : undefined;
21
+ if (isSessionId(id))
22
+ return id;
23
+ }
24
+ return undefined;
25
+ }
26
+ /** The first UUID after the `resume` subcommand (`codex resume [options] <id>`). */
27
+ export function resumeSubcommandSession(args) {
28
+ const opts = options(args);
29
+ const at = opts.indexOf('resume');
30
+ return at === -1 ? undefined : opts.slice(at + 1).find(isSessionId);
31
+ }
32
+ /** Modification time in ms of the first of `paths` that exists with the wanted type. */
33
+ async function firstWrittenAt(paths, type) {
34
+ for (const p of paths) {
35
+ try {
36
+ const st = await stat(p);
37
+ if (type === 'file' ? st.isFile() : st.isDirectory())
38
+ return st.mtimeMs;
39
+ }
40
+ catch {
41
+ // not here
42
+ }
43
+ }
44
+ return undefined;
45
+ }
46
+ async function subdirs(dir) {
47
+ try {
48
+ return (await readdir(dir, { withFileTypes: true }))
49
+ .filter((e) => e.isDirectory())
50
+ .map((e) => join(dir, e.name));
51
+ }
52
+ catch {
53
+ return [];
54
+ }
55
+ }
56
+ /** `<dir>/*` + `/<entry>`: one level of grouping directories (Claude projects, Grok working directories). */
57
+ export async function groupedEntryWrittenAt(dir, entry, type) {
58
+ return firstWrittenAt((await subdirs(dir)).map((d) => join(d, entry)), type);
59
+ }
60
+ /** A file anywhere under `dir` whose name ends with `suffix` (Codex `sessions/YYYY/MM/DD/rollout-…-<id>.jsonl`). */
61
+ export async function nestedFileWrittenAt(dir, suffix) {
62
+ let names;
63
+ try {
64
+ names = await readdir(dir, { recursive: true });
65
+ }
66
+ catch {
67
+ return undefined;
68
+ }
69
+ return firstWrittenAt(names.filter((n) => n.endsWith(suffix)).map((n) => join(dir, n)), 'file');
70
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * `key = "value"` among the top-level keys of a TOML file, i.e. before the first `[table]` header.
3
+ * Only a whole-line header ends the top level, so a multi-line array line such as ` ["a"],` does not.
4
+ */
5
+ export declare function topLevelTomlString(text: string, key: string): string | undefined;
@@ -0,0 +1,16 @@
1
+ const TABLE_HEADER = /^\s*\[\[?[^\]\s][^\]]*\]\]?\s*(#.*)?$/;
2
+ /**
3
+ * `key = "value"` among the top-level keys of a TOML file, i.e. before the first `[table]` header.
4
+ * Only a whole-line header ends the top level, so a multi-line array line such as ` ["a"],` does not.
5
+ */
6
+ export function topLevelTomlString(text, key) {
7
+ const pattern = new RegExp(`^\\s*${key}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`);
8
+ for (const line of text.split(/\r?\n/)) {
9
+ if (TABLE_HEADER.test(line) && !/^\s*\[\s*["'\d]/.test(line))
10
+ return undefined;
11
+ const m = pattern.exec(line);
12
+ if (m)
13
+ return m[1] ?? m[2];
14
+ }
15
+ return undefined;
16
+ }
@@ -0,0 +1,5 @@
1
+ export { createPanelHandler, type PanelFamily, type PanelHandler, type PanelHandlerOptions, type PanelState, } from './panel/handler.ts';
2
+ export { startPanelServer } from './panel/server.ts';
3
+ export type { PanelTheme, PanelThemeTokens } from './panel/theme.ts';
4
+ export { type AccountStatus, createRuntime, type DoctorOptions, type FamilyDetail, type FamilyInfo, type Runtime, type SharedItemInfo, } from './runtime.ts';
5
+ export type * from './types.ts';
@@ -0,0 +1,4 @@
1
+ // Library entry: the Panel handler for embedding, the Runtime for tools built on sideby, and Plugin types.
2
+ export { createPanelHandler, } from './panel/handler.js';
3
+ export { startPanelServer } from './panel/server.js';
4
+ export { createRuntime, } from './runtime.js';
@@ -0,0 +1,62 @@
1
+ import type { Paths } from '../core/paths.ts';
2
+ import type { Env } from '../types.ts';
3
+ export declare const DETACHED_FLAG = "serve-detached";
4
+ export interface PanelPidFile {
5
+ pid: number;
6
+ port: number;
7
+ url: string;
8
+ startedAt: string;
9
+ /** Random id the Panel also returns on `/api/health`. */
10
+ instance: string;
11
+ /** `ps -o lstart=` of the process, when `ps` gave one; a reused pid has another. */
12
+ processStart?: string;
13
+ }
14
+ export declare const pidFilePath: (p: Paths) => string;
15
+ export declare const logFilePath: (p: Paths) => string;
16
+ export declare function readPidFile(p: Paths): Promise<PanelPidFile | undefined>;
17
+ /** When `pid` started, as `ps` prints it; with the pid it names one process, which a reused pid does not match. */
18
+ export declare const processStartTime: (pid: number) => Promise<string | undefined>;
19
+ /** URL of a running background Panel, or of any sideby Panel on `port`; removes a stale pid file. */
20
+ export declare function findRunningPanel(p: Paths, port: number): Promise<string | undefined>;
21
+ export interface StartBackgroundOptions {
22
+ paths: Paths;
23
+ env: Env;
24
+ /** Absolute path of the sideby CLI entry the detached process runs. */
25
+ entry: string;
26
+ port?: number;
27
+ }
28
+ /**
29
+ * Makes sure one background Panel runs and returns its URL. Reuses a running one; otherwise starts
30
+ * `sideby ui --serve-detached` in its own session, logging to the state directory, and waits until it listens.
31
+ */
32
+ export declare function startBackgroundPanel(opts: StartBackgroundOptions): Promise<{
33
+ url: string;
34
+ reused: boolean;
35
+ pid?: number;
36
+ replaced?: boolean;
37
+ }>;
38
+ /**
39
+ * Body of the detached process: serve the Panel with Hosts found through the login shell's PATH, tell the
40
+ * launching process the URL over IPC, keep the pid file while running, and remove it on SIGTERM or SIGINT.
41
+ */
42
+ export declare function serveDetached(opts: {
43
+ paths: Paths;
44
+ env: Env;
45
+ port?: number;
46
+ runtimeFactory: (env: Env) => Promise<import('../runtime.ts').Runtime>;
47
+ log: (s: string) => void;
48
+ }): Promise<number>;
49
+ export type StopResult = {
50
+ status: 'stopped';
51
+ pid: number;
52
+ url: string;
53
+ } | {
54
+ status: 'not-running';
55
+ detail?: string;
56
+ } | {
57
+ status: 'failed';
58
+ pid: number;
59
+ detail: string;
60
+ };
61
+ /** Stops the background Panel named in the pid file, signalling it only when checkPanel confirms it. */
62
+ export declare function stopBackgroundPanel(paths: Paths): Promise<StopResult>;
@@ -0,0 +1,274 @@
1
+ // `sideby ui --background` and `--stop`: one detached Panel per state directory, found again through a pid file.
2
+ // The detached process runs `sideby ui --serve-detached`; that flag is internal. `--stop` signals a pid only when
3
+ // the process, its start time and the Panel answering on the recorded port all match the pid file.
4
+ import { execFile, spawn } from 'node:child_process';
5
+ import { randomBytes } from 'node:crypto';
6
+ import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
7
+ import { join } from 'node:path';
8
+ import { loginShellPath } from '../core/login-path.js';
9
+ import { buildId } from '../core/version.js';
10
+ import { DEFAULT_PANEL_PORT, isSidebyPanel, panelIdentity, startPanelServer, } from './server.js';
11
+ export const DETACHED_FLAG = 'serve-detached';
12
+ const START_TIMEOUT_MS = 20_000;
13
+ const LOCK_STALE_MS = 30_000;
14
+ const STOP_TIMEOUT_MS = 5000;
15
+ const LOG_MAX_BYTES = 1_000_000;
16
+ export const pidFilePath = (p) => join(p.stateDir, 'panel.json');
17
+ export const logFilePath = (p) => join(p.stateDir, 'panel.log');
18
+ const lockPath = (p) => join(p.stateDir, 'panel.lock');
19
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
20
+ export async function readPidFile(p) {
21
+ try {
22
+ const d = JSON.parse(await readFile(pidFilePath(p), 'utf8'));
23
+ if (Number.isInteger(d.pid) && Number.isInteger(d.port) && typeof d.url === 'string')
24
+ return { ...d, instance: typeof d.instance === 'string' ? d.instance : '' };
25
+ }
26
+ catch { }
27
+ return undefined;
28
+ }
29
+ function alive(pid) {
30
+ try {
31
+ process.kill(pid, 0);
32
+ return true;
33
+ }
34
+ catch (err) {
35
+ return err.code === 'EPERM';
36
+ }
37
+ }
38
+ /** Output of `ps -o <field>= -p <pid>`, or undefined when the process is gone or `ps` is unavailable. */
39
+ function psField(pid, field) {
40
+ return new Promise((resolve) => {
41
+ execFile('ps', ['-ww', '-o', `${field}=`, '-p', String(pid)], { timeout: 3000, env: { ...process.env, LC_ALL: 'C' } }, (err, stdout) => resolve(err ? undefined : stdout.trim() || undefined));
42
+ });
43
+ }
44
+ /** When `pid` started, as `ps` prints it; with the pid it names one process, which a reused pid does not match. */
45
+ export const processStartTime = (pid) => psField(pid, 'lstart');
46
+ /**
47
+ * Checks that the pid file names a live detached Panel: the process is alive, started when the pid file says,
48
+ * runs `ui --serve-detached`, and the Panel on the recorded port answers with the same instance id and pid.
49
+ */
50
+ async function checkPanel(rec) {
51
+ const stale = (reason) => ({ status: 'stale', reason });
52
+ if (!rec.instance)
53
+ return stale('it names no panel instance');
54
+ if (!alive(rec.pid))
55
+ return stale(`process ${rec.pid} is not running`);
56
+ if (rec.processStart && (await processStartTime(rec.pid)) !== rec.processStart)
57
+ return stale(`process ${rec.pid} is not the one that wrote it`);
58
+ const cmd = await psField(rec.pid, 'command');
59
+ if (!(cmd && / ui( |$)/.test(cmd) && cmd.includes(`--${DETACHED_FLAG}`)))
60
+ return stale(`process ${rec.pid} is not a sideby panel`);
61
+ const id = await panelIdentity(rec.port);
62
+ if (!id)
63
+ return {
64
+ status: 'unconfirmed',
65
+ reason: `process ${rec.pid} does not answer as a sideby panel on port ${rec.port}; if it is one, stop it with \`kill ${rec.pid}\``,
66
+ };
67
+ if (id.instance !== rec.instance || id.pid !== rec.pid)
68
+ return stale(`the panel on port ${rec.port} is not the one it names`);
69
+ return { status: 'ours', ...(id.build ? { build: id.build } : {}) };
70
+ }
71
+ /** URL of a running background Panel, or of any sideby Panel on `port`; removes a stale pid file. */
72
+ export async function findRunningPanel(p, port) {
73
+ const rec = await readPidFile(p);
74
+ if (rec) {
75
+ const check = await checkPanel(rec);
76
+ if (check.status === 'ours')
77
+ return rec.url;
78
+ if (check.status === 'stale')
79
+ await rm(pidFilePath(p), { force: true });
80
+ }
81
+ return (await isSidebyPanel(port)) ? `http://127.0.0.1:${port}/` : undefined;
82
+ }
83
+ /**
84
+ * Stops this state directory's background Panel when it runs another build than `build` (or is older than build
85
+ * ids). Only a Panel the pid file names and that answers as itself is stopped; true when one was.
86
+ */
87
+ async function stopOutdated(p, build) {
88
+ const rec = await readPidFile(p);
89
+ if (!rec)
90
+ return false;
91
+ const check = await checkPanel(rec);
92
+ if (check.status !== 'ours' || check.build === build)
93
+ return false;
94
+ return (await stopBackgroundPanel(p)).status === 'stopped';
95
+ }
96
+ /** Takes the start lock, waiting while another launch holds it; a lock older than 30 s is taken over. */
97
+ async function withStartLock(p, fn) {
98
+ const lock = lockPath(p);
99
+ const deadline = Date.now() + START_TIMEOUT_MS;
100
+ for (;;) {
101
+ try {
102
+ const fh = await open(lock, 'wx', 0o600);
103
+ await fh.writeFile(String(process.pid));
104
+ await fh.close();
105
+ break;
106
+ }
107
+ catch (err) {
108
+ if (err.code !== 'EEXIST')
109
+ throw err;
110
+ const age = await stat(lock).then((s) => Date.now() - s.mtimeMs, () => 0);
111
+ if (age > LOCK_STALE_MS || Date.now() > deadline)
112
+ await rm(lock, { force: true });
113
+ else
114
+ await sleep(200);
115
+ }
116
+ }
117
+ try {
118
+ return await fn();
119
+ }
120
+ finally {
121
+ await rm(lock, { force: true });
122
+ }
123
+ }
124
+ async function openLog(p) {
125
+ const log = logFilePath(p);
126
+ const size = await stat(log).then((s) => s.size, () => 0);
127
+ if (size > LOG_MAX_BYTES)
128
+ await rename(log, `${log}.1`).catch(() => { });
129
+ return open(log, 'a', 0o600);
130
+ }
131
+ /**
132
+ * Makes sure one background Panel runs and returns its URL. Reuses a running one; otherwise starts
133
+ * `sideby ui --serve-detached` in its own session, logging to the state directory, and waits until it listens.
134
+ */
135
+ export async function startBackgroundPanel(opts) {
136
+ const { paths } = opts;
137
+ const port = opts.port ?? DEFAULT_PANEL_PORT;
138
+ // After an upgrade or reinstall the old background Panel still runs the old code: replace it.
139
+ const replaced = await stopOutdated(paths, buildId(opts.env));
140
+ const running = await findRunningPanel(paths, port);
141
+ if (running)
142
+ return { url: running, reused: true };
143
+ await mkdir(paths.stateDir, { recursive: true, mode: 0o700 });
144
+ return withStartLock(paths, async () => {
145
+ // Another launch may have started one while this one waited for the lock.
146
+ const again = await findRunningPanel(paths, port);
147
+ if (again)
148
+ return { url: again, reused: true };
149
+ const log = await openLog(paths);
150
+ try {
151
+ await log.write(`\n[${new Date().toISOString()}] starting background panel on port ${port}\n`);
152
+ const child = spawn(process.execPath, [opts.entry, 'ui', `--${DETACHED_FLAG}`, '--no-open', '--port', String(port)], {
153
+ env: opts.env,
154
+ detached: true,
155
+ stdio: ['ignore', log.fd, log.fd, 'ipc'],
156
+ });
157
+ const msg = await new Promise((resolve, reject) => {
158
+ const timer = setTimeout(() => {
159
+ child.kill('SIGTERM');
160
+ reject(new Error(`the background panel did not start; see ${logFilePath(paths)}`));
161
+ }, START_TIMEOUT_MS);
162
+ child.once('message', (m) => {
163
+ clearTimeout(timer);
164
+ resolve(m);
165
+ });
166
+ child.once('error', (err) => {
167
+ clearTimeout(timer);
168
+ reject(err);
169
+ });
170
+ child.once('exit', (code) => {
171
+ clearTimeout(timer);
172
+ reject(new Error(`the background panel exited (code ${code}); see ${logFilePath(paths)}`));
173
+ });
174
+ });
175
+ child.removeAllListeners();
176
+ if (child.connected)
177
+ child.disconnect();
178
+ child.unref();
179
+ if (msg.reused)
180
+ return { url: msg.url, reused: true };
181
+ return { url: msg.url, reused: false, pid: child.pid, ...(replaced ? { replaced } : {}) };
182
+ }
183
+ finally {
184
+ await log.close();
185
+ }
186
+ });
187
+ }
188
+ /**
189
+ * Body of the detached process: serve the Panel with Hosts found through the login shell's PATH, tell the
190
+ * launching process the URL over IPC, keep the pid file while running, and remove it on SIGTERM or SIGINT.
191
+ */
192
+ export async function serveDetached(opts) {
193
+ const { paths, log } = opts;
194
+ // Started now, awaited by the first request: the server listens without waiting for the shell.
195
+ const envReady = loginShellPath(opts.env).then((r) => {
196
+ log(r.source === 'login-shell'
197
+ ? 'host PATH: from the login shell'
198
+ : `host PATH: login shell unusable (${r.reason}); using the inherited PATH`);
199
+ return { ...opts.env, PATH: r.path };
200
+ });
201
+ const instance = randomBytes(16).toString('hex');
202
+ const srv = await startPanelServer({
203
+ runtimeFactory: async () => opts.runtimeFactory(await envReady),
204
+ instance,
205
+ ...(opts.port ? { port: opts.port } : {}),
206
+ open: false,
207
+ log,
208
+ });
209
+ const send = (m) => {
210
+ if (process.send)
211
+ process.send(m);
212
+ if (process.connected)
213
+ process.disconnect();
214
+ };
215
+ if (srv.reused) {
216
+ send({ url: srv.url, reused: true });
217
+ return 0;
218
+ }
219
+ const rec = {
220
+ pid: process.pid,
221
+ port: Number(new URL(srv.url).port),
222
+ url: srv.url,
223
+ startedAt: new Date().toISOString(),
224
+ instance,
225
+ };
226
+ const processStart = await processStartTime(process.pid);
227
+ if (processStart)
228
+ rec.processStart = processStart;
229
+ await writeFile(pidFilePath(paths), `${JSON.stringify(rec, null, 2)}\n`, { mode: 0o600 });
230
+ send({ url: srv.url, reused: false });
231
+ await new Promise((resolve) => {
232
+ const stop = (sig) => {
233
+ log(`[${new Date().toISOString()}] ${sig}: stopping`);
234
+ void srv.close().finally(resolve);
235
+ };
236
+ process.once('SIGTERM', () => stop('SIGTERM'));
237
+ process.once('SIGINT', () => stop('SIGINT'));
238
+ process.on('SIGHUP', () => { });
239
+ });
240
+ const now = await readPidFile(paths);
241
+ if (now?.pid === process.pid)
242
+ await rm(pidFilePath(paths), { force: true });
243
+ return 0;
244
+ }
245
+ /** Stops the background Panel named in the pid file, signalling it only when checkPanel confirms it. */
246
+ export async function stopBackgroundPanel(paths) {
247
+ const rec = await readPidFile(paths);
248
+ if (!rec)
249
+ return { status: 'not-running' };
250
+ const check = await checkPanel(rec);
251
+ if (check.status === 'stale') {
252
+ await rm(pidFilePath(paths), { force: true });
253
+ return { status: 'not-running', detail: `removed a stale pid file (${check.reason})` };
254
+ }
255
+ if (check.status === 'unconfirmed')
256
+ return { status: 'failed', pid: rec.pid, detail: check.reason };
257
+ try {
258
+ process.kill(rec.pid, 'SIGTERM');
259
+ }
260
+ catch (err) {
261
+ return { status: 'failed', pid: rec.pid, detail: err.message };
262
+ }
263
+ const deadline = Date.now() + STOP_TIMEOUT_MS;
264
+ while (alive(rec.pid) && Date.now() < deadline)
265
+ await sleep(100);
266
+ if (alive(rec.pid))
267
+ return {
268
+ status: 'failed',
269
+ pid: rec.pid,
270
+ detail: `process ${rec.pid} did not exit within ${STOP_TIMEOUT_MS / 1000} s; stop it with \`kill ${rec.pid}\``,
271
+ };
272
+ await rm(pidFilePath(paths), { force: true });
273
+ return { status: 'stopped', pid: rec.pid, url: rec.url };
274
+ }