@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.
- package/LICENSE +21 -0
- package/README.md +437 -0
- package/dist/adapters/claude.js +83 -0
- package/dist/adapters/claude.js.map +1 -0
- package/dist/adapters/codex.js +382 -0
- package/dist/adapters/codex.js.map +1 -0
- package/dist/adapters/index.js +21 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/opencode.js +104 -0
- package/dist/adapters/opencode.js.map +1 -0
- package/dist/adapters/pi.js +152 -0
- package/dist/adapters/pi.js.map +1 -0
- package/dist/adapters/reasonix.js +358 -0
- package/dist/adapters/reasonix.js.map +1 -0
- package/dist/adapters/types.js +2 -0
- package/dist/adapters/types.js.map +1 -0
- package/dist/agent-markers.js +3 -0
- package/dist/agent-markers.js.map +1 -0
- package/dist/aipricing.js +109 -0
- package/dist/aipricing.js.map +1 -0
- package/dist/catalog.js +274 -0
- package/dist/catalog.js.map +1 -0
- package/dist/cli.js +766 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/approve.js +23 -0
- package/dist/commands/approve.js.map +1 -0
- package/dist/commands/auth.js +41 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/catalog.js +172 -0
- package/dist/commands/catalog.js.map +1 -0
- package/dist/commands/code.js +113 -0
- package/dist/commands/code.js.map +1 -0
- package/dist/commands/dispatch.js +148 -0
- package/dist/commands/dispatch.js.map +1 -0
- package/dist/commands/doctor.js +545 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/gc.js +25 -0
- package/dist/commands/gc.js.map +1 -0
- package/dist/commands/init-log.js +83 -0
- package/dist/commands/init-log.js.map +1 -0
- package/dist/commands/init.js +157 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/log.js +24 -0
- package/dist/commands/log.js.map +1 -0
- package/dist/commands/route.js +660 -0
- package/dist/commands/route.js.map +1 -0
- package/dist/commands/run.js +199 -0
- package/dist/commands/run.js.map +1 -0
- package/dist/commands/runs.js +37 -0
- package/dist/commands/runs.js.map +1 -0
- package/dist/commands/serve.js +818 -0
- package/dist/commands/serve.js.map +1 -0
- package/dist/commands/status.js +14 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/sync.js +258 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/commands/tail.js +229 -0
- package/dist/commands/tail.js.map +1 -0
- package/dist/commands/usage.js +85 -0
- package/dist/commands/usage.js.map +1 -0
- package/dist/commands/verify.js +69 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/commands/wait.js +38 -0
- package/dist/commands/wait.js.map +1 -0
- package/dist/config.js +588 -0
- package/dist/config.js.map +1 -0
- package/dist/detect.js +445 -0
- package/dist/detect.js.map +1 -0
- package/dist/filelock.js +83 -0
- package/dist/filelock.js.map +1 -0
- package/dist/init/apply.js +96 -0
- package/dist/init/apply.js.map +1 -0
- package/dist/init/candidates.js +115 -0
- package/dist/init/candidates.js.map +1 -0
- package/dist/init/discover.js +216 -0
- package/dist/init/discover.js.map +1 -0
- package/dist/init/helpers.js +429 -0
- package/dist/init/helpers.js.map +1 -0
- package/dist/init/interactive-state.js +128 -0
- package/dist/init/interactive-state.js.map +1 -0
- package/dist/init/plan.js +225 -0
- package/dist/init/plan.js.map +1 -0
- package/dist/init/scripted-state.js +130 -0
- package/dist/init/scripted-state.js.map +1 -0
- package/dist/init/toml.js +83 -0
- package/dist/init/toml.js.map +1 -0
- package/dist/init/validate.js +149 -0
- package/dist/init/validate.js.map +1 -0
- package/dist/ledger.js +123 -0
- package/dist/ledger.js.map +1 -0
- package/dist/mcp/protocol.js +64 -0
- package/dist/mcp/protocol.js.map +1 -0
- package/dist/mcp/server.js +96 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools.js +210 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/mode.js +64 -0
- package/dist/mode.js.map +1 -0
- package/dist/native/claude-shim.js +228 -0
- package/dist/native/claude-shim.js.map +1 -0
- package/dist/native/codex-auth.js +177 -0
- package/dist/native/codex-auth.js.map +1 -0
- package/dist/native/copilot-auth.js +105 -0
- package/dist/native/copilot-auth.js.map +1 -0
- package/dist/native/credentials.js +83 -0
- package/dist/native/credentials.js.map +1 -0
- package/dist/native/hosts.js +60 -0
- package/dist/native/hosts.js.map +1 -0
- package/dist/native/litellm.js +103 -0
- package/dist/native/litellm.js.map +1 -0
- package/dist/native/models.js +96 -0
- package/dist/native/models.js.map +1 -0
- package/dist/native/oauth-login.js +112 -0
- package/dist/native/oauth-login.js.map +1 -0
- package/dist/native/router.js +469 -0
- package/dist/native/router.js.map +1 -0
- package/dist/native/tls.js +73 -0
- package/dist/native/tls.js.map +1 -0
- package/dist/native/usage.js +119 -0
- package/dist/native/usage.js.map +1 -0
- package/dist/normalize.js +98 -0
- package/dist/normalize.js.map +1 -0
- package/dist/pricing.js +86 -0
- package/dist/pricing.js.map +1 -0
- package/dist/roles.js +49 -0
- package/dist/roles.js.map +1 -0
- package/dist/sessions.js +62 -0
- package/dist/sessions.js.map +1 -0
- package/dist/settings.js +131 -0
- package/dist/settings.js.map +1 -0
- package/dist/store.js +70 -0
- package/dist/store.js.map +1 -0
- package/dist/tmux.js +89 -0
- package/dist/tmux.js.map +1 -0
- package/dist/tui-ink/app-state.js +340 -0
- package/dist/tui-ink/app-state.js.map +1 -0
- package/dist/tui-ink/app.js +156 -0
- package/dist/tui-ink/app.js.map +1 -0
- package/dist/tui-ink/components/byok-step.js +102 -0
- package/dist/tui-ink/components/byok-step.js.map +1 -0
- package/dist/tui-ink/components/login-screen.js +80 -0
- package/dist/tui-ink/components/login-screen.js.map +1 -0
- package/dist/tui-ink/components/models-step.js +92 -0
- package/dist/tui-ink/components/models-step.js.map +1 -0
- package/dist/tui-ink/components/multi-select-state.js +56 -0
- package/dist/tui-ink/components/multi-select-state.js.map +1 -0
- package/dist/tui-ink/components/multi-select.js +69 -0
- package/dist/tui-ink/components/multi-select.js.map +1 -0
- package/dist/tui-ink/components/providers-step.js +285 -0
- package/dist/tui-ink/components/providers-step.js.map +1 -0
- package/dist/tui-ink/components/ranked-select-state.js +41 -0
- package/dist/tui-ink/components/ranked-select-state.js.map +1 -0
- package/dist/tui-ink/components/ranked-select.js +61 -0
- package/dist/tui-ink/components/ranked-select.js.map +1 -0
- package/dist/tui-ink/components/search-select.js +47 -0
- package/dist/tui-ink/components/search-select.js.map +1 -0
- package/dist/tui-ink/components/text-input-state.js +42 -0
- package/dist/tui-ink/components/text-input-state.js.map +1 -0
- package/dist/tui-ink/components/text-input.js +48 -0
- package/dist/tui-ink/components/text-input.js.map +1 -0
- package/dist/tui-ink/run.js +38 -0
- package/dist/tui-ink/run.js.map +1 -0
- package/dist/tui-ink/test-compile.js +4 -0
- package/dist/tui-ink/test-compile.js.map +1 -0
- package/dist/tui-ink/types.js +2 -0
- package/dist/tui-ink/types.js.map +1 -0
- package/dist/tui.js +330 -0
- package/dist/tui.js.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/watchdog.js +85 -0
- package/dist/watchdog.js.map +1 -0
- package/hooks/capture-mode.mjs +56 -0
- package/hooks/ensure-serve.mjs +140 -0
- package/hooks/hooks.json +12 -0
- package/hooks/route-session.mjs +47 -0
- package/hooks/route-subagent.mjs +54 -0
- package/package.json +64 -0
- package/roles/code.md +13 -0
- package/roles/explore.md +9 -0
- package/roles/plan.md +10 -0
- package/roles/review.md +11 -0
- 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
|