@zhihaohong52/sonata 0.8.2 → 0.9.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 (75) hide show
  1. package/README.md +4 -0
  2. package/dist/adapters/claude.js +21 -3
  3. package/dist/adapters/claude.js.map +1 -1
  4. package/dist/adapters/codex.js +11 -1
  5. package/dist/adapters/codex.js.map +1 -1
  6. package/dist/adapters/opencode.js +19 -1
  7. package/dist/adapters/opencode.js.map +1 -1
  8. package/dist/adapters/pi.js +14 -1
  9. package/dist/adapters/pi.js.map +1 -1
  10. package/dist/adapters/reasonix.js +10 -1
  11. package/dist/adapters/reasonix.js.map +1 -1
  12. package/dist/catalog.js +252 -33
  13. package/dist/catalog.js.map +1 -1
  14. package/dist/cli.js +127 -9
  15. package/dist/cli.js.map +1 -1
  16. package/dist/commands/agents.js +258 -0
  17. package/dist/commands/agents.js.map +1 -0
  18. package/dist/commands/catalog.js +74 -11
  19. package/dist/commands/catalog.js.map +1 -1
  20. package/dist/commands/dispatch.js +27 -10
  21. package/dist/commands/dispatch.js.map +1 -1
  22. package/dist/commands/doctor.js +82 -5
  23. package/dist/commands/doctor.js.map +1 -1
  24. package/dist/commands/reset.js +340 -0
  25. package/dist/commands/reset.js.map +1 -0
  26. package/dist/commands/route.js +139 -16
  27. package/dist/commands/route.js.map +1 -1
  28. package/dist/commands/run.js +6 -0
  29. package/dist/commands/run.js.map +1 -1
  30. package/dist/commands/tail.js +30 -2
  31. package/dist/commands/tail.js.map +1 -1
  32. package/dist/commands/usage.js +19 -0
  33. package/dist/commands/usage.js.map +1 -1
  34. package/dist/commands/verify.js +16 -3
  35. package/dist/commands/verify.js.map +1 -1
  36. package/dist/config.js +25 -4
  37. package/dist/config.js.map +1 -1
  38. package/dist/detect.js +14 -4
  39. package/dist/detect.js.map +1 -1
  40. package/dist/effort.js +68 -0
  41. package/dist/effort.js.map +1 -0
  42. package/dist/extended-context.js +3 -2
  43. package/dist/extended-context.js.map +1 -1
  44. package/dist/init/guidance.js +117 -36
  45. package/dist/init/guidance.js.map +1 -1
  46. package/dist/init/helpers.js +4 -1
  47. package/dist/init/helpers.js.map +1 -1
  48. package/dist/init/interactive-state.js +25 -0
  49. package/dist/init/interactive-state.js.map +1 -1
  50. package/dist/init/plan.js +48 -7
  51. package/dist/init/plan.js.map +1 -1
  52. package/dist/init/toml.js +154 -2
  53. package/dist/init/toml.js.map +1 -1
  54. package/dist/ledger.js.map +1 -1
  55. package/dist/modelsdev.js +113 -0
  56. package/dist/modelsdev.js.map +1 -1
  57. package/dist/native/router.js +285 -6
  58. package/dist/native/router.js.map +1 -1
  59. package/dist/native/tenants.js +12 -1
  60. package/dist/native/tenants.js.map +1 -1
  61. package/dist/pricing.js +129 -1
  62. package/dist/pricing.js.map +1 -1
  63. package/dist/settings.js +28 -0
  64. package/dist/settings.js.map +1 -1
  65. package/dist/tui-ink/agents-app.js +67 -0
  66. package/dist/tui-ink/agents-app.js.map +1 -0
  67. package/dist/tui-ink/agents-run.js +23 -0
  68. package/dist/tui-ink/agents-run.js.map +1 -0
  69. package/dist/tui-ink/app-state.js +106 -2
  70. package/dist/tui-ink/app-state.js.map +1 -1
  71. package/dist/tui-ink/app.js +107 -16
  72. package/dist/tui-ink/app.js.map +1 -1
  73. package/dist/tui-ink/components/models-step.js +17 -4
  74. package/dist/tui-ink/components/models-step.js.map +1 -1
  75. package/package.json +1 -1
@@ -0,0 +1,340 @@
1
+ /**
2
+ * `sonata reset` — undo what `sonata init` wrote, and nothing else.
3
+ *
4
+ * Init scatters its output across five places: the config, the generated tier
5
+ * agents, the loop skill, a block inside a `CLAUDE.md` sonata does not own,
6
+ * and two settings files holding the routing env, four lifecycle hooks, the
7
+ * permission hook and the tool allow-list. Undoing that by hand means knowing
8
+ * all five, which is why the only reliable answer used to be "delete the repo
9
+ * and start again".
10
+ *
11
+ * Three rules shape the whole command:
12
+ *
13
+ * - **It removes only what sonata wrote.** An agent file without sonata's
14
+ * marker is left alone; `permissions.allow` keeps every entry that is not
15
+ * sonata's; `CLAUDE.md` loses what is between the markers and not one byte
16
+ * more. A settings file is rewritten, never deleted — sonata is one writer
17
+ * of it among several.
18
+ * - **It keeps everything that is expensive to recreate**: API keys, the usage
19
+ * ledger, the ranking catalogs, and the `.sonata/` run store. Those are not
20
+ * configuration, and a user resetting a bad setup should not lose their
21
+ * spend history or have to re-enter every key. The kept list is printed, so
22
+ * "reset" cannot be read as "removed everything".
23
+ * - **It plans first, then applies.** The plan is a list of typed actions with
24
+ * a label each, so the confirmation names every path before anything is
25
+ * touched, and the set shown is exactly the set removed.
26
+ */
27
+ import { existsSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
28
+ import { join } from 'node:path';
29
+ import { GLOBAL_CONFIG_RELATIVE } from '../config.js';
30
+ import { isSonataAgentText } from '../detect.js';
31
+ import { agentsDirFor, configPathFor } from '../init/helpers.js';
32
+ import { removeGuidance } from '../init/guidance.js';
33
+ import { readSettings, revokeSonataTools, settingsPath, uninstallHook, writeSettings, } from '../settings.js';
34
+ import { planRouteManual, planRouteOff, routeSessionsFile, routeSettingsFile, routeSubagentsFile } from './route.js';
35
+ /**
36
+ * The agent files in this directory that sonata generated.
37
+ *
38
+ * Only a missing directory is an empty answer. Every other failure — a
39
+ * permission error, a bad mount, an unreadable file — used to be flattened to
40
+ * `[]` or to `false`, which produced a plan with no agents in it and a command
41
+ * that reported success having left every agent installed. That is the worst
42
+ * shape a cleanup can fail in: silent, and indistinguishable from having had
43
+ * nothing to do. Unreadable files are named as warnings instead, and the
44
+ * ownership test is the same one `sync` and `prune` use.
45
+ */
46
+ function sonataAgents(dir) {
47
+ const warnings = [];
48
+ let entries;
49
+ try {
50
+ entries = readdirSync(dir);
51
+ }
52
+ catch (err) {
53
+ if (err.code === 'ENOENT')
54
+ return { files: [], warnings };
55
+ return { files: [], warnings: [`could not read ${dir}: ${err.message} — agent files left in place`] };
56
+ }
57
+ const files = [];
58
+ for (const f of entries.filter((name) => name.endsWith('.md')).sort()) {
59
+ const path = join(dir, f);
60
+ try {
61
+ if (isSonataAgentText(readFileSync(path, 'utf8')))
62
+ files.push(f);
63
+ }
64
+ catch (err) {
65
+ warnings.push(`could not read ${path}: ${err.message} — left in place`);
66
+ }
67
+ }
68
+ return { files, warnings };
69
+ }
70
+ /**
71
+ * Strip sonata out of one settings file.
72
+ *
73
+ * Every transform is by command *shape* rather than by this install's own
74
+ * path: a user who ran `sonata init` from a different checkout — an npm
75
+ * install now replaced by a clone, say — would otherwise have their hooks left
76
+ * behind by the very command whose job is removing them. `planRouteOff` can
77
+ * refuse (a non-localhost base URL it did not write), and that refusal must
78
+ * not take the rest of the file down with it, so it is caught and reported.
79
+ */
80
+ export function planSettingsReset(settings, packageRoot, scope) {
81
+ let next = settings;
82
+ let changed = false;
83
+ const warnings = [];
84
+ try {
85
+ const off = planRouteOff(next, packageRoot);
86
+ next = off.settings;
87
+ changed = changed || off.changed;
88
+ }
89
+ catch (err) {
90
+ warnings.push(`left the routing env in place: ${err.message}`);
91
+ }
92
+ const manual = planRouteManual(next, packageRoot, scope);
93
+ next = manual.settings;
94
+ changed = changed || manual.changed;
95
+ // The permission hook is found by the file it runs, not by this install's
96
+ // absolute path, for the same reason `planRouteOff` finds its own that way.
97
+ for (const [event, needle] of [
98
+ ['PreToolUse', 'capture-mode.mjs'],
99
+ ['SessionStart', 'ensure-serve.mjs'],
100
+ ['SessionStart', 'route-session.mjs'],
101
+ ['SessionEnd', 'route-session.mjs'],
102
+ ['SubagentStart', 'route-subagent.mjs'],
103
+ ['SubagentStop', 'route-subagent.mjs'],
104
+ ]) {
105
+ for (;;) {
106
+ const command = (next.hooks?.[event] ?? [])
107
+ .flatMap((entry) => entry.hooks)
108
+ .find((h) => h.command.includes(needle))?.command;
109
+ if (command === undefined)
110
+ break;
111
+ const removed = uninstallHook(next, command, event);
112
+ next = removed.settings;
113
+ changed = changed || removed.changed;
114
+ // `uninstallHook` matches by exact command, so a second install's copy
115
+ // of the same hook survives the first pass. Without this guard, a
116
+ // no-op removal would also spin here forever.
117
+ if (!removed.changed)
118
+ break;
119
+ }
120
+ }
121
+ const revoked = revokeSonataTools(next);
122
+ next = revoked.settings;
123
+ changed = changed || revoked.changed;
124
+ return { settings: next, changed, warnings };
125
+ }
126
+ /**
127
+ * What `sonata reset` would do, without doing any of it.
128
+ *
129
+ * Reads the filesystem — the plan has to name real paths — but writes nothing,
130
+ * so it doubles as the dry run behind the confirmation prompt.
131
+ */
132
+ export function planReset(opts) {
133
+ const scope = opts.scope ?? 'project';
134
+ const { cwd, home, packageRoot } = opts;
135
+ const base = scope === 'global' ? home : cwd;
136
+ const actions = [];
137
+ const warnings = [];
138
+ const configPath = configPathFor(scope, cwd, home);
139
+ if (existsSync(configPath)) {
140
+ actions.push({ kind: 'delete-file', label: 'config', path: configPath });
141
+ }
142
+ const agentsDir = agentsDirFor(scope, cwd, home);
143
+ const agents = sonataAgents(agentsDir);
144
+ warnings.push(...agents.warnings);
145
+ if (agents.files.length > 0) {
146
+ actions.push({
147
+ kind: 'delete-agents',
148
+ label: `${agents.files.length} generated agent${agents.files.length === 1 ? '' : 's'}`,
149
+ dir: agentsDir,
150
+ files: agents.files,
151
+ });
152
+ }
153
+ const skillDir = join(base, '.claude', 'skills', 'sonata-loop');
154
+ if (existsSync(skillDir)) {
155
+ actions.push({ kind: 'delete-dir', label: 'sonata-loop skill', path: skillDir });
156
+ }
157
+ // The CLAUDE.md block is the one artifact in a file sonata does not own, so
158
+ // it is spliced out rather than the file being removed — and a file whose
159
+ // markers do not pair up is left entirely alone, with the reason reported.
160
+ const guidancePath = scope === 'global'
161
+ ? join(home, '.claude', 'CLAUDE.md')
162
+ : join(cwd, 'CLAUDE.md');
163
+ if (existsSync(guidancePath)) {
164
+ try {
165
+ const existing = readFileSync(guidancePath, 'utf8');
166
+ const without = removeGuidance(existing);
167
+ if (without !== undefined) {
168
+ actions.push({
169
+ kind: 'write-file', label: 'the sonata block in CLAUDE.md', path: guidancePath, content: without,
170
+ });
171
+ }
172
+ }
173
+ catch (err) {
174
+ warnings.push(`left ${guidancePath} unchanged: ${err.message}`);
175
+ }
176
+ }
177
+ // Project scope writes routing into `settings.local.json` and the permission
178
+ // hook into `settings.json`; global scope puts both in one file. Deduplicated
179
+ // so the same file is never planned twice.
180
+ const settingsFiles = [...new Set([
181
+ routeSettingsFile(cwd, scope, home),
182
+ settingsPath(scope, cwd, home),
183
+ ])];
184
+ for (const path of settingsFiles) {
185
+ if (!existsSync(path))
186
+ continue;
187
+ const current = readSettings(path);
188
+ const stripped = planSettingsReset(current, packageRoot, scope);
189
+ warnings.push(...stripped.warnings);
190
+ if (stripped.changed) {
191
+ actions.push({
192
+ kind: 'write-settings', label: 'routing, hooks and the tool allow-list', path, settings: stripped.settings,
193
+ });
194
+ }
195
+ }
196
+ for (const path of [routeSessionsFile(cwd, scope, home), routeSubagentsFile(cwd, scope, home)]) {
197
+ if (existsSync(path)) {
198
+ actions.push({ kind: 'delete-file', label: 'routed-session registry', path });
199
+ }
200
+ }
201
+ return {
202
+ scope,
203
+ actions,
204
+ kept: [
205
+ `gateway keys (${join(home, '.config', 'sonata', 'credentials')}) — \`sonata auth list\``,
206
+ `the usage ledger (${join(home, '.config', 'sonata', 'usage')}) — \`sonata usage\``,
207
+ 'the ranking and pricing caches',
208
+ scope === 'global'
209
+ ? `any project's own sonata.toml`
210
+ : `the machine config (${join(home, GLOBAL_CONFIG_RELATIVE)})`,
211
+ ],
212
+ warnings,
213
+ };
214
+ }
215
+ /** The plan as the lines a user is asked to approve. */
216
+ export function describeReset(plan) {
217
+ const lines = [];
218
+ if (plan.actions.length === 0) {
219
+ lines.push(` nothing to remove — no sonata ${plan.scope} setup found`);
220
+ }
221
+ else {
222
+ lines.push(` sonata reset (${plan.scope}) will remove:`);
223
+ for (const action of plan.actions) {
224
+ lines.push(` ${action.kind === 'write-settings' || action.kind === 'write-file' ? '~' : '-'} ${action.label}`);
225
+ if (action.kind === 'delete-agents') {
226
+ // Every path, never a truncated sample. Elsewhere a "… and N more" is
227
+ // a courtesy; here the list *is* the thing being agreed to, and a
228
+ // confirmation that hides paths it then deletes is not a confirmation.
229
+ for (const f of action.files)
230
+ lines.push(` ${join(action.dir, f)}`);
231
+ }
232
+ else {
233
+ lines.push(` ${action.path}`);
234
+ // `writeSettings` copies the file aside before writing, so the reset
235
+ // touches a second path — and would overwrite a backup already there.
236
+ // Naming it keeps "the set shown is the set touched" literally true,
237
+ // and the copy is the undo for this command, so it is kept.
238
+ if (action.kind === 'write-settings') {
239
+ lines.push(` ${action.path}.bak (previous contents, overwritten if one exists)`);
240
+ }
241
+ }
242
+ }
243
+ lines.push('');
244
+ lines.push(' it keeps:');
245
+ for (const k of plan.kept)
246
+ lines.push(` · ${k}`);
247
+ }
248
+ for (const w of plan.warnings)
249
+ lines.push(` ! ${w}`);
250
+ return lines;
251
+ }
252
+ /**
253
+ * Delete one path, reporting anything that is not "it was already gone".
254
+ *
255
+ * `ENOENT` is the only benign failure: a concurrent `sonata sync` or a
256
+ * half-finished earlier reset is a race, not a fault. Every other errno —
257
+ * `EACCES`, `EPERM`, `EBUSY`, `EISDIR` — means the artifact is still there,
258
+ * and swallowing it let the command report success over a setup it had not
259
+ * removed.
260
+ */
261
+ function removePath(path, touched, failures) {
262
+ try {
263
+ unlinkSync(path);
264
+ touched.push(path);
265
+ }
266
+ catch (err) {
267
+ if (err.code === 'ENOENT')
268
+ return;
269
+ failures.push(`${path}: ${err.message}`);
270
+ }
271
+ }
272
+ /** Carry out a plan. */
273
+ export function applyReset(plan) {
274
+ const touched = [];
275
+ const failures = [];
276
+ for (const action of plan.actions) {
277
+ switch (action.kind) {
278
+ case 'delete-file':
279
+ removePath(action.path, touched, failures);
280
+ break;
281
+ case 'delete-dir':
282
+ try {
283
+ rmSync(action.path, { recursive: true, force: true });
284
+ touched.push(action.path);
285
+ }
286
+ catch (err) {
287
+ failures.push(`${action.path}: ${err.message}`);
288
+ }
289
+ break;
290
+ case 'delete-agents':
291
+ for (const f of action.files)
292
+ removePath(join(action.dir, f), touched, failures);
293
+ break;
294
+ case 'write-file':
295
+ try {
296
+ writeFileSync(action.path, action.content);
297
+ touched.push(action.path);
298
+ }
299
+ catch (err) {
300
+ failures.push(`${action.path}: ${err.message}`);
301
+ }
302
+ break;
303
+ case 'write-settings':
304
+ try {
305
+ writeSettings(action.path, action.settings);
306
+ touched.push(action.path);
307
+ }
308
+ catch (err) {
309
+ failures.push(`${action.path}: ${err.message}`);
310
+ }
311
+ break;
312
+ }
313
+ }
314
+ return { touched, failures };
315
+ }
316
+ export async function cmdReset(opts, io) {
317
+ const plan = planReset(opts);
318
+ for (const line of describeReset(plan))
319
+ io.out(line);
320
+ if (plan.actions.length === 0)
321
+ return 0;
322
+ io.out('');
323
+ if (!opts.yes && !await io.confirm('Remove these?')) {
324
+ io.out(' · nothing removed');
325
+ return 1;
326
+ }
327
+ const { touched, failures } = applyReset(plan);
328
+ io.out(` ✓ removed ${touched.length} path${touched.length === 1 ? '' : 's'}`);
329
+ if (failures.length > 0) {
330
+ // A partial reset that exits 0 is the failure this command exists to
331
+ // avoid: the user believes the setup is gone and it is not.
332
+ io.out(` ! ${failures.length} path(s) could not be removed:`);
333
+ for (const f of failures)
334
+ io.out(` ${f}`);
335
+ return 1;
336
+ }
337
+ io.out(' ❯ run `sonata init` to set up again');
338
+ return 0;
339
+ }
340
+ //# sourceMappingURL=reset.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reset.js","sourceRoot":"","sources":["../../src/commands/reset.ts"],"names":[],"mappings":"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"}
@@ -17,8 +17,12 @@
17
17
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
18
18
  import { dirname, join } from 'node:path';
19
19
  import { homedir } from 'node:os';
20
+ import { spawn } from 'node:child_process';
20
21
  import { readSettings, writeSettings, installHook, uninstallHook, hookInstalled } from '../settings.js';
21
22
  import { loadConfig, GLOBAL_CONFIG_RELATIVE, NoConfigError, parseConfig } from '../config.js';
23
+ import { assertEffortsPinned, loadAaCatalog } from '../catalog.js';
24
+ import { loadModelsDev } from '../modelsdev.js';
25
+ import { configUpstreamFor } from '../pricing.js';
22
26
  import { SONATA_PROJECT_HEADER } from '../native/tenants.js';
23
27
  import { SONATA_TOKEN_HEADER, ensureRouterToken } from '../native/router-token.js';
24
28
  import { nativeSessionEnv } from './code.js';
@@ -290,14 +294,22 @@ export function sessionHookCommand(packageRoot, phase, scope = 'project') {
290
294
  /**
291
295
  * Which subagents are worth turning routing on for.
292
296
  *
293
- * Every agent `sonata sync` generates is `<role>-…` or `native-<role>-…`, and
294
- * the four roles are fixed. Matching those rather than every subagent keeps
295
- * the window — the only period in which a launching session loses Remote
296
- * Control — as short as the work actually requires. Claude Code's own
297
- * built-ins (`general-purpose`, `Explore`, `Plan`) do not match: the regex is
298
- * case-sensitive and requires the trailing hyphen.
297
+ * Every agent `sonata sync` generates is `<role>-<tier>`, `native-<role>-…`,
298
+ * or — when a role's two tier lists are element-wise identical and
299
+ * `tiersCollapse` folds them — the bare `<role>`. The four roles are fixed.
300
+ *
301
+ * The role may therefore be the WHOLE name, which is why the boundary is
302
+ * `(-|$)` and not a bare hyphen. Requiring the hyphen silently excluded every
303
+ * collapsed agent: an `explore` dispatch fired no hook, wrote no routing env,
304
+ * and died with `model_not_found` at api.anthropic.com, indistinguishable from
305
+ * a broken agent (measured 2026-09-14). A matcher and a filename rule that
306
+ * disagree about what sonata generates is the failure this shape prevents.
307
+ *
308
+ * Claude Code's own built-ins still do not match: the regex is case-sensitive,
309
+ * so `Explore` and `Plan` are excluded by their capital, and the boundary
310
+ * keeps a longer name like `planner` out rather than matching on its prefix.
299
311
  */
300
- export const SONATA_AGENT_MATCHER = '^(native-)?(code|review|explore|plan)-';
312
+ export const SONATA_AGENT_MATCHER = '^(native-)?(code|review|explore|plan)(-|$)';
301
313
  /**
302
314
  * The SubagentStart/SubagentStop command auto mode installs, alongside the
303
315
  * session pair.
@@ -482,7 +494,15 @@ export async function cmdRoute(action, opts) {
482
494
  if (!existsSync(globalPath)) {
483
495
  throw new NoConfigError(`No sonata.toml found at ${globalPath}. Run \`sonata init\` or create one.`);
484
496
  }
485
- return { config: parseConfig(readFileSync(globalPath, 'utf8')) };
497
+ const config = parseConfig(readFileSync(globalPath, 'utf8'));
498
+ // The project branch above goes through `loadConfig`, which refuses an
499
+ // unpinned effort candidate; this one parsed the text directly, so
500
+ // `route on --global` installed env and hooks for a router that then
501
+ // refuses every request — and reported success. The comment above about
502
+ // `on`/`auto` installing something that depends on the config loading
503
+ // applies to the machine config exactly as it does to the project's.
504
+ assertEffortsPinned(config, loadAaCatalog(opts.home), configUpstreamFor(config, loadModelsDev(opts.home)));
505
+ return { config };
486
506
  }
487
507
  catch (err) {
488
508
  return { config: { native: undefined }, error: err };
@@ -541,6 +561,67 @@ export async function cmdRoute(action, opts) {
541
561
  }
542
562
  return status(settings);
543
563
  }
564
+ /**
565
+ * How long the routing env stays in the settings file before the settle takes
566
+ * it out again.
567
+ *
568
+ * It only has to outlast this session's own read of the file, which was
569
+ * measured as effectively immediate — a session's first routed request landed
570
+ * in the same second as the hook's write. The margin is for a loaded machine,
571
+ * not for a slow mechanism.
572
+ */
573
+ export const ROUTE_SETTLE_MS = 5_000;
574
+ /**
575
+ * Takes the routing env back out of the settings file, leaving the session
576
+ * registered and still routing.
577
+ *
578
+ * This is the half of the design that buys Remote Control back. A session
579
+ * keeps routing on the value it has already read — measured 2026-09-14 at 54
580
+ * minutes and still routing, confirmed per session id in the ledger rather
581
+ * than by log line — so what the removal changes is not this session but the
582
+ * NEXT one, which then launches into a clean file and keeps Remote Control.
583
+ *
584
+ * The registries are deliberately left alone: clearing this session's
585
+ * registration would make the next SessionEnd believe it was the last one out
586
+ * and tear routing down under a session that is still working.
587
+ *
588
+ * **Only the newest registered session clears.** Two sessions starting inside
589
+ * one settle window otherwise race: s1's settle fires while s2 has written the
590
+ * env but may not have read it yet, and s2 then runs unrouted — silently, and
591
+ * indistinguishably from a broken agent, which is the single failure this
592
+ * design exists to remove. Deferring to the newest registration means the env
593
+ * survives until a full window after the LAST start, and every session in the
594
+ * burst has had its own window.
595
+ *
596
+ * The check and the removal are taken under the session lock, the same lock
597
+ * `cmdRouteSession('start')` holds while it registers and writes the env, so
598
+ * "is anyone newer?" cannot be answered against a list that changes underneath.
599
+ *
600
+ * A session that is no longer registered does nothing. That direction is
601
+ * deliberate: an env left in place costs the NEXT session's Remote Control,
602
+ * which is visible at launch and recoverable, while clearing one that a live
603
+ * session has not read yet costs that session's routing, which is not.
604
+ *
605
+ * The honest limit: the earlier `bdf8e27` experiment saw a session keep
606
+ * routing for tens of minutes after a removal and then stop, so the value a
607
+ * session holds is known to be long-lived and NOT known to be permanent.
608
+ */
609
+ export async function cmdRouteSettle(sessionId, opts, deps = {}) {
610
+ const scope = opts.scope ?? 'project';
611
+ const wait = deps.delay ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
612
+ await wait(ROUTE_SETTLE_MS);
613
+ const registry = routeSessionsFile(opts.cwd, scope, opts.home);
614
+ await withSessionLock(registry, () => {
615
+ const registered = readSessions(registry);
616
+ // The newest registration, not merely membership. Registration order is
617
+ // the file's order — `cmdRouteSession('start')` appends — so the last
618
+ // entry is the most recent session to have written the env, and only its
619
+ // own settle may take that env away again.
620
+ if (registered[registered.length - 1] !== sessionId)
621
+ return;
622
+ routeOffKeepingRegistries(opts, scope);
623
+ });
624
+ }
544
625
  export async function cmdRouteSession(phase, sessionId, opts, deps = {}) {
545
626
  // ONE scope resolution for this entry point; everything below takes the
546
627
  // resolved value. The writer and the cleaner of `route-subagents.json` used
@@ -645,9 +726,15 @@ export async function cmdRouteSession(phase, sessionId, opts, deps = {}) {
645
726
  }
646
727
  }
647
728
  }
648
- await withSessionLock(registry, () => {
729
+ // Registering and writing the routing env are ONE critical section, because
730
+ // `cmdRouteSettle` decides whether to clear that env by asking who is
731
+ // newest. Split apart, a settle could read the registry between another
732
+ // session's append and its write and answer that question against a list
733
+ // that was about to change.
734
+ await withSessionLock(registry, async () => {
649
735
  const current = readSessions(registry);
650
736
  writeSessions(registry, current.includes(sessionId) ? current : [...current, sessionId]);
737
+ await cmdRoute('on', opts);
651
738
  });
652
739
  // Records which project this session belongs to, so `sonata usage --by
653
740
  // project` can attribute the router's rows. Guarded: a session must start
@@ -656,14 +743,50 @@ export async function cmdRouteSession(phase, sessionId, opts, deps = {}) {
656
743
  await recordSession(opts.home, { session: sessionId, cwd: opts.cwd, started: new Date().toISOString() });
657
744
  }
658
745
  catch { /* attribution is a nicety; starting the session is not */ }
659
- // Deliberately does NOT route. Routing on here is what made `route auto`
660
- // degrade into `route on`: it stayed on while any registered session lived,
661
- // which with overlapping sessions is forever, so every session after the
662
- // first launched into a dirty file and lost Remote Control. Routing is now
663
- // turned on per foreign-model subagent instead — see cmdRouteSubagent. This
664
- // phase only ensures the router daemon is up and records liveness.
746
+ // Routes, and then schedules the settle that takes the env back out.
747
+ //
748
+ // Routing here once made `route auto` degrade into `route on` — the env
749
+ // stayed for as long as any session lived, so every later session read it at
750
+ // startup and lost Remote Control. The answer then was to route at
751
+ // SubagentStart instead, which routes nothing: the subagent that FIRES that
752
+ // hook has already resolved its endpoint, so it reaches api.anthropic.com
753
+ // and dies with `model_not_found`, which reads as a broken agent.
754
+ //
755
+ // Writing it HERE happens before any subagent exists, so nothing races it;
756
+ // taking it out again a moment later is what keeps the next session's launch
757
+ // clean. Both halves were measured on 2026-09-14 (Claude Code 2.1.270) — see
758
+ // `cmdRouteSettle` and the tests.
759
+ (deps.settle ?? ((id) => spawnSettle(id, opts, scope)))(sessionId);
665
760
  const after = readSessions(registry);
666
- return { sessions: after.length, routing: 'off' };
761
+ return { sessions: after.length, routing: 'on' };
762
+ }
763
+ /**
764
+ * Runs the settle in a detached child, so the hook that started this session
765
+ * returns immediately.
766
+ *
767
+ * The delay must not be waited out in-process: Claude Code blocks on a
768
+ * SessionStart hook, so every session would pay it before showing a prompt.
769
+ */
770
+ function spawnSettle(sessionId, opts, scope) {
771
+ try {
772
+ // The installed CLI, resolved the same way the hook commands are, rather
773
+ // than `process.argv[1]`: that is whatever binary happens to be running
774
+ // this code — a test runner, an editor integration — and re-invoking it
775
+ // with sonata's arguments is not a thing to do blind. A package root with
776
+ // no built CLI simply schedules nothing.
777
+ const cli = join(opts.packageRoot, 'dist', 'cli.js');
778
+ if (!existsSync(cli))
779
+ return;
780
+ const args = [cli, 'route', 'session-settle', '--id', sessionId];
781
+ if (scope === 'global')
782
+ args.push('--global');
783
+ spawn(process.execPath, args, { cwd: opts.cwd, detached: true, stdio: 'ignore' }).unref();
784
+ }
785
+ catch {
786
+ // A settle that cannot be scheduled costs the NEXT session's Remote
787
+ // Control, never this session's routing. Failing the hook over it would
788
+ // trade a small loss for the whole feature.
789
+ }
667
790
  }
668
791
  /**
669
792
  * Removes the routing env, leaving both registries alone.