@zhihaohong52/sonata 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 (183) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +437 -0
  3. package/dist/adapters/claude.js +83 -0
  4. package/dist/adapters/claude.js.map +1 -0
  5. package/dist/adapters/codex.js +382 -0
  6. package/dist/adapters/codex.js.map +1 -0
  7. package/dist/adapters/index.js +21 -0
  8. package/dist/adapters/index.js.map +1 -0
  9. package/dist/adapters/opencode.js +104 -0
  10. package/dist/adapters/opencode.js.map +1 -0
  11. package/dist/adapters/pi.js +152 -0
  12. package/dist/adapters/pi.js.map +1 -0
  13. package/dist/adapters/reasonix.js +358 -0
  14. package/dist/adapters/reasonix.js.map +1 -0
  15. package/dist/adapters/types.js +2 -0
  16. package/dist/adapters/types.js.map +1 -0
  17. package/dist/agent-markers.js +3 -0
  18. package/dist/agent-markers.js.map +1 -0
  19. package/dist/aipricing.js +109 -0
  20. package/dist/aipricing.js.map +1 -0
  21. package/dist/catalog.js +274 -0
  22. package/dist/catalog.js.map +1 -0
  23. package/dist/cli.js +766 -0
  24. package/dist/cli.js.map +1 -0
  25. package/dist/commands/approve.js +23 -0
  26. package/dist/commands/approve.js.map +1 -0
  27. package/dist/commands/auth.js +41 -0
  28. package/dist/commands/auth.js.map +1 -0
  29. package/dist/commands/catalog.js +172 -0
  30. package/dist/commands/catalog.js.map +1 -0
  31. package/dist/commands/code.js +113 -0
  32. package/dist/commands/code.js.map +1 -0
  33. package/dist/commands/dispatch.js +148 -0
  34. package/dist/commands/dispatch.js.map +1 -0
  35. package/dist/commands/doctor.js +545 -0
  36. package/dist/commands/doctor.js.map +1 -0
  37. package/dist/commands/gc.js +25 -0
  38. package/dist/commands/gc.js.map +1 -0
  39. package/dist/commands/init-log.js +83 -0
  40. package/dist/commands/init-log.js.map +1 -0
  41. package/dist/commands/init.js +157 -0
  42. package/dist/commands/init.js.map +1 -0
  43. package/dist/commands/log.js +24 -0
  44. package/dist/commands/log.js.map +1 -0
  45. package/dist/commands/route.js +660 -0
  46. package/dist/commands/route.js.map +1 -0
  47. package/dist/commands/run.js +199 -0
  48. package/dist/commands/run.js.map +1 -0
  49. package/dist/commands/runs.js +37 -0
  50. package/dist/commands/runs.js.map +1 -0
  51. package/dist/commands/serve.js +818 -0
  52. package/dist/commands/serve.js.map +1 -0
  53. package/dist/commands/status.js +14 -0
  54. package/dist/commands/status.js.map +1 -0
  55. package/dist/commands/sync.js +258 -0
  56. package/dist/commands/sync.js.map +1 -0
  57. package/dist/commands/tail.js +229 -0
  58. package/dist/commands/tail.js.map +1 -0
  59. package/dist/commands/usage.js +85 -0
  60. package/dist/commands/usage.js.map +1 -0
  61. package/dist/commands/verify.js +69 -0
  62. package/dist/commands/verify.js.map +1 -0
  63. package/dist/commands/wait.js +38 -0
  64. package/dist/commands/wait.js.map +1 -0
  65. package/dist/config.js +588 -0
  66. package/dist/config.js.map +1 -0
  67. package/dist/detect.js +445 -0
  68. package/dist/detect.js.map +1 -0
  69. package/dist/filelock.js +83 -0
  70. package/dist/filelock.js.map +1 -0
  71. package/dist/init/apply.js +96 -0
  72. package/dist/init/apply.js.map +1 -0
  73. package/dist/init/candidates.js +115 -0
  74. package/dist/init/candidates.js.map +1 -0
  75. package/dist/init/discover.js +216 -0
  76. package/dist/init/discover.js.map +1 -0
  77. package/dist/init/helpers.js +429 -0
  78. package/dist/init/helpers.js.map +1 -0
  79. package/dist/init/interactive-state.js +128 -0
  80. package/dist/init/interactive-state.js.map +1 -0
  81. package/dist/init/plan.js +225 -0
  82. package/dist/init/plan.js.map +1 -0
  83. package/dist/init/scripted-state.js +130 -0
  84. package/dist/init/scripted-state.js.map +1 -0
  85. package/dist/init/toml.js +83 -0
  86. package/dist/init/toml.js.map +1 -0
  87. package/dist/init/validate.js +149 -0
  88. package/dist/init/validate.js.map +1 -0
  89. package/dist/ledger.js +123 -0
  90. package/dist/ledger.js.map +1 -0
  91. package/dist/mcp/protocol.js +64 -0
  92. package/dist/mcp/protocol.js.map +1 -0
  93. package/dist/mcp/server.js +96 -0
  94. package/dist/mcp/server.js.map +1 -0
  95. package/dist/mcp/tools.js +210 -0
  96. package/dist/mcp/tools.js.map +1 -0
  97. package/dist/mode.js +64 -0
  98. package/dist/mode.js.map +1 -0
  99. package/dist/native/claude-shim.js +228 -0
  100. package/dist/native/claude-shim.js.map +1 -0
  101. package/dist/native/codex-auth.js +177 -0
  102. package/dist/native/codex-auth.js.map +1 -0
  103. package/dist/native/copilot-auth.js +105 -0
  104. package/dist/native/copilot-auth.js.map +1 -0
  105. package/dist/native/credentials.js +83 -0
  106. package/dist/native/credentials.js.map +1 -0
  107. package/dist/native/hosts.js +60 -0
  108. package/dist/native/hosts.js.map +1 -0
  109. package/dist/native/litellm.js +103 -0
  110. package/dist/native/litellm.js.map +1 -0
  111. package/dist/native/models.js +96 -0
  112. package/dist/native/models.js.map +1 -0
  113. package/dist/native/oauth-login.js +112 -0
  114. package/dist/native/oauth-login.js.map +1 -0
  115. package/dist/native/router.js +469 -0
  116. package/dist/native/router.js.map +1 -0
  117. package/dist/native/tls.js +73 -0
  118. package/dist/native/tls.js.map +1 -0
  119. package/dist/native/usage.js +119 -0
  120. package/dist/native/usage.js.map +1 -0
  121. package/dist/normalize.js +98 -0
  122. package/dist/normalize.js.map +1 -0
  123. package/dist/pricing.js +86 -0
  124. package/dist/pricing.js.map +1 -0
  125. package/dist/roles.js +49 -0
  126. package/dist/roles.js.map +1 -0
  127. package/dist/sessions.js +62 -0
  128. package/dist/sessions.js.map +1 -0
  129. package/dist/settings.js +131 -0
  130. package/dist/settings.js.map +1 -0
  131. package/dist/store.js +70 -0
  132. package/dist/store.js.map +1 -0
  133. package/dist/tmux.js +89 -0
  134. package/dist/tmux.js.map +1 -0
  135. package/dist/tui-ink/app-state.js +340 -0
  136. package/dist/tui-ink/app-state.js.map +1 -0
  137. package/dist/tui-ink/app.js +156 -0
  138. package/dist/tui-ink/app.js.map +1 -0
  139. package/dist/tui-ink/components/byok-step.js +102 -0
  140. package/dist/tui-ink/components/byok-step.js.map +1 -0
  141. package/dist/tui-ink/components/login-screen.js +80 -0
  142. package/dist/tui-ink/components/login-screen.js.map +1 -0
  143. package/dist/tui-ink/components/models-step.js +92 -0
  144. package/dist/tui-ink/components/models-step.js.map +1 -0
  145. package/dist/tui-ink/components/multi-select-state.js +56 -0
  146. package/dist/tui-ink/components/multi-select-state.js.map +1 -0
  147. package/dist/tui-ink/components/multi-select.js +69 -0
  148. package/dist/tui-ink/components/multi-select.js.map +1 -0
  149. package/dist/tui-ink/components/providers-step.js +285 -0
  150. package/dist/tui-ink/components/providers-step.js.map +1 -0
  151. package/dist/tui-ink/components/ranked-select-state.js +41 -0
  152. package/dist/tui-ink/components/ranked-select-state.js.map +1 -0
  153. package/dist/tui-ink/components/ranked-select.js +61 -0
  154. package/dist/tui-ink/components/ranked-select.js.map +1 -0
  155. package/dist/tui-ink/components/search-select.js +47 -0
  156. package/dist/tui-ink/components/search-select.js.map +1 -0
  157. package/dist/tui-ink/components/text-input-state.js +42 -0
  158. package/dist/tui-ink/components/text-input-state.js.map +1 -0
  159. package/dist/tui-ink/components/text-input.js +48 -0
  160. package/dist/tui-ink/components/text-input.js.map +1 -0
  161. package/dist/tui-ink/run.js +38 -0
  162. package/dist/tui-ink/run.js.map +1 -0
  163. package/dist/tui-ink/test-compile.js +4 -0
  164. package/dist/tui-ink/test-compile.js.map +1 -0
  165. package/dist/tui-ink/types.js +2 -0
  166. package/dist/tui-ink/types.js.map +1 -0
  167. package/dist/tui.js +330 -0
  168. package/dist/tui.js.map +1 -0
  169. package/dist/types.js +2 -0
  170. package/dist/types.js.map +1 -0
  171. package/dist/watchdog.js +85 -0
  172. package/dist/watchdog.js.map +1 -0
  173. package/hooks/capture-mode.mjs +56 -0
  174. package/hooks/ensure-serve.mjs +140 -0
  175. package/hooks/hooks.json +12 -0
  176. package/hooks/route-session.mjs +47 -0
  177. package/hooks/route-subagent.mjs +54 -0
  178. package/package.json +64 -0
  179. package/roles/code.md +13 -0
  180. package/roles/explore.md +9 -0
  181. package/roles/plan.md +10 -0
  182. package/roles/review.md +11 -0
  183. package/skills/loop/SKILL.md +43 -0
@@ -0,0 +1,660 @@
1
+ /**
2
+ * `sonata route <on|off|status|auto|manual>` — route a plain `claude` session in
3
+ * the project through the native router.
4
+ *
5
+ * `sonata code` routes one session by spawning `claude` with env vars on the
6
+ * process. `sonata route on` writes those same env vars into the project's
7
+ * `.claude/settings.local.json`, so *every* plain `claude` launched in the
8
+ * directory (from an editor integration, a `.mcp.json` entry, a shell alias)
9
+ * is routed too — no wrapper needed. The env vars come from `nativeSessionEnv`
10
+ * in code.ts, so the two session paths cannot drift.
11
+ *
12
+ * A SessionStart hook (`hooks/ensure-serve.mjs`) makes the router come up like
13
+ * `sonata code` does: `claude` alone has nothing of sonata running to start the
14
+ * daemon, and the first thing an unrouted session would do is cache the error
15
+ * from a router that is not there.
16
+ */
17
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
18
+ import { dirname, join } from 'node:path';
19
+ import { homedir } from 'node:os';
20
+ import { readSettings, writeSettings, installHook, uninstallHook, hookInstalled } from '../settings.js';
21
+ import { configPath as resolveSonataConfigPath, loadConfig, GLOBAL_CONFIG_RELATIVE, parseConfig } from '../config.js';
22
+ import { nativeSessionEnv } from './code.js';
23
+ import { isSonataRouter, sonataRouterConfigPath, startServeDaemon } from './serve.js';
24
+ import { recordSession } from '../sessions.js';
25
+ import { withSessionLock } from '../filelock.js';
26
+ /** Where `route` always writes — the project's local, never-shared settings. */
27
+ export function routeSettingsFile(cwd, scope = 'project', home = homedir()) {
28
+ return scope === 'global'
29
+ ? join(home, '.claude', 'settings.json')
30
+ : join(cwd, '.claude', 'settings.local.json');
31
+ }
32
+ /**
33
+ * The SessionStart command that keeps the router up for a routed session,
34
+ * pointing at this installation's `ensure-serve.mjs` with the routing port.
35
+ *
36
+ * The `--global` marker matters: a daemon this hook starts for global-scope
37
+ * routing is shared by every project, so it must resolve the machine config
38
+ * regardless of which project's session happens to trigger it — `--global`
39
+ * tells `ensure-serve.mjs` to start it from `home`, not its own inherited cwd.
40
+ */
41
+ export function ensureServeCommand(packageRoot, port, scope = 'project') {
42
+ const base = `node ${JSON.stringify(join(packageRoot, 'hooks', 'ensure-serve.mjs'))} ${port}`;
43
+ return scope === 'global' ? `${base} --global` : base;
44
+ }
45
+ /** The two env keys a routed session needs. */
46
+ export const ROUTE_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'CLAUDE_CODE_MAX_CONTEXT_TOKENS'];
47
+ /** `settings.env` as a string map. Claude Code settings put env as strings. */
48
+ export function routeEnv(settings) {
49
+ const raw = settings.env;
50
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
51
+ return {};
52
+ const out = {};
53
+ for (const [k, v] of Object.entries(raw)) {
54
+ if (typeof v === 'string')
55
+ out[k] = v;
56
+ }
57
+ return out;
58
+ }
59
+ /**
60
+ * What `route on` would write. Pure — no disk access; the caller passes the
61
+ * current settings and gets back the amended ones plus whether anything changed.
62
+ */
63
+ export function planRouteOn(settings, config, packageRoot, scope = 'project') {
64
+ if (!config.native)
65
+ throw new Error('sonata route on: no [native] table in sonata.toml');
66
+ const port = config.native.ports.router;
67
+ const target = nativeSessionEnv(config);
68
+ // Never clobber a base URL sonata did not write. A stale sonata port
69
+ // (`http://localhost:<other>`) is rewritten — that is the drift `sonata
70
+ // doctor` sends people here to fix — but anything else (a corporate
71
+ // gateway, a cloud proxy) was put there by someone with a reason.
72
+ const existing = routeEnv(settings).ANTHROPIC_BASE_URL;
73
+ if (existing !== undefined && existing !== target.ANTHROPIC_BASE_URL && !isLocalhostUrl(existing)) {
74
+ throw new Error(`sonata route on: ANTHROPIC_BASE_URL is already set to ${existing} in `
75
+ + `.claude/settings.local.json — remove it yourself if sonata should take over`);
76
+ }
77
+ // Merge the routing env in over whatever the user already has, preserving
78
+ // every unrelated env var.
79
+ const env = { ...routeEnv(settings), ...target };
80
+ let next = envChanged(settings, target) ? { ...settings, env } : settings;
81
+ // Simplest to always attempt the hook install; it is a no-op when present.
82
+ const command = ensureServeCommand(packageRoot, port, scope);
83
+ const hook = installHook(next, command, '', 'SessionStart');
84
+ if (hook.changed)
85
+ next = hook.settings;
86
+ return { settings: next, changed: hook.changed || envChanged(settings, target) };
87
+ }
88
+ /**
89
+ * The shape of every URL sonata's router answers on — the ownership test.
90
+ *
91
+ * Exported because `sonata doctor` has to make the same distinction before it
92
+ * advises anything: `route on` refuses to clobber a URL failing this test and
93
+ * `route off` refuses to remove one, so telling that user to run `route auto`
94
+ * recommends a command that throws.
95
+ */
96
+ export function isLocalhostUrl(url) {
97
+ return /^http:\/\/localhost:\d+$/.test(url);
98
+ }
99
+ function envChanged(settings, target) {
100
+ const current = routeEnv(settings);
101
+ return ROUTE_ENV_KEYS.some((k) => current[k] !== target[k]);
102
+ }
103
+ /**
104
+ * What `route off` would write: drop the routing env keys and the SessionStart
105
+ * hook, preserving everything else including unrelated env vars and hooks.
106
+ */
107
+ export function planRouteOff(settings, packageRoot) {
108
+ let next = settings;
109
+ let any = false;
110
+ // The hook was installed under a matcher-less SessionStart entry, so find it
111
+ // by command shape and uninstall exactly that. `uninstallHook` is a no-op when
112
+ // the command is absent, and an entry that ends up with no hooks is dropped.
113
+ const s = settings.hooks?.SessionStart ?? [];
114
+ const command = s
115
+ .flatMap((entry) => entry.hooks)
116
+ .find((h) => h.command.includes('ensure-serve.mjs'))?.command;
117
+ if (command) {
118
+ const removed = uninstallHook(next, command, 'SessionStart');
119
+ next = removed.settings;
120
+ any = removed.changed;
121
+ }
122
+ // Same ownership test as `route on`: a base URL that is not a localhost
123
+ // router was written by someone else, and `route off` must not remove it.
124
+ const base = routeEnv(settings).ANTHROPIC_BASE_URL;
125
+ if (base !== undefined && !isLocalhostUrl(base)) {
126
+ throw new Error(`sonata route off: ANTHROPIC_BASE_URL is set to ${base}, which sonata did `
127
+ + `not write — remove it yourself if that is intended`);
128
+ }
129
+ // Prune only when the routing base URL itself is present: a lone
130
+ // CLAUDE_CODE_MAX_CONTEXT_TOKENS with no base URL was set by the user for
131
+ // their own reasons, not by `route on`, and is not ours to remove.
132
+ const env = settings.env;
133
+ if (base !== undefined && env && typeof env === 'object' && !Array.isArray(env)
134
+ && ROUTE_ENV_KEYS.some((k) => k in env)) {
135
+ const pruned = { ...env };
136
+ for (const k of ROUTE_ENV_KEYS)
137
+ delete pruned[k];
138
+ // Drop the `env` block entirely when nothing is left in it, so the file
139
+ // stays as close to untouched as the write allows.
140
+ next = Object.keys(pruned).length === 0 ? omit(next, 'env') : { ...next, env: pruned };
141
+ any = true;
142
+ }
143
+ return { settings: next, changed: any };
144
+ }
145
+ function omit(obj, key) {
146
+ const out = { ...obj };
147
+ delete out[key];
148
+ return out;
149
+ }
150
+ /* --- auto mode: route a session without giving up Remote Control ------------
151
+ *
152
+ * `route on` leaves the routing env in the settings file permanently, so the
153
+ * *next* session reads it at launch — and Claude Code decides there whether the
154
+ * session keeps Remote Control, by looking at `ANTHROPIC_BASE_URL`. A session
155
+ * launched under `route on` therefore always loses it.
156
+ *
157
+ * The two decisions are made at different times, and that asymmetry is the
158
+ * whole trick. The Remote Control gate is evaluated once, at launch; the env
159
+ * block is re-read per request. So a session that launches with a *clean*
160
+ * settings file keeps Remote Control, and picks up routing anyway if the env
161
+ * appears afterwards — which is exactly what a SessionStart hook can do.
162
+ * Probed live on 2026-08-25: a session launched before `route on` kept Remote
163
+ * Control and still dispatched a native subagent that the router logged as
164
+ * `-> litellm`.
165
+ *
166
+ * Auto mode makes that deliberate: SessionStart turns routing on, SessionEnd
167
+ * turns it back off so the next session also launches clean.
168
+ *
169
+ * Concurrent sessions are why the ids are counted rather than a boolean. Under
170
+ * `route on` a live session cannot be un-routed (its env was exported at
171
+ * launch and survives the key's removal), but an auto session has no exported
172
+ * env — it reads the file every request — so a sibling's SessionEnd would cut
173
+ * its routing mid-run. `route off` only fires when the last id is gone.
174
+ *
175
+ * A session that dies without its SessionEnd hook leaves its id behind, and
176
+ * routing stays on. That is the safe direction to fail: the cost is one
177
+ * launch without Remote Control, not a native agent whose model silently
178
+ * became Claude. `sonata route off` clears the registry outright.
179
+ */
180
+ /**
181
+ * Where the ids of currently-routed auto sessions live. `.sonata/` is ignored.
182
+ *
183
+ * Global scope is one shared router across every project, so its session
184
+ * count has to be shared too — a per-project registry would let a session
185
+ * ending in project A (whose own registry hits zero) turn off routing while
186
+ * project B's sessions, tracked in a registry A never sees, are still live.
187
+ */
188
+ export function routeSessionsFile(cwd, scope = 'project', home = homedir()) {
189
+ return scope === 'global'
190
+ ? join(home, '.config', 'sonata', 'route-sessions.json')
191
+ : join(cwd, '.sonata', 'route-sessions.json');
192
+ }
193
+ export function readSessions(file) {
194
+ if (!existsSync(file))
195
+ return [];
196
+ try {
197
+ const doc = JSON.parse(readFileSync(file, 'utf8'));
198
+ if (!Array.isArray(doc))
199
+ return [];
200
+ return doc.filter((id) => typeof id === 'string');
201
+ }
202
+ catch {
203
+ // A corrupt registry must not stop a session from starting; treat it as
204
+ // empty and let the write below replace it.
205
+ return [];
206
+ }
207
+ }
208
+ export function writeSessions(file, ids) {
209
+ if (ids.length === 0) {
210
+ rmSync(file, { force: true });
211
+ return;
212
+ }
213
+ mkdirSync(dirname(file), { recursive: true });
214
+ writeFileSync(file, `${JSON.stringify(ids, null, 2)}\n`);
215
+ }
216
+ /**
217
+ * The SessionStart/SessionEnd command auto mode installs.
218
+ *
219
+ * The `--global` marker matters the same way it does for `ensureServeCommand`:
220
+ * without it, `route-session.mjs` calls `sonata route session-<phase>` with no
221
+ * scope, the CLI defaults that to project scope, and a global auto session
222
+ * ends up writing project-local routing settings and probing/starting a
223
+ * project-configured daemon — silently recreating the cross-project
224
+ * model/config leakage the machine-config fix (bd72ec4) was meant to prevent.
225
+ */
226
+ export function sessionHookCommand(packageRoot, phase, scope = 'project') {
227
+ const base = `node ${JSON.stringify(join(packageRoot, 'hooks', 'route-session.mjs'))} ${phase}`;
228
+ return scope === 'global' ? `${base} --global` : base;
229
+ }
230
+ /**
231
+ * Which subagents are worth turning routing on for.
232
+ *
233
+ * Every agent `sonata sync` generates is `<role>-…` or `native-<role>-…`, and
234
+ * the four roles are fixed. Matching those rather than every subagent keeps
235
+ * the window — the only period in which a launching session loses Remote
236
+ * Control — as short as the work actually requires. Claude Code's own
237
+ * built-ins (`general-purpose`, `Explore`, `Plan`) do not match: the regex is
238
+ * case-sensitive and requires the trailing hyphen.
239
+ */
240
+ export const SONATA_AGENT_MATCHER = '^(native-)?(code|review|explore|plan)-';
241
+ /**
242
+ * The SubagentStart/SubagentStop command auto mode installs, alongside the
243
+ * session pair.
244
+ */
245
+ export function subagentHookCommand(packageRoot, phase, scope = 'project') {
246
+ const base = `node ${JSON.stringify(join(packageRoot, 'hooks', 'route-subagent.mjs'))} ${phase}`;
247
+ return scope === 'global' ? `${base} --global` : base;
248
+ }
249
+ /** Where the ids of currently-running foreign-model subagents are counted. */
250
+ export function routeSubagentsFile(cwd, scope = 'project', home = homedir()) {
251
+ return scope === 'global'
252
+ ? join(home, '.config', 'sonata', 'route-subagents.json')
253
+ : join(cwd, '.sonata', 'route-subagents.json');
254
+ }
255
+ /** Whether both lifecycle hooks for this install are present. */
256
+ export function autoInstalled(settings, packageRoot, scope = 'project') {
257
+ return hookInstalled(settings, sessionHookCommand(packageRoot, 'start', scope), 'SessionStart')
258
+ && hookInstalled(settings, sessionHookCommand(packageRoot, 'end', scope), 'SessionEnd')
259
+ // The subagent pair is what actually routes, so an install carrying only
260
+ // the session pair is not auto mode — it is an install from before routing
261
+ // moved off the session lifetime, and it would never route at all. Failing
262
+ // this check is what sends `sonata doctor` to tell the user to re-run
263
+ // `sonata route auto`.
264
+ && hookInstalled(settings, subagentHookCommand(packageRoot, 'start', scope), 'SubagentStart')
265
+ && hookInstalled(settings, subagentHookCommand(packageRoot, 'stop', scope), 'SubagentStop');
266
+ }
267
+ /** The four lifecycle events `route auto` installs into, and what it puts there. */
268
+ const AUTO_HOOK_EVENTS = ['SessionStart', 'SessionEnd', 'SubagentStart', 'SubagentStop'];
269
+ /** Matches a sonata routing hook command whatever install wrote it. */
270
+ const ROUTE_HOOK_PATH = /"([^"]+)[/\\]hooks[/\\]route-(?:session|subagent)\.mjs"/;
271
+ /** Every sonata routing hook command in these settings, from any install. */
272
+ function routeHookRoots(settings) {
273
+ const roots = [];
274
+ for (const event of AUTO_HOOK_EVENTS) {
275
+ for (const entry of settings.hooks?.[event] ?? []) {
276
+ for (const hook of entry.hooks) {
277
+ const root = ROUTE_HOOK_PATH.exec(hook.command)?.[1];
278
+ if (root !== undefined)
279
+ roots.push(root);
280
+ }
281
+ }
282
+ }
283
+ return roots;
284
+ }
285
+ /**
286
+ * Classifies why auto-mode routing is not installed for this scope.
287
+ *
288
+ * The `other-install` case is not hypothetical: the hook command embeds the
289
+ * absolute `packageRoot`, so re-installing sonata anywhere else — an npm
290
+ * global over a source checkout, a moved clone, a second worktree — leaves
291
+ * four valid-looking hooks that run a *different* sonata, and every check
292
+ * that compares command strings reads them as missing.
293
+ */
294
+ export function diagnoseRouteAuto(settings, packageRoot, scope = 'project') {
295
+ if (autoInstalled(settings, packageRoot, scope))
296
+ return { kind: 'installed' };
297
+ const roots = routeHookRoots(settings);
298
+ if (roots.length === 0)
299
+ return { kind: 'absent' };
300
+ // A foreign install is also "missing" every expected command, so this order
301
+ // matters: reporting it as partial would be true and useless.
302
+ const foreign = [...new Set(roots.filter((root) => root !== packageRoot))];
303
+ if (foreign.length > 0)
304
+ return { kind: 'other-install', roots: foreign };
305
+ const missing = AUTO_HOOK_EVENTS.filter((event) => {
306
+ const command = event.startsWith('Session')
307
+ ? sessionHookCommand(packageRoot, event === 'SessionStart' ? 'start' : 'end', scope)
308
+ : subagentHookCommand(packageRoot, event === 'SubagentStart' ? 'start' : 'stop', scope);
309
+ return !hookInstalled(settings, command, event);
310
+ });
311
+ return { kind: 'partial', missing };
312
+ }
313
+ /** What `route auto` would write: the SessionStart/SessionEnd hook pair. */
314
+ export function planRouteAuto(settings, packageRoot, scope = 'project', hasLiveSessions = false) {
315
+ // Auto mode's guarantee is that a session launches from a clean settings
316
+ // file; switching directly from `route on` must not leave the persistent
317
+ // ANTHROPIC_BASE_URL/ensure-serve hook behind for the next session to
318
+ // inherit before its own lifecycle hook ever runs. But if auto sessions
319
+ // from an earlier `route auto` are already live and registered — e.g.
320
+ // `sonata init` rerunning `route auto` from inside a session that is
321
+ // already auto-routed — stripping that persistent env right now would cut
322
+ // native routing out from under them immediately: Claude Code reads a
323
+ // session's settings `env` per request, not just at launch, and a session
324
+ // already past its own SessionStart has no lifecycle event left to restore
325
+ // it. Skip the cleanup in that case; only a session launched afterward
326
+ // needs (and gets, via its own SessionStart hook) a clean start.
327
+ let next = settings;
328
+ let changed = false;
329
+ if (!hasLiveSessions) {
330
+ const cleared = planRouteOff(settings, packageRoot);
331
+ next = cleared.settings;
332
+ changed = cleared.changed;
333
+ }
334
+ for (const phase of ['start', 'end']) {
335
+ const event = phase === 'start' ? 'SessionStart' : 'SessionEnd';
336
+ const res = installHook(next, sessionHookCommand(packageRoot, phase, scope), '', event);
337
+ next = res.settings;
338
+ changed = changed || res.changed;
339
+ }
340
+ // The subagent pair is what actually routes. The session pair only keeps the
341
+ // router daemon up and tracks liveness, so that a session which launches
342
+ // while nothing is dispatched sees a clean settings file and keeps Remote
343
+ // Control.
344
+ for (const phase of ['start', 'stop']) {
345
+ const event = phase === 'start' ? 'SubagentStart' : 'SubagentStop';
346
+ const res = installHook(next, subagentHookCommand(packageRoot, phase, scope), SONATA_AGENT_MATCHER, event);
347
+ next = res.settings;
348
+ changed = changed || res.changed;
349
+ }
350
+ return { settings: next, changed };
351
+ }
352
+ /** What `route manual` would write: the same pair removed. */
353
+ export function planRouteManual(settings, packageRoot, scope = 'project') {
354
+ let next = settings;
355
+ let changed = false;
356
+ for (const phase of ['start', 'end']) {
357
+ const event = phase === 'start' ? 'SessionStart' : 'SessionEnd';
358
+ const res = uninstallHook(next, sessionHookCommand(packageRoot, phase, scope), event);
359
+ next = res.settings;
360
+ changed = changed || res.changed;
361
+ }
362
+ for (const phase of ['start', 'stop']) {
363
+ const event = phase === 'start' ? 'SubagentStart' : 'SubagentStop';
364
+ const res = uninstallHook(next, subagentHookCommand(packageRoot, phase, scope), event);
365
+ next = res.settings;
366
+ changed = changed || res.changed;
367
+ }
368
+ return { settings: next, changed };
369
+ }
370
+ function routeScopeStatus(settings, config, packageRoot, scope = 'project') {
371
+ const env = routeEnv(settings);
372
+ const port = config.native?.ports.router;
373
+ const base = env.ANTHROPIC_BASE_URL;
374
+ const command = port !== undefined ? ensureServeCommand(packageRoot, port, scope) : '';
375
+ const hook = command !== '' && hookInstalled(settings, command, 'SessionStart');
376
+ return {
377
+ on: !!port && base === `http://localhost:${port}` && hook,
378
+ auto: autoInstalled(settings, packageRoot, scope),
379
+ env,
380
+ hook: { installed: hook },
381
+ };
382
+ }
383
+ /** Whether a project's local settings currently route through the router. */
384
+ export function routeStatus(settings, config, packageRoot, cwd, scopedSettings, scope = 'project',
385
+ /**
386
+ * Global routing is one shared router resolving the *machine* config, not
387
+ * whichever project's session happens to check status — defaults to
388
+ * `config` so existing single-config callers (and tests) are unaffected,
389
+ * but `cmdRoute` passes the machine config here explicitly so a global
390
+ * status/port is never read from a project's own sonata.toml.
391
+ */
392
+ globalConfig = config, home = homedir()) {
393
+ const project = scopedSettings?.project ?? settings;
394
+ const global = scopedSettings?.global ?? {};
395
+ const activeConfig = scope === 'global' ? globalConfig : config;
396
+ const current = routeScopeStatus(settings, activeConfig, packageRoot, scope);
397
+ const projectStatus = scopedSettings ? routeScopeStatus(project, config, packageRoot, 'project') : current;
398
+ const globalStatus = routeScopeStatus(global, globalConfig, packageRoot, 'global');
399
+ const sessions = cwd === undefined ? 0 : readSessions(routeSessionsFile(cwd, scope, home)).length;
400
+ return {
401
+ ...current,
402
+ sessions,
403
+ port: activeConfig.native?.ports.router,
404
+ scopes: { project: projectStatus, global: globalStatus },
405
+ };
406
+ }
407
+ export async function cmdRoute(action, opts) {
408
+ const scope = opts.scope ?? 'project';
409
+ const file = routeSettingsFile(opts.cwd, scope, opts.home);
410
+ const settings = readSettings(file);
411
+ const loadOrEmpty = (dir) => {
412
+ try {
413
+ return { config: loadConfig(dir, opts.home) };
414
+ }
415
+ catch (err) {
416
+ return { config: { native: undefined }, error: err };
417
+ }
418
+ };
419
+ const loadGlobalOrEmpty = () => {
420
+ const globalPath = join(opts.home, GLOBAL_CONFIG_RELATIVE);
421
+ try {
422
+ if (!existsSync(globalPath)) {
423
+ throw new Error(`No sonata.toml found at ${globalPath}. Run \`sonata init\` or create one.`);
424
+ }
425
+ return { config: parseConfig(readFileSync(globalPath, 'utf8')) };
426
+ }
427
+ catch (err) {
428
+ return { config: { native: undefined }, error: err };
429
+ }
430
+ };
431
+ // Global routing is one shared router that always resolves the *machine*
432
+ // config, regardless of which project's session manages it — checking the
433
+ // invoking project's config here would bake that project's router port
434
+ // into ANTHROPIC_BASE_URL even though the daemon (per bd72ec4) resolves the
435
+ // machine config, pointing settings at a port the daemon never opens.
436
+ const projectLoaded = loadOrEmpty(opts.cwd);
437
+ const globalLoaded = loadGlobalOrEmpty();
438
+ const active = scope === 'global' ? globalLoaded : projectLoaded;
439
+ // route off/status can still describe a broken config; route on and auto
440
+ // both install something that depends on the config actually loading —
441
+ // auto's failure mode is silent (its hook swallows cmdRouteSession's own
442
+ // load error by design), so catching it here, loudly, is the only chance.
443
+ if ((action === 'on' || action === 'auto') && active.error !== undefined)
444
+ throw active.error;
445
+ const config = projectLoaded.config;
446
+ const globalConfig = globalLoaded.config;
447
+ const activeConfig = active.config;
448
+ const status = (current) => routeStatus(current, config, opts.packageRoot, opts.cwd, {
449
+ project: readSettings(routeSettingsFile(opts.cwd, 'project', opts.home)),
450
+ global: readSettings(routeSettingsFile(opts.cwd, 'global', opts.home)),
451
+ }, scope, globalConfig, opts.home);
452
+ if (action === 'on') {
453
+ const plan = planRouteOn(settings, activeConfig, opts.packageRoot, scope);
454
+ if (plan.changed)
455
+ writeSettings(file, plan.settings);
456
+ return status(plan.settings);
457
+ }
458
+ if (action === 'off') {
459
+ const plan = planRouteOff(settings, opts.packageRoot);
460
+ if (plan.changed)
461
+ writeSettings(file, plan.settings);
462
+ // An explicit `off` means stop routing, so the auto registry goes with it —
463
+ // otherwise a stale id from a crashed session would have the next
464
+ // SessionStart turn routing straight back on. This clears only this
465
+ // scope's registry: an explicit `route off` (project-scoped) must not
466
+ // wipe the shared global session count out from under other projects.
467
+ writeSessions(routeSessionsFile(opts.cwd, scope, opts.home), []);
468
+ return status(plan.settings);
469
+ }
470
+ if (action === 'auto' || action === 'manual') {
471
+ const hasLiveSessions = action === 'auto'
472
+ && readSessions(routeSessionsFile(opts.cwd, scope, opts.home)).length > 0;
473
+ const plan = action === 'auto'
474
+ ? planRouteAuto(settings, opts.packageRoot, scope, hasLiveSessions)
475
+ : planRouteManual(settings, opts.packageRoot, scope);
476
+ if (plan.changed)
477
+ writeSettings(file, plan.settings);
478
+ return status(plan.settings);
479
+ }
480
+ return status(settings);
481
+ }
482
+ export async function cmdRouteSession(phase, sessionId, opts, deps = {}) {
483
+ // A global session shares one machine-wide registry with every other
484
+ // routed project — otherwise this project's own registry hitting zero
485
+ // would turn off the single shared router while another project's global
486
+ // sessions, tracked in a registry this one never sees, are still live.
487
+ const registry = routeSessionsFile(opts.cwd, opts.scope ?? 'project', opts.home);
488
+ // Using the machine config's own DIRECTORY as `configCwd` for global scope
489
+ // — not `opts.home` itself — matters: configPath()'s first check is
490
+ // `join(cwd, 'sonata.toml')`, and if `cwd` were `opts.home`, that check
491
+ // would land on a stray `~/sonata.toml` (a known leftover some upgrades
492
+ // still have) before ever reaching the real machine config. This same
493
+ // resolution is reused below for both this phase's config validation and
494
+ // the router startup logic further down, so both agree on which config
495
+ // global scope actually means.
496
+ const configCwd = opts.scope === 'global'
497
+ ? dirname(join(opts.home, GLOBAL_CONFIG_RELATIVE))
498
+ : opts.cwd;
499
+ if (phase === 'start') {
500
+ // A global hook runs in every directory; validate the project (or, for
501
+ // global scope, the machine config via `configCwd`) before touching its
502
+ // own registry (project scope) or the shared one (global scope) so an
503
+ // unrelated, configless directory remains completely untouched. Using
504
+ // `configCwd` here — not `opts.cwd` unconditionally — matters for global
505
+ // scope: an unrelated broken local sonata.toml in the invoking directory
506
+ // must not block the global hook, which only ever depends on the machine
507
+ // config. SessionEnd's cleanup must not depend on this at all: if the
508
+ // config was deleted or became malformed while the session was running,
509
+ // the session still needs to be removed from the registry and routing
510
+ // turned off — and since the SessionEnd hook that calls this command
511
+ // swallows a thrown failure by design, validating unconditionally here
512
+ // would leave that cleanup silently stuck forever.
513
+ loadConfig(configCwd, opts.home);
514
+ }
515
+ if (phase === 'end') {
516
+ // The zero-session decision and the `off` transition must be one atomic
517
+ // critical section — deciding "zero remain" and then acting on it as two
518
+ // separate lock acquisitions leaves a gap where a concurrent SessionStart
519
+ // can register and turn routing on, only for this stale decision to turn
520
+ // it back off. Doing the read, write, decision, and (conditionally)
521
+ // `cmdRoute('off')` inside a single lock hold closes that gap: whichever
522
+ // of a concurrent start/end's lock acquisitions runs second always sees
523
+ // the other's completed write.
524
+ return await withSessionLock(registry, async () => {
525
+ const current = readSessions(registry);
526
+ const left = current.filter((id) => id !== sessionId);
527
+ writeSessions(registry, left);
528
+ if (left.length > 0)
529
+ return { sessions: left.length, routing: 'off' };
530
+ // Last session out clears the subagent count and routing with it. A
531
+ // subagent killed before its SubagentStop hook would otherwise leak a
532
+ // reference and leave routing on for good; bounding that leak by the
533
+ // session's own lifetime is what keeps it from becoming permanent.
534
+ writeSessions(routeSubagentsFile(opts.cwd, opts.scope ?? 'project', opts.home), []);
535
+ await cmdRoute('off', opts);
536
+ return { sessions: 0, routing: 'off' };
537
+ });
538
+ }
539
+ // `route on` installs the ensure-serve hook, but that hook cannot fire in the
540
+ // session that just started — it was added after launch. So auto mode starts
541
+ // the router here, the way `sonata code` does, or the session's first native
542
+ // dispatch caches a connection error from a router that is not there.
543
+ //
544
+ // This whole block runs BEFORE the session is registered or routing is
545
+ // turned on: the SessionStart hook that calls this command ignores a
546
+ // thrown/nonzero exit, so if this validation happened after those writes,
547
+ // a same-port-different-config collision would still leave the session
548
+ // registered and routed through the wrong router despite the error.
549
+ const probe = deps.probe ?? isSonataRouter;
550
+ const startDaemon = deps.startDaemon ?? startServeDaemon;
551
+ // Global routing is one shared router for every project — its config has
552
+ // to be the machine one regardless of which project's session happens to
553
+ // start it, or every other routed project silently inherits this one's
554
+ // tiers, models and gateways for as long as the daemon lives. The port
555
+ // probed here must be the same config's port, or a project whose own
556
+ // [native.ports].router differs from the machine's would probe (and then
557
+ // start the daemon on) the wrong port entirely.
558
+ //
559
+ const config = loadConfig(configCwd, opts.home);
560
+ const port = config.native?.ports.router;
561
+ if (port !== undefined) {
562
+ const expectedConfigPath = resolveSonataConfigPath(configCwd, opts.home);
563
+ const running = await probe(port);
564
+ if (running && deps.probe === undefined) {
565
+ // Only verify identity against the real network probe — an injected
566
+ // test probe already encodes the scenario under test, and re-checking
567
+ // against the real network here would defeat it. A router that cannot
568
+ // or does not report its own configPath is treated the same as a
569
+ // mismatch, not silently trusted.
570
+ const actualConfigPath = await sonataRouterConfigPath(port);
571
+ if (expectedConfigPath !== null && (actualConfigPath === null || actualConfigPath !== expectedConfigPath)) {
572
+ throw new Error(actualConfigPath === null
573
+ ? `sonata: router port ${port} answered but did not report which sonata configuration ` +
574
+ `it is running (too old, or its own config resolution failed) — refusing to trust it. ` +
575
+ `Restart it with \`sonata restart\` once confirmed to be this project's own router.`
576
+ : `sonata: router port ${port} is already serving a different sonata configuration ` +
577
+ `(${actualConfigPath}) than this project resolves to (${expectedConfigPath}). ` +
578
+ `Two projects cannot share one router port — set a different [native.ports].router ` +
579
+ `in one of the two configs.`);
580
+ }
581
+ }
582
+ if (!running) {
583
+ await startDaemon(opts.home, opts.serveArgv, {}, configCwd);
584
+ }
585
+ }
586
+ await withSessionLock(registry, () => {
587
+ const current = readSessions(registry);
588
+ writeSessions(registry, current.includes(sessionId) ? current : [...current, sessionId]);
589
+ });
590
+ // Records which project this session belongs to, so `sonata usage --by
591
+ // project` can attribute the router's rows. Guarded: a session must start
592
+ // even if this bookkeeping cannot be written.
593
+ try {
594
+ await recordSession(opts.home, { session: sessionId, cwd: opts.cwd, started: new Date().toISOString() });
595
+ }
596
+ catch { /* attribution is a nicety; starting the session is not */ }
597
+ // Deliberately does NOT route. Routing on here is what made `route auto`
598
+ // degrade into `route on`: it stayed on while any registered session lived,
599
+ // which with overlapping sessions is forever, so every session after the
600
+ // first launched into a dirty file and lost Remote Control. Routing is now
601
+ // turned on per foreign-model subagent instead — see cmdRouteSubagent. This
602
+ // phase only ensures the router daemon is up and records liveness.
603
+ const after = readSessions(registry);
604
+ return { sessions: after.length, routing: 'off' };
605
+ }
606
+ /**
607
+ * Removes the routing env, leaving both registries alone.
608
+ *
609
+ * `cmdRoute('off')` means "the user said stop routing" and clears the session
610
+ * registry with it. The subagent path needs only the settings half.
611
+ */
612
+ function routeOffKeepingRegistries(opts) {
613
+ const file = routeSettingsFile(opts.cwd, opts.scope ?? 'project', opts.home);
614
+ const plan = planRouteOff(readSettings(file), opts.packageRoot);
615
+ if (plan.changed)
616
+ writeSettings(file, plan.settings);
617
+ }
618
+ /**
619
+ * The body of the subagent hooks: routing follows the foreign-model subagents
620
+ * that actually need it, and nothing else.
621
+ *
622
+ * Two measured facts shape this. **Adding** the routing env is picked up by an
623
+ * already-running session within seconds, which is why turning it on at
624
+ * SubagentStart is enough for the subagent's very first request to be routed —
625
+ * verified live 2026-08-27, with routing off at dispatch and the router
626
+ * logging `model=sonata-explore-simple -> gpt-5.6-luna -> litellm` moments
627
+ * later. **Removing** it is only observed eventually, on a timescale not
628
+ * measured, which is why routing is held for the whole run rather than cleaned
629
+ * up on a timer — a subagent whose routing vanished mid-task would send its
630
+ * `sonata-*` alias to api.anthropic.com and die on an unknown model.
631
+ *
632
+ * Counted rather than a boolean because subagents run concurrently: one
633
+ * finishing must not un-route its siblings.
634
+ */
635
+ export async function cmdRouteSubagent(phase, agentId, opts) {
636
+ const registry = routeSubagentsFile(opts.cwd, opts.scope ?? 'global', opts.home);
637
+ // Read, decide and act inside one lock hold, for the same reason the session
638
+ // registry does: deciding "none left" and acting on it as two acquisitions
639
+ // leaves a gap where a concurrent start can register and route, only for
640
+ // this stale decision to un-route it.
641
+ return await withSessionLock(registry, async () => {
642
+ const current = readSessions(registry);
643
+ if (phase === 'stop') {
644
+ const left = current.filter((id) => id !== agentId);
645
+ writeSessions(registry, left);
646
+ if (left.length > 0)
647
+ return { subagents: left.length, routing: 'on' };
648
+ // Not `cmdRoute('off')`: that also clears the *session* registry, which
649
+ // is a different lifetime entirely. A finishing subagent erasing session
650
+ // liveness would make the next SessionEnd believe it was the last one.
651
+ routeOffKeepingRegistries(opts);
652
+ return { subagents: 0, routing: 'off' };
653
+ }
654
+ const next = current.includes(agentId) ? current : [...current, agentId];
655
+ writeSessions(registry, next);
656
+ await cmdRoute('on', opts);
657
+ return { subagents: next.length, routing: 'on' };
658
+ });
659
+ }
660
+ //# sourceMappingURL=route.js.map