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.
Files changed (135) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +2 -1
  3. package/.next/build-manifest.json +2 -2
  4. package/.next/prerender-manifest.json +3 -3
  5. package/.next/routes-manifest.json +8 -0
  6. package/.next/server/app/_global-error/page.js +2 -2
  7. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/server/app/_global-error.html +1 -1
  9. package/.next/server/app/_global-error.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  14. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  15. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/server/app/_not-found/page.js +2 -2
  17. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/server/app/_not-found.html +1 -1
  19. package/.next/server/app/_not-found.rsc +1 -1
  20. package/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  21. package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  22. package/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  23. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  24. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  25. package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  26. package/.next/server/app/api/daemon/[...path]/route.js +1 -1
  27. package/.next/server/app/api/run-artifacts/[chatId]/route.js +1 -1
  28. package/.next/server/app/connect/page.js +2 -2
  29. package/.next/server/app/connect/page_client-reference-manifest.js +1 -1
  30. package/.next/server/app/demo/[scenario]/page.js +2 -0
  31. package/.next/server/app/demo/[scenario]/page.js.nft.json +1 -0
  32. package/.next/server/app/demo/[scenario]/page_client-reference-manifest.js +1 -0
  33. package/.next/server/app/favicon.ico/route.js +1 -1
  34. package/.next/server/app/icon.svg/route.js +1 -1
  35. package/.next/server/app/new/page.js +2 -2
  36. package/.next/server/app/new/page_client-reference-manifest.js +1 -1
  37. package/.next/server/app/new.html +1 -1
  38. package/.next/server/app/new.rsc +2 -2
  39. package/.next/server/app/new.segments/_full.segment.rsc +2 -2
  40. package/.next/server/app/new.segments/_head.segment.rsc +1 -1
  41. package/.next/server/app/new.segments/_index.segment.rsc +1 -1
  42. package/.next/server/app/new.segments/_tree.segment.rsc +1 -1
  43. package/.next/server/app/new.segments/new/__PAGE__.segment.rsc +2 -2
  44. package/.next/server/app/new.segments/new.segment.rsc +1 -1
  45. package/.next/server/app/onboarding/page.js +2 -2
  46. package/.next/server/app/onboarding/page_client-reference-manifest.js +1 -1
  47. package/.next/server/app/onboarding.html +1 -1
  48. package/.next/server/app/onboarding.rsc +2 -2
  49. package/.next/server/app/onboarding.segments/_full.segment.rsc +2 -2
  50. package/.next/server/app/onboarding.segments/_head.segment.rsc +1 -1
  51. package/.next/server/app/onboarding.segments/_index.segment.rsc +1 -1
  52. package/.next/server/app/onboarding.segments/_tree.segment.rsc +1 -1
  53. package/.next/server/app/onboarding.segments/onboarding/__PAGE__.segment.rsc +2 -2
  54. package/.next/server/app/onboarding.segments/onboarding.segment.rsc +1 -1
  55. package/.next/server/app/page.js +2 -2
  56. package/.next/server/app/page.js.nft.json +1 -1
  57. package/.next/server/app/page_client-reference-manifest.js +1 -1
  58. package/.next/server/app/personas/page.js +2 -2
  59. package/.next/server/app/personas/page_client-reference-manifest.js +1 -1
  60. package/.next/server/app/personas.html +1 -1
  61. package/.next/server/app/personas.rsc +2 -2
  62. package/.next/server/app/personas.segments/_full.segment.rsc +2 -2
  63. package/.next/server/app/personas.segments/_head.segment.rsc +1 -1
  64. package/.next/server/app/personas.segments/_index.segment.rsc +1 -1
  65. package/.next/server/app/personas.segments/_tree.segment.rsc +1 -1
  66. package/.next/server/app/personas.segments/personas/__PAGE__.segment.rsc +2 -2
  67. package/.next/server/app/personas.segments/personas.segment.rsc +1 -1
  68. package/.next/server/app/runs/[runId]/page.js +2 -2
  69. package/.next/server/app/runs/[runId]/page.js.nft.json +1 -1
  70. package/.next/server/app/runs/[runId]/page_client-reference-manifest.js +1 -1
  71. package/.next/server/app/runs/page.js +2 -2
  72. package/.next/server/app/runs/page_client-reference-manifest.js +1 -1
  73. package/.next/server/app/settings/page.js +3 -3
  74. package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  75. package/.next/server/app/settings/permissions/page.js +2 -2
  76. package/.next/server/app/settings/permissions/page_client-reference-manifest.js +1 -1
  77. package/.next/server/app/settings.html +1 -1
  78. package/.next/server/app/settings.rsc +2 -2
  79. package/.next/server/app/settings.segments/_full.segment.rsc +2 -2
  80. package/.next/server/app/settings.segments/_head.segment.rsc +1 -1
  81. package/.next/server/app/settings.segments/_index.segment.rsc +1 -1
  82. package/.next/server/app/settings.segments/_tree.segment.rsc +1 -1
  83. package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +2 -2
  84. package/.next/server/app/settings.segments/settings.segment.rsc +1 -1
  85. package/.next/server/app/templates/page.js +2 -2
  86. package/.next/server/app/templates/page_client-reference-manifest.js +1 -1
  87. package/.next/server/app/templates.html +1 -1
  88. package/.next/server/app/templates.rsc +2 -2
  89. package/.next/server/app/templates.segments/_full.segment.rsc +2 -2
  90. package/.next/server/app/templates.segments/_head.segment.rsc +1 -1
  91. package/.next/server/app/templates.segments/_index.segment.rsc +1 -1
  92. package/.next/server/app/templates.segments/_tree.segment.rsc +1 -1
  93. package/.next/server/app/templates.segments/templates/__PAGE__.segment.rsc +2 -2
  94. package/.next/server/app/templates.segments/templates.segment.rsc +1 -1
  95. package/.next/server/app-paths-manifest.json +2 -1
  96. package/.next/server/chunks/597.js +1 -0
  97. package/.next/server/chunks/668.js +1 -1
  98. package/.next/server/middleware-build-manifest.js +1 -1
  99. package/.next/server/pages/404.html +1 -1
  100. package/.next/server/pages/500.html +1 -1
  101. package/.next/server/server-reference-manifest.json +1 -1
  102. package/.next/static/chunks/{116-0372d11bc2a9ac4d.js → 116-8bf7e014066cedde.js} +1 -1
  103. package/.next/static/chunks/977-26354f62110c63d4.js +1 -0
  104. package/.next/static/chunks/app/demo/[scenario]/page-a84f68aa9f4b9ab8.js +1 -0
  105. package/.next/static/chunks/app/runs/[runId]/page-642397282cb68c60.js +1 -0
  106. package/.next/static/{VWSkj_Yx6OKSjPmYY8ewj → i0LC5zObFeek7b2zwUxsX}/_buildManifest.js +1 -1
  107. package/.next/trace +20 -19
  108. package/.next/trace-build +1 -1
  109. package/.next/types/app/demo/[scenario]/page.ts +87 -0
  110. package/.next/types/routes.d.ts +2 -1
  111. package/.next/types/validator.ts +9 -0
  112. package/dist/cli/commands/start.js +395 -167
  113. package/dist/cli/commands/start.js.map +1 -1
  114. package/dist/cli/commands/status.js +6 -4
  115. package/dist/cli/commands/status.js.map +1 -1
  116. package/dist/cli/commands/stop.js +34 -19
  117. package/dist/cli/commands/stop.js.map +1 -1
  118. package/dist/cli/index.js +12 -4
  119. package/dist/cli/index.js.map +1 -1
  120. package/dist/cli/shared.js +1 -16
  121. package/dist/cli/shared.js.map +1 -1
  122. package/dist/daemon/index.js +25 -2
  123. package/dist/daemon/index.js.map +1 -1
  124. package/dist/lib/daemon-discovery.js +219 -0
  125. package/dist/lib/daemon-discovery.js.map +1 -0
  126. package/dist/mcp/client.js +125 -15
  127. package/dist/mcp/client.js.map +1 -1
  128. package/dist/mcp/index.js +21 -1
  129. package/dist/mcp/index.js.map +1 -1
  130. package/dist/mcp/tools.js +16 -1
  131. package/dist/mcp/tools.js.map +1 -1
  132. package/package.json +1 -1
  133. package/.next/server/chunks/418.js +0 -1
  134. package/.next/static/chunks/app/runs/[runId]/page-aa118bfeb6508779.js +0 -1
  135. /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
- const pidFile = path_1.default.join(chorusDir, 'daemon.pid');
25
- if (await alreadyRunning(pidFile, options.ui))
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
- if (await alreadyRunningOnDefaultPort(options.ui))
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
- await reapOrphans();
30
- warnIfTmuxMissing();
31
- // Capture the interactive PATH from the user's terminal BEFORE
32
- // forking the daemon. The daemon's own spawn loses this — it
33
- // runs from a non-interactive shell that skips ~/.bashrc, so
34
- // tools installed to ~/.opencode/bin etc. would be missing.
35
- // Re-capturing on every start (not just init) means a user who
36
- // adds a new tool to PATH and restarts picks it up automatically.
37
- await captureAndPersistPath();
38
- spawnDaemonAndCockpit(chorusDir, pidFile);
39
- scheduleAutoOpenBrowser(options.ui);
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
- * Pidfile-less variant of `alreadyRunning`. Covers the case where
69
- * the daemon is healthy on :7707 but the pidfile is missing or stale
70
- * (manual deletion, /tmp wipe, prior crash before pidfile write,
71
- * sudo-started daemon vs. user-invoked `chorus start`, etc.).
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
- * Without this, a healthy chorus + missing pidfile would fall through
74
- * to reapOrphans() and either kill the live daemon or hit the
75
- * "couldn't identify the PID" dead-end (when the daemon runs as a
76
- * different user and `ss -p` redacts the PID).
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 alreadyRunningOnDefaultPort(uiFlag) {
84
- // Short timeout we're trying to fail fast and fall through to reap
85
- // logic when the daemon is actually dead. 1.5s covers a slow loopback
86
- // round-trip on resource-starved CI runners without making a healthy
87
- // system feel sluggish.
88
- let healthyVersion = null;
89
- try {
90
- const ac = new AbortController();
91
- const timer = setTimeout(() => ac.abort(), 1500);
92
- const res = await fetch(`${shared_js_1.DAEMON_URL}/api/v1/health`, {
93
- signal: ac.signal,
94
- });
95
- clearTimeout(timer);
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 ${healthyVersion}`));
117
- (0, shared_js_1.printCockpitAccessHint)();
118
- if (uiFlag && (0, runtime_env_js_1.shouldAutoOpenBrowser)((0, runtime_env_js_1.detectRuntimeEnv)())) {
119
- (0, open_1.default)(shared_js_1.COCKPIT_URL);
120
- }
121
- return true;
122
- }
123
- async function alreadyRunning(pidFile, uiFlag) {
124
- if (!fs_1.default.existsSync(pidFile))
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
- console.log((0, ui_js_1.header)(ui_js_1.sym.ok, 'Chorus is already running', `daemon PID ${oldPid}`));
139
- (0, shared_js_1.printCockpitAccessHint)();
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
- catch {
146
- // Process doesn't exist, clean up the stale pidfile.
147
- fs_1.default.unlinkSync(pidFile);
148
- return false;
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
- * Pre-spawn orphan reap. Pidfile-based liveness only catches the
153
- * recorded daemon PID it misses a stale next-server (cockpit) or
154
- * daemon that survived a previous `chorus stop` because the SIGTERM
155
- * was ignored or the pidfile got out of sync. Without this sweep, a
156
- * fresh `chorus start` would race against the orphan on :5050 / :7707,
157
- * the new spawn would lose, and the user would see 500s served by the
158
- * ghost (incident 2026-05-03).
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 (Grafana on :5050, a
162
- * colleague's `next dev` on :7707) is bound, refuse to kill it and ask
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
- [7707, 'daemon'],
169
- [5050, 'cockpit'],
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
- // sudo also couldn't see — either no passwordless sudo, or
188
- // something exotic (Windows-host port reservation through WSL2,
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((0, ui_js_1.header)(ui_js_1.sym.err, `Port :${port} is in use, but I can't see which process owns it`, `likely cause: the holder is owned by another user (or sudo)`));
193
- console.log('');
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((0, ui_js_1.header)(ui_js_1.sym.err, `Port :${port} is in use by a non-chorus process`, `PID ${pid}: ${cmdline ?? '(unreadable)'}`));
213
- console.log('');
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(' http://127.0.0.1:5050/settings#transport and pick "Tmux — attach & take over".'));
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, pidFile) {
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
- if (fs_1.default.existsSync(nextEntry) &&
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', [nextEntry, 'start', '-p', '5050', '-H', '127.0.0.1'], {
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(' The daemon API is still up on port 7707 if you only need MCP.'));
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
- (0, shared_js_1.printCockpitAccessHint)();
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)(shared_js_1.COCKPIT_URL);
556
+ (0, open_1.default)(`http://127.0.0.1:${cockpitPort}`);
329
557
  }
330
558
  }, 1000);
331
559
  }