sideby 0.1.0 → 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 (38) hide show
  1. package/README.md +71 -413
  2. package/README.zh-CN.md +71 -414
  3. package/dist/build.json +1 -1
  4. package/dist/src/cli/commands.d.ts +13 -0
  5. package/dist/src/cli/commands.js +86 -7
  6. package/dist/src/cli/json-schemas.js +35 -0
  7. package/dist/src/cli/main.js +99 -9
  8. package/dist/src/core/accounts.d.ts +2 -0
  9. package/dist/src/core/accounts.js +15 -0
  10. package/dist/src/core/config.d.ts +1 -0
  11. package/dist/src/core/config.js +3 -0
  12. package/dist/src/core/launch.d.ts +3 -1
  13. package/dist/src/core/resume.d.ts +9 -0
  14. package/dist/src/core/resume.js +15 -0
  15. package/dist/src/core/share-meaning.d.ts +6 -0
  16. package/dist/src/core/share-meaning.js +32 -0
  17. package/dist/src/core/shell-init.d.ts +7 -1
  18. package/dist/src/core/shell-init.js +25 -2
  19. package/dist/src/families/claude/index.js +3 -0
  20. package/dist/src/families/codex/index.js +3 -0
  21. package/dist/src/families/grok/index.js +4 -0
  22. package/dist/src/families/shared/sessions.d.ts +9 -0
  23. package/dist/src/families/shared/sessions.js +70 -0
  24. package/dist/src/index.d.ts +1 -1
  25. package/dist/src/panel/handler.d.ts +0 -2
  26. package/dist/src/panel/handler.js +1 -16
  27. package/dist/src/runtime.d.ts +48 -2
  28. package/dist/src/runtime.js +54 -1
  29. package/dist/src/types.d.ts +7 -0
  30. package/dist/testing/index.js +2 -1
  31. package/package.json +1 -1
  32. package/schemas/config.json +4 -0
  33. package/schemas/families.json +205 -0
  34. package/schemas/new.json +18 -0
  35. package/skills/sideby/SKILL.md +15 -6
  36. package/skills/sideby/references/accounts.md +1 -1
  37. package/skills/sideby/references/setup.md +31 -0
  38. package/skills/sideby/references/sharing.md +17 -0
@@ -1,10 +1,13 @@
1
1
  import { fileURLToPath } from 'node:url';
2
+ import { aliasesByAccount } from '../core/accounts.js';
3
+ import { aliasRefs } from '../core/config.js';
2
4
  import { UserError } from '../core/errors.js';
3
5
  import { runHost } from '../core/launch.js';
4
6
  import { resolvePaths, tildify } from '../core/paths.js';
5
7
  import { USAGE_DAYS } from '../core/quota-levels.js';
6
8
  import { SHELLS } from '../core/shell-init.js';
7
- import { createRuntime } from '../runtime.js';
9
+ import { suggestAlias, suggestName } from '../panel/page-logic.js';
10
+ import { createRuntime, UnknownFamilyError } from '../runtime.js';
8
11
  import { UsageError } from './args.js';
9
12
  import { c, clock, quotaText, table, usageText } from './format.js';
10
13
  const json = (io, data) => io.out(JSON.stringify({ schemaVersion: 1, ...data }, null, 2));
@@ -67,20 +70,49 @@ export async function cmdList(a, io) {
67
70
  io.err(c.yellow(`plugin ${e.where}: ${e.message}`));
68
71
  return 0;
69
72
  }
73
+ /**
74
+ * `new --next`: the next name of a Family, and a short command following the pattern of its existing aliases,
75
+ * from the same helpers the Panel uses (spec §3.16).
76
+ */
77
+ async function suggestNew(rt, family, alias) {
78
+ if (!(await rt.familyInfo()).some((f) => f.id === family))
79
+ throw new UnknownFamilyError(`unknown family "${family}"; run \`sideby families\` to see them`);
80
+ const all = await rt.accounts();
81
+ const mine = all.filter((x) => x.family === family);
82
+ const name = suggestName(mine.map((x) => x.name));
83
+ if (alias !== undefined)
84
+ return { name };
85
+ const byRef = aliasesByAccount(aliasRefs(rt.config.aliases), all);
86
+ const offer = suggestAlias(mine.map((x) => ({ family: x.family, name: x.name, ref: x.ref, aliases: byRef.get(x.ref) ?? [] })), family, name);
87
+ if (!offer)
88
+ return { name };
89
+ const problem = rt.aliasProblem(offer, `${family}:${name}`);
90
+ return problem ? { name, aliasProblem: `${offer}: ${problem}` } : { name, alias: offer };
91
+ }
70
92
  export async function cmdNew(a, io) {
71
- const [family, name] = a.positionals;
72
- if (!family || !name || a.positionals.length > 2)
73
- throw new UsageError('usage: sideby new <family> <name> [--api] [--alias <short-command>]');
74
- const alias = a.flags.get('alias');
93
+ const [family, given] = a.positionals;
94
+ const next = a.flags.has('next');
95
+ if (!family || (next ? given !== undefined : !given) || a.positionals.length > 2)
96
+ throw new UsageError(next
97
+ ? 'usage: sideby new <family> --next [--api] [--alias <short-command>]; --next picks the name, so leave it out'
98
+ : 'usage: sideby new <family> <name> [--api] [--alias <short-command>], or `--next` instead of <name>');
99
+ const givenAlias = a.flags.get('alias');
75
100
  const rt = await createRuntime();
101
+ const suggestion = next
102
+ ? await suggestNew(rt, family, typeof givenAlias === 'string' ? givenAlias : undefined)
103
+ : undefined;
104
+ const name = suggestion?.name ?? given;
105
+ const alias = typeof givenAlias === 'string' ? givenAlias : suggestion?.alias;
76
106
  const res = await rt.createAccount(family, name, {
77
107
  api: a.flags.has('api'),
78
- ...(typeof alias === 'string' ? { alias } : {}),
108
+ ...(alias !== undefined ? { alias } : {}),
79
109
  });
80
110
  if (a.flags.has('json')) {
81
- json(io, res);
111
+ json(io, suggestion ? { ...res, suggestion } : res);
82
112
  return res.ok ? 0 : 1;
83
113
  }
114
+ if (suggestion?.aliasProblem)
115
+ io.out(`${c.yellow('!')} no short command: the pattern suggests ${suggestion.aliasProblem}`);
84
116
  const home = rt.paths.home;
85
117
  const where = tildify(res.account.dir, home);
86
118
  if (res.ok)
@@ -122,6 +154,20 @@ export async function cmdRun(a, io, command) {
122
154
  io.err(c.cyan(prepared.notice));
123
155
  return runHost(prepared);
124
156
  }
157
+ /**
158
+ * `sideby resume <family>`: what the Host-named shell function calls (spec §3.15). Starts the Account that holds the
159
+ * session the arguments resume by id, or runs the Host unchanged.
160
+ */
161
+ export async function cmdResume(a, io) {
162
+ const [family, ...extra] = a.positionals;
163
+ if (!family)
164
+ throw new UsageError('usage: sideby resume <family> [-- host args]');
165
+ const rt = await createRuntime();
166
+ const route = await rt.resumeLaunch(family, [...extra, ...a.rest]);
167
+ if (route.account)
168
+ io.err(c.dim(`sideby: session ${route.sessionId} is in ${route.account.ref}; starting it there`));
169
+ return runHost(route.launch);
170
+ }
125
171
  /** Quotes a Host argument for a command line the user may paste; plain words stay as they are. */
126
172
  function shellQuote(arg) {
127
173
  return /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`;
@@ -485,3 +531,36 @@ export async function cmdAlias(a, io) {
485
531
  printShellInitWrites(io, res.shellInitFiles ?? [], rt.paths.home);
486
532
  return res.ok ? 0 : 1;
487
533
  }
534
+ /** `sideby families [family]`: each Family's layout, sign-in and Shared Items, from its FamilyDef (spec §3.16). */
535
+ export async function cmdFamilies(a, io) {
536
+ const [only, ...extra] = a.positionals;
537
+ if (extra.length)
538
+ throw new UsageError('usage: sideby families [family] [--json]');
539
+ const rt = await createRuntime();
540
+ const all = await rt.familyDetails();
541
+ const families = only === undefined ? all : all.filter((f) => f.id === only);
542
+ if (only !== undefined && families.length === 0)
543
+ throw new UnknownFamilyError(`unknown family "${only}"; available: ${all.map((f) => f.id).join(', ') || 'none (check `sideby plugins`)'}`);
544
+ if (a.flags.has('json')) {
545
+ json(io, { families });
546
+ return 0;
547
+ }
548
+ families.forEach((f, i) => {
549
+ if (i)
550
+ io.out('');
551
+ io.out(`${c.bold(f.title)} (${f.id}) ${f.installed ? c.green('installed') : c.yellow(`not installed: ${f.installUrl}`)}`);
552
+ io.out(` Accounts ~/${f.layout.main} (main), ~/${f.layout.account}; selected with ${f.selectVar}`);
553
+ io.out(` Sign in sideby login ${f.id}:<name> ${c.dim(f.login.args ? `(runs ${[f.bin, ...f.login.args].join(' ')})` : `(${f.login.hint})`)}`);
554
+ const yes = (b) => (b ? c.green('yes') : c.dim('no public source'));
555
+ io.out(` Quota ${yes(f.quota)}${f.quotaSetup ? c.dim(`, after \`sideby quota setup ${f.id}\``) : ''} Usage ${yes(f.usage)}`);
556
+ io.out(' Shared items, and how each account holds them:');
557
+ const rows = f.shared.map((s) => [
558
+ ` ${s.path}${s.key ? `#${s.key}` : ''}`,
559
+ s.source === 'config' ? `${s.mode} ${c.dim('(config)')}` : s.mode,
560
+ s.meaning,
561
+ ]);
562
+ io.out(table(rows, [' ITEM', 'MODE', 'IN EACH ACCOUNT']));
563
+ io.out(c.dim(' Everything else in an account directory is its own: sign-in, sessions and history.'));
564
+ });
565
+ return 0;
566
+ }
@@ -42,6 +42,37 @@ const FamilyInfo = Type.Object({
42
42
  installed: Type.Boolean(),
43
43
  plugin: Str,
44
44
  });
45
+ const ShareModeSchema = Type.Union(['link', 'copy', 'link-or-copy', 'link-or-local', 'local', 'local-if-api', 'info', 'json-key'].map((m) => Type.Literal(m)));
46
+ const FamilyDetail = Type.Intersect([
47
+ FamilyInfo,
48
+ Type.Object({
49
+ layout: Type.Object({ main: Str, account: Str }, { description: 'Relative to HOME, for example `.claude` and `.claude-<name>`.' }),
50
+ selectVar: Type.String({
51
+ description: 'Environment variable that selects a non-main Account directory.',
52
+ }),
53
+ login: Type.Object({
54
+ args: Type.Union([Type.Array(Str), Type.Null()], {
55
+ description: "Arguments of the Host's sign-in command; null when sign-in happens inside the Host.",
56
+ }),
57
+ hint: Str,
58
+ }),
59
+ quota: Type.Boolean(),
60
+ usage: Type.Boolean(),
61
+ quotaSetup: Type.Boolean({ description: 'Quota needs `sideby quota setup <family>` once.' }),
62
+ shared: Type.Array(Type.Object({
63
+ path: Str,
64
+ mode: ShareModeSchema,
65
+ key: Type.Optional(Str),
66
+ mainPath: Type.Optional(Str),
67
+ credential: Type.Optional(Type.Boolean()),
68
+ noSymlink: Type.Optional(Type.Boolean()),
69
+ source: Type.Union([Type.Literal('family'), Type.Literal('config')]),
70
+ meaning: Type.String({
71
+ description: 'What the item looks like in each Account, in one fixed sentence.',
72
+ }),
73
+ })),
74
+ }),
75
+ ]);
45
76
  const PluginError = Type.Object({ where: Str, message: Str });
46
77
  const Finding = Type.Object({
47
78
  level: Level,
@@ -168,6 +199,9 @@ export const OUTPUT_SCHEMAS = {
168
199
  nextSteps: Type.Array(Str),
169
200
  alias: Type.Optional(Type.Object({ name: Str, added: Type.Boolean(), message: Type.Optional(Str) }, { description: 'The `--alias` short command; `added: false` means the Account exists without it.' })),
170
201
  shellInitFiles: Type.Optional(ShellInitFiles),
202
+ suggestion: Type.Optional(Type.Object({ name: Str, alias: Type.Optional(Str), aliasProblem: Type.Optional(Str) }, {
203
+ description: 'Only with `--next`: the picked name, the short command that follows the existing pattern, or why that short command was left out.',
204
+ })),
171
205
  }, { title: 'sideby new --json' }),
172
206
  alias: Type.Object({
173
207
  schemaVersion: Version,
@@ -195,6 +229,7 @@ export const OUTPUT_SCHEMAS = {
195
229
  })),
196
230
  errors: Type.Array(PluginError),
197
231
  }, { title: 'sideby plugins --json' }),
232
+ families: Type.Object({ schemaVersion: Version, families: Type.Array(FamilyDetail) }, { title: 'sideby families --json' }),
198
233
  'app-install': Type.Object({
199
234
  schemaVersion: Version,
200
235
  platform: AppPlatform,
@@ -8,14 +8,19 @@ const HELP = `sideby — run every AI coding account side by side
8
8
  Usage:
9
9
  sideby list accounts (same as \`sideby list\`)
10
10
  sideby run <account> [-- args] start the host CLI for one account
11
- sideby new <family> <name> [--api] [--alias <short>]
11
+ sideby new <family> <name>|--next [--api] [--alias <short>]
12
12
  create an account (families: claude, codex, grok, pi);
13
+ --next picks the next name and short command (cc008);
13
14
  --alias also adds a short command such as cc008
15
+ sideby families [family] what each host's accounts share with the main account,
16
+ where they live and how they sign in
14
17
  sideby alias add <short> <account> [-- args]
15
18
  add a short command, optionally with host arguments
16
19
  (sideby alias add pi-kimi pi:main -- --model kimi-coding/k3)
17
20
  sideby alias rm <short> remove a short command
18
21
  sideby login <account> sign in to an account
22
+ sideby resume <family> [-- args] run the host; a session resumed by id (claude --resume <id>,
23
+ codex resume <id>) starts in the account that holds it
19
24
  sideby next <family> [--dry-run] [--include-api] [-- args]
20
25
  recommend the account with the most quota left (claude, codex)
21
26
  and start it; --dry-run only recommends
@@ -35,11 +40,90 @@ Usage:
35
40
  sideby plugins list loaded plugins and load errors
36
41
 
37
42
  Options:
38
- --json machine-readable output (list, new, next, alias, doctor, quota, plugins, app)
39
- -h, --help show this help
43
+ --json machine-readable output (list, new, next, alias, doctor, quota, families, plugins, app)
44
+ -h, --help show this help; \`sideby <command> --help\` for new, alias, login, doctor, families
40
45
  -v, --version show the version
41
46
 
42
- An <account> is <family>:<name> (claude:work) or just <name> when it is unique.`;
47
+ An <account> is <family>:<name> (claude:work) or just <name> when it is unique.
48
+
49
+ For AI agents:
50
+ Add --json and read the result; JSON Schemas ship in the package under schemas/.
51
+ Install the skill: npx skills add Adonis0123/sideby -g
52
+ Setup steps and the full contract: https://github.com/Adonis0123/sideby/blob/main/llms.txt
53
+ Run everything else yourself, but hand the user these as one line each: \`sideby login <account>\`
54
+ (browser sign-in), filling in proxy.env, and \`sideby run\` or \`sideby next\` (interactive sessions).
55
+ Exit 10 means a change was shown: ask the user, then run it again with --yes.`;
56
+ /** `sideby <command> --help` for the commands agents use to set up and repair accounts (spec §3.16). */
57
+ const COMMAND_HELP = {
58
+ new: `sideby new <family> <name> [--api] [--alias <short>] [--json]
59
+ sideby new <family> --next [--api] [--alias <short>] [--json]
60
+
61
+ Create an account directory (~/.claude-<name>, ~/.codex-<name>, ~/.grok-<name>, ~/.pi-<name>/agent) and link or
62
+ copy the main account's shared items into it. \`sideby families <family>\` lists them.
63
+
64
+ --next pick the name: the next number after the highest numbered account (001…007 → 008), else work;
65
+ without --alias, also add a short command that follows your existing ones (cc007 → cc008)
66
+ --api API account: also writes an empty proxy.env template (mode 600) for the user to fill in
67
+ --alias <short> add a short command for it to the config (it becomes a shell function)
68
+
69
+ Examples:
70
+ sideby new claude --next --json # ~/.claude-008 plus cc008 when cc001…cc007 exist
71
+ sideby new claude work # ~/.claude-work
72
+ sideby new claude deepseek --api # then the user fills in ~/.claude-deepseek/proxy.env
73
+
74
+ Writes: the account directory; the config \`aliases\` and every \`shellInitFile\` when a short command is added.
75
+ Next: the user signs in with \`sideby login <account>\` (subscription) or fills in proxy.env (API).
76
+ Exit: 0 done; 1 invalid name or alias, directory exists, or partly failed; 2 malformed command line.`,
77
+ alias: `sideby alias add <short> <account> [-- host args] [--json]
78
+ sideby alias rm <short> [--json]
79
+
80
+ A short command starts one account: \`sideby run <short>\`, or just \`<short>\` once your shell loads
81
+ \`sideby shell-init\`. Host arguments after -- are added only when it starts through the short command; it is
82
+ the same account (same sign-in, same sessions), so do not create an account per model.
83
+
84
+ Examples:
85
+ sideby alias add cc008 claude:008
86
+ sideby alias add pi-kimi pi:main -- --model kimi-coding/k3
87
+ sideby alias rm cc008
88
+
89
+ Writes: the config \`aliases\` and every \`shellInitFile\`. New shell functions work in a new shell, or after
90
+ eval "$(sideby shell-init zsh)" (put that line in ~/.zshrc once).
91
+ Exit: 0 done (also when it already exists or is absent); 1 invalid, reserved or taken; 2 malformed.`,
92
+ login: `sideby login <account>
93
+
94
+ Run the host's own sign-in for one account: claude auth login, codex login, grok login. For pi it starts pi;
95
+ then type /login. It opens a browser and needs the person who owns the account, so an agent hands this
96
+ line to the user instead of running it (in Claude Code: ! sideby login claude:008).
97
+
98
+ sideby never reads, copies or stores the credentials the host writes.
99
+ Exit: the host's exit code.`,
100
+ doctor: `sideby doctor [account|family] [--fix] [--force] [--json]
101
+
102
+ Check every account's shared items against the main account, credential file modes (600), backup leftovers,
103
+ and whether the installed sideby skill matches this version.
104
+
105
+ --fix repair what is safe: add missing links, refresh copies, chmod 600, sync MCP servers
106
+ --force with --fix, also let a synced MCP list (json-key) drop entries the account has
107
+
108
+ --fix never replaces a real file with a link and never changes where an existing link points; those are
109
+ reported with the command to run.
110
+
111
+ Examples:
112
+ sideby doctor --json
113
+ sideby doctor claude:008 --fix
114
+ Exit: 0 when nothing fails (warnings allowed); 1 when a check fails.`,
115
+ families: `sideby families [family] [--json]
116
+
117
+ For each host (built in or from a plugin): where its accounts live, the variable that selects one, how to sign
118
+ in, whether quota and usage can be read, and every shared item with what it looks like in each account
119
+ (a link to the main account's, a copy, or the account's own file). Everything else in an account directory
120
+ (sign-in, sessions, history) belongs to that account.
121
+
122
+ Examples:
123
+ sideby families
124
+ sideby families claude --json
125
+ Exit: 0; 1 unknown family; 2 malformed command line.`,
126
+ };
43
127
  function nodeTooOld() {
44
128
  const [maj = 0, min = 0] = process.versions.node.split('.').map(Number);
45
129
  return maj < MIN_NODE[0] || (maj === MIN_NODE[0] && min < MIN_NODE[1]);
@@ -47,9 +131,10 @@ function nodeTooOld() {
47
131
  const COMMANDS = {
48
132
  list: { flags: ['json'] },
49
133
  run: { flags: [] },
134
+ resume: { flags: [] },
50
135
  login: { flags: [] },
51
136
  next: { flags: ['dry-run', 'include-api', 'json'] },
52
- new: { flags: ['api', 'json'], valued: ['alias'] },
137
+ new: { flags: ['api', 'json', 'next'], valued: ['alias'] },
53
138
  alias: { flags: ['json'] },
54
139
  doctor: { flags: ['fix', 'force', 'json'] },
55
140
  quota: { flags: ['json', 'yes'] },
@@ -57,6 +142,7 @@ const COMMANDS = {
57
142
  app: { flags: ['json'], valued: ['url'] },
58
143
  'shell-init': { flags: ['write'] },
59
144
  plugins: { flags: ['json'] },
145
+ families: { flags: ['json'] },
60
146
  };
61
147
  export async function main(argv) {
62
148
  const [first, ...rest] = argv;
@@ -70,7 +156,7 @@ export async function main(argv) {
70
156
  return 0;
71
157
  }
72
158
  if (first === '-h' || first === '--help' || first === 'help') {
73
- console.log(HELP);
159
+ console.log((rest[0] !== undefined && first === 'help' && COMMAND_HELP[rest[0]]) || HELP);
74
160
  return 0;
75
161
  }
76
162
  if (nodeTooOld()) {
@@ -88,9 +174,9 @@ export async function main(argv) {
88
174
  try {
89
175
  if (!spec)
90
176
  throw new UsageError(`unknown command "${cmd}"; run \`sideby --help\``);
91
- // `run` passes everything after the account through to the host, with or without `--`.
177
+ // `run` and `resume` pass everything after the account or family through to the host, with or without `--`.
92
178
  let parsed;
93
- if (cmd === 'run') {
179
+ if (cmd === 'run' || cmd === 'resume') {
94
180
  const sep = args.indexOf('--');
95
181
  const head = sep === -1 ? args : args.slice(0, sep);
96
182
  const [ref, ...more] = head;
@@ -116,7 +202,7 @@ export async function main(argv) {
116
202
  }
117
203
  }
118
204
  if (parsed.flags.has('help')) {
119
- console.log(HELP);
205
+ console.log(COMMAND_HELP[cmd] ?? HELP);
120
206
  return 0;
121
207
  }
122
208
  const cmds = await import('./commands.js');
@@ -127,6 +213,8 @@ export async function main(argv) {
127
213
  return await cmds.cmdRun(parsed, io, 'run');
128
214
  case 'login':
129
215
  return await cmds.cmdRun(parsed, io, 'login');
216
+ case 'resume':
217
+ return await cmds.cmdResume(parsed, io);
130
218
  case 'next':
131
219
  return await cmds.cmdNext(parsed, io);
132
220
  case 'new':
@@ -145,6 +233,8 @@ export async function main(argv) {
145
233
  return await cmds.cmdShellInit(parsed, io);
146
234
  case 'plugins':
147
235
  return await cmds.cmdPlugins(parsed, io);
236
+ case 'families':
237
+ return await cmds.cmdFamilies(parsed, io);
148
238
  }
149
239
  return 2;
150
240
  }
@@ -16,3 +16,5 @@ export declare class AccountRefError extends UserError {
16
16
  }
17
17
  /** Resolves `<family>:<name>`, an alias from the config, or a name unique across Families. */
18
18
  export declare function resolveRef(ref: string, accounts: readonly Account[], aliases?: Readonly<Record<string, string>>): Account;
19
+ /** Alias names per Account ref, sorted. An alias whose target matches no Account is left out. */
20
+ export declare function aliasesByAccount(aliases: Readonly<Record<string, string>> | undefined, accounts: readonly Account[]): Map<string, string[]>;
@@ -114,3 +114,18 @@ export function resolveRef(ref, accounts, aliases = {}) {
114
114
  throw new AccountRefError(`no account named ${ref}; run \`sideby list\` to see accounts`);
115
115
  throw new AccountRefError(`${ref} is ambiguous; use one of: ${hits.map((a) => a.ref).join(', ')}`);
116
116
  }
117
+ /** Alias names per Account ref, sorted. An alias whose target matches no Account is left out. */
118
+ export function aliasesByAccount(aliases, accounts) {
119
+ const out = new Map();
120
+ for (const [alias, target] of Object.entries(aliases ?? {})) {
121
+ let ref;
122
+ try {
123
+ ref = resolveRef(target, accounts).ref;
124
+ }
125
+ catch {
126
+ continue;
127
+ }
128
+ out.set(ref, [...(out.get(ref) ?? []), alias].sort());
129
+ }
130
+ return out;
131
+ }
@@ -34,6 +34,7 @@ export declare const ConfigSchema: Type.TObject<{
34
34
  noSymlink: Type.TOptional<Type.TBoolean>;
35
35
  credential: Type.TOptional<Type.TBoolean>;
36
36
  }>>>>;
37
+ resumeRouting: Type.TOptional<Type.TBoolean>;
37
38
  shellInitFile: Type.TOptional<Type.TObject<{
38
39
  zsh: Type.TOptional<Type.TString>;
39
40
  bash: Type.TOptional<Type.TString>;
@@ -57,6 +57,9 @@ export const ConfigSchema = Type.Object({
57
57
  description: 'Per-Plugin settings; `enabled: false` turns a Plugin off.',
58
58
  })),
59
59
  extraSharedItems: Type.Optional(Type.Record(Type.String(), Type.Array(SharedItemSchema))),
60
+ resumeRouting: Type.Optional(Type.Boolean({
61
+ description: 'Make `sideby shell-init` also define Host-named functions (`claude`, `codex`, `grok`) that resume a session by id in the Account that holds it. Off by default.',
62
+ })),
60
63
  shellInitFile: Type.Optional(Type.Object({
61
64
  zsh: Type.Optional(Type.String({ minLength: 1 })),
62
65
  bash: Type.Optional(Type.String({ minLength: 1 })),
@@ -9,6 +9,8 @@ export interface PreparedLaunch {
9
9
  /** Printed before launching when sign-in happens inside the Host. */
10
10
  notice?: string;
11
11
  }
12
+ /** What `runHost` needs: a prepared Account launch, or the Host run unchanged (`sideby resume`). */
13
+ export type HostLaunch = Pick<PreparedLaunch, 'family' | 'bin' | 'args' | 'env'>;
12
14
  /** Finds an executable on PATH; returns null when the Host is not installed. */
13
15
  export declare function which(bin: string, env: Env): Promise<string | null>;
14
16
  /**
@@ -32,4 +34,4 @@ export declare function prepareLaunch(opts: {
32
34
  * process group, so the Host receives them directly) and forwards SIGTERM/SIGHUP once. SIGTSTP keeps its
33
35
  * default action so Ctrl-Z suspends sideby together with the Host and `fg` resumes both.
34
36
  */
35
- export declare function runHost(p: PreparedLaunch): Promise<number>;
37
+ export declare function runHost(p: HostLaunch): Promise<number>;
@@ -0,0 +1,9 @@
1
+ import type { Account, FamilyDef } from '../types.ts';
2
+ export interface ResumeRoute {
3
+ /** The session the Host arguments resume by id; absent when they resume none. */
4
+ sessionId?: string;
5
+ /** The Account that last wrote that session; absent when no Account holds it. */
6
+ account?: Account;
7
+ }
8
+ /** Asks every Account of the Family for the session and picks the one that wrote it last. */
9
+ export declare function findResumeRoute(family: FamilyDef, accounts: readonly Account[], args: readonly string[]): Promise<ResumeRoute>;
@@ -0,0 +1,15 @@
1
+ /** Asks every Account of the Family for the session and picks the one that wrote it last. */
2
+ export async function findResumeRoute(family, accounts, args) {
3
+ const sessionId = family.resumedSession?.(args);
4
+ if (!sessionId || !family.sessionWrittenAt)
5
+ return {};
6
+ const own = accounts.filter((a) => a.family === family.id);
7
+ const written = await Promise.all(own.map((a) => family.sessionWrittenAt(a, sessionId).catch(() => undefined)));
8
+ let best;
9
+ own.forEach((account, i) => {
10
+ const at = written[i];
11
+ if (at !== undefined && (!best || at > best.at))
12
+ best = { account, at };
13
+ });
14
+ return best ? { sessionId, account: best.account } : { sessionId };
15
+ }
@@ -0,0 +1,6 @@
1
+ import type { SharedItemDef } from '../types.ts';
2
+ /**
3
+ * What one Shared Item looks like in an Account, in one fixed English sentence for `sideby families` (spec §3.16).
4
+ * Agents quote it to users, so it follows the Share Mode rules of spec §3.3 and never promises more.
5
+ */
6
+ export declare function shareMeaning(item: SharedItemDef): string;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * What one Shared Item looks like in an Account, in one fixed English sentence for `sideby families` (spec §3.16).
3
+ * Agents quote it to users, so it follows the Share Mode rules of spec §3.3 and never promises more.
4
+ */
5
+ export function shareMeaning(item) {
6
+ const main = item.mainPath ? `~/${item.mainPath}` : "the Main Account's";
7
+ const base = (() => {
8
+ switch (item.mode) {
9
+ case 'link':
10
+ return "A link to the Main Account's: one copy for every Account.";
11
+ case 'copy':
12
+ return "A copy of the Main Account's, kept equal by `doctor --fix`; the Host does not accept a link here.";
13
+ case 'link-or-copy':
14
+ return "A link to the Main Account's, or a copy kept equal by `doctor --fix`.";
15
+ case 'link-or-local':
16
+ return "A link to the Main Account's, unless the Account keeps its own file.";
17
+ case 'local':
18
+ return "The Account's own file, first copied from the Main Account and never synced.";
19
+ case 'local-if-api':
20
+ return item.noSymlink
21
+ ? "Subscription Accounts: a copy of the Main Account's, kept equal by `doctor --fix`. API Accounts: their own file."
22
+ : "Subscription Accounts: a link to the Main Account's, or a copy kept equal. API Accounts: their own file.";
23
+ case 'info':
24
+ return item.credential
25
+ ? "The Account's own sign-in: never shared and never read; sideby checks only that it is mode 600."
26
+ : "The Account's own; sideby only reports it.";
27
+ case 'json-key':
28
+ return `The Account's own file; only its \`${item.key}\` entry is kept equal to ${main}${item.credential ? ', and sideby reads no other part of it' : ''}.`;
29
+ }
30
+ })();
31
+ return item.noSymlink && item.mode !== 'copy' ? `${base} Never a link: the Host refuses one.` : base;
32
+ }
@@ -3,11 +3,17 @@ export declare const SHELLS: readonly ['zsh', 'bash'];
3
3
  export type Shell = (typeof SHELLS)[number];
4
4
  /** First line of every generated script; an existing file without it is never overwritten. */
5
5
  export declare const SHELL_INIT_HEADER = "# sideby shell-init: one function per account, plus aliases from the sideby config";
6
+ /** A Family whose bare Host command `shell-init` routes through `sideby resume` (config `resumeRouting`). */
7
+ export interface ResumeRouteFunction {
8
+ family: string;
9
+ bin: string;
10
+ selectVar: string;
11
+ }
6
12
  /**
7
13
  * The script for one shell, ending in a newline: exactly what `sideby shell-init <shell>` prints. zsh and bash
8
14
  * read the same function syntax, so the shell only names the file it goes to.
9
15
  */
10
- export declare function shellInitScript(accounts: readonly Account[], aliases: Readonly<Record<string, unknown>> | undefined): string;
16
+ export declare function shellInitScript(accounts: readonly Account[], aliases: Readonly<Record<string, unknown>> | undefined, routes?: readonly ResumeRouteFunction[]): string;
11
17
  export interface ShellInitWrite {
12
18
  shell: Shell;
13
19
  /** The configured path, as written in the config (`~/…` kept). */
@@ -2,18 +2,35 @@
2
2
  // in sync with them whenever it adds an Account or an alias.
3
3
  import { mkdir, readFile } from 'node:fs/promises';
4
4
  import { dirname } from 'node:path';
5
- import { aliasProblem } from './config.js';
5
+ import { ALIAS_NAME, aliasProblem, RESERVED_ALIASES } from './config.js';
6
6
  import { realpathOrNull, statOrNull, writeFileAtomic } from './fs-safe.js';
7
7
  import { expandUserPath } from './paths.js';
8
8
  export const SHELLS = ['zsh', 'bash'];
9
9
  /** First line of every generated script; an existing file without it is never overwritten. */
10
10
  export const SHELL_INIT_HEADER = '# sideby shell-init: one function per account, plus aliases from the sideby config';
11
11
  const quote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
12
+ const SHELL_VAR = /^[A-Za-z_][A-Za-z0-9_]*$/;
13
+ /** A shell `case` pattern for a session id: 36 characters with dashes where a UUID has them. */
14
+ const SESSION_ID_GLOB = '????????-????-????-????-????????????';
15
+ /**
16
+ * The Host-named function for one routed Family, or null when its names are not safe in a shell (spec §3.15). It
17
+ * starts sideby only when an argument looks like a session id, so a plain Host command costs nothing extra.
18
+ * `function name {` because zsh expands an alias of the same name in `name() {`.
19
+ */
20
+ function resumeRouteFunction(r) {
21
+ if (!ALIAS_NAME.test(r.bin) || RESERVED_ALIASES.has(r.bin) || !SHELL_VAR.test(r.selectVar))
22
+ return null;
23
+ const id = SESSION_ID_GLOB;
24
+ return [
25
+ `function ${r.bin} { local a; if [ -z "\${${r.selectVar}-}" ]; then for a in "$@"; do case $a in ${id}|--resume=${id})`,
26
+ ` command sideby resume ${quote(r.family)} -- "$@"; return;; esac; done; fi; command ${r.bin} "$@"; }`,
27
+ ].join('');
28
+ }
12
29
  /**
13
30
  * The script for one shell, ending in a newline: exactly what `sideby shell-init <shell>` prints. zsh and bash
14
31
  * read the same function syntax, so the shell only names the file it goes to.
15
32
  */
16
- export function shellInitScript(accounts, aliases) {
33
+ export function shellInitScript(accounts, aliases, routes = []) {
17
34
  const lines = [SHELL_INIT_HEADER];
18
35
  for (const acc of accounts)
19
36
  lines.push(`sideby-${acc.family}-${acc.name}() { command sideby run ${quote(acc.ref)} -- "$@"; }`);
@@ -21,6 +38,12 @@ export function shellInitScript(accounts, aliases) {
21
38
  for (const alias of Object.keys(aliases ?? {}))
22
39
  if (!aliasProblem(alias))
23
40
  lines.push(`${alias}() { command sideby run ${quote(alias)} -- "$@"; }`);
41
+ // An Alias with the Host's name wins: it already starts a chosen Account.
42
+ for (const r of routes) {
43
+ const fn = aliases && Object.hasOwn(aliases, r.bin) ? null : resumeRouteFunction(r);
44
+ if (fn)
45
+ lines.push(fn);
46
+ }
24
47
  return `${lines.join('\n')}\n`;
25
48
  }
26
49
  /**
@@ -5,6 +5,7 @@ import { identityFromJsonFile, identityOf } from '../../core/identity.js';
5
5
  import { readSecretFile } from '../../core/secret-file.js';
6
6
  import { claudeStatusline } from '../../quota/claude-statusline.js';
7
7
  import { BUILTIN_LOGOS } from '../logos.js';
8
+ import { groupedEntryWrittenAt, resumeFlagSession } from '../shared/sessions.js';
8
9
  import { CLAUDE_LAYOUT } from './layout.js';
9
10
  import { readClaudeQuota } from './quota.js';
10
11
  import { readClaudeUsage } from './usage.js';
@@ -115,6 +116,8 @@ export const claudeFamily = {
115
116
  readQuota: readClaudeQuota,
116
117
  readUsage: readClaudeUsage,
117
118
  quotaSetup: claudeStatusline,
119
+ resumedSession: resumeFlagSession,
120
+ sessionWrittenAt: (account, id) => groupedEntryWrittenAt(join(account.dir, 'projects'), `${id}.jsonl`, 'file'),
118
121
  };
119
122
  export const claudePlugin = {
120
123
  name: 'claude',
@@ -2,6 +2,7 @@ import { readFile, stat } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { identityFromJsonFile, identityOf, jwtEmail } from '../../core/identity.js';
4
4
  import { BUILTIN_LOGOS } from '../logos.js';
5
+ import { nestedFileWrittenAt, resumeSubcommandSession } from '../shared/sessions.js';
5
6
  import { topLevelTomlString } from '../shared/toml.js';
6
7
  import { readCodexQuota, readCodexUsage } from './rollout.js';
7
8
  const FILE_STORE_KEY = 'cli_auth_credentials_store';
@@ -58,6 +59,8 @@ export const codexFamily = {
58
59
  identity: codexIdentity,
59
60
  readQuota: (account) => readCodexQuota(account.dir),
60
61
  readUsage: (account, ctx) => readCodexUsage(account.dir, ctx.now),
62
+ resumedSession: resumeSubcommandSession,
63
+ sessionWrittenAt: (account, id) => nestedFileWrittenAt(join(account.dir, 'sessions'), `-${id}.jsonl`),
61
64
  };
62
65
  export const codexPlugin = {
63
66
  name: 'codex',
@@ -2,6 +2,7 @@ import { lstat, readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { identityFromJsonFile, identityOf } from '../../core/identity.js';
4
4
  import { BUILTIN_LOGOS } from '../logos.js';
5
+ import { groupedEntryWrittenAt, resumeFlagSession } from '../shared/sessions.js';
5
6
  import { topLevelTomlString } from '../shared/toml.js';
6
7
  // Grok's sandbox rejects these paths when they are symlinks; doctor quotes the Host's own words.
7
8
  const HOOKS_DIR_MESSAGE = 'Grok hooks directory has wrong type (expected real directory)';
@@ -69,6 +70,9 @@ export const grokFamily = {
69
70
  },
70
71
  model: readModel,
71
72
  identity: grokIdentity,
73
+ resumedSession: resumeFlagSession,
74
+ // One directory per session, grouped by working directory: sessions/<encoded cwd>/<id>/.
75
+ sessionWrittenAt: (account, id) => groupedEntryWrittenAt(join(account.dir, 'sessions'), id, 'dir'),
72
76
  };
73
77
  export const grokPlugin = {
74
78
  name: 'grok',
@@ -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>;