sideby 0.3.0 → 0.4.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 (115) hide show
  1. package/README.md +27 -10
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +8 -1
  4. package/dist/src/cli/commands.js +330 -9
  5. package/dist/src/cli/format.d.ts +3 -1
  6. package/dist/src/cli/format.js +25 -0
  7. package/dist/src/cli/json-schemas.js +113 -0
  8. package/dist/src/cli/main.d.ts +5 -0
  9. package/dist/src/cli/main.js +40 -8
  10. package/dist/src/core/config.d.ts +40 -0
  11. package/dist/src/core/config.js +83 -11
  12. package/dist/src/core/file-memo.d.ts +23 -0
  13. package/dist/src/core/file-memo.js +37 -0
  14. package/dist/src/core/handoff-brief.d.ts +17 -0
  15. package/dist/src/core/handoff-brief.js +75 -0
  16. package/dist/src/core/handoff-chain.d.ts +74 -0
  17. package/dist/src/core/handoff-chain.js +74 -0
  18. package/dist/src/core/handoff-doctor.d.ts +13 -0
  19. package/dist/src/core/handoff-doctor.js +35 -0
  20. package/dist/src/core/handoff-run.d.ts +31 -0
  21. package/dist/src/core/handoff-run.js +346 -0
  22. package/dist/src/core/handoff-select.d.ts +42 -0
  23. package/dist/src/core/handoff-select.js +88 -0
  24. package/dist/src/core/handoff-status.d.ts +66 -0
  25. package/dist/src/core/handoff-status.js +198 -0
  26. package/dist/src/core/launch.d.ts +15 -0
  27. package/dist/src/core/launch.js +25 -2
  28. package/dist/src/core/memo-store.d.ts +20 -0
  29. package/dist/src/core/memo-store.js +92 -0
  30. package/dist/src/core/resume.d.ts +7 -2
  31. package/dist/src/core/resume.js +22 -9
  32. package/dist/src/core/tuning-apply.d.ts +51 -0
  33. package/dist/src/core/tuning-apply.js +237 -0
  34. package/dist/src/core/tuning.d.ts +98 -0
  35. package/dist/src/core/tuning.js +176 -0
  36. package/dist/src/families/claude/brief.d.ts +5 -0
  37. package/dist/src/families/claude/brief.js +63 -0
  38. package/dist/src/families/claude/handoff-hook.d.ts +2 -0
  39. package/dist/src/families/claude/handoff-hook.js +31 -0
  40. package/dist/src/families/claude/handoff.d.ts +5 -0
  41. package/dist/src/families/claude/handoff.js +115 -0
  42. package/dist/src/families/claude/index.js +8 -1
  43. package/dist/src/families/claude/tuning.d.ts +22 -0
  44. package/dist/src/families/claude/tuning.js +187 -0
  45. package/dist/src/families/claude/usage.d.ts +1 -0
  46. package/dist/src/families/claude/usage.js +2 -2
  47. package/dist/src/families/codex/brief.d.ts +5 -0
  48. package/dist/src/families/codex/brief.js +49 -0
  49. package/dist/src/families/codex/handoff-hook.d.ts +2 -0
  50. package/dist/src/families/codex/handoff-hook.js +16 -0
  51. package/dist/src/families/codex/handoff.d.ts +5 -0
  52. package/dist/src/families/codex/handoff.js +121 -0
  53. package/dist/src/families/codex/index.js +4 -0
  54. package/dist/src/families/codex/rate-limits.d.ts +16 -0
  55. package/dist/src/families/codex/rate-limits.js +92 -0
  56. package/dist/src/families/codex/rollout.d.ts +1 -2
  57. package/dist/src/families/codex/rollout.js +4 -48
  58. package/dist/src/families/codex/tuning.d.ts +16 -0
  59. package/dist/src/families/codex/tuning.js +145 -0
  60. package/dist/src/families/grok/brief.d.ts +5 -0
  61. package/dist/src/families/grok/brief.js +74 -0
  62. package/dist/src/families/grok/handoff-hook.d.ts +4 -0
  63. package/dist/src/families/grok/handoff-hook.js +26 -0
  64. package/dist/src/families/grok/handoff.d.ts +47 -0
  65. package/dist/src/families/grok/handoff.js +219 -0
  66. package/dist/src/families/grok/index.js +2 -0
  67. package/dist/src/families/pi/handoff.d.ts +2 -0
  68. package/dist/src/families/pi/handoff.js +13 -0
  69. package/dist/src/families/pi/index.js +2 -0
  70. package/dist/src/families/shared/brief.d.ts +24 -0
  71. package/dist/src/families/shared/brief.js +42 -0
  72. package/dist/src/families/shared/handoff-args.d.ts +18 -0
  73. package/dist/src/families/shared/handoff-args.js +57 -0
  74. package/dist/src/families/shared/sessions.d.ts +4 -0
  75. package/dist/src/families/shared/sessions.js +19 -0
  76. package/dist/src/families/shared/toml.d.ts +5 -0
  77. package/dist/src/families/shared/toml.js +30 -0
  78. package/dist/src/handoff/hook.d.ts +16 -0
  79. package/dist/src/handoff/hook.js +166 -0
  80. package/dist/src/handoff/messages.d.ts +7 -0
  81. package/dist/src/handoff/messages.js +27 -0
  82. package/dist/src/handoff/reader.d.ts +11 -0
  83. package/dist/src/handoff/reader.js +9 -0
  84. package/dist/src/panel/handler.d.ts +6 -0
  85. package/dist/src/panel/handler.js +110 -0
  86. package/dist/src/panel/i18n.d.ts +96 -0
  87. package/dist/src/panel/i18n.js +192 -0
  88. package/dist/src/panel/page-logic.d.ts +49 -0
  89. package/dist/src/panel/page-logic.js +67 -0
  90. package/dist/src/panel/page-script.js +454 -4
  91. package/dist/src/panel/page-styles.js +28 -2
  92. package/dist/src/panel/page.js +4 -1
  93. package/dist/src/quota/claude-statusline.js +4 -7
  94. package/dist/src/runtime.d.ts +40 -3
  95. package/dist/src/runtime.js +210 -41
  96. package/dist/src/types.d.ts +93 -0
  97. package/dist/testing/index.d.ts +5 -0
  98. package/dist/testing/index.js +1 -0
  99. package/package.json +16 -4
  100. package/schemas/config.json +73 -0
  101. package/schemas/families.json +5 -1
  102. package/schemas/handoff-disable.json +106 -0
  103. package/schemas/handoff-enable.json +106 -0
  104. package/schemas/handoff-ready.json +23 -0
  105. package/schemas/handoff-setup.json +60 -0
  106. package/schemas/handoff-status.json +137 -0
  107. package/schemas/handoff-teardown.json +30 -0
  108. package/schemas/list.json +5 -1
  109. package/schemas/tune-change.json +108 -0
  110. package/schemas/tune.json +243 -0
  111. package/skills/sideby/SKILL.md +5 -3
  112. package/skills/sideby/references/badge.md +1 -1
  113. package/skills/sideby/references/diagnose.md +4 -0
  114. package/skills/sideby/references/handoff.md +24 -2
  115. package/README.zh-CN.md +0 -112
@@ -41,6 +41,7 @@ const FamilyInfo = Type.Object({
41
41
  installUrl: Str,
42
42
  installed: Type.Boolean(),
43
43
  plugin: Str,
44
+ tuning: Type.Boolean(),
44
45
  });
45
46
  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
47
  const FamilyDetail = Type.Intersect([
@@ -100,6 +101,50 @@ const Quota = Type.Union([
100
101
  }),
101
102
  Type.Object({ status: Type.Literal('unavailable'), reason: Str, detail: Type.Optional(Str) }),
102
103
  ]);
104
+ const TuningModel = Type.Object({
105
+ model: Str,
106
+ calls: Type.Number(),
107
+ contextWindow: Type.Number(),
108
+ configured: Type.Union([Type.Number(), Type.Null()], {
109
+ description: 'Configured threshold in tokens; null is the Host default.',
110
+ }),
111
+ effective: Type.Union([Type.Number(), Type.Null()]),
112
+ compactions: Type.Object({
113
+ total: Type.Number({
114
+ description: 'All compactions, including those whose trigger the Host does not record.',
115
+ }),
116
+ auto: Type.Number(),
117
+ manual: Type.Number(),
118
+ preTokensP50: Type.Union([Type.Number(), Type.Null()]),
119
+ preTokensMax: Type.Union([Type.Number(), Type.Null()]),
120
+ }),
121
+ suggestion: Type.Union([
122
+ Type.Object({
123
+ threshold: Type.Number(),
124
+ savedPercent: Type.Union([Type.Number(), Type.Null()]),
125
+ savedWeighted: Type.Union([Type.Number(), Type.Null()]),
126
+ }),
127
+ Type.Null(),
128
+ ], { description: 'The estimated cheapest threshold; null when `reason` says why there is none.' }),
129
+ reason: Type.Union([
130
+ Type.Literal('suggested'),
131
+ Type.Literal('already-best'),
132
+ Type.Literal('insufficient-calls'),
133
+ Type.Literal('no-compactions'),
134
+ Type.Literal('no-optimum'),
135
+ Type.Literal('unknown-model'),
136
+ Type.Literal('observe-only'),
137
+ ]),
138
+ });
139
+ const Tuning = Type.Union([
140
+ Type.Object({
141
+ status: Type.Literal('ok'),
142
+ days: Type.Number(),
143
+ estimate: Type.Literal(true),
144
+ models: Type.Array(TuningModel),
145
+ }),
146
+ Type.Object({ status: Type.Literal('unavailable'), reason: Str, detail: Type.Optional(Str) }),
147
+ ]);
103
148
  const Usage = Type.Union([
104
149
  Type.Object({
105
150
  status: Type.Literal('ok'),
@@ -153,6 +198,27 @@ export const OUTPUT_SCHEMAS = {
153
198
  general: Type.Array(Finding),
154
199
  fixes: Type.Array(Type.Object({ account: Str, item: Str, code: Str, ok: Type.Boolean(), message: Str })),
155
200
  }, { title: 'sideby doctor --json' }),
201
+ 'tune-change': Type.Object({
202
+ schemaVersion: Version,
203
+ account: Str,
204
+ action: Type.Union([Type.Literal('apply'), Type.Literal('undo')]),
205
+ applied: Type.Boolean({ description: 'True once the change was written (or undone).' }),
206
+ plan: Type.Optional(Type.Object({
207
+ status: Type.Union([Type.Literal('ready'), Type.Literal('unchanged'), Type.Literal('blocked')]),
208
+ file: Str,
209
+ link: Type.Boolean(),
210
+ affected: Type.Array(Str),
211
+ current: Type.Union([Type.Number(), Type.Null()]),
212
+ next: Type.Union([Type.Number(), Type.Null()]),
213
+ creates: Type.Boolean(),
214
+ reason: Type.Optional(Str),
215
+ })),
216
+ message: Type.Optional(Str),
217
+ }, { title: 'sideby tune apply|undo --json' }),
218
+ tune: Type.Object({
219
+ schemaVersion: Version,
220
+ accounts: Type.Array(Type.Object({ ref: Str, family: Str, tuning: Tuning })),
221
+ }, { title: 'sideby tune --json' }),
156
222
  quota: Type.Object({
157
223
  schemaVersion: Version,
158
224
  accounts: Type.Array(Type.Object({ ref: Str, kind: Kind, quota: Quota, usage: Usage })),
@@ -189,6 +255,53 @@ export const OUTPUT_SCHEMAS = {
189
255
  })),
190
256
  }, { title: 'sideby next --json' }),
191
257
  'quota-setup': Type.Object({ schemaVersion: Version, family: Str, applied: Type.Boolean(), plan: QuotaSetupPlan }, { title: 'sideby quota setup --json' }),
258
+ 'handoff-status': Type.Object({
259
+ schemaVersion: Version,
260
+ auto: Type.Boolean(),
261
+ sameFamily: Type.Boolean(),
262
+ prepareAt: Type.Number(),
263
+ threshold: Type.Number(),
264
+ sideby: Type.Object({ ok: Type.Boolean(), problem: Type.Optional(Str) }),
265
+ families: Type.Array(Type.Object({
266
+ family: Str,
267
+ starts: Type.Array(Type.Union([Type.Literal('quota'), Type.Literal('limit')])),
268
+ ready: Type.Boolean(),
269
+ problems: Type.Array(Str),
270
+ notes: Type.Array(Str),
271
+ order: Type.Object({
272
+ policy: Type.Union([Type.Literal('pressure'), Type.Literal('order')]),
273
+ order: Type.Array(Str),
274
+ unknown: Type.Array(Str),
275
+ }),
276
+ })),
277
+ nextSteps: Type.Array(Str),
278
+ }, { title: 'sideby handoff status --json' }),
279
+ ...Object.fromEntries(['enable', 'disable'].map((sub) => [
280
+ `handoff-${sub}`,
281
+ Type.Object({
282
+ schemaVersion: Version,
283
+ applied: Type.Boolean(),
284
+ file: Str,
285
+ diff: Str,
286
+ settings: Type.Object({
287
+ auto: Type.Boolean(),
288
+ sameFamily: Type.Boolean(),
289
+ prepareAt: Type.Number(),
290
+ threshold: Type.Number(),
291
+ waitIfResetWithinMinutes: Type.Number(),
292
+ countdownSeconds: Type.Number(),
293
+ crossOrganization: Type.Array(Str),
294
+ families: Type.Record(Str, Type.Object({
295
+ policy: Type.Union([Type.Literal('pressure'), Type.Literal('order')]),
296
+ order: Type.Array(Str),
297
+ })),
298
+ }),
299
+ nextSteps: Type.Array(Str),
300
+ }, { title: `sideby handoff ${sub} --json` }),
301
+ ])),
302
+ 'handoff-ready': Type.Object({ schemaVersion: Version, requested: Type.Literal(true), brief: Type.Optional(Str) }, { title: 'sideby handoff ready --json' }),
303
+ 'handoff-setup': Type.Object({ schemaVersion: Version, family: Str, applied: Type.Boolean(), plan: QuotaSetupPlan }, { title: 'sideby handoff setup --json' }),
304
+ 'handoff-teardown': Type.Object({ schemaVersion: Version, family: Str, ok: Type.Boolean(), message: Str, diff: Type.Optional(Str) }, { title: 'sideby handoff teardown --json' }),
192
305
  'quota-teardown': Type.Object({ schemaVersion: Version, family: Str, ok: Type.Boolean(), message: Str, diff: Type.Optional(Str) }, { title: 'sideby quota teardown --json' }),
193
306
  new: Type.Object({
194
307
  schemaVersion: Version,
@@ -1,2 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ /**
3
+ * True below Node 22.18. npm's `engines` field only warns (`EBADENGINE`); this is the check that exits.
4
+ * `version` defaults to the running Node so tests can pass a string.
5
+ */
6
+ export declare function nodeTooOld(version?: string): boolean;
2
7
  export declare function main(argv: string[]): Promise<number>;
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- // Entry point. Keeps imports lazy so `statusline-tap`, which Claude Code runs on every refresh, starts fast.
2
+ // Entry point. Keeps imports lazy so `statusline-tap` (run on every status line refresh) and `handoff-hook` (run on
3
+ // every tool call while Auto Handoff is on) start fast.
3
4
  import { realpathSync } from 'node:fs';
4
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
5
6
  const MIN_NODE = [22, 18];
@@ -29,6 +30,16 @@ Usage:
29
30
  sideby quota [account] quota and 7-day token usage
30
31
  sideby quota setup claude [--yes] turn on Claude quota (shows the change first)
31
32
  sideby quota teardown claude undo it, restoring the original file
33
+ sideby tune [account|family] estimate the cheapest auto-compact threshold per model
34
+ from your last 7 days of sessions (read-only)
35
+ sideby handoff status what Auto Handoff still needs on this machine
36
+ sideby handoff enable [--yes] turn it on (shows the config change first); --same-family,
37
+ --order claude=codex001,cc002, --threshold n, --prepare-at n
38
+ sideby handoff disable [--yes] turn it off, keeping the other settings
39
+ sideby handoff ready [--brief <f>] inside a session sideby started with Auto Handoff: hand over to
40
+ the next account when this turn ends (config \`handoff\`)
41
+ sideby handoff setup grok [--yes] add sideby's hook file so Grok can hand over at its limit
42
+ sideby handoff teardown grok remove it
32
43
  sideby ui [--port n] [--no-open] open the local panel
33
44
  sideby ui --background same, without a terminal: keeps running after this command exits
34
45
  sideby ui --stop stop the background panel
@@ -40,7 +51,7 @@ Usage:
40
51
  sideby plugins list loaded plugins and load errors
41
52
 
42
53
  Options:
43
- --json machine-readable output (list, new, next, alias, doctor, quota, families, plugins, app)
54
+ --json machine-readable output (list, new, next, alias, doctor, quota, tune, handoff, families, plugins, app)
44
55
  -h, --help show this help; \`sideby <command> --help\` for new, alias, login, doctor, families
45
56
  -v, --version show the version
46
57
 
@@ -124,8 +135,12 @@ Examples:
124
135
  sideby families claude --json
125
136
  Exit: 0; 1 unknown family; 2 malformed command line.`,
126
137
  };
127
- function nodeTooOld() {
128
- const [maj = 0, min = 0] = process.versions.node.split('.').map(Number);
138
+ /**
139
+ * True below Node 22.18. npm's `engines` field only warns (`EBADENGINE`); this is the check that exits.
140
+ * `version` defaults to the running Node so tests can pass a string.
141
+ */
142
+ export function nodeTooOld(version = process.versions.node) {
143
+ const [maj = 0, min = 0] = version.split('.').map(Number);
129
144
  return maj < MIN_NODE[0] || (maj === MIN_NODE[0] && min < MIN_NODE[1]);
130
145
  }
131
146
  const COMMANDS = {
@@ -138,6 +153,11 @@ const COMMANDS = {
138
153
  alias: { flags: ['json'] },
139
154
  doctor: { flags: ['fix', 'force', 'json'] },
140
155
  quota: { flags: ['json', 'yes'] },
156
+ tune: { flags: ['json', 'yes'] },
157
+ handoff: {
158
+ flags: ['json', 'yes', 'same-family', 'no-same-family'],
159
+ valued: ['brief', 'order', 'prepare-at', 'threshold', 'wait-minutes', 'countdown'],
160
+ },
141
161
  ui: { flags: ['no-open', 'background', 'stop', 'serve-detached'], valued: ['port'] },
142
162
  app: { flags: ['json'], valued: ['url'] },
143
163
  'shell-init': { flags: ['write'] },
@@ -146,10 +166,22 @@ const COMMANDS = {
146
166
  };
147
167
  export async function main(argv) {
148
168
  const [first, ...rest] = argv;
169
+ // Claude Code runs this on every status-line refresh. It must still launch the original command when Node is
170
+ // older than 22.18, so the check below does not apply here (spec §3.1).
149
171
  if (first === 'statusline-tap') {
150
172
  const { runStatuslineTap } = await import('../quota/statusline-tap.js');
151
173
  return runStatuslineTap(rest, process.env, process.stdin, process.stdout);
152
174
  }
175
+ // The handoff hook runs on every tool call while Auto Handoff is on; like the tap it comes before the Node check,
176
+ // so a Host never gets a version error from its own hook (spec §3.17).
177
+ if (first === 'handoff-hook') {
178
+ const { runHandoffHook } = await import('../handoff/hook.js');
179
+ return runHandoffHook(rest, process.env, process.stdin, process.stdout);
180
+ }
181
+ if (nodeTooOld()) {
182
+ console.error(`sideby needs Node ${MIN_NODE.join('.')} or newer (you have ${process.versions.node}). Upgrade: https://nodejs.org`);
183
+ return 1;
184
+ }
153
185
  if (first === '-v' || first === '--version' || first === 'version') {
154
186
  const { packageVersion } = await import('../core/version.js');
155
187
  console.log(packageVersion());
@@ -159,10 +191,6 @@ export async function main(argv) {
159
191
  console.log((rest[0] !== undefined && first === 'help' && COMMAND_HELP[rest[0]]) || HELP);
160
192
  return 0;
161
193
  }
162
- if (nodeTooOld()) {
163
- console.error(`sideby needs Node ${MIN_NODE.join('.')} or newer (you have ${process.versions.node}). Upgrade: https://nodejs.org`);
164
- return 1;
165
- }
166
194
  const cmd = first === undefined || first.startsWith('--') ? 'list' : first;
167
195
  const args = first === undefined || first.startsWith('--') ? argv : rest;
168
196
  const spec = COMMANDS[cmd];
@@ -225,6 +253,10 @@ export async function main(argv) {
225
253
  return await cmds.cmdDoctor(parsed, io);
226
254
  case 'quota':
227
255
  return await cmds.cmdQuota(parsed, io);
256
+ case 'tune':
257
+ return await cmds.cmdTune(parsed, io);
258
+ case 'handoff':
259
+ return await cmds.cmdHandoff(parsed, io);
228
260
  case 'ui':
229
261
  return await cmds.cmdUi(parsed, io);
230
262
  case 'app':
@@ -16,6 +16,19 @@ export declare const aliasAccount: (value: AliasValue) => string;
16
16
  export declare const aliasArgs: (value: AliasValue | undefined) => string[];
17
17
  /** `aliases` as alias → Account ref, for lookups that ignore the arguments. */
18
18
  export declare function aliasRefs(aliases: Readonly<Record<string, AliasValue>> | undefined): Record<string, string>;
19
+ export interface HandoffSettings {
20
+ auto: boolean;
21
+ sameFamily: boolean;
22
+ prepareAt: number;
23
+ threshold: number;
24
+ waitIfResetWithinMinutes: number;
25
+ countdownSeconds: number;
26
+ crossOrganization: string[];
27
+ families: Record<string, {
28
+ policy: 'pressure' | 'order';
29
+ order: string[];
30
+ }>;
31
+ }
19
32
  export declare const ConfigSchema: Type.TObject<{
20
33
  $schema: Type.TOptional<Type.TString>;
21
34
  accounts: Type.TOptional<Type.TRecord<"^.*$", Type.TObject<{
@@ -36,15 +49,42 @@ export declare const ConfigSchema: Type.TObject<{
36
49
  }>>>>;
37
50
  accountTitle: Type.TOptional<Type.TBoolean>;
38
51
  resumeRouting: Type.TOptional<Type.TBoolean>;
52
+ handoff: Type.TOptional<Type.TObject<{
53
+ auto: Type.TOptional<Type.TBoolean>;
54
+ sameFamily: Type.TOptional<Type.TBoolean>;
55
+ prepareAt: Type.TOptional<Type.TInteger>;
56
+ threshold: Type.TOptional<Type.TInteger>;
57
+ waitIfResetWithinMinutes: Type.TOptional<Type.TInteger>;
58
+ countdownSeconds: Type.TOptional<Type.TInteger>;
59
+ crossOrganization: Type.TOptional<Type.TArray<Type.TString>>;
60
+ families: Type.TOptional<Type.TRecord<"^.*$", Type.TObject<{
61
+ policy: Type.TOptional<Type.TUnion<[Type.TLiteral<"pressure">, Type.TLiteral<"order">]>>;
62
+ order: Type.TOptional<Type.TArray<Type.TString>>;
63
+ }>>>;
64
+ }>>;
39
65
  shellInitFile: Type.TOptional<Type.TObject<{
40
66
  zsh: Type.TOptional<Type.TString>;
41
67
  bash: Type.TOptional<Type.TString>;
42
68
  }>>;
43
69
  }>;
44
70
  export type Config = Static<typeof ConfigSchema>;
71
+ /** The `handoff` settings with every default filled in. */
72
+ export declare function handoffSettings(config: Config): HandoffSettings;
45
73
  export declare class ConfigError extends UserError {
46
74
  }
47
75
  export declare function loadConfig(file: string): Promise<Config>;
76
+ export type HandoffConfig = NonNullable<Config['handoff']>;
77
+ /**
78
+ * `sideby handoff enable|disable` (spec §3.17): changes the config `handoff` key with `edit` (see `editConfigKey`
79
+ * for how the file is written). With `dryRun`, shows the change without writing.
80
+ */
81
+ export declare function editConfigHandoff(file: string, edit: (current: HandoffConfig) => HandoffConfig, opts?: {
82
+ dryRun?: boolean;
83
+ }): Promise<{
84
+ changed: boolean;
85
+ before: HandoffConfig | undefined;
86
+ after: HandoffConfig | undefined;
87
+ }>;
48
88
  /**
49
89
  * Adds `alias → ref` to the config file's `aliases`, as `{ account, args }` when `args` is not empty (see
50
90
  * `editConfigAliases` for how the file is written). Refuses an invalid alias, or one that already starts another
@@ -45,6 +45,35 @@ const describeAlias = (v) => {
45
45
  const args = aliasArgs(v);
46
46
  return args.length ? `${aliasAccount(v)} with ${args.join(' ')}` : aliasAccount(v);
47
47
  };
48
+ const Percent = (description) => Type.Optional(Type.Integer({ minimum: 1, maximum: 100, description }));
49
+ /** Auto Handoff (spec §3.17, ADR-0010). Every key is optional; `handoffSettings` fills the defaults. */
50
+ const HandoffSchema = Type.Object({
51
+ auto: Type.Optional(Type.Boolean({
52
+ description: 'Hand a running session to the next account when its Quota runs low: only to other families and API accounts unless `sameFamily` is on. Off by default.',
53
+ })),
54
+ sameFamily: Type.Optional(Type.Boolean({
55
+ description: 'Also hand off to an account of the same family (cc001 → cc002). The kind vendors are most likely to act on. Off by default.',
56
+ })),
57
+ prepareAt: Percent('Quota Pressure at which the session starts keeping a Handoff Brief (default 80).'),
58
+ threshold: Percent('Quota Pressure at which the Handoff begins (default 95).'),
59
+ waitIfResetWithinMinutes: Type.Optional(Type.Integer({
60
+ minimum: 0,
61
+ description: 'Stay and wait when the full window resets within this many minutes (default 30; 0 never waits).',
62
+ })),
63
+ countdownSeconds: Type.Optional(Type.Integer({
64
+ minimum: 0,
65
+ description: 'Countdown before the next account starts; Enter cancels (default 10; 0 skips it).',
66
+ })),
67
+ crossOrganization: Type.Optional(Type.Array(Type.String(), {
68
+ description: 'Accounts (ref or short command) that may take over from an account of another organization.',
69
+ })),
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())),
73
+ }, { additionalProperties: false }), {
74
+ description: 'Handoff Order per family the session starts in: `pressure` (default) or an `order` list.',
75
+ })),
76
+ }, { additionalProperties: false });
48
77
  export const ConfigSchema = Type.Object({
49
78
  $schema: Type.Optional(Type.String()),
50
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>`.' })),
@@ -63,6 +92,7 @@ export const ConfigSchema = Type.Object({
63
92
  resumeRouting: Type.Optional(Type.Boolean({
64
93
  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.',
65
94
  })),
95
+ handoff: Type.Optional(HandoffSchema),
66
96
  shellInitFile: Type.Optional(Type.Object({
67
97
  zsh: Type.Optional(Type.String({ minLength: 1 })),
68
98
  bash: Type.Optional(Type.String({ minLength: 1 })),
@@ -71,6 +101,23 @@ export const ConfigSchema = Type.Object({
71
101
  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.',
72
102
  })),
73
103
  }, { additionalProperties: false, title: 'sideby config' });
104
+ /** The `handoff` settings with every default filled in. */
105
+ export function handoffSettings(config) {
106
+ const h = config.handoff ?? {};
107
+ return {
108
+ auto: h.auto ?? false,
109
+ sameFamily: h.sameFamily ?? false,
110
+ prepareAt: h.prepareAt ?? 80,
111
+ threshold: h.threshold ?? 95,
112
+ waitIfResetWithinMinutes: h.waitIfResetWithinMinutes ?? 30,
113
+ countdownSeconds: h.countdownSeconds ?? 10,
114
+ crossOrganization: [...(h.crossOrganization ?? [])],
115
+ families: Object.fromEntries(Object.entries(h.families ?? {}).map(([f, v]) => [
116
+ f,
117
+ { policy: v.policy ?? 'pressure', order: [...(v.order ?? [])] },
118
+ ])),
119
+ };
120
+ }
74
121
  export class ConfigError extends UserError {
75
122
  }
76
123
  export async function loadConfig(file) {
@@ -95,6 +142,9 @@ export async function loadConfig(file) {
95
142
  const where = first?.instancePath || '/';
96
143
  throw new ConfigError(`${file}: ${where} ${first?.message ?? 'is invalid'}; see schemas/config.json`);
97
144
  }
145
+ const handoff = handoffSettings(data);
146
+ if (handoff.prepareAt >= handoff.threshold)
147
+ throw new ConfigError(`${file}: handoff.prepareAt (${handoff.prepareAt}) must be lower than handoff.threshold (${handoff.threshold}); lower prepareAt or raise threshold`);
98
148
  // TypeBox does not enforce key patterns on records; alias names become shell function names.
99
149
  for (const alias of Object.keys(data.aliases ?? {})) {
100
150
  const problem = aliasProblem(alias);
@@ -108,12 +158,14 @@ async function configTarget(file) {
108
158
  return (await lstatOrNull(file))?.isSymbolicLink() ? ((await realpathOrNull(file)) ?? file) : file;
109
159
  }
110
160
  /**
111
- * Re-reads the config file, lets `edit` change its `aliases`, and writes the result: every other key and their
112
- * order kept (with `$schema`), 2-space JSON with a trailing newline, atomically, keeping the file's mode (0600 for
113
- * a new file). Refuses, without writing, a file that is not valid JSON or not a valid config, or one that changed
114
- * while sideby wrote it. `edit` returns null to leave the file alone. `retry` names what to do again after a fix.
161
+ * Re-reads the config file, lets `edit` change one top-level `key` (the only two sideby writes are `aliases` and
162
+ * `handoff`, spec §3.10), and writes the result: every other key and their order kept (with `$schema`), 2-space JSON
163
+ * with a trailing newline, atomically, keeping the file's mode (0600 for a new file). Refuses, without writing, a
164
+ * file that is not valid JSON or not a valid config, a result that is not a valid config, or a file that changed
165
+ * while sideby wrote it. `edit` returns null to leave the file alone; `dryRun` returns the change without writing.
166
+ * `retry` names what to do again after a fix.
115
167
  */
116
- async function editConfigAliases(file, retry, edit) {
168
+ async function editConfigKey(file, key, retry, edit, opts = {}) {
117
169
  const target = await configTarget(file);
118
170
  let raw = null;
119
171
  try {
@@ -138,19 +190,39 @@ async function editConfigAliases(file, retry, edit) {
138
190
  throw new ConfigError(`${file} does not match schemas/config.json; nothing was changed. Run \`sideby list\` to see why`);
139
191
  data = parsed;
140
192
  }
141
- const current = (data.aliases ?? {});
142
- const aliases = edit(current);
143
- if (!aliases)
144
- return current;
193
+ const before = data[key];
194
+ const after = edit(before);
195
+ if (after === null || JSON.stringify(after) === JSON.stringify(before))
196
+ return { changed: false, before, after: before };
145
197
  // A new file gets the schema link editors use for completion; an existing file keeps its keys in order.
146
- const next = raw === null ? { $schema: 'https://unpkg.com/sideby/schemas/config.json', aliases } : { ...data, aliases };
198
+ const next = raw === null
199
+ ? { $schema: 'https://unpkg.com/sideby/schemas/config.json', [key]: after }
200
+ : { ...data, [key]: after };
201
+ if (!Value.Check(ConfigSchema, next))
202
+ throw new ConfigError(`the new ${key} would not match schemas/config.json`);
203
+ const h = handoffSettings(next);
204
+ if (h.prepareAt >= h.threshold)
205
+ throw new ConfigError(`handoff.prepareAt must be lower than handoff.threshold; fix them in ${file} and ${retry} again`);
206
+ if (opts.dryRun)
207
+ return { changed: true, before, after };
147
208
  const st = raw === null ? null : await lstatOrNull(target);
148
209
  if (raw === null)
149
210
  await mkdir(dirname(target), { recursive: true, mode: 0o700 });
150
211
  const written = await writeIfUnchanged(target, raw === null ? null : sha256(raw), `${JSON.stringify(next, null, 2)}\n`, st ? st.mode & 0o777 : 0o600);
151
212
  if (!written)
152
213
  throw new ConfigError(`${file} changed while sideby was writing it; nothing was changed. Try again`);
153
- return aliases;
214
+ return { changed: true, before, after };
215
+ }
216
+ async function editConfigAliases(file, retry, edit) {
217
+ const r = await editConfigKey(file, 'aliases', retry, (current) => edit(current ?? {}));
218
+ return r.after ?? {};
219
+ }
220
+ /**
221
+ * `sideby handoff enable|disable` (spec §3.17): changes the config `handoff` key with `edit` (see `editConfigKey`
222
+ * for how the file is written). With `dryRun`, shows the change without writing.
223
+ */
224
+ export function editConfigHandoff(file, edit, opts = {}) {
225
+ return editConfigKey(file, 'handoff', 'run the command', (current) => edit(current ?? {}), opts);
154
226
  }
155
227
  /**
156
228
  * Adds `alias → ref` to the config file's `aliases`, as `{ account, args }` when `args` is not empty (see
@@ -9,6 +9,18 @@ export interface MemoPass<T> {
9
9
  /** Ends the pass: the scope now holds only the files this pass asked for. */
10
10
  done(): void;
11
11
  }
12
+ /**
13
+ * How a memo is kept on disk. `name` is its key in the cache file; bump `version` whenever the parsed value's shape
14
+ * or meaning changes, so a cache written by an older sideby is dropped instead of misread.
15
+ */
16
+ export interface MemoPersist {
17
+ name: string;
18
+ version: number;
19
+ }
20
+ /** A memo as stored on disk: per scope, per file, the signature and the parsed value. */
21
+ export type MemoDump = Record<string, Record<string, [sig: string, value: unknown]>>;
22
+ /** Every memo created with `persist`, by name. */
23
+ export declare const persistedMemos: Map<string, FileMemo<unknown>>;
12
24
  export declare class FileMemo<T> {
13
25
  #private;
14
26
  /** Counters for tests and timing. */
@@ -16,8 +28,19 @@ export declare class FileMemo<T> {
16
28
  hits: number;
17
29
  misses: number;
18
30
  };
31
+ /** True when a pass changed what is remembered since the last `dump`. */
32
+ dirty: boolean;
33
+ readonly persist?: MemoPersist;
34
+ constructor(persist?: MemoPersist);
19
35
  /** Starts a pass over `scope`, such as one Account's directory. */
20
36
  pass(scope: string): MemoPass<T>;
37
+ /** What is remembered, for the cache file; clears `dirty`. */
38
+ dump(): MemoDump;
39
+ /**
40
+ * Takes remembered parses from the cache file for scopes this process has not read yet. The values are trusted
41
+ * to have the shape `persist.version` stands for; a stale one never matches, because its signature differs.
42
+ */
43
+ restore(data: MemoDump): void;
21
44
  /** Files remembered for a scope (for tests). */
22
45
  size(scope: string): number;
23
46
  clear(): void;
@@ -1,10 +1,21 @@
1
1
  export function fileSignature(st) {
2
2
  return `${st.size}:${st.mtimeMs}:${st.ctimeMs}:${st.ino}`;
3
3
  }
4
+ /** Every memo created with `persist`, by name. */
5
+ export const persistedMemos = new Map();
4
6
  export class FileMemo {
5
7
  #scopes = new Map();
6
8
  /** Counters for tests and timing. */
7
9
  stats = { hits: 0, misses: 0 };
10
+ /** True when a pass changed what is remembered since the last `dump`. */
11
+ dirty = false;
12
+ persist;
13
+ constructor(persist) {
14
+ if (!persist)
15
+ return;
16
+ this.persist = persist;
17
+ persistedMemos.set(persist.name, this);
18
+ }
8
19
  /** Starts a pass over `scope`, such as one Account's directory. */
9
20
  pass(scope) {
10
21
  const before = this.#scopes.get(scope);
@@ -25,16 +36,42 @@ export class FileMemo {
25
36
  return value;
26
37
  },
27
38
  done: () => {
39
+ if (!before || before.size !== seen.size || [...seen].some(([k, v]) => before.get(k) !== v))
40
+ this.dirty = true;
28
41
  this.#scopes.set(scope, seen);
29
42
  },
30
43
  };
31
44
  }
45
+ /** What is remembered, for the cache file; clears `dirty`. */
46
+ dump() {
47
+ this.dirty = false;
48
+ const out = {};
49
+ for (const [scope, files] of this.#scopes)
50
+ out[scope] = Object.fromEntries([...files].map(([path, e]) => [path, [e.sig, e.value]]));
51
+ return out;
52
+ }
53
+ /**
54
+ * Takes remembered parses from the cache file for scopes this process has not read yet. The values are trusted
55
+ * to have the shape `persist.version` stands for; a stale one never matches, because its signature differs.
56
+ */
57
+ restore(data) {
58
+ for (const [scope, files] of Object.entries(data)) {
59
+ if (this.#scopes.has(scope) || !files || typeof files !== 'object')
60
+ continue;
61
+ const map = new Map();
62
+ for (const [path, entry] of Object.entries(files))
63
+ if (Array.isArray(entry) && typeof entry[0] === 'string')
64
+ map.set(path, { sig: entry[0], value: entry[1] });
65
+ this.#scopes.set(scope, map);
66
+ }
67
+ }
32
68
  /** Files remembered for a scope (for tests). */
33
69
  size(scope) {
34
70
  return this.#scopes.get(scope)?.size ?? 0;
35
71
  }
36
72
  clear() {
37
73
  this.#scopes.clear();
74
+ this.dirty = false;
38
75
  this.stats.hits = 0;
39
76
  this.stats.misses = 0;
40
77
  }
@@ -0,0 +1,17 @@
1
+ import type { ReadyFile } from './handoff-chain.ts';
2
+ /** Branch, uncommitted files and `git diff --stat`, as Markdown; '' outside a git work tree. Never commits or stashes. */
3
+ export declare function gitSection(cwd: string): Promise<string>;
4
+ export declare const ASSEMBLED_NOTE = "> Put together by sideby from the previous session\u2019s records, without a model; it may be incomplete. Check the repository before continuing.";
5
+ /**
6
+ * Writes the final Brief to `target` (mode 600) and says where it came from: the user's or the session's Brief
7
+ * named in `ready`, the session's Brief already at `target`, else `assembled`, plus the repository state.
8
+ */
9
+ export declare function finishBrief(opts: {
10
+ target: string;
11
+ ready: Pick<ReadyFile, 'brief' | 'briefSource'>;
12
+ assembled?: string;
13
+ cwd: string;
14
+ }): Promise<{
15
+ path: string;
16
+ source: 'agent' | 'sideby' | 'user';
17
+ }>;
@@ -0,0 +1,75 @@
1
+ // Finishing a Handoff Brief before the next Account starts (spec §3.17 "Handoff Brief"): keep what the session or
2
+ // the user wrote, else what sideby put together from the session's records, and add the repository state. No model.
3
+ import { execFile } from 'node:child_process';
4
+ import { readFile } from 'node:fs/promises';
5
+ import { promisify } from 'node:util';
6
+ import { writeFileAtomic } from './fs-safe.js';
7
+ const run = promisify(execFile);
8
+ const GIT_TIMEOUT_MS = 3000;
9
+ const MAX_STATUS_LINES = 50;
10
+ async function git(cwd, args) {
11
+ const { stdout } = await run('git', args, { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: 1 << 20 });
12
+ return stdout.trimEnd();
13
+ }
14
+ /** Branch, uncommitted files and `git diff --stat`, as Markdown; '' outside a git work tree. Never commits or stashes. */
15
+ export async function gitSection(cwd) {
16
+ try {
17
+ if ((await git(cwd, ['rev-parse', '--is-inside-work-tree'])) !== 'true')
18
+ return '';
19
+ }
20
+ catch {
21
+ return '';
22
+ }
23
+ const out = ['## Repository state (added by sideby)', '', `Working directory: ${cwd}`];
24
+ try {
25
+ out.push(`Branch: ${(await git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])) || '(detached)'}`);
26
+ }
27
+ catch {
28
+ // a repository without commits
29
+ }
30
+ try {
31
+ const status = (await git(cwd, ['status', '--porcelain'])).split('\n').filter(Boolean);
32
+ out.push('', 'Uncommitted files:', '');
33
+ out.push(...(status.length ? status.slice(0, MAX_STATUS_LINES).map((l) => ` ${l}`) : [' (none)']));
34
+ if (status.length > MAX_STATUS_LINES)
35
+ out.push(` … and ${status.length - MAX_STATUS_LINES} more`);
36
+ const stat = await git(cwd, ['diff', '--stat']);
37
+ if (stat)
38
+ out.push('', 'Diff stat:', '', ...stat.split('\n').map((l) => ` ${l}`));
39
+ }
40
+ catch {
41
+ // git refused; the branch line is still useful
42
+ }
43
+ return `${out.join('\n')}\n`;
44
+ }
45
+ async function nonEmpty(path) {
46
+ if (!path)
47
+ return null;
48
+ try {
49
+ const text = await readFile(path, 'utf8');
50
+ return text.trim() ? text : null;
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ export const ASSEMBLED_NOTE = '> Put together by sideby from the previous session’s records, without a model; it may be incomplete. Check the repository before continuing.';
57
+ /**
58
+ * Writes the final Brief to `target` (mode 600) and says where it came from: the user's or the session's Brief
59
+ * named in `ready`, the session's Brief already at `target`, else `assembled`, plus the repository state.
60
+ */
61
+ export async function finishBrief(opts) {
62
+ let body = await nonEmpty(opts.ready.brief);
63
+ let source = opts.ready.briefSource ?? 'agent';
64
+ if (!body) {
65
+ body = await nonEmpty(opts.target);
66
+ source = 'agent';
67
+ }
68
+ if (!body) {
69
+ source = 'sideby';
70
+ body = `${ASSEMBLED_NOTE}\n\n${opts.assembled ?? 'sideby could not read the previous session’s records.\n'}`;
71
+ }
72
+ const repo = await gitSection(opts.cwd);
73
+ await writeFileAtomic(opts.target, `${body.trimEnd()}\n${repo ? `\n${repo}` : ''}`, 0o600);
74
+ return { path: opts.target, source };
75
+ }