@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,818 @@
1
+ import { spawn, execFileSync } from 'node:child_process';
2
+ import { randomBytes, randomUUID } from 'node:crypto';
3
+ import { existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { loadAiPricing } from '../aipricing.js';
7
+ import { configPath as resolveSonataConfigPath, loadConfig, resolveTierAlias } from '../config.js';
8
+ import { appendRow, LEDGER_RETENTION_DAYS, pruneLedger } from '../ledger.js';
9
+ import { pruneSessions } from '../sessions.js';
10
+ import { resolveKeyFromSource, resolveKeys } from '../native/credentials.js';
11
+ import { codexAuthPath, opencodeAuthPath, readChatGptOAuth } from '../native/codex-auth.js';
12
+ import { credentialDir } from '../native/oauth-login.js';
13
+ import { readCopilotToken } from '../native/copilot-auth.js';
14
+ import { envVarForGateway, litellmConfigYaml } from '../native/litellm.js';
15
+ import { createRouterServer } from '../native/router.js';
16
+ import { resolvePrice } from '../pricing.js';
17
+ import { timestampedLogPath } from './init-log.js';
18
+ /**
19
+ * Where a serve instance records its own pid and its litellm child's pid.
20
+ *
21
+ * The router dies with the process that started serve, but the spawned litellm
22
+ * child is reparented and survives. The next
23
+ * serve then cannot bind the litellm port: its own child dies silently, and
24
+ * the new router forwards a new master key to the ORPHANED litellm, whose
25
+ * virtual-key lookup fails as "No connected db". Measured 2026-08-20, twice.
26
+ * Recording the pid lets the next serve kill its predecessor's orphan —
27
+ * only a pid sonata itself recorded is ever killed.
28
+ *
29
+ * `routerPid` is `process.pid` at the point the router successfully binds,
30
+ * allowing `sonata restart` to stop only a process Sonata recorded itself.
31
+ * `sonata restart` reads it to kill a stale router without guessing a pid by
32
+ * scanning the OS.
33
+ */
34
+ export function serveStatePath(home) {
35
+ return join(home, '.config', 'sonata', 'serve-state.json');
36
+ }
37
+ function readServeState(home) {
38
+ const path = serveStatePath(home);
39
+ if (!existsSync(path))
40
+ return undefined;
41
+ try {
42
+ return JSON.parse(readFileSync(path, 'utf8'));
43
+ }
44
+ catch {
45
+ return undefined;
46
+ }
47
+ }
48
+ function writeServeState(home, state) {
49
+ const path = serveStatePath(home);
50
+ mkdirSync(dirname(path), { recursive: true });
51
+ writeFileSync(path, JSON.stringify({ ...state, recordedAt: new Date().toISOString() }));
52
+ }
53
+ function killPid(pid) {
54
+ if (typeof pid !== 'number' || pid <= 0)
55
+ return;
56
+ try {
57
+ process.kill(pid);
58
+ }
59
+ catch { /* already dead */ }
60
+ }
61
+ function killRecordedOrphan(home) {
62
+ const state = readServeState(home);
63
+ killPid(state?.litellmPid);
64
+ try {
65
+ unlinkSync(serveStatePath(home));
66
+ }
67
+ catch { /* gone is the goal */ }
68
+ }
69
+ function recordLitellmPid(home, pid) {
70
+ writeServeState(home, { ...readServeState(home), litellmPid: pid });
71
+ }
72
+ function recordRouterPid(home, pid) {
73
+ writeServeState(home, { ...readServeState(home), routerPid: pid });
74
+ }
75
+ /** Polls litellm until it answers, so a silent bind failure surfaces here. */
76
+ async function defaultWaitForLitellm(port) {
77
+ const deadline = Date.now() + 30_000;
78
+ for (;;) {
79
+ try {
80
+ const res = await fetch(`http://localhost:${port}/health/liveliness`);
81
+ if (res.ok)
82
+ return;
83
+ }
84
+ catch { /* not up yet */ }
85
+ if (Date.now() > deadline) {
86
+ throw new Error(`sonata serve: litellm did not come up on port ${port} within 30s — ` +
87
+ 'it may have failed to bind (another litellm running?) or failed to start. ' +
88
+ 'Check `litellm --config` by hand.');
89
+ }
90
+ await new Promise((r) => setTimeout(r, 500));
91
+ }
92
+ }
93
+ export function serveHealthUrl(routerPort) {
94
+ return `http://localhost:${routerPort}/__sonata_health`;
95
+ }
96
+ /** Whether whatever holds a port is a sonata router. */
97
+ export async function isSonataRouter(port, doFetch = fetch) {
98
+ try {
99
+ const response = await doFetch(serveHealthUrl(port), {
100
+ signal: AbortSignal.timeout(2000),
101
+ });
102
+ if (!response.ok)
103
+ return false;
104
+ const body = await response.json();
105
+ return body?.sonata === true;
106
+ }
107
+ catch {
108
+ return false;
109
+ }
110
+ }
111
+ /** The resolved sonata.toml path a running sonata router reports, or null if the port isn't a sonata router (or reports no configPath). */
112
+ export async function sonataRouterConfigPath(port, doFetch = fetch) {
113
+ try {
114
+ const response = await doFetch(serveHealthUrl(port), {
115
+ signal: AbortSignal.timeout(2000),
116
+ });
117
+ if (!response.ok)
118
+ return null;
119
+ const body = await response.json();
120
+ if (body?.sonata !== true)
121
+ return null;
122
+ return typeof body.configPath === 'string' ? body.configPath : null;
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ }
128
+ /** The instance id a running sonata router reports on /__sonata_health, or null if the port isn't a sonata router (or reports none). */
129
+ export async function sonataRouterInstanceId(port, doFetch = fetch) {
130
+ try {
131
+ const response = await doFetch(serveHealthUrl(port), {
132
+ signal: AbortSignal.timeout(2000),
133
+ });
134
+ if (!response.ok)
135
+ return null;
136
+ const body = await response.json();
137
+ if (body?.sonata !== true)
138
+ return null;
139
+ return typeof body.instanceId === 'string' ? body.instanceId : null;
140
+ }
141
+ catch {
142
+ return null;
143
+ }
144
+ }
145
+ /**
146
+ * What to say when the router port is taken.
147
+ *
148
+ * The occupant may be another Sonata process. Probe the health endpoint
149
+ * before describing the listener so the error tells the user what actually
150
+ * holds the port.
151
+ */
152
+ export async function occupiedPortMessage(port, doFetch = fetch) {
153
+ if (await isSonataRouter(port, doFetch)) {
154
+ return `sonata serve: router port ${port} is already served by another sonata router — ` +
155
+ 'usually an earlier native router. Use that one, restart it to retire it, ' +
156
+ `or give this instance a different [native.ports] router port.`;
157
+ }
158
+ return `sonata serve: router port ${port} is occupied by a non-sonata listener`;
159
+ }
160
+ /**
161
+ * LiteLLM's own output is the only place a per-model startup failure appears.
162
+ *
163
+ * It drops a deployment it cannot authenticate and carries on, so the model
164
+ * simply vanishes from the catalogue and the next request answers "no healthy
165
+ * deployments for this model" — with the actual cause (for one real case, a 403
166
+ * from GitHub's Copilot token exchange) written only to a stream nobody read.
167
+ */
168
+ function defaultSpawnLitellm(configPath, env, port) {
169
+ const child = spawn('litellm', ['--config', configPath, '--port', String(port)], {
170
+ env,
171
+ stdio: ['ignore', 'inherit', 'inherit'],
172
+ });
173
+ return {
174
+ pid: child.pid ?? 0,
175
+ kill: () => child.kill(),
176
+ forceKill: () => child.kill('SIGKILL'),
177
+ onExit: (cb) => child.on('exit', cb),
178
+ };
179
+ }
180
+ function buildChildEnv(native, home, tempDir) {
181
+ // LiteLLM still needs PATH for executable lookup; no other parent values are forwarded.
182
+ const childEnv = process.env.PATH ? { PATH: process.env.PATH } : {};
183
+ const automaticallyResolved = Object.entries(native.gateways)
184
+ .filter(([, gateway]) => gateway.auth !== 'api-key' || gateway.credentialSource === undefined)
185
+ .map(([name]) => name);
186
+ for (const { gateway, key } of resolveKeys(automaticallyResolved, home)) {
187
+ childEnv[envVarForGateway(gateway)] = key;
188
+ }
189
+ for (const [name, gateway] of Object.entries(native.gateways)) {
190
+ const source = gateway.credentialSource;
191
+ if (gateway.auth !== 'api-key' || (source !== 'sonata' && source !== 'opencode'))
192
+ continue;
193
+ const key = resolveKeyFromSource(name, home, source);
194
+ if (key === undefined) {
195
+ throw new Error(`sonata serve: gateway "${name}" takes its credential from ${source} but none was found — ` +
196
+ `run \`sonata auth add ${name}\` (for sonata) or check opencode's own credential store.`);
197
+ }
198
+ childEnv[envVarForGateway(name)] = key;
199
+ }
200
+ // A sonata-owned credential is already in LiteLLM's native format. Point
201
+ // directly at its persistent directory so LiteLLM's refresh survives serve.
202
+ const chatgptGateway = Object.entries(native.gateways)
203
+ .find(([, gateway]) => gateway.auth === 'codex-oauth');
204
+ if (chatgptGateway) {
205
+ const [name, gateway] = chatgptGateway;
206
+ if (gateway.credentialSource === 'sonata') {
207
+ const dir = credentialDir(home, name);
208
+ if (!existsSync(join(dir, 'auth.json'))) {
209
+ throw new Error(`sonata serve: gateway "${name}" takes its credential from sonata but none is stored — ` +
210
+ `run \`sonata auth login ${name}\`.`);
211
+ }
212
+ childEnv.CHATGPT_TOKEN_DIR = dir;
213
+ }
214
+ else {
215
+ const record = readChatGptOAuth(home, gateway.credentialSource);
216
+ if (record === null) {
217
+ throw new Error('sonata serve: a native gateway uses codex-oauth but no ChatGPT credential was found ' +
218
+ `in ${codexAuthPath(home)} or ${opencodeAuthPath(home)} — ` +
219
+ `run \`sonata auth login ${name}\`, or \`codex login\`.`);
220
+ }
221
+ const tokenDir = join(tempDir, 'chatgpt');
222
+ mkdirSync(tokenDir, { recursive: true, mode: 0o700 });
223
+ writeFileSync(join(tokenDir, 'auth.json'), JSON.stringify(record), { mode: 0o600 });
224
+ childEnv.CHATGPT_TOKEN_DIR = tokenDir;
225
+ }
226
+ }
227
+ // Copilot's api-key.json is refreshed and re-exchanged in place too, so a
228
+ // sonata-owned credential must likewise avoid the temporary directory.
229
+ const copilotGateway = Object.entries(native.gateways)
230
+ .find(([, gateway]) => gateway.auth === 'copilot-oauth');
231
+ if (copilotGateway) {
232
+ const [name, gateway] = copilotGateway;
233
+ if (gateway.credentialSource === 'sonata') {
234
+ const dir = credentialDir(home, name);
235
+ if (!existsSync(join(dir, 'api-key.json'))) {
236
+ throw new Error(`sonata serve: gateway "${name}" takes its credential from sonata but none is stored — ` +
237
+ `run \`sonata auth login ${name}\`.`);
238
+ }
239
+ childEnv.GITHUB_COPILOT_TOKEN_DIR = dir;
240
+ }
241
+ else {
242
+ const token = readCopilotToken(home);
243
+ if (token === null) {
244
+ throw new Error('sonata serve: a native gateway uses copilot-oauth but no Copilot login was found ' +
245
+ `in ${opencodeAuthPath(home)} — run \`sonata auth login ${name}\`, ` +
246
+ 'or `opencode auth login` and choose github-copilot.');
247
+ }
248
+ const tokenDir = join(tempDir, 'copilot');
249
+ mkdirSync(tokenDir, { recursive: true, mode: 0o700 });
250
+ writeFileSync(join(tokenDir, 'access-token'), token, { mode: 0o600 });
251
+ childEnv.GITHUB_COPILOT_TOKEN_DIR = tokenDir;
252
+ }
253
+ }
254
+ return childEnv;
255
+ }
256
+ /** Default bound on how long a model-registry restart waits for the old litellm child to exit (see `litellmExitTimeoutMs`). */
257
+ const LITELLM_EXIT_TIMEOUT_MS = 5000;
258
+ /** Resolves `true` if `promise` had not settled after `timeoutMs`, `false` if it settled first. Never rejects. */
259
+ async function raceTimeout(promise, timeoutMs, sleepFn) {
260
+ let timedOut = false;
261
+ await Promise.race([
262
+ promise,
263
+ sleepFn(timeoutMs).then(() => { timedOut = true; }),
264
+ ]);
265
+ return timedOut;
266
+ }
267
+ function listen(server, port) {
268
+ return new Promise((resolve, reject) => {
269
+ const onError = (error) => {
270
+ server.off('listening', onListening);
271
+ reject(error);
272
+ };
273
+ const onListening = () => {
274
+ server.off('error', onError);
275
+ resolve();
276
+ };
277
+ server.once('error', onError);
278
+ server.once('listening', onListening);
279
+ server.listen(port, 'localhost');
280
+ });
281
+ }
282
+ /**
283
+ * A shutdown, not a graceful drain: `server.close()` alone stops accepting
284
+ * new connections but waits for every existing one to end on its own —
285
+ * including idle keep-alive sockets, which under an active session can sit
286
+ * open well past any reasonable restart timeout. That's what made a live
287
+ * `sonata restart` report a killed router pid as "still running" long after
288
+ * the process should have exited (observed 2026-08-24, `stopServe`'s 10s
289
+ * wait). Idle connections are closed immediately, since nothing is lost;
290
+ * anything genuinely in-flight gets a short grace window before every
291
+ * remaining connection is forced closed, so this can never hang forever.
292
+ */
293
+ function close(server) {
294
+ return new Promise((resolve, reject) => {
295
+ let settled = false;
296
+ server.close((error) => {
297
+ if (settled)
298
+ return;
299
+ settled = true;
300
+ error ? reject(error) : resolve();
301
+ });
302
+ server.closeIdleConnections();
303
+ // `closeIdleConnections()` only reaches keep-alive sockets that already
304
+ // completed a request — measured directly against a connection that
305
+ // never sent one (e.g. a lingering TCP probe), it does nothing. A
306
+ // restart wants to be fast, not gentle: losing an in-flight response is
307
+ // an acceptable cost of the user explicitly asking to restart, so this
308
+ // window is short.
309
+ setTimeout(() => {
310
+ if (settled)
311
+ return;
312
+ server.closeAllConnections();
313
+ }, 500).unref();
314
+ });
315
+ }
316
+ /**
317
+ * Attaches a price to a row the router produced unpriced.
318
+ *
319
+ * Pricing lives here rather than in the router because it needs the config and
320
+ * the price cache, and because a token count must never be lost to a pricing
321
+ * failure — the row is written either way, with `source: 'none'` when no rate
322
+ * applies.
323
+ *
324
+ * Priced at the row's own timestamp, not at now: a row is priced by when the
325
+ * request ran, which is what makes a time-windowed rate mean anything.
326
+ */
327
+ export function priceRow(config, home, row) {
328
+ try {
329
+ const price = resolvePrice(config, row.key, row.tokens, new Date(row.ts), loadAiPricing(home));
330
+ return { ...row, price };
331
+ }
332
+ catch {
333
+ return row; // an unpriceable row is still a row
334
+ }
335
+ }
336
+ export async function cmdServe(opts) {
337
+ const config = loadConfig(opts.cwd, opts.home);
338
+ if (!config.native)
339
+ throw new Error('sonata serve: no [native] table');
340
+ const native = config.native;
341
+ const masterKey = `sk-sonata-${randomBytes(32).toString('hex')}`;
342
+ const instanceId = opts.instanceId ?? process.env.SONATA_SERVE_INSTANCE_ID ?? randomUUID();
343
+ const tempDir = opts.tempDir ?? mkdtempSync(join(tmpdir(), 'sonata-litellm-'));
344
+ mkdirSync(tempDir, { recursive: true });
345
+ // Everything from here to a listening router owns `tempDir`. Cleanup used to
346
+ // be duplicated on two failure branches and absent from every other throw, so
347
+ // a run that died in between left its config behind.
348
+ let child;
349
+ let router;
350
+ let stopping = false;
351
+ const now = opts.now ?? Date.now;
352
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
353
+ const respawnDelayMs = opts.respawnDelayMs ?? 1000;
354
+ const maxRespawns = opts.maxRespawns ?? 5;
355
+ const respawnWindowMs = opts.respawnWindowMs ?? 60_000;
356
+ const respawnTimestamps = [];
357
+ try {
358
+ const configPath = join(tempDir, 'config.json');
359
+ writeFileSync(configPath, litellmConfigYaml(native, masterKey, config.unifiedModels), { mode: 0o600 });
360
+ let childEnv = buildChildEnv(native, opts.home, tempDir);
361
+ // A predecessor's orphaned litellm would hold the port and answer with the
362
+ // wrong master key; kill it (recorded pid only) before spawning our own.
363
+ killRecordedOrphan(opts.home);
364
+ // The litellm child dying on its own (not via `stop()`) used to go
365
+ // unnoticed until the next request 502'd and someone ran `sonata restart`
366
+ // by hand — measured directly: the child exits, nothing is watching, the
367
+ // router stays up and answers requests with a dead upstream. This watches
368
+ // the exact child this process spawned and respawns it in place, which is
369
+ // why it's safe where `ensure-serve.mjs`'s external health-probe respawn
370
+ // (bug D in the ledger) was not: there is only ever one spawn racing here,
371
+ // never a second `serve` guessing whether an existing one is healthy.
372
+ // Resolves once the current child is confirmed healthy, or once serve has
373
+ // given up waiting on it — awaited by the router before every litellm-bound
374
+ // request so a request landing in a respawn's brief startup gap waits for
375
+ // it, rather than getting a connection-refused failure that would cool the
376
+ // candidate down for a crash it already recovered from. The `.catch` means
377
+ // a hard failure resolves this too: the router's own fetch then fails for
378
+ // real, and a genuine outage cools down exactly as before.
379
+ // Tracks the native config LiteLLM's own config file currently reflects
380
+ // — legacy [native.models], unified [models], AND gateways, not just the
381
+ // unified table — so a config change (a new model added, an existing
382
+ // model's id/gateway edited under EITHER table, or a gateway's
383
+ // base_url/wire_format/auth/credential_source edited) while the daemon
384
+ // is already running can be told apart from "nothing changed" without
385
+ // restarting litellm on every single tier-resolution call. Gateways
386
+ // matter here even when the model list itself is unchanged: rerunning
387
+ // `sonata init` without touching model selection can still rewrite a
388
+ // gateway's endpoint or credential source, and litellm's own config
389
+ // would otherwise keep the stale one indefinitely. Legacy
390
+ // `native.models` matters too — `litellmConfig` (native/litellm.ts)
391
+ // builds the model list from `native.models` first, unconditionally, so
392
+ // a transitional config editing a legacy entry's id/gateway needs the
393
+ // same restart a unified edit gets.
394
+ const activeNativeSnapshot = (cfg) => JSON.stringify({ legacyModels: cfg.native?.models, models: cfg.unifiedModels, gateways: cfg.native?.gateways });
395
+ let activeModelsJson = activeNativeSnapshot(config);
396
+ // Set right before a deliberate kill-for-config-change so the crash-exit
397
+ // handler below (which fires for ANY exit, deliberate or not) does not
398
+ // also schedule its own duplicate respawn on top of the one already in
399
+ // flight from that deliberate restart.
400
+ let expectingConfigRestart = false;
401
+ let litellmReady = Promise.resolve();
402
+ const spawnLitellmChild = () => {
403
+ const spawned = (opts.spawnLitellm ?? defaultSpawnLitellm)(configPath, childEnv, native.ports.litellm);
404
+ recordLitellmPid(opts.home, spawned.pid);
405
+ spawned.onExit?.((code, signal) => {
406
+ if (stopping)
407
+ return;
408
+ if (expectingConfigRestart) {
409
+ expectingConfigRestart = false;
410
+ return;
411
+ }
412
+ const nowMs = now();
413
+ respawnTimestamps.push(nowMs);
414
+ while (respawnTimestamps.length > 0 && nowMs - respawnTimestamps[0] > respawnWindowMs) {
415
+ respawnTimestamps.shift();
416
+ }
417
+ console.error(`sonata serve: litellm exited unexpectedly (code=${code}, signal=${signal})`);
418
+ if (respawnTimestamps.length > maxRespawns) {
419
+ console.error(`sonata serve: litellm crashed ${respawnTimestamps.length} times within ` +
420
+ `${Math.round(respawnWindowMs / 1000)}s — giving up on automatic respawn. ` +
421
+ 'Fix the underlying problem, then run `sonata restart`.');
422
+ return;
423
+ }
424
+ litellmReady = (async () => {
425
+ await sleep(respawnDelayMs);
426
+ if (stopping)
427
+ return;
428
+ console.error('sonata serve: respawning litellm...');
429
+ child = spawnLitellmChild();
430
+ await (opts.waitForLitellm ?? defaultWaitForLitellm)(native.ports.litellm);
431
+ })().catch((error) => {
432
+ console.error(`sonata serve: respawned litellm never came up: ${String(error)}`);
433
+ });
434
+ });
435
+ return spawned;
436
+ };
437
+ // Detects a model-registry change (e.g. `sonata init` adding a new
438
+ // model while this daemon is already running) and restarts litellm with
439
+ // a freshly generated config so it actually knows about the new model —
440
+ // hot-reloading only the tier half (as `resolveTier` below already does)
441
+ // is not enough, since litellm's own model list is otherwise frozen at
442
+ // whatever it was given at startup. Reuses the same respawn machinery
443
+ // already proven for crash recovery, including the `litellmReady` gate
444
+ // every request already awaits before reaching litellm.
445
+ const maybeRestartForModelChange = async (freshConfig) => {
446
+ if (stopping)
447
+ return;
448
+ const freshModelsJson = activeNativeSnapshot(freshConfig);
449
+ if (freshModelsJson === activeModelsJson)
450
+ return;
451
+ if (!freshConfig.native) {
452
+ activeModelsJson = freshModelsJson;
453
+ return;
454
+ }
455
+ // Only committed once the replacement config and credentials are
456
+ // successfully prepared below — not up front. A gateway added without
457
+ // its credential yet available makes `buildChildEnv` throw; if this
458
+ // were set before that point, a later request (after the credential is
459
+ // fixed) would see `freshModelsJson === activeModelsJson` and never
460
+ // retry, leaving the new model unreachable until a manual `sonata
461
+ // restart` or another edit. Left unset here, the next request's
462
+ // comparison still differs and tries the restart again.
463
+ try {
464
+ writeFileSync(configPath, litellmConfigYaml(freshConfig.native, masterKey, freshConfig.unifiedModels), { mode: 0o600 });
465
+ childEnv = buildChildEnv(freshConfig.native, opts.home, tempDir);
466
+ activeModelsJson = freshModelsJson;
467
+ console.error('sonata serve: model registry changed — restarting litellm to pick it up...');
468
+ const oldChild = child;
469
+ expectingConfigRestart = true;
470
+ litellmReady = (async () => {
471
+ // Wait for the old child's actual exit before spawning its
472
+ // replacement: kill() only requests termination, and racing a new
473
+ // spawn/probe against a still-alive old process can either fail to
474
+ // bind the port or let the health probe see the stale
475
+ // (old-model-list) process and declare the restart done before it
476
+ // actually happened. `onExit` supports multiple independent
477
+ // listeners (it's backed by `child.on('exit', cb)`), so this does
478
+ // not disturb the crash-respawn handler's own listener on the
479
+ // same child.
480
+ //
481
+ // The wait is bounded: a litellm that ignores SIGTERM (hung, or
482
+ // wedged on a slow shutdown) would otherwise leave this promise
483
+ // unresolved forever, and since it's installed as `litellmReady`,
484
+ // every subsequent foreign-model request would then wait forever
485
+ // too. Escalate to `forceKill` (SIGKILL) once, wait once more
486
+ // bounded by the same timeout, then proceed regardless — a stray
487
+ // process holding the port fails the following bind/probe loudly,
488
+ // which is recoverable; a hung `litellmReady` is not.
489
+ const exited = new Promise((resolve) => {
490
+ if (oldChild?.onExit)
491
+ oldChild.onExit(() => resolve());
492
+ else
493
+ resolve();
494
+ });
495
+ const exitTimeoutMs = opts.litellmExitTimeoutMs ?? LITELLM_EXIT_TIMEOUT_MS;
496
+ if (await raceTimeout(exited, exitTimeoutMs, sleep)) {
497
+ console.error(`sonata serve: old litellm child did not exit within ${exitTimeoutMs}ms — sending SIGKILL`);
498
+ (oldChild?.forceKill ?? oldChild?.kill)?.call(oldChild);
499
+ if (await raceTimeout(exited, exitTimeoutMs, sleep)) {
500
+ console.error('sonata serve: old litellm child still has not exited after SIGKILL — proceeding anyway; ' +
501
+ 'a stray process may be holding the litellm port');
502
+ }
503
+ }
504
+ if (stopping)
505
+ return;
506
+ child = spawnLitellmChild();
507
+ await (opts.waitForLitellm ?? defaultWaitForLitellm)(native.ports.litellm);
508
+ })().catch((error) => {
509
+ console.error(`sonata serve: restarted litellm never came up: ${String(error)}`);
510
+ });
511
+ oldChild?.kill();
512
+ await litellmReady;
513
+ }
514
+ catch (error) {
515
+ console.error(`sonata serve: failed to restart litellm for a model registry change: ${String(error)}`);
516
+ }
517
+ };
518
+ child = spawnLitellmChild();
519
+ await (opts.waitForLitellm ?? defaultWaitForLitellm)(native.ports.litellm);
520
+ // Retention is enforced where the writer starts, so a long-lived daemon
521
+ // cannot accumulate day-files indefinitely the way opencode's event table
522
+ // did (6.5 GB, and not something sonata gets to repeat in its own store).
523
+ try {
524
+ const removed = pruneLedger(opts.home, LEDGER_RETENTION_DAYS);
525
+ if (removed > 0)
526
+ console.log(`ledger: pruned ${removed} day file(s) older than ${LEDGER_RETENTION_DAYS}d`);
527
+ }
528
+ catch { /* pruning is housekeeping; it never blocks serving */ }
529
+ // Same retention window, same defensive posture: a long-lived daemon must
530
+ // also expire the session->project map, not just the ledger it relies on.
531
+ // Awaited so `serve` does not race its own lock against a concurrent hook;
532
+ // its own failure is swallowed identically to the ledger prune above.
533
+ try {
534
+ const removedSessions = await pruneSessions(opts.home, LEDGER_RETENTION_DAYS);
535
+ if (removedSessions > 0)
536
+ console.log(`sessions: pruned ${removedSessions} record(s) older than ${LEDGER_RETENTION_DAYS}d`);
537
+ }
538
+ catch { /* pruning is housekeeping; it never blocks serving */ }
539
+ router = createRouterServer({
540
+ fetch,
541
+ litellmBase: `http://localhost:${native.ports.litellm}`,
542
+ litellmKey: masterKey,
543
+ health: true,
544
+ // Which sonata.toml actually started this router, so a caller sharing the
545
+ // default port can tell this project's router apart from another
546
+ // project's on the same port.
547
+ configPath: resolveSonataConfigPath(opts.cwd, opts.home) ?? undefined,
548
+ instanceId,
549
+ // Goes to serve's stdout, which --daemon captures to its log file. This
550
+ // is the only record of which upstream served a request: litellm's access
551
+ // log has the path and status but not the model, so without it "did that
552
+ // agent really run on the foreign model?" cannot be answered from
553
+ // evidence.
554
+ log: (line) => console.log(line),
555
+ // Config is re-read per call (not the `config`/`native` closed over
556
+ // above) so a tier edit in sonata.toml takes effect without a restart.
557
+ resolveTier: (alias) => resolveTierAlias(loadConfig(opts.cwd, opts.home), alias),
558
+ // A direct `--model <key>` request's key maps to its gateway through the
559
+ // same unified-model table tier resolution uses, so such a row carries
560
+ // `gateway` and can reach pricing's gateway step. Config is re-read here
561
+ // too, for the same reason as `resolveTier` above.
562
+ resolveGateway: (key) => loadConfig(opts.cwd, opts.home).unifiedModels[key]?.gateway,
563
+ // Fire-and-forget, called on every litellm-bound request (direct model
564
+ // calls and each tier candidate alike) — not just tier resolution,
565
+ // since a direct `--model <key>` request for a newly added native-only
566
+ // model never calls `resolveTier` at all. The request that triggers
567
+ // this still awaits the current readiness gate below before reaching
568
+ // litellm, so a config-triggered restart is awaited without making
569
+ // this check itself asynchronous.
570
+ checkModelChange: () => {
571
+ const freshConfig = loadConfig(opts.cwd, opts.home);
572
+ void maybeRestartForModelChange(freshConfig).catch((error) => {
573
+ console.error(`sonata serve: model-registry restart check failed: ${String(error)}`);
574
+ });
575
+ },
576
+ litellmReady: () => litellmReady,
577
+ // The injected seam (when present) receives the router's raw, unpriced
578
+ // row unchanged. The default below prices it against the config and
579
+ // writes it. Either way a ledger write is fire-and-forget.
580
+ recordUsage: opts.recordUsage ?? ((row) => {
581
+ // Deferred past the current I/O cycle so the synchronous loadConfig,
582
+ // pricing, and appendFileSync below never sit on the request-response
583
+ // path: the response is handed back to the client first. `appendRow`
584
+ // stays synchronous by design (Task 2); this only moves WHEN it runs.
585
+ setImmediate(() => {
586
+ let priced = row;
587
+ try {
588
+ priced = priceRow(loadConfig(opts.cwd, opts.home), opts.home, row);
589
+ }
590
+ catch {
591
+ // A config that will not load is still no reason to drop the row —
592
+ // priceRow's guarantee ("written either way, with source 'none'")
593
+ // must hold here too. `priced` stays the raw router row.
594
+ }
595
+ try {
596
+ appendRow(opts.home, priced);
597
+ }
598
+ catch { /* a ledger write never breaks a request */ }
599
+ });
600
+ }),
601
+ });
602
+ try {
603
+ await listen(router, native.ports.router);
604
+ }
605
+ catch (error) {
606
+ if (error.code === 'EADDRINUSE') {
607
+ throw new Error(await occupiedPortMessage(native.ports.router, opts.probeHealth));
608
+ }
609
+ throw error;
610
+ }
611
+ recordRouterPid(opts.home, process.pid);
612
+ }
613
+ catch (error) {
614
+ // Suppress the respawn watcher before killing the child — otherwise its
615
+ // `exit` handler schedules a respawn against `configPath`, which the
616
+ // `rmSync` below is about to delete, producing a doomed child spawned
617
+ // after this whole call has already thrown.
618
+ stopping = true;
619
+ child?.kill();
620
+ rmSync(tempDir, { force: true, recursive: true });
621
+ throw error;
622
+ }
623
+ // Router is assigned by the time the try block completes; the catch rethrows.
624
+ // `child` is read fresh in `stop()` below (not frozen here) because a respawn
625
+ // can replace it after this point.
626
+ const startedRouter = router;
627
+ const address = startedRouter.address();
628
+ const routerPort = typeof address === 'object' && address !== null ? address.port : native.ports.router;
629
+ let stopped = false;
630
+ return {
631
+ routerPort,
632
+ litellmPort: native.ports.litellm,
633
+ async stop() {
634
+ if (stopped)
635
+ return;
636
+ stopped = true;
637
+ stopping = true;
638
+ child?.kill();
639
+ try {
640
+ unlinkSync(serveStatePath(opts.home));
641
+ }
642
+ catch { /* already gone */ }
643
+ try {
644
+ await close(startedRouter);
645
+ }
646
+ finally {
647
+ rmSync(tempDir, { force: true, recursive: true });
648
+ }
649
+ },
650
+ };
651
+ }
652
+ /**
653
+ * Starts `sonata serve` in a detached child and waits until it answers.
654
+ *
655
+ * `--daemon` used to be parsed, passed to `cmdServe`, and then ignored — the
656
+ * command blocked forever like the foreground one, which is what "the flag does
657
+ * nothing" looked like from a shell.
658
+ *
659
+ * The wait is the part worth keeping: a detached child that fails (an occupied
660
+ * port, a gateway LiteLLM drops) would otherwise exit silently, leaving the
661
+ * user with a success message and no server. Its output goes to a log file for
662
+ * the same reason — a detached process has nowhere else to say why it stopped.
663
+ */
664
+ export async function startServeDaemon(home, argv, deps = {}, cwd = process.cwd()) {
665
+ const spawnFn = deps.spawn ?? spawn;
666
+ const probe = deps.probe ?? (async (port, id) => (await sonataRouterInstanceId(port)) === id);
667
+ const now = deps.now ?? Date.now;
668
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
669
+ const timeoutMs = deps.timeoutMs ?? 60_000;
670
+ const config = loadConfig(cwd, home);
671
+ if (!config.native)
672
+ throw new Error('sonata serve: no [native] table');
673
+ const port = config.native.ports.router;
674
+ const logPath = timestampedLogPath(home, 'serve');
675
+ mkdirSync(dirname(logPath), { recursive: true });
676
+ const log = openSync(logPath, 'a');
677
+ // Generated here, before spawning, and handed to the child via its own
678
+ // environment — so the polling loop below can tell its own freshly-spawned
679
+ // process apart from a stale router that happens to still be answering the
680
+ // same port, which is what let `sonata restart` false-report success
681
+ // against a leftover daemon (see the design doc for the reproduction).
682
+ const instanceId = randomUUID();
683
+ // Explicit, not inherited: a daemon started to serve *every* project
684
+ // (`route on/auto --global`) must not bind itself to whichever project's
685
+ // session happened to trigger it first — the router is a single process,
686
+ // so its config has to be the one every routed project actually shares.
687
+ // The caller passes `home` here for that case (see route.ts); a plain
688
+ // `sonata serve --daemon` keeps inheriting the shell's own cwd.
689
+ const child = spawnFn(argv[0], argv.slice(1), {
690
+ detached: true,
691
+ stdio: ['ignore', log, log],
692
+ cwd,
693
+ env: { ...process.env, SONATA_SERVE_INSTANCE_ID: instanceId },
694
+ });
695
+ child.unref();
696
+ const deadline = now() + timeoutMs;
697
+ for (;;) {
698
+ if (await probe(port, instanceId))
699
+ return { pid: child.pid ?? 0, port, logPath };
700
+ if (now() > deadline) {
701
+ throw new Error(`sonata serve: the daemon did not answer on port ${port} within ${Math.round(timeoutMs / 1000)}s. ` +
702
+ `See ${logPath}`);
703
+ }
704
+ await sleep(500);
705
+ }
706
+ }
707
+ /**
708
+ * Finds the OS pid bound to a TCP port, purely so `stopServe` can print it —
709
+ * sonata never acts on what this returns. `lsof -ti` prints one pid per line;
710
+ * an empty or ambiguous (more than one) result means "don't know", which the
711
+ * caller treats the same as a lookup failure.
712
+ */
713
+ function defaultFindPortPid(port) {
714
+ try {
715
+ const out = execFileSync('lsof', ['-ti', `:${port}`, '-sTCP:LISTEN'], { encoding: 'utf8' }).trim();
716
+ if (out === '')
717
+ return undefined;
718
+ const pids = out.split('\n').filter((line) => line !== '');
719
+ return pids.length === 1 ? pids[0] : undefined;
720
+ }
721
+ catch {
722
+ return undefined;
723
+ }
724
+ }
725
+ /**
726
+ * Whether a pid still exists. `process.kill(pid, 0)` sends no signal, only
727
+ * probes: `ESRCH` means the process is gone, anything else (including
728
+ * `EPERM` — exists, just not owned by us) means it is still alive. An
729
+ * unrecognized error is treated as alive too, so a probe failure never makes
730
+ * `stopServe` declare victory early.
731
+ */
732
+ function defaultIsAlive(pid) {
733
+ try {
734
+ process.kill(pid, 0);
735
+ return true;
736
+ }
737
+ catch (err) {
738
+ return err.code !== 'ESRCH';
739
+ }
740
+ }
741
+ /**
742
+ * Kills whatever sonata router currently holds the configured port, using
743
+ * only the pids `cmdServe` itself recorded — never a pid found by scanning
744
+ * the OS, which could belong to an unrelated process reusing the port after
745
+ * a previous sonata instance already exited.
746
+ *
747
+ * The recorded router pid is `process.pid` of the process that called
748
+ * `cmdServe` and won the bind. Killing it is intentional: `sonata restart`
749
+ * makes the lifecycle trade explicit instead of leaving a stale router
750
+ * unreachable forever.
751
+ */
752
+ export async function stopServe(opts) {
753
+ const config = loadConfig(opts.cwd, opts.home);
754
+ if (!config.native)
755
+ throw new Error('sonata restart: no [native] table');
756
+ const port = config.native.ports.router;
757
+ const probeHealth = opts.probeHealth;
758
+ const now = opts.now ?? Date.now;
759
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
760
+ const timeoutMs = opts.timeoutMs ?? 10_000;
761
+ if (!(await isSonataRouter(port, probeHealth)))
762
+ return { killed: false };
763
+ const state = readServeState(opts.home);
764
+ if (state?.routerPid === undefined) {
765
+ const findPortPid = opts.findPortPid ?? defaultFindPortPid;
766
+ const foundPid = findPortPid(port);
767
+ const nextStep = foundPid !== undefined
768
+ ? ` Kill it yourself, then run \`sonata serve --daemon\`:\n kill ${foundPid}`
769
+ : ' Kill it by hand, then run `sonata serve --daemon`.';
770
+ throw new Error(`sonata restart: router port ${port} answers as a sonata router, but no recorded pid for it ` +
771
+ `was found in ${serveStatePath(opts.home)} — it may have been started by a different sonata ` +
772
+ `install or an older version.${nextStep}`);
773
+ }
774
+ const kill = opts.kill ?? killPid;
775
+ const isAlive = opts.isAlive ?? defaultIsAlive;
776
+ const pids = [state.routerPid, state.litellmPid].filter((pid) => pid !== undefined);
777
+ for (const pid of pids)
778
+ kill(pid);
779
+ try {
780
+ unlinkSync(serveStatePath(opts.home));
781
+ }
782
+ catch { /* already gone */ }
783
+ // Wait for the pids we killed to actually exit — not for the port to go
784
+ // quiet. Polling the port instead confused "still dying" with "already
785
+ // replaced": an external supervisor that respawns `sonata serve` the
786
+ // instant the port frees can put a brand-new, legitimate router on the
787
+ // same port before our old process has finished exiting, so the port
788
+ // never stops answering and this used to time out reporting failure even
789
+ // though the kill had already succeeded. Checking the specific pids
790
+ // sidesteps that race entirely.
791
+ const deadline = now() + timeoutMs;
792
+ while (pids.some((pid) => isAlive(pid))) {
793
+ if (now() > deadline) {
794
+ const stillAlive = pids.filter((pid) => isAlive(pid));
795
+ throw new Error(`sonata restart: killed the recorded process(es) but pid(s) ${stillAlive.join(', ')} ` +
796
+ `are still running after ${Math.round(timeoutMs / 1000)}s.`);
797
+ }
798
+ await sleep(300);
799
+ }
800
+ return { killed: true };
801
+ }
802
+ /**
803
+ * Stops whatever router currently holds the configured port, then starts a
804
+ * fresh daemon in its place. The two-step split — rather than one call that
805
+ * always wins the bind — exists so a stale in-process router or a daemon left
806
+ * over from a previous build gets cleared out first: `startServeDaemon` alone
807
+ * just times out with "the daemon did not answer" against `EADDRINUSE`,
808
+ * which reads as a startup failure rather than the actual cause.
809
+ */
810
+ export async function cmdRestart(home, argv, opts = { cwd: process.cwd() }) {
811
+ await stopServe({
812
+ cwd: opts.cwd, home,
813
+ probeHealth: opts.probeHealth, now: opts.now, sleep: opts.sleep, timeoutMs: opts.timeoutMs, kill: opts.kill,
814
+ findPortPid: opts.findPortPid,
815
+ });
816
+ return startServeDaemon(home, argv, opts);
817
+ }
818
+ //# sourceMappingURL=serve.js.map