@cordfuse/crosstalk 7.0.0-alpha.9 → 8.0.0-alpha.1

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 (72) hide show
  1. package/GUIDE-CLI.md +322 -0
  2. package/GUIDE-PROMPTS.md +118 -0
  3. package/LICENSE +21 -0
  4. package/README.md +422 -61
  5. package/bin/crosstalk.js +88 -54
  6. package/commands/agent.js +69 -0
  7. package/commands/auth.js +273 -0
  8. package/commands/channel.js +54 -44
  9. package/commands/chat.js +107 -71
  10. package/commands/daemon.js +120 -0
  11. package/commands/logs.js +108 -19
  12. package/commands/message.js +125 -0
  13. package/commands/server.js +185 -0
  14. package/commands/settings.js +49 -0
  15. package/commands/status.js +37 -13
  16. package/commands/token.js +136 -0
  17. package/commands/transport.js +299 -0
  18. package/commands/version.js +3 -3
  19. package/commands/workflow.js +234 -0
  20. package/deploy/crosstalk@.service +62 -0
  21. package/deploy/install.sh +82 -0
  22. package/lib/api-client.js +77 -22
  23. package/lib/credentials.js +207 -0
  24. package/lib/nativeServer.js +182 -0
  25. package/lib/resolve.js +106 -37
  26. package/package.json +27 -4
  27. package/src/activation.ts +104 -0
  28. package/src/api.ts +1717 -0
  29. package/src/auth/enforce.ts +68 -0
  30. package/src/auth/handlers.ts +266 -0
  31. package/src/auth/middleware.ts +132 -0
  32. package/src/auth/setup.ts +263 -0
  33. package/src/auth/tokens.ts +285 -0
  34. package/src/auth/users.ts +267 -0
  35. package/src/dispatch.ts +530 -0
  36. package/src/dispatchers.ts +91 -0
  37. package/src/filenames.ts +28 -0
  38. package/src/frontmatter.ts +26 -0
  39. package/src/init.ts +116 -0
  40. package/src/invoke.ts +201 -0
  41. package/src/log-buffer.ts +67 -0
  42. package/src/models.ts +300 -0
  43. package/src/resolve.ts +100 -0
  44. package/src/state.ts +191 -0
  45. package/src/stop.ts +37 -0
  46. package/src/transport.ts +353 -0
  47. package/src/web/auth-pages.ts +160 -0
  48. package/src/web/channels.ts +395 -0
  49. package/src/web/chat-page.ts +636 -0
  50. package/src/web/chat-pty.ts +254 -0
  51. package/src/web/dashboard.ts +130 -0
  52. package/src/web/layout.ts +237 -0
  53. package/src/web/stubs.ts +510 -0
  54. package/src/web/workflows.ts +490 -0
  55. package/src/workflow.ts +470 -0
  56. package/template/CLAUDE.md +10 -0
  57. package/template/CROSSTALK-VERSION +1 -0
  58. package/template/CROSSTALK.md +262 -0
  59. package/template/PROTOCOL.md +70 -0
  60. package/template/README.md +64 -0
  61. package/template/auth/.gitkeep +0 -0
  62. package/template/auth/README.md +224 -0
  63. package/template/data/crosstalk.yaml +196 -0
  64. package/template/gitignore +4 -0
  65. package/commands/down.js +0 -40
  66. package/commands/init.js +0 -243
  67. package/commands/pull.js +0 -22
  68. package/commands/replies.js +0 -40
  69. package/commands/restart.js +0 -29
  70. package/commands/rm.js +0 -109
  71. package/commands/run.js +0 -115
  72. package/commands/up.js +0 -135
package/lib/api-client.js CHANGED
@@ -1,27 +1,32 @@
1
- // api-client.js — HTTP client over loopback to crosstalkd's local API.
1
+ // api-client.js — HTTP client to a crosstalk engine.
2
2
  //
3
- // All operator commands route through this. The engine binds 127.0.0.1
4
- // inside the container; docker-compose maps that to the host loopback.
5
- // The host port differs per container — default container 'crosstalk'
6
- // gets 7000; named containers pick a free port at init time, recorded in
7
- // `<base>/<name>/api-port`. apiPortFor() resolves name port.
3
+ // Two routing modes:
4
+ // 1. **Local (default)** engine binds 127.0.0.1 on the host. Port
5
+ // is per-transport (default 'crosstalk' transport gets 7000; named
6
+ // transports record their port in `<base>/<name>/api-port`).
7
+ // 2. **Remote** active credentials profile carries a non-loopback
8
+ // `server` URL (e.g. https://crosstalk.cordfuse.io). When set, the
9
+ // profile server overrides loopback routing for this invocation.
8
10
  //
9
- // Port resolution order:
11
+ // Port resolution order (local mode only):
10
12
  // 1. opts.port (explicit override from caller)
11
13
  // 2. CROSSTALK_API_PORT env var (test/dev override)
12
14
  // 3. apiPortFor(name) — reads `<base>/<name>/api-port` file
13
15
  // 4. DEFAULT_API_PORT (7000) as last resort
14
16
  //
15
- // No auth: engine binds 127.0.0.1 only, no token. Same model as ollama.
17
+ // Auth: bearer token from the active credentials profile, sent on every
18
+ // authenticated call. opts.skipAuth=true suppresses (used by login).
16
19
  //
17
20
  // Version skew: engine sets X-Crosstalk-Engine-Version on every response.
18
21
  // We compare to client version on each call, emit one warning per process
19
22
  // if mismatched. Pre-1.0 client and engine ship in lockstep.
20
23
 
21
- import { readFileSync } from 'fs';
24
+ import { readFileSync, existsSync } from 'fs';
22
25
  import { dirname, join } from 'path';
26
+ import { homedir } from 'os';
23
27
  import { fileURLToPath } from 'url';
24
28
  import { apiPortFor, DEFAULT_API_PORT, parseContainerName } from './resolve.js';
29
+ import { readToken, getActiveProfile } from './credentials.js';
25
30
 
26
31
  const CLIENT_VERSION = (() => {
27
32
  try {
@@ -59,6 +64,12 @@ function apiUrl(path, port) {
59
64
  return `http://127.0.0.1:${port}${path}`;
60
65
  }
61
66
 
67
+ function remoteApiUrl(server, path) {
68
+ // Trim any trailing slash so we don't end up with double-slashes.
69
+ const base = server.replace(/\/+$/, '');
70
+ return `${base}${path}`;
71
+ }
72
+
62
73
  async function parseJsonOrThrow(res) {
63
74
  let body;
64
75
  try {
@@ -82,28 +93,64 @@ export class ApiError extends Error {
82
93
  }
83
94
 
84
95
  export class ConnectError extends Error {
85
- constructor(port, cause) {
96
+ constructor(target, cause) {
97
+ const isUrl = typeof target === 'string' && /^https?:/.test(target);
86
98
  super(
87
- `cannot reach crosstalkd on 127.0.0.1:${port} — is the container running? ` +
88
- `Try 'crosstalk up' to start it. (underlying: ${cause})`,
99
+ isUrl
100
+ ? `cannot reach crosstalk engine at ${target}. (underlying: ${cause})`
101
+ : `cannot reach crosstalk engine on 127.0.0.1:${target} — is the daemon running? ` +
102
+ `Try 'crosstalk server start' to start it. (underlying: ${cause})`,
89
103
  );
90
104
  this.name = 'ConnectError';
91
- this.port = port;
105
+ this.target = target;
106
+ this.port = isUrl ? null : target;
92
107
  }
93
108
  }
94
109
 
95
110
  async function call(method, path, body, opts = {}) {
96
- const port = resolvePort(opts);
111
+ // Resolve the target server, in priority order:
112
+ // 1. opts.serverOverride — explicit per-call URL. Used by `auth login`
113
+ // so a new --server flag doesn't get shadowed by an existing profile.
114
+ // 2. active credentials profile's `server` field — the steady-state
115
+ // path: the profile chosen at login carries the engine URL.
116
+ // 3. resolvePort(opts) — fallback for pre-login state (`crosstalk
117
+ // version` from a fresh install).
118
+ const profile = getActiveProfile(opts.argv || []);
119
+
120
+ let target, url;
121
+ if (opts.serverOverride) {
122
+ target = opts.serverOverride;
123
+ url = remoteApiUrl(opts.serverOverride, path);
124
+ } else if (profile && profile.server) {
125
+ target = profile.server;
126
+ url = remoteApiUrl(profile.server, path);
127
+ } else {
128
+ const port = resolvePort(opts);
129
+ target = port;
130
+ url = apiUrl(path, port);
131
+ }
132
+
133
+ // v8 uniform-auth: read the CLI's bearer token from the active
134
+ // credentials profile and send it on every call. If missing, the
135
+ // engine will 401 with a hint pointing at `crosstalk auth login`.
136
+ // Callers can pass opts.skipAuth=true to suppress this (used by the
137
+ // login command itself, which doesn't yet have a token).
138
+ const headers = {};
139
+ if (body) headers['Content-Type'] = 'application/json';
140
+ if (!opts.skipAuth) {
141
+ const token = readToken(opts.argv || []);
142
+ if (token) headers['Authorization'] = `Bearer ${token}`;
143
+ }
97
144
  const init = {
98
145
  method,
99
- headers: body ? { 'Content-Type': 'application/json' } : undefined,
146
+ headers: Object.keys(headers).length > 0 ? headers : undefined,
100
147
  body: body ? JSON.stringify(body) : undefined,
101
148
  };
102
149
  let res;
103
150
  try {
104
- res = await fetch(apiUrl(path, port), init);
151
+ res = await fetch(url, init);
105
152
  } catch (err) {
106
- throw new ConnectError(port, err.message || String(err));
153
+ throw new ConnectError(target, err.message || String(err));
107
154
  }
108
155
  checkSkew(res.headers.get('x-crosstalk-engine-version'));
109
156
  return parseJsonOrThrow(res);
@@ -113,7 +160,7 @@ async function call(method, path, body, opts = {}) {
113
160
  // either pass argv (auto-resolves name) or an explicit name. Internally
114
161
  // each call resolves the port via the name's api-port file. Validation
115
162
  // errors print cleanly (no stack trace) — bug C fix.
116
- export function apiFor(argv) {
163
+ export function apiFor(argv, baseOpts = {}) {
117
164
  let name;
118
165
  try {
119
166
  name = parseContainerName(argv);
@@ -121,12 +168,20 @@ export function apiFor(argv) {
121
168
  process.stderr.write(`crosstalk: ${err.message}\n`);
122
169
  process.exit(1);
123
170
  }
171
+ // argv is threaded into every call so the profile resolver can
172
+ // honour `--profile <name>` overrides per-invocation. baseOpts is
173
+ // merged into every call — used by `auth login` to pass
174
+ // serverOverride when the profile/argv would otherwise route to the
175
+ // wrong engine.
124
176
  return {
125
177
  name,
126
- get: (path, opts = {}) => call('GET', path, undefined, { ...opts, name }),
127
- post: (path, body, opts = {}) => call('POST', path, body, { ...opts, name }),
128
- patch: (path, body, opts = {}) => call('PATCH', path, body, { ...opts, name }),
129
- delete: (path, opts = {}) => call('DELETE', path, undefined, { ...opts, name }),
178
+ get: (path, opts = {}) => call('GET', path, undefined, { ...baseOpts, ...opts, name, argv }),
179
+ post: (path, body, opts = {}) => call('POST', path, body, { ...baseOpts, ...opts, name, argv }),
180
+ patch: (path, body, opts = {}) => call('PATCH', path, body, { ...baseOpts, ...opts, name, argv }),
181
+ delete: (path, opts = {}) => call('DELETE', path, undefined, { ...baseOpts, ...opts, name, argv }),
182
+ // Unauthenticated variants used by `crosstalk auth login` — it
183
+ // doesn't have a token yet when calling POST /api/auth/login.
184
+ postNoAuth: (path, body, opts = {}) => call('POST', path, body, { ...baseOpts, ...opts, name, argv, skipAuth: true }),
130
185
  };
131
186
  }
132
187
 
@@ -0,0 +1,207 @@
1
+ // credentials.js — CLI-side persisted credentials (multi-profile).
2
+ //
3
+ // Replaces v7's `auth-token.js` (single-token at cli-token). Stores
4
+ // the active profile + all known profiles in a single JSON file. Each
5
+ // profile carries its own server URL + bearer token, so the same CLI
6
+ // can talk to multiple crosstalk engines (e.g. local loopback +
7
+ // hosted prod).
8
+ //
9
+ // Path: $XDG_CONFIG_HOME/crosstalk/credentials.json, falling back to
10
+ // $HOME/.config/crosstalk/credentials.json. File mode 0600.
11
+ //
12
+ // Shape:
13
+ // {
14
+ // "activeProfile": "default",
15
+ // "profiles": {
16
+ // "default": {
17
+ // "server": "http://127.0.0.1:7000",
18
+ // "username": "stevekrisjanovs",
19
+ // "token": "sas_<id>.<secret>",
20
+ // "tokenId": "<id>",
21
+ // "createdAt": "2026-06-22T16:54:00Z"
22
+ // }
23
+ // }
24
+ // }
25
+ //
26
+ // Legacy migration: on first read, if a v7-era `cli-token` file exists
27
+ // and `credentials.json` does not, the single token is ingested as the
28
+ // `default` profile (server = loopback default) and the legacy file is
29
+ // removed. Migration is best-effort; if it fails the engine just
30
+ // 401s and the operator re-runs `auth login`.
31
+
32
+ import {
33
+ existsSync,
34
+ readFileSync,
35
+ writeFileSync,
36
+ renameSync,
37
+ unlinkSync,
38
+ mkdirSync,
39
+ chmodSync,
40
+ } from 'fs';
41
+ import { join } from 'path';
42
+ import { homedir } from 'os';
43
+
44
+ const DEFAULT_LOOPBACK_SERVER = 'http://127.0.0.1:7000';
45
+
46
+ function configDir() {
47
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
48
+ return join(base, 'crosstalk');
49
+ }
50
+
51
+ function credentialsPath() {
52
+ return join(configDir(), 'credentials.json');
53
+ }
54
+
55
+ function legacyTokenPath() {
56
+ return join(configDir(), 'cli-token');
57
+ }
58
+
59
+ function emptyCredentials() {
60
+ return { activeProfile: null, profiles: {} };
61
+ }
62
+
63
+ function readJsonOrEmpty(path) {
64
+ try {
65
+ const raw = readFileSync(path, 'utf-8');
66
+ const parsed = JSON.parse(raw);
67
+ if (!parsed || typeof parsed !== 'object') return emptyCredentials();
68
+ if (!parsed.profiles || typeof parsed.profiles !== 'object') parsed.profiles = {};
69
+ return parsed;
70
+ } catch {
71
+ return emptyCredentials();
72
+ }
73
+ }
74
+
75
+ let migrationAttempted = false;
76
+ function maybeMigrate() {
77
+ if (migrationAttempted) return;
78
+ migrationAttempted = true;
79
+ const credPath = credentialsPath();
80
+ const legacyPath = legacyTokenPath();
81
+ if (existsSync(credPath)) return;
82
+ if (!existsSync(legacyPath)) return;
83
+ let token;
84
+ try {
85
+ token = readFileSync(legacyPath, 'utf-8').trim();
86
+ } catch {
87
+ return;
88
+ }
89
+ if (!token) return;
90
+ const cred = emptyCredentials();
91
+ cred.activeProfile = 'default';
92
+ cred.profiles.default = {
93
+ server: DEFAULT_LOOPBACK_SERVER,
94
+ username: null,
95
+ token,
96
+ tokenId: null,
97
+ createdAt: new Date().toISOString(),
98
+ };
99
+ writeCredentials(cred);
100
+ try { unlinkSync(legacyPath); } catch { /* best-effort */ }
101
+ }
102
+
103
+ export function readCredentials() {
104
+ maybeMigrate();
105
+ return readJsonOrEmpty(credentialsPath());
106
+ }
107
+
108
+ export function writeCredentials(cred) {
109
+ const dir = configDir();
110
+ mkdirSync(dir, { recursive: true });
111
+ const path = credentialsPath();
112
+ const tmp = `${path}.tmp`;
113
+ writeFileSync(tmp, JSON.stringify(cred, null, 2) + '\n', { mode: 0o600 });
114
+ try { chmodSync(tmp, 0o600); } catch { /* best-effort */ }
115
+ renameSync(tmp, path);
116
+ return path;
117
+ }
118
+
119
+ export function credentialsFilePath() {
120
+ return credentialsPath();
121
+ }
122
+
123
+ function profileNameOverride(argv) {
124
+ if (!Array.isArray(argv)) return null;
125
+ for (let i = 0; i < argv.length - 1; i++) {
126
+ if (argv[i] === '--profile') return argv[i + 1];
127
+ }
128
+ return null;
129
+ }
130
+
131
+ export function getActiveProfileName(argv = []) {
132
+ const override = profileNameOverride(argv);
133
+ if (override) return override;
134
+ const cred = readCredentials();
135
+ return cred.activeProfile || null;
136
+ }
137
+
138
+ export function getActiveProfile(argv = []) {
139
+ const name = getActiveProfileName(argv);
140
+ if (!name) return null;
141
+ const cred = readCredentials();
142
+ return cred.profiles[name] || null;
143
+ }
144
+
145
+ export function listProfiles() {
146
+ const cred = readCredentials();
147
+ return Object.entries(cred.profiles).map(([name, p]) => ({
148
+ name,
149
+ server: p.server,
150
+ username: p.username,
151
+ tokenId: p.tokenId,
152
+ active: cred.activeProfile === name,
153
+ }));
154
+ }
155
+
156
+ export function setActiveProfile(name) {
157
+ const cred = readCredentials();
158
+ if (!cred.profiles[name]) {
159
+ throw new Error(`profile '${name}' not found`);
160
+ }
161
+ cred.activeProfile = name;
162
+ writeCredentials(cred);
163
+ }
164
+
165
+ export function saveProfile(name, data) {
166
+ const cred = readCredentials();
167
+ cred.profiles[name] = {
168
+ server: data.server || DEFAULT_LOOPBACK_SERVER,
169
+ username: data.username ?? null,
170
+ token: data.token,
171
+ tokenId: data.tokenId ?? null,
172
+ createdAt: data.createdAt || new Date().toISOString(),
173
+ };
174
+ if (!cred.activeProfile) cred.activeProfile = name;
175
+ writeCredentials(cred);
176
+ }
177
+
178
+ export function deleteProfile(name) {
179
+ const cred = readCredentials();
180
+ if (!cred.profiles[name]) return false;
181
+ delete cred.profiles[name];
182
+ if (cred.activeProfile === name) {
183
+ const remaining = Object.keys(cred.profiles);
184
+ cred.activeProfile = remaining.length > 0 ? remaining[0] : null;
185
+ }
186
+ writeCredentials(cred);
187
+ return true;
188
+ }
189
+
190
+ export function deleteActiveProfile() {
191
+ const cred = readCredentials();
192
+ const name = cred.activeProfile;
193
+ if (!name) return null;
194
+ delete cred.profiles[name];
195
+ const remaining = Object.keys(cred.profiles);
196
+ cred.activeProfile = remaining.length > 0 ? remaining[0] : null;
197
+ writeCredentials(cred);
198
+ return name;
199
+ }
200
+
201
+ // Back-compat wrapper preserved so `lib/api-client.js` can keep its
202
+ // readToken()-shaped call site without knowing about profiles. The
203
+ // active profile drives the token; absent active profile → null.
204
+ export function readToken(argv = []) {
205
+ const p = getActiveProfile(argv);
206
+ return p ? p.token : null;
207
+ }
@@ -0,0 +1,182 @@
1
+ // nativeServer.js — host-process control for the crosstalk engine.
2
+ //
3
+ // v8-native replaces the docker lifecycle. The engine main loop is
4
+ // `crosstalk daemon dispatch …`, spawned as a detached child of the
5
+ // operator's host shell. PID is recorded in crosstalk-state/crosstalk.pid
6
+ // and stdio is redirected to crosstalk-state/crosstalk.log.
7
+ //
8
+ // The daemon is an internal subcommand of the `crosstalk` CLI;
9
+ // startEngine() spawns `process.execPath bin/crosstalk.js daemon dispatch …`
10
+ // against the package's own bin script, resolved relative to this file.
11
+ //
12
+ // Trust + isolation notes:
13
+ // - User mode (default): engine inherits the operator's $HOME, so
14
+ // agent CLI OAuth tokens land in ~/.claude, ~/.codex, etc. — the
15
+ // same places the operator's interactive sessions use. Simpler and
16
+ // less surprising than the v7 container's CROSSTALK_HOME isolation.
17
+ // - System mode: engine runs as the `crosstalk` system user (per
18
+ // systemd unit), with $HOME set to /var/lib/crosstalk so agents
19
+ // write their state under the service user, not /home/*.
20
+
21
+ import { spawn } from 'child_process';
22
+ import { openSync, existsSync, writeFileSync, mkdirSync, statSync } from 'fs';
23
+ import { dirname, join, resolve } from 'path';
24
+ import { fileURLToPath } from 'url';
25
+ import { storagePaths, apiPortFor, runningPid } from './resolve.js';
26
+
27
+ const _thisDir = dirname(fileURLToPath(import.meta.url));
28
+
29
+ /**
30
+ * Resolve the path to this package's own `crosstalk` CLI entrypoint.
31
+ * The daemon is now `crosstalk daemon dispatch …`, so startEngine
32
+ * re-invokes the very script that called it — using process.execPath
33
+ * (the running Node) + this bin path.
34
+ */
35
+ export function resolveSelfBin() {
36
+ return resolve(_thisDir, '..', 'bin', 'crosstalk.js');
37
+ }
38
+
39
+ /**
40
+ * Start the crosstalk engine (`crosstalk daemon dispatch`) as a detached host process for the given transport.
41
+ * Returns { ok: true, pid } on success, { ok: false, error } otherwise.
42
+ *
43
+ * Behavior:
44
+ * - Refuses if already running (use stop/restart instead).
45
+ * - Creates the state dir if missing.
46
+ * - Opens the log file in append mode; stdin/stdout/stderr all go there.
47
+ * - Spawns detached so the parent can exit and the engine keeps running.
48
+ * - Verifies the PID is alive after a short delay (~150ms) before returning.
49
+ */
50
+ export function startEngine(name, opts = {}) {
51
+ const paths = storagePaths(name);
52
+ if (runningPid(name)) {
53
+ return { ok: false, error: `'${name}' is already running (pid ${runningPid(name)})` };
54
+ }
55
+
56
+ const selfBin = resolveSelfBin();
57
+ if (!existsSync(selfBin)) {
58
+ return {
59
+ ok: false,
60
+ error: `crosstalk CLI entry missing at ${selfBin} — package install looks broken`,
61
+ };
62
+ }
63
+
64
+ mkdirSync(paths.crosstalkState, { recursive: true });
65
+
66
+ const alias = opts.alias ?? process.env.CROSSTALK_ALIAS ?? name;
67
+ const poll = String(opts.pollSeconds ?? process.env.DISPATCH_POLL_SECONDS ?? 30);
68
+ const port = String(apiPortFor(name));
69
+
70
+ // --path: the transport lives at an arbitrary directory (e.g. a mounted
71
+ // share). Point the engine there via CROSSTALK_TRANSPORT_PATH, and pin
72
+ // state machine-local (by name) so cursor/heartbeat never land on the
73
+ // share — each machine sharing the dir keeps its own local state.
74
+ const transportDir = opts.transportPath ? resolve(opts.transportPath) : paths.transportDir;
75
+ const env = {
76
+ ...process.env,
77
+ CROSSTALK_API_PORT: port,
78
+ TRANSPORT_DIR: transportDir,
79
+ };
80
+ if (opts.transportPath) {
81
+ env.CROSSTALK_TRANSPORT_PATH = transportDir;
82
+ env.CROSSTALK_STATE_DIR = paths.crosstalkState;
83
+ }
84
+
85
+ const logFd = openSync(paths.logFile, 'a');
86
+ const child = spawn(
87
+ process.execPath,
88
+ [selfBin, 'daemon', 'dispatch', '--alias', alias, '--poll', poll, '--json'],
89
+ {
90
+ cwd: transportDir,
91
+ env,
92
+ stdio: ['ignore', logFd, logFd],
93
+ detached: true,
94
+ },
95
+ );
96
+ if (!child.pid) {
97
+ return { ok: false, error: 'spawn failed to allocate a pid' };
98
+ }
99
+ writeFileSync(paths.pidFile, String(child.pid) + '\n');
100
+ child.unref(); // let parent exit without waiting on the child
101
+
102
+ // Brief verify the child didn't die immediately (missing transport
103
+ // dir, broken tsx resolution, etc.). 150ms is enough to catch
104
+ // immediate ENOENT-class failures without making `server start`
105
+ // feel sluggish.
106
+ const start = Date.now();
107
+ while (Date.now() - start < 150) {
108
+ try { process.kill(child.pid, 0); } catch {
109
+ return { ok: false, error: 'engine exited immediately — check logs at ' + paths.logFile };
110
+ }
111
+ }
112
+ return { ok: true, pid: child.pid, logFile: paths.logFile, port: Number(port) };
113
+ }
114
+
115
+ /**
116
+ * Stop a running crosstalk engine. SIGTERM first, then SIGKILL after a grace
117
+ * period (default 5s). Returns { ok, wasRunning, killed? }.
118
+ *
119
+ * `killed: true` indicates SIGKILL was required (clean shutdown failed).
120
+ */
121
+ export function stopEngine(name, opts = {}) {
122
+ const paths = storagePaths(name);
123
+ const pid = runningPid(name);
124
+ if (!pid) {
125
+ // No running engine; mop up any stale pid file and report idempotent success.
126
+ if (existsSync(paths.pidFile)) {
127
+ try { writeFileSync(paths.pidFile, ''); } catch { /* best effort */ }
128
+ }
129
+ return { ok: true, wasRunning: false };
130
+ }
131
+
132
+ const graceMs = (opts.graceSeconds ?? 5) * 1000;
133
+ try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ }
134
+
135
+ const deadline = Date.now() + graceMs;
136
+ let killed = false;
137
+ while (Date.now() < deadline) {
138
+ try { process.kill(pid, 0); } catch {
139
+ // Process is gone — clean exit.
140
+ try { writeFileSync(paths.pidFile, ''); } catch {}
141
+ return { ok: true, wasRunning: true, pid };
142
+ }
143
+ // Tight spin — 50ms granularity is fine for a 5s window.
144
+ const until = Date.now() + 50;
145
+ while (Date.now() < until) { /* idle */ }
146
+ }
147
+ // Timed out. Hard-kill.
148
+ try { process.kill(pid, 'SIGKILL'); killed = true; } catch { /* race: gone */ }
149
+ try { writeFileSync(paths.pidFile, ''); } catch {}
150
+ return { ok: true, wasRunning: true, pid, killed };
151
+ }
152
+
153
+ /**
154
+ * Snapshot the engine's status for the given transport. Returns
155
+ * { running, pid?, port?, uptimeSeconds?, apiReachable? }.
156
+ *
157
+ * apiReachable requires an HTTP probe; pass { probeApi: true } to opt in.
158
+ */
159
+ export async function engineStatus(name, opts = {}) {
160
+ const paths = storagePaths(name);
161
+ const pid = runningPid(name);
162
+ if (!pid) return { running: false };
163
+
164
+ let uptimeSeconds;
165
+ try {
166
+ const stat = statSync(`/proc/${pid}/stat`);
167
+ uptimeSeconds = Math.round((Date.now() - stat.ctimeMs) / 1000);
168
+ } catch { /* /proc not available on macOS — leave undefined */ }
169
+
170
+ const port = apiPortFor(name);
171
+ const out = { running: true, pid, port, ...(uptimeSeconds !== undefined ? { uptimeSeconds } : {}) };
172
+
173
+ if (opts.probeApi) {
174
+ try {
175
+ const resp = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: AbortSignal.timeout(2000) });
176
+ out.apiReachable = resp.ok;
177
+ } catch {
178
+ out.apiReachable = false;
179
+ }
180
+ }
181
+ return out;
182
+ }