chorus-codes 0.7.5 → 0.8.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/.next/BUILD_ID +1 -1
- package/.next/app-path-routes-manifest.json +2 -1
- package/.next/build-manifest.json +2 -2
- package/.next/prerender-manifest.json +3 -3
- package/.next/routes-manifest.json +8 -0
- package/.next/server/app/_global-error/page.js +2 -2
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page.js +2 -2
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +1 -1
- package/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/api/daemon/[...path]/route.js +1 -1
- package/.next/server/app/api/run-artifacts/[chatId]/route.js +1 -1
- package/.next/server/app/connect/page.js +2 -2
- package/.next/server/app/connect/page_client-reference-manifest.js +1 -1
- package/.next/server/app/demo/[scenario]/page.js +2 -0
- package/.next/server/app/demo/[scenario]/page.js.nft.json +1 -0
- package/.next/server/app/demo/[scenario]/page_client-reference-manifest.js +1 -0
- package/.next/server/app/favicon.ico/route.js +1 -1
- package/.next/server/app/icon.svg/route.js +1 -1
- package/.next/server/app/new/page.js +2 -2
- package/.next/server/app/new/page_client-reference-manifest.js +1 -1
- package/.next/server/app/new.html +1 -1
- package/.next/server/app/new.rsc +2 -2
- package/.next/server/app/new.segments/_full.segment.rsc +2 -2
- package/.next/server/app/new.segments/_head.segment.rsc +1 -1
- package/.next/server/app/new.segments/_index.segment.rsc +1 -1
- package/.next/server/app/new.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/new.segments/new/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/new.segments/new.segment.rsc +1 -1
- package/.next/server/app/onboarding/page.js +2 -2
- package/.next/server/app/onboarding/page_client-reference-manifest.js +1 -1
- package/.next/server/app/onboarding.html +1 -1
- package/.next/server/app/onboarding.rsc +2 -2
- package/.next/server/app/onboarding.segments/_full.segment.rsc +2 -2
- package/.next/server/app/onboarding.segments/_head.segment.rsc +1 -1
- package/.next/server/app/onboarding.segments/_index.segment.rsc +1 -1
- package/.next/server/app/onboarding.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/onboarding.segments/onboarding/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/onboarding.segments/onboarding.segment.rsc +1 -1
- package/.next/server/app/page.js +2 -2
- package/.next/server/app/page.js.nft.json +1 -1
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app/personas/page.js +2 -2
- package/.next/server/app/personas/page_client-reference-manifest.js +1 -1
- package/.next/server/app/personas.html +1 -1
- package/.next/server/app/personas.rsc +2 -2
- package/.next/server/app/personas.segments/_full.segment.rsc +2 -2
- package/.next/server/app/personas.segments/_head.segment.rsc +1 -1
- package/.next/server/app/personas.segments/_index.segment.rsc +1 -1
- package/.next/server/app/personas.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/personas.segments/personas/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/personas.segments/personas.segment.rsc +1 -1
- package/.next/server/app/runs/[runId]/page.js +2 -2
- package/.next/server/app/runs/[runId]/page.js.nft.json +1 -1
- package/.next/server/app/runs/[runId]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/runs/page.js +2 -2
- package/.next/server/app/runs/page_client-reference-manifest.js +1 -1
- package/.next/server/app/settings/page.js +3 -3
- package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/server/app/settings/permissions/page.js +2 -2
- package/.next/server/app/settings/permissions/page_client-reference-manifest.js +1 -1
- package/.next/server/app/settings.html +1 -1
- package/.next/server/app/settings.rsc +2 -2
- package/.next/server/app/settings.segments/_full.segment.rsc +2 -2
- package/.next/server/app/settings.segments/_head.segment.rsc +1 -1
- package/.next/server/app/settings.segments/_index.segment.rsc +1 -1
- package/.next/server/app/settings.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/settings.segments/settings.segment.rsc +1 -1
- package/.next/server/app/templates/page.js +2 -2
- package/.next/server/app/templates/page_client-reference-manifest.js +1 -1
- package/.next/server/app/templates.html +1 -1
- package/.next/server/app/templates.rsc +2 -2
- package/.next/server/app/templates.segments/_full.segment.rsc +2 -2
- package/.next/server/app/templates.segments/_head.segment.rsc +1 -1
- package/.next/server/app/templates.segments/_index.segment.rsc +1 -1
- package/.next/server/app/templates.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/templates.segments/templates/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/templates.segments/templates.segment.rsc +1 -1
- package/.next/server/app-paths-manifest.json +2 -1
- package/.next/server/chunks/597.js +1 -0
- package/.next/server/chunks/668.js +1 -1
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/chunks/{116-0372d11bc2a9ac4d.js → 116-8bf7e014066cedde.js} +1 -1
- package/.next/static/chunks/977-26354f62110c63d4.js +1 -0
- package/.next/static/chunks/app/demo/[scenario]/page-a84f68aa9f4b9ab8.js +1 -0
- package/.next/static/chunks/app/runs/[runId]/page-642397282cb68c60.js +1 -0
- package/.next/static/{VWSkj_Yx6OKSjPmYY8ewj → i0LC5zObFeek7b2zwUxsX}/_buildManifest.js +1 -1
- package/.next/trace +20 -19
- package/.next/trace-build +1 -1
- package/.next/types/app/demo/[scenario]/page.ts +87 -0
- package/.next/types/routes.d.ts +2 -1
- package/.next/types/validator.ts +9 -0
- package/dist/cli/commands/start.js +395 -167
- package/dist/cli/commands/start.js.map +1 -1
- package/dist/cli/commands/status.js +6 -4
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/commands/stop.js +34 -19
- package/dist/cli/commands/stop.js.map +1 -1
- package/dist/cli/index.js +12 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/shared.js +1 -16
- package/dist/cli/shared.js.map +1 -1
- package/dist/daemon/index.js +25 -2
- package/dist/daemon/index.js.map +1 -1
- package/dist/lib/daemon-discovery.js +219 -0
- package/dist/lib/daemon-discovery.js.map +1 -0
- package/dist/mcp/client.js +125 -15
- package/dist/mcp/client.js.map +1 -1
- package/dist/mcp/index.js +21 -1
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/tools.js +16 -1
- package/dist/mcp/tools.js.map +1 -1
- package/package.json +1 -1
- package/.next/server/chunks/418.js +0 -1
- package/.next/static/chunks/app/runs/[runId]/page-aa118bfeb6508779.js +0 -1
- /package/.next/static/{VWSkj_Yx6OKSjPmYY8ewj → i0LC5zObFeek7b2zwUxsX}/_ssgManifest.js +0 -0
|
@@ -9,6 +9,7 @@ const fs_1 = __importDefault(require("fs"));
|
|
|
9
9
|
const open_1 = __importDefault(require("open"));
|
|
10
10
|
const os_1 = __importDefault(require("os"));
|
|
11
11
|
const path_1 = __importDefault(require("path"));
|
|
12
|
+
const daemon_discovery_js_1 = require("../../lib/daemon-discovery.js");
|
|
12
13
|
const port_utils_js_1 = require("../port-utils.js");
|
|
13
14
|
const runtime_env_js_1 = require("../runtime-env.js");
|
|
14
15
|
const shared_js_1 = require("../shared.js");
|
|
@@ -17,26 +18,74 @@ function registerStartCommand(program) {
|
|
|
17
18
|
program
|
|
18
19
|
.command('start')
|
|
19
20
|
.option('--ui', 'Open browser UI after starting daemon')
|
|
21
|
+
.option('--daemon-only', 'Skip cockpit (Next.js UI). Used by MCP auto-start.')
|
|
20
22
|
.description('Start the Chorus daemon (PM2-style fork)')
|
|
21
23
|
.action(async (options) => {
|
|
22
24
|
try {
|
|
23
25
|
const chorusDir = path_1.default.join(os_1.default.homedir(), '.chorus');
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
// First-pass already-running check. If a daemon is up AND
|
|
27
|
+
// (we don't need the cockpit OR the cockpit is also up),
|
|
28
|
+
// bail out. Otherwise fall through to the upgrade path
|
|
29
|
+
// below.
|
|
30
|
+
const alreadyHandled = await alreadyRunningHealthy(options.ui);
|
|
31
|
+
if (alreadyHandled === 'satisfied')
|
|
26
32
|
return;
|
|
27
|
-
|
|
33
|
+
// Upgrade path: daemon is healthy but cockpit isn't running
|
|
34
|
+
// and the user asked for --ui. Spawn just the cockpit and
|
|
35
|
+
// update daemon.json. No need to acquire the start.lock —
|
|
36
|
+
// we're not racing another start, just attaching the missing
|
|
37
|
+
// process.
|
|
38
|
+
if (alreadyHandled === 'cockpit_missing_ui_requested') {
|
|
39
|
+
await spawnCockpitForExistingDaemon(chorusDir);
|
|
28
40
|
return;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
}
|
|
42
|
+
// Concurrent-start guard. Two MCP shims hitting auto-start
|
|
43
|
+
// simultaneously would otherwise both pickPortPair → spawn
|
|
44
|
+
// a daemon → write daemon.json. Whichever writes second
|
|
45
|
+
// overwrites the first, leaving an orphan listening on a
|
|
46
|
+
// forgotten port. Using O_EXCL on the lockfile means the
|
|
47
|
+
// loser fails fast and falls through to alreadyRunningHealthy
|
|
48
|
+
// on retry (after the winner finishes spawning).
|
|
49
|
+
const acquired = acquireStartLock(chorusDir);
|
|
50
|
+
if (!acquired) {
|
|
51
|
+
// Another start is in flight. Poll briefly for daemon.json
|
|
52
|
+
// to appear; if it shows up, we're done. If the lock is
|
|
53
|
+
// stale (owner PID dead), reclaim it. Never blindly steal
|
|
54
|
+
// the lock on timeout — the winner may just be slow.
|
|
55
|
+
await waitForAnotherStartToWin();
|
|
56
|
+
const second = await alreadyRunningHealthy(options.ui);
|
|
57
|
+
if (second === 'satisfied')
|
|
58
|
+
return;
|
|
59
|
+
if (second === 'cockpit_missing_ui_requested') {
|
|
60
|
+
await spawnCockpitForExistingDaemon(chorusDir);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// No winner appeared. Check whether the lock owner is
|
|
64
|
+
// actually dead before clearing — a slow but live winner
|
|
65
|
+
// must NOT be elbowed aside or both processes will spawn.
|
|
66
|
+
if (!isLockOwnerAlive(chorusDir)) {
|
|
67
|
+
clearStartLock(chorusDir);
|
|
68
|
+
if (!acquireStartLock(chorusDir)) {
|
|
69
|
+
throw new Error('Could not reclaim stale start lock. Try `chorus stop` then retry.');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
throw new Error('Another `chorus start` is still running. If this persists, run `chorus stop` then retry.');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
await reapOrphans();
|
|
78
|
+
warnIfTmuxMissing();
|
|
79
|
+
await captureAndPersistPath();
|
|
80
|
+
const ports = await pickPortPair();
|
|
81
|
+
await spawnDaemonAndCockpit(chorusDir, ports, {
|
|
82
|
+
daemonOnly: options.daemonOnly === true,
|
|
83
|
+
});
|
|
84
|
+
scheduleAutoOpenBrowser(options.ui, ports.cockpitPort);
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
releaseStartLock(chorusDir);
|
|
88
|
+
}
|
|
40
89
|
}
|
|
41
90
|
catch (error) {
|
|
42
91
|
console.error('Failed to start daemon:', error);
|
|
@@ -44,14 +93,77 @@ function registerStartCommand(program) {
|
|
|
44
93
|
}
|
|
45
94
|
});
|
|
46
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Acquire ~/.chorus/start.lock with O_EXCL. Returns true on success;
|
|
98
|
+
* false if another start has the lock. Stale-lock recovery is the
|
|
99
|
+
* caller's responsibility.
|
|
100
|
+
*/
|
|
101
|
+
function acquireStartLock(chorusDir) {
|
|
102
|
+
fs_1.default.mkdirSync(chorusDir, { recursive: true });
|
|
103
|
+
const lockPath = path_1.default.join(chorusDir, 'start.lock');
|
|
104
|
+
try {
|
|
105
|
+
// 'wx' = O_CREAT | O_EXCL | O_WRONLY. Atomic create-or-fail.
|
|
106
|
+
const fd = fs_1.default.openSync(lockPath, 'wx');
|
|
107
|
+
fs_1.default.writeSync(fd, String(process.pid));
|
|
108
|
+
fs_1.default.closeSync(fd);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function releaseStartLock(chorusDir) {
|
|
116
|
+
const lockPath = path_1.default.join(chorusDir, 'start.lock');
|
|
117
|
+
try {
|
|
118
|
+
fs_1.default.unlinkSync(lockPath);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
/* already gone */
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function clearStartLock(chorusDir) {
|
|
125
|
+
releaseStartLock(chorusDir);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Read the PID stamped into start.lock and check if that process is
|
|
129
|
+
* still alive. Used to distinguish a stale lock (winner crashed before
|
|
130
|
+
* unlinking) from a slow-but-live winner. Pre-fix the loser would
|
|
131
|
+
* blindly steal the lock after an 8s timeout, allowing both processes
|
|
132
|
+
* to spawn daemons concurrently when the winner happened to be slow.
|
|
133
|
+
*/
|
|
134
|
+
function isLockOwnerAlive(chorusDir) {
|
|
135
|
+
const lockPath = path_1.default.join(chorusDir, 'start.lock');
|
|
136
|
+
try {
|
|
137
|
+
const raw = fs_1.default.readFileSync(lockPath, 'utf-8').trim();
|
|
138
|
+
const pid = Number.parseInt(raw, 10);
|
|
139
|
+
if (!Number.isFinite(pid) || pid <= 0)
|
|
140
|
+
return false;
|
|
141
|
+
return (0, daemon_discovery_js_1.isPidAlive)(pid);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// Lock file vanished between the failed acquire and this read —
|
|
145
|
+
// treat as "not alive" so the caller retries acquisition cleanly.
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The lock-loser polls for daemon.json to appear, then defers to
|
|
151
|
+
* alreadyRunningHealthy. Bounded wait — the winner's spawn-and-record
|
|
152
|
+
* flow takes ~5s on healthy machines.
|
|
153
|
+
*/
|
|
154
|
+
async function waitForAnotherStartToWin() {
|
|
155
|
+
const deadline = Date.now() + 8000;
|
|
156
|
+
while (Date.now() < deadline) {
|
|
157
|
+
const live = await (0, daemon_discovery_js_1.readLiveDaemonInfo)({ healthTimeoutMs: 500 });
|
|
158
|
+
if (live)
|
|
159
|
+
return;
|
|
160
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
47
163
|
/**
|
|
48
164
|
* Run the user's interactive shell once and stash $PATH so the daemon
|
|
49
165
|
* (running in a non-interactive shell that skips .bashrc/.zshrc) can
|
|
50
166
|
* find CLIs the user installed via official curl-bash scripts.
|
|
51
|
-
*
|
|
52
|
-
* Best-effort. Capture or persist failures are swallowed — the daemon
|
|
53
|
-
* has fallback known-install probes and the previous saved value, if
|
|
54
|
-
* any, stays put.
|
|
55
167
|
*/
|
|
56
168
|
async function captureAndPersistPath() {
|
|
57
169
|
try {
|
|
@@ -65,118 +177,190 @@ async function captureAndPersistPath() {
|
|
|
65
177
|
}
|
|
66
178
|
}
|
|
67
179
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
180
|
+
* Detect a healthy chorus already running on this host before we try
|
|
181
|
+
* to spawn another one. Returns:
|
|
182
|
+
* - 'satisfied': nothing to do (or just printed status + opened
|
|
183
|
+
* browser). Caller should return immediately.
|
|
184
|
+
* - 'cockpit_missing_ui_requested': the daemon is alive but was
|
|
185
|
+
* started in --daemon-only mode; user just asked for --ui. Caller
|
|
186
|
+
* should upgrade by spawning only the cockpit.
|
|
187
|
+
* - 'not_running': fall through to the normal start sequence.
|
|
72
188
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
* The HTTP probe alone is a strong signal — chorus's /api/v1/health
|
|
79
|
-
* returns a versioned envelope no random other process happens to
|
|
80
|
-
* mimic. The PID-cmdline cross-check is belt-and-braces: only treat
|
|
81
|
-
* "already running" as authoritative when both agree it's chorus.
|
|
189
|
+
* Cross-uid hardening: daemon.json lives in $HOME, so we only see
|
|
190
|
+
* *our* daemon. A sudo-started daemon's daemon.json lives in root's
|
|
191
|
+
* $HOME and is invisible to the unprivileged probe. The orphan reaper
|
|
192
|
+
* handles that case via cmdline + cwd matching.
|
|
82
193
|
*/
|
|
83
|
-
async function
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (!res.ok)
|
|
97
|
-
return false;
|
|
98
|
-
const envelope = (await res.json());
|
|
99
|
-
if (envelope.ok !== true || !envelope.data?.version)
|
|
100
|
-
return false;
|
|
101
|
-
healthyVersion = envelope.data.version;
|
|
102
|
-
}
|
|
103
|
-
catch {
|
|
104
|
-
// ECONNREFUSED / abort / parse error → not a healthy chorus on :7707.
|
|
105
|
-
return false;
|
|
194
|
+
async function alreadyRunningHealthy(uiFlag) {
|
|
195
|
+
// Longer health timeout than runtime resolution: WSL loopback is
|
|
196
|
+
// slow on cold start (3-4s observed). We'd rather wait 5s once than
|
|
197
|
+
// mis-diagnose a healthy daemon and pile a second one on top.
|
|
198
|
+
const live = await (0, daemon_discovery_js_1.readLiveDaemonInfo)({ healthTimeoutMs: 5000 });
|
|
199
|
+
if (!live)
|
|
200
|
+
return 'not_running';
|
|
201
|
+
const cockpitRunning = live.cockpitPid !== null && (0, daemon_discovery_js_1.isPidAlive)(live.cockpitPid);
|
|
202
|
+
// Upgrade path: daemon up, no cockpit, user wants --ui. Tell the
|
|
203
|
+
// caller to handle this without printing anything; the cockpit
|
|
204
|
+
// spawn will print the URL when it lands.
|
|
205
|
+
if (uiFlag && !cockpitRunning) {
|
|
206
|
+
return 'cockpit_missing_ui_requested';
|
|
106
207
|
}
|
|
107
|
-
// Cross-check: the PID listening on :7707 must look like chorus.
|
|
108
|
-
// Without this, a foreign service that happens to respond 200 on
|
|
109
|
-
// /api/v1/health with a matching envelope shape could fool us into
|
|
110
|
-
// sending the user to a wrong cockpit URL. Belt-and-braces.
|
|
111
|
-
const pids = (0, port_utils_js_1.findPidsOnPort)(7707);
|
|
112
|
-
const looksLikeChorus = pids.some((pid) => (0, port_utils_js_1.pidLooksLikeChorus)(pid).match);
|
|
113
|
-
if (pids.length > 0 && !looksLikeChorus)
|
|
114
|
-
return false;
|
|
115
208
|
console.log('');
|
|
116
|
-
console.log((0, ui_js_1.header)(ui_js_1.sym.ok, 'Chorus is already running', `version ${
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
return false;
|
|
126
|
-
const oldPid = parseInt(fs_1.default.readFileSync(pidFile, 'utf-8'), 10);
|
|
127
|
-
try {
|
|
128
|
-
// Cross-platform liveness probe. process.kill(pid, 0) throws if no
|
|
129
|
-
// process with that pid exists; works on Windows/macOS/Linux unlike
|
|
130
|
-
// the unix-only `kill -0` shell command we used to invoke here.
|
|
131
|
-
if (Number.isFinite(oldPid) && oldPid > 0) {
|
|
132
|
-
process.kill(oldPid, 0);
|
|
133
|
-
}
|
|
134
|
-
else {
|
|
135
|
-
throw new Error('invalid pid');
|
|
209
|
+
console.log((0, ui_js_1.header)(ui_js_1.sym.ok, 'Chorus is already running', `version ${live.version || shared_js_1.pkg.version}`));
|
|
210
|
+
if (cockpitRunning) {
|
|
211
|
+
const cockpitUrl = `http://127.0.0.1:${live.cockpitPort}`;
|
|
212
|
+
console.log('');
|
|
213
|
+
console.log(` ${ui_js_1.c.gray('Open')} ${ui_js_1.c.cyan(cockpitUrl)}`);
|
|
214
|
+
const env = (0, runtime_env_js_1.detectRuntimeEnv)();
|
|
215
|
+
if (env.hint) {
|
|
216
|
+
console.log('');
|
|
217
|
+
console.log((0, ui_js_1.tip)(env.hint));
|
|
136
218
|
}
|
|
137
219
|
console.log('');
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (uiFlag && (0, runtime_env_js_1.shouldAutoOpenBrowser)((0, runtime_env_js_1.detectRuntimeEnv)())) {
|
|
141
|
-
(0, open_1.default)(shared_js_1.COCKPIT_URL);
|
|
220
|
+
if (uiFlag && (0, runtime_env_js_1.shouldAutoOpenBrowser)(env)) {
|
|
221
|
+
(0, open_1.default)(cockpitUrl);
|
|
142
222
|
}
|
|
143
|
-
return true;
|
|
144
223
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
224
|
+
else {
|
|
225
|
+
console.log('');
|
|
226
|
+
console.log(ui_js_1.c.dim(' Daemon-only mode. Run `chorus start --ui` to bring up the cockpit.'));
|
|
227
|
+
console.log('');
|
|
149
228
|
}
|
|
229
|
+
return 'satisfied';
|
|
150
230
|
}
|
|
151
231
|
/**
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
232
|
+
* Bring up just the Next.js cockpit and re-write daemon.json with the
|
|
233
|
+
* new cockpitPid. Used when a `--daemon-only` daemon is already running
|
|
234
|
+
* and the user now passes `--ui` to attach the UI without restarting.
|
|
235
|
+
*/
|
|
236
|
+
async function spawnCockpitForExistingDaemon(chorusDir) {
|
|
237
|
+
const live = await (0, daemon_discovery_js_1.readLiveDaemonInfo)({ healthTimeoutMs: 5000 });
|
|
238
|
+
if (!live) {
|
|
239
|
+
// Edge case: daemon died between the alreadyRunningHealthy check
|
|
240
|
+
// and this call. Caller's own logic will pick it up on retry.
|
|
241
|
+
throw new Error('Daemon disappeared while attaching cockpit. Retry `chorus start`.');
|
|
242
|
+
}
|
|
243
|
+
const packageRoot = path_1.default.resolve(__dirname, '..', '..', '..');
|
|
244
|
+
const nextEntry = path_1.default.resolve(packageRoot, 'node_modules', 'next', 'dist', 'bin', 'next');
|
|
245
|
+
if (!fs_1.default.existsSync(nextEntry) ||
|
|
246
|
+
!fs_1.default.existsSync(path_1.default.join(packageRoot, '.next'))) {
|
|
247
|
+
console.log('');
|
|
248
|
+
console.log(ui_js_1.c.red(' ✗ Cockpit UI not found. Try `npm install -g chorus-codes` to repair.'));
|
|
249
|
+
console.log('');
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const logsDir = path_1.default.join(chorusDir, 'logs');
|
|
253
|
+
fs_1.default.mkdirSync(logsDir, { recursive: true });
|
|
254
|
+
const webLogPath = path_1.default.join(logsDir, 'web.log');
|
|
255
|
+
const webLogFd = fs_1.default.openSync(webLogPath, 'a');
|
|
256
|
+
const webPidFile = path_1.default.join(chorusDir, 'web.pid');
|
|
257
|
+
const webChild = (0, child_process_1.spawn)('node', [
|
|
258
|
+
nextEntry,
|
|
259
|
+
'start',
|
|
260
|
+
'-p',
|
|
261
|
+
String(live.cockpitPort),
|
|
262
|
+
'-H',
|
|
263
|
+
'127.0.0.1',
|
|
264
|
+
], {
|
|
265
|
+
cwd: packageRoot,
|
|
266
|
+
detached: true,
|
|
267
|
+
stdio: ['ignore', webLogFd, webLogFd],
|
|
268
|
+
env: {
|
|
269
|
+
...process.env,
|
|
270
|
+
CHORUS_DAEMON_URL: `http://127.0.0.1:${live.daemonPort}`,
|
|
271
|
+
PORT: String(live.cockpitPort),
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
if (!webChild.pid) {
|
|
275
|
+
throw new Error('Failed to spawn cockpit process');
|
|
276
|
+
}
|
|
277
|
+
fs_1.default.writeFileSync(webPidFile, webChild.pid.toString());
|
|
278
|
+
webChild.unref();
|
|
279
|
+
// Update daemon.json so future stops know about the cockpit PID.
|
|
280
|
+
(0, daemon_discovery_js_1.writeDaemonInfo)({
|
|
281
|
+
schemaVersion: 1,
|
|
282
|
+
daemonPort: live.daemonPort,
|
|
283
|
+
cockpitPort: live.cockpitPort,
|
|
284
|
+
daemonPid: live.daemonPid,
|
|
285
|
+
cockpitPid: webChild.pid,
|
|
286
|
+
startedAt: live.startedAt,
|
|
287
|
+
version: live.version,
|
|
288
|
+
});
|
|
289
|
+
console.log('');
|
|
290
|
+
console.log((0, ui_js_1.header)(ui_js_1.sym.ok, 'Cockpit attached', `cockpit PID ${webChild.pid}`));
|
|
291
|
+
const cockpitUrl = `http://127.0.0.1:${live.cockpitPort}`;
|
|
292
|
+
console.log('');
|
|
293
|
+
console.log(` ${ui_js_1.c.gray('Open')} ${ui_js_1.c.cyan(cockpitUrl)}`);
|
|
294
|
+
const env = (0, runtime_env_js_1.detectRuntimeEnv)();
|
|
295
|
+
if (env.hint) {
|
|
296
|
+
console.log('');
|
|
297
|
+
console.log((0, ui_js_1.tip)(env.hint));
|
|
298
|
+
}
|
|
299
|
+
console.log('');
|
|
300
|
+
if ((0, runtime_env_js_1.shouldAutoOpenBrowser)(env)) {
|
|
301
|
+
(0, open_1.default)(cockpitUrl);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Pick a free (daemon, cockpit) port pair. Honours CHORUS_DAEMON_PORT
|
|
306
|
+
* and CHORUS_COCKPIT_PORT env overrides as the *preferred* starting
|
|
307
|
+
* point — the walk still fires off them if taken. If the walk
|
|
308
|
+
* exhausts, exit with the same actionable diagnostic the v0.7 reaper
|
|
309
|
+
* used.
|
|
310
|
+
*/
|
|
311
|
+
async function pickPortPair() {
|
|
312
|
+
const preferredDaemon = parseEnvPort('CHORUS_DAEMON_PORT', daemon_discovery_js_1.DEFAULT_DAEMON_PORT);
|
|
313
|
+
const preferredCockpit = parseEnvPort('CHORUS_COCKPIT_PORT', daemon_discovery_js_1.DEFAULT_COCKPIT_PORT);
|
|
314
|
+
const daemonPort = await (0, daemon_discovery_js_1.pickFreePort)(preferredDaemon, daemon_discovery_js_1.DAEMON_PORT_RANGE, port_utils_js_1.isPortInUse);
|
|
315
|
+
if (daemonPort === null) {
|
|
316
|
+
failPortWalk('daemon', preferredDaemon, daemon_discovery_js_1.DAEMON_PORT_RANGE);
|
|
317
|
+
}
|
|
318
|
+
const cockpitPort = await (0, daemon_discovery_js_1.pickFreePort)(preferredCockpit, daemon_discovery_js_1.COCKPIT_PORT_RANGE, port_utils_js_1.isPortInUse);
|
|
319
|
+
if (cockpitPort === null) {
|
|
320
|
+
failPortWalk('cockpit', preferredCockpit, daemon_discovery_js_1.COCKPIT_PORT_RANGE);
|
|
321
|
+
}
|
|
322
|
+
return { daemonPort: daemonPort, cockpitPort: cockpitPort };
|
|
323
|
+
}
|
|
324
|
+
function parseEnvPort(name, fallback) {
|
|
325
|
+
const raw = process.env[name];
|
|
326
|
+
if (!raw)
|
|
327
|
+
return fallback;
|
|
328
|
+
const n = Number.parseInt(raw, 10);
|
|
329
|
+
return Number.isFinite(n) && n > 0 && n < 65536 ? n : fallback;
|
|
330
|
+
}
|
|
331
|
+
function failPortWalk(label, start, range) {
|
|
332
|
+
const end = start + range - 1;
|
|
333
|
+
console.log('');
|
|
334
|
+
console.log((0, ui_js_1.header)(ui_js_1.sym.err, `No free ${label} port in range :${start}–:${end}`, 'every candidate port is held by another process'));
|
|
335
|
+
console.log('');
|
|
336
|
+
console.log(ui_js_1.c.dim(' Find what owns these ports:'));
|
|
337
|
+
for (let p = start; p <= end; p += 1) {
|
|
338
|
+
console.log(` sudo lsof -iTCP:${p} -sTCP:LISTEN`);
|
|
339
|
+
}
|
|
340
|
+
console.log('');
|
|
341
|
+
console.log(ui_js_1.c.dim(' Or pick a different starting port:'));
|
|
342
|
+
console.log(` CHORUS_${label.toUpperCase()}_PORT=<port> chorus start`);
|
|
343
|
+
console.log('');
|
|
344
|
+
process.exit(1);
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Pre-spawn orphan reap. Sweeps the *default* daemon + cockpit ports
|
|
348
|
+
* because that's where v0.7 daemons would have bound — we want to
|
|
349
|
+
* absorb stale v0.7 processes during the v0.8 transition. The picker
|
|
350
|
+
* still walks past whatever survives, so the reap is best-effort
|
|
351
|
+
* cleanup, not a prerequisite.
|
|
159
352
|
*
|
|
160
353
|
* Foreign-process guard: only reap PIDs whose cmdline looks like a
|
|
161
|
-
* chorus daemon/cockpit. If something else
|
|
162
|
-
*
|
|
163
|
-
* the user to free the port. Pre-fix the reaper would silently SIGKILL
|
|
164
|
-
* whatever it found.
|
|
354
|
+
* chorus daemon/cockpit. If something else is bound, refuse to kill
|
|
355
|
+
* it and ask the user to free the port — same behaviour as v0.7.
|
|
165
356
|
*/
|
|
166
357
|
async function reapOrphans() {
|
|
167
358
|
for (const [port, label] of [
|
|
168
|
-
[
|
|
169
|
-
[
|
|
359
|
+
[daemon_discovery_js_1.DEFAULT_DAEMON_PORT, 'daemon'],
|
|
360
|
+
[daemon_discovery_js_1.DEFAULT_COCKPIT_PORT, 'cockpit'],
|
|
170
361
|
]) {
|
|
171
362
|
if (!(await (0, port_utils_js_1.isPortInUse)(port)))
|
|
172
363
|
continue;
|
|
173
|
-
// Two-tier PID lookup. The unprivileged probe (ss / lsof without
|
|
174
|
-
// sudo) returns [] when the port is held by a process owned by a
|
|
175
|
-
// different uid — typically a daemon a prior `sudo chorus start`
|
|
176
|
-
// left behind. Falling back to `sudo -n` recovers the PID when the
|
|
177
|
-
// user has passwordless sudo configured. If sudo would prompt, the
|
|
178
|
-
// -n flag fails fast and we drop to the actionable diagnostic
|
|
179
|
-
// instead of blocking the terminal on a password prompt.
|
|
180
364
|
let pids = (0, port_utils_js_1.findPidsOnPort)(port);
|
|
181
365
|
let needsSudoToKill = false;
|
|
182
366
|
if (pids.length === 0) {
|
|
@@ -184,43 +368,21 @@ async function reapOrphans() {
|
|
|
184
368
|
needsSudoToKill = pids.length > 0;
|
|
185
369
|
}
|
|
186
370
|
if (pids.length === 0) {
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
// a docker bridge, etc.). Print the exact remediation commands
|
|
190
|
-
// so the user can self-rescue without guessing.
|
|
371
|
+
// Couldn't see who owns the default port — the picker will walk
|
|
372
|
+
// past it. Don't fail; just note it.
|
|
191
373
|
console.log('');
|
|
192
|
-
console.log(
|
|
193
|
-
|
|
194
|
-
console.log(ui_js_1.c.dim(' Find it:'));
|
|
195
|
-
console.log(` sudo lsof -iTCP:${port} -sTCP:LISTEN`);
|
|
196
|
-
console.log('');
|
|
197
|
-
console.log(ui_js_1.c.dim(' Free it:'));
|
|
198
|
-
console.log(` sudo fuser -k ${port}/tcp`);
|
|
199
|
-
console.log(ui_js_1.c.dim(` (macOS: kill $(sudo lsof -ti :${port}))`));
|
|
200
|
-
if (label === 'daemon') {
|
|
201
|
-
console.log('');
|
|
202
|
-
console.log(ui_js_1.c.dim(' Or relocate the daemon:'));
|
|
203
|
-
console.log(` CHORUS_DAEMON_PORT=${port + 1} chorus start`);
|
|
204
|
-
}
|
|
205
|
-
console.log('');
|
|
206
|
-
process.exit(1);
|
|
374
|
+
console.log(ui_js_1.c.dim(` ${ui_js_1.sym.info} Port :${port} is in use but the owner isn't visible. Will pick the next free port.`));
|
|
375
|
+
continue;
|
|
207
376
|
}
|
|
208
377
|
for (const pid of pids) {
|
|
209
378
|
const { match, cmdline } = (0, port_utils_js_1.pidLooksLikeChorus)(pid);
|
|
210
379
|
if (!match) {
|
|
380
|
+
// Foreign process on the default port — let the picker walk
|
|
381
|
+
// past it. Don't fail.
|
|
211
382
|
console.log('');
|
|
212
|
-
console.log(
|
|
213
|
-
|
|
214
|
-
console.log((0, ui_js_1.tip)(label === 'daemon'
|
|
215
|
-
? `Free :${port} (stop the other process, or relocate the daemon via CHORUS_DAEMON_PORT) and retry \`chorus start\`.`
|
|
216
|
-
: `Free :${port} (stop the other process listening on the cockpit port) and retry \`chorus start\`.`));
|
|
217
|
-
console.log('');
|
|
218
|
-
process.exit(1);
|
|
383
|
+
console.log(ui_js_1.c.dim(` ${ui_js_1.sym.info} Port :${port} is held by ${cmdline ?? `PID ${pid}`} — will pick the next free port.`));
|
|
384
|
+
continue;
|
|
219
385
|
}
|
|
220
|
-
// Cross-uid orphan: must use sudo -n kill or the SIGTERM bounces
|
|
221
|
-
// off with EPERM. Same chorus-shape match guard means we only
|
|
222
|
-
// ever sudo-kill processes whose cmdline already proved they're
|
|
223
|
-
// chorus orphans — never escalate against foreign code.
|
|
224
386
|
const dead = needsSudoToKill
|
|
225
387
|
? await (0, port_utils_js_1.killWithSudoAndVerify)(pid, `${label} orphan`)
|
|
226
388
|
: await (0, port_utils_js_1.killAndVerify)(pid, `${label} orphan`);
|
|
@@ -234,8 +396,6 @@ async function reapOrphans() {
|
|
|
234
396
|
* Default transport is 'headless' (no tmux needed). tmux is the
|
|
235
397
|
* OPTIONAL backup mode for users who want to attach to a live voice
|
|
236
398
|
* session and take over / watch step-by-step / hand off mid-run.
|
|
237
|
-
* Surfaced once at start so power users know the feature exists.
|
|
238
|
-
* Soft-info (not a warning) so we don't scare default-path users.
|
|
239
399
|
*/
|
|
240
400
|
function warnIfTmuxMissing() {
|
|
241
401
|
try {
|
|
@@ -245,26 +405,18 @@ function warnIfTmuxMissing() {
|
|
|
245
405
|
console.log('');
|
|
246
406
|
console.log(ui_js_1.c.dim(` ${ui_js_1.sym.info} tmux not detected. Chorus runs headless by default — this is fine.`));
|
|
247
407
|
console.log(ui_js_1.c.dim(' Optional backup mode: install tmux, then open'));
|
|
248
|
-
console.log(ui_js_1.c.dim('
|
|
408
|
+
console.log(ui_js_1.c.dim(' /settings#transport in the cockpit and pick "Tmux — attach & take over".'));
|
|
249
409
|
console.log(ui_js_1.c.dim(' `tmux attach -t <name>` lets you watch step-by-step or take over mid-run.'));
|
|
250
410
|
console.log(ui_js_1.c.dim(' macOS: brew install tmux · Ubuntu/Debian: apt install tmux · Fedora: dnf install tmux'));
|
|
251
411
|
console.log('');
|
|
252
412
|
}
|
|
253
413
|
}
|
|
254
|
-
function spawnDaemonAndCockpit(chorusDir,
|
|
255
|
-
// Prefer the compiled JS so a global install works (no src/ shipped,
|
|
256
|
-
// no tsx loader registered); fall back to the .ts source when running
|
|
257
|
-
// in dev mode where the user only has src/ on disk.
|
|
414
|
+
async function spawnDaemonAndCockpit(chorusDir, ports, options = { daemonOnly: false }) {
|
|
258
415
|
const daemonJs = path_1.default.resolve(__dirname, '..', '..', 'daemon', 'index.js');
|
|
259
416
|
const daemonTs = path_1.default.resolve(__dirname, '..', '..', '..', 'src', 'daemon', 'index.ts');
|
|
260
417
|
const useCompiled = fs_1.default.existsSync(daemonJs);
|
|
261
418
|
const daemonPath = useCompiled ? daemonJs : daemonTs;
|
|
262
419
|
const spawnArgs = useCompiled ? [daemonPath] : ['-r', 'tsx/cjs', daemonPath];
|
|
263
|
-
// Pipe daemon stdout + stderr to a log file so the user (and we, when
|
|
264
|
-
// debugging) can see why a chat went sideways. Previously stdio was
|
|
265
|
-
// 'ignore' which made silent failures impossible to diagnose. Logs
|
|
266
|
-
// rotate manually; truncated to 10 MB max via periodic rotate inside
|
|
267
|
-
// the daemon (TODO).
|
|
268
420
|
fs_1.default.mkdirSync(chorusDir, { recursive: true });
|
|
269
421
|
const logsDir = path_1.default.join(chorusDir, 'logs');
|
|
270
422
|
fs_1.default.mkdirSync(logsDir, { recursive: true });
|
|
@@ -273,37 +425,57 @@ function spawnDaemonAndCockpit(chorusDir, pidFile) {
|
|
|
273
425
|
const child = (0, child_process_1.spawn)('node', spawnArgs, {
|
|
274
426
|
detached: true,
|
|
275
427
|
stdio: ['ignore', daemonLogFd, daemonLogFd],
|
|
428
|
+
env: {
|
|
429
|
+
...process.env,
|
|
430
|
+
CHORUS_DAEMON_PORT: String(ports.daemonPort),
|
|
431
|
+
CHORUS_COCKPIT_PORT: String(ports.cockpitPort),
|
|
432
|
+
},
|
|
276
433
|
});
|
|
277
434
|
if (!child.pid) {
|
|
278
435
|
throw new Error('Failed to spawn daemon process');
|
|
279
436
|
}
|
|
437
|
+
const pidFile = path_1.default.join(chorusDir, 'daemon.pid');
|
|
280
438
|
fs_1.default.writeFileSync(pidFile, child.pid.toString());
|
|
281
|
-
// Spawn the cockpit web UI alongside the daemon. The package ships a
|
|
282
|
-
// built .next directory; run next from the package root so it picks
|
|
283
|
-
// up the bundled bun_modules. Web PID is tracked separately so
|
|
284
|
-
// `chorus stop` can clean up both.
|
|
285
439
|
const packageRoot = path_1.default.resolve(__dirname, '..', '..', '..');
|
|
286
440
|
const nextEntry = path_1.default.resolve(packageRoot, 'node_modules', 'next', 'dist', 'bin', 'next');
|
|
287
441
|
const webPidFile = path_1.default.join(chorusDir, 'web.pid');
|
|
288
|
-
|
|
442
|
+
let cockpitPid = null;
|
|
443
|
+
if (options.daemonOnly) {
|
|
444
|
+
// Skip cockpit spawn — used by MCP auto-start where the user
|
|
445
|
+
// hasn't asked for the UI. Daemon API alone is enough for the
|
|
446
|
+
// editor to make tool calls.
|
|
447
|
+
}
|
|
448
|
+
else if (fs_1.default.existsSync(nextEntry) &&
|
|
289
449
|
fs_1.default.existsSync(path_1.default.join(packageRoot, '.next'))) {
|
|
290
450
|
const webLogPath = path_1.default.join(logsDir, 'web.log');
|
|
291
451
|
const webLogFd = fs_1.default.openSync(webLogPath, 'a');
|
|
292
|
-
const webChild = (0, child_process_1.spawn)('node', [
|
|
452
|
+
const webChild = (0, child_process_1.spawn)('node', [
|
|
453
|
+
nextEntry,
|
|
454
|
+
'start',
|
|
455
|
+
'-p',
|
|
456
|
+
String(ports.cockpitPort),
|
|
457
|
+
'-H',
|
|
458
|
+
'127.0.0.1',
|
|
459
|
+
], {
|
|
293
460
|
cwd: packageRoot,
|
|
294
461
|
detached: true,
|
|
295
462
|
stdio: ['ignore', webLogFd, webLogFd],
|
|
463
|
+
env: {
|
|
464
|
+
...process.env,
|
|
465
|
+
// Tell the cockpit's server-side proxy where the daemon is.
|
|
466
|
+
// Without this the proxy would fall through to the legacy
|
|
467
|
+
// 7707 default and miss our shifted port.
|
|
468
|
+
CHORUS_DAEMON_URL: `http://127.0.0.1:${ports.daemonPort}`,
|
|
469
|
+
PORT: String(ports.cockpitPort),
|
|
470
|
+
},
|
|
296
471
|
});
|
|
297
472
|
if (webChild.pid) {
|
|
298
473
|
fs_1.default.writeFileSync(webPidFile, webChild.pid.toString());
|
|
474
|
+
cockpitPid = webChild.pid;
|
|
299
475
|
webChild.unref();
|
|
300
476
|
}
|
|
301
477
|
}
|
|
302
478
|
else {
|
|
303
|
-
// Loud, actionable error — the previous yellow warning was easy to
|
|
304
|
-
// miss and left users at a blank cockpit URL with no idea why. We
|
|
305
|
-
// keep the daemon running (MCP + API still work) but make the
|
|
306
|
-
// remediation steps obvious.
|
|
307
479
|
console.log('');
|
|
308
480
|
console.log(ui_js_1.c.red(' ✗ Cockpit UI not found.'));
|
|
309
481
|
if (fs_1.default.existsSync(path_1.default.join(packageRoot, 'src'))) {
|
|
@@ -314,18 +486,74 @@ function spawnDaemonAndCockpit(chorusDir, pidFile) {
|
|
|
314
486
|
console.log(ui_js_1.c.dim(' The published install should ship a built UI. Try reinstalling:'));
|
|
315
487
|
console.log(` ${ui_js_1.c.bold('npm install -g chorus-codes')}`);
|
|
316
488
|
}
|
|
317
|
-
console.log(ui_js_1.c.dim(
|
|
489
|
+
console.log(ui_js_1.c.dim(` The daemon API is still up on port ${ports.daemonPort} if you only need MCP.`));
|
|
318
490
|
console.log('');
|
|
319
491
|
}
|
|
492
|
+
// Wait for the daemon to answer health, THEN write daemon.json.
|
|
493
|
+
// Must await: the parent CLI process exits as soon as this function
|
|
494
|
+
// returns; if we fire-and-forget, the file never gets written and
|
|
495
|
+
// every consumer falls back to defaults forever.
|
|
496
|
+
await waitForDaemonListenerThenRecord(ports, child.pid, cockpitPid);
|
|
320
497
|
child.unref();
|
|
321
498
|
console.log('');
|
|
322
499
|
console.log((0, ui_js_1.header)(ui_js_1.sym.ok, 'Chorus started', `daemon PID ${child.pid}`));
|
|
323
|
-
(
|
|
500
|
+
if (!options.daemonOnly) {
|
|
501
|
+
const cockpitUrl = `http://127.0.0.1:${ports.cockpitPort}`;
|
|
502
|
+
console.log('');
|
|
503
|
+
console.log(` ${ui_js_1.c.gray('Open')} ${ui_js_1.c.cyan(cockpitUrl)}`);
|
|
504
|
+
const env = (0, runtime_env_js_1.detectRuntimeEnv)();
|
|
505
|
+
if (env.hint) {
|
|
506
|
+
console.log('');
|
|
507
|
+
console.log((0, ui_js_1.tip)(env.hint));
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
console.log('');
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Poll the daemon's health endpoint up to 5 seconds, then write
|
|
514
|
+
* daemon.json once it responds. Background fire-and-forget — the
|
|
515
|
+
* caller has already returned to the user; we just need to make sure
|
|
516
|
+
* the file is in place before any consumer reads it.
|
|
517
|
+
*
|
|
518
|
+
* If the daemon never comes up, write the file anyway with the recorded
|
|
519
|
+
* ports — better stale data than no data, since the next `chorus start`
|
|
520
|
+
* will detect the dead daemon via PID liveness and overwrite.
|
|
521
|
+
*/
|
|
522
|
+
async function waitForDaemonListenerThenRecord(ports, daemonPid, cockpitPid) {
|
|
523
|
+
const deadline = Date.now() + 5000;
|
|
524
|
+
while (Date.now() < deadline) {
|
|
525
|
+
try {
|
|
526
|
+
const ac = new AbortController();
|
|
527
|
+
const timer = setTimeout(() => ac.abort(), 500);
|
|
528
|
+
const res = await fetch(`http://127.0.0.1:${ports.daemonPort}/api/v1/health`, { signal: ac.signal });
|
|
529
|
+
clearTimeout(timer);
|
|
530
|
+
if (res.ok)
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
catch {
|
|
534
|
+
/* not up yet */
|
|
535
|
+
}
|
|
536
|
+
if (!(0, daemon_discovery_js_1.isPidAlive)(daemonPid)) {
|
|
537
|
+
// Spawned process died before the listener came up — bail rather
|
|
538
|
+
// than write a junk daemon.json.
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
542
|
+
}
|
|
543
|
+
(0, daemon_discovery_js_1.writeDaemonInfo)({
|
|
544
|
+
schemaVersion: 1,
|
|
545
|
+
daemonPort: ports.daemonPort,
|
|
546
|
+
cockpitPort: ports.cockpitPort,
|
|
547
|
+
daemonPid,
|
|
548
|
+
cockpitPid,
|
|
549
|
+
startedAt: new Date().toISOString(),
|
|
550
|
+
version: shared_js_1.pkg.version,
|
|
551
|
+
});
|
|
324
552
|
}
|
|
325
|
-
function scheduleAutoOpenBrowser(uiFlag) {
|
|
553
|
+
function scheduleAutoOpenBrowser(uiFlag, cockpitPort) {
|
|
326
554
|
setTimeout(() => {
|
|
327
555
|
if (uiFlag && (0, runtime_env_js_1.shouldAutoOpenBrowser)((0, runtime_env_js_1.detectRuntimeEnv)())) {
|
|
328
|
-
(0, open_1.default)(
|
|
556
|
+
(0, open_1.default)(`http://127.0.0.1:${cockpitPort}`);
|
|
329
557
|
}
|
|
330
558
|
}, 1000);
|
|
331
559
|
}
|