@cordfuse/crosstalk 7.0.0-alpha.8 → 7.0.0-beta.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 +402 -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 +153 -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 +270 -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 +173 -0
  25. package/lib/resolve.js +101 -34
  26. package/package.json +27 -4
  27. package/src/activation.ts +104 -0
  28. package/src/api.ts +1716 -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 +492 -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 +283 -0
  43. package/src/resolve.ts +100 -0
  44. package/src/state.ts +190 -0
  45. package/src/stop.ts +37 -0
  46. package/src/transport.ts +243 -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 +129 -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 -122
  72. package/commands/up.js +0 -135
@@ -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,173 @@
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
+ const env = {
71
+ ...process.env,
72
+ CROSSTALK_API_PORT: port,
73
+ TRANSPORT_DIR: paths.transportDir,
74
+ };
75
+
76
+ const logFd = openSync(paths.logFile, 'a');
77
+ const child = spawn(
78
+ process.execPath,
79
+ [selfBin, 'daemon', 'dispatch', '--alias', alias, '--poll', poll, '--json'],
80
+ {
81
+ cwd: paths.transportDir,
82
+ env,
83
+ stdio: ['ignore', logFd, logFd],
84
+ detached: true,
85
+ },
86
+ );
87
+ if (!child.pid) {
88
+ return { ok: false, error: 'spawn failed to allocate a pid' };
89
+ }
90
+ writeFileSync(paths.pidFile, String(child.pid) + '\n');
91
+ child.unref(); // let parent exit without waiting on the child
92
+
93
+ // Brief verify the child didn't die immediately (missing transport
94
+ // dir, broken tsx resolution, etc.). 150ms is enough to catch
95
+ // immediate ENOENT-class failures without making `server start`
96
+ // feel sluggish.
97
+ const start = Date.now();
98
+ while (Date.now() - start < 150) {
99
+ try { process.kill(child.pid, 0); } catch {
100
+ return { ok: false, error: 'engine exited immediately — check logs at ' + paths.logFile };
101
+ }
102
+ }
103
+ return { ok: true, pid: child.pid, logFile: paths.logFile, port: Number(port) };
104
+ }
105
+
106
+ /**
107
+ * Stop a running crosstalk engine. SIGTERM first, then SIGKILL after a grace
108
+ * period (default 5s). Returns { ok, wasRunning, killed? }.
109
+ *
110
+ * `killed: true` indicates SIGKILL was required (clean shutdown failed).
111
+ */
112
+ export function stopEngine(name, opts = {}) {
113
+ const paths = storagePaths(name);
114
+ const pid = runningPid(name);
115
+ if (!pid) {
116
+ // No running engine; mop up any stale pid file and report idempotent success.
117
+ if (existsSync(paths.pidFile)) {
118
+ try { writeFileSync(paths.pidFile, ''); } catch { /* best effort */ }
119
+ }
120
+ return { ok: true, wasRunning: false };
121
+ }
122
+
123
+ const graceMs = (opts.graceSeconds ?? 5) * 1000;
124
+ try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ }
125
+
126
+ const deadline = Date.now() + graceMs;
127
+ let killed = false;
128
+ while (Date.now() < deadline) {
129
+ try { process.kill(pid, 0); } catch {
130
+ // Process is gone — clean exit.
131
+ try { writeFileSync(paths.pidFile, ''); } catch {}
132
+ return { ok: true, wasRunning: true, pid };
133
+ }
134
+ // Tight spin — 50ms granularity is fine for a 5s window.
135
+ const until = Date.now() + 50;
136
+ while (Date.now() < until) { /* idle */ }
137
+ }
138
+ // Timed out. Hard-kill.
139
+ try { process.kill(pid, 'SIGKILL'); killed = true; } catch { /* race: gone */ }
140
+ try { writeFileSync(paths.pidFile, ''); } catch {}
141
+ return { ok: true, wasRunning: true, pid, killed };
142
+ }
143
+
144
+ /**
145
+ * Snapshot the engine's status for the given transport. Returns
146
+ * { running, pid?, port?, uptimeSeconds?, apiReachable? }.
147
+ *
148
+ * apiReachable requires an HTTP probe; pass { probeApi: true } to opt in.
149
+ */
150
+ export async function engineStatus(name, opts = {}) {
151
+ const paths = storagePaths(name);
152
+ const pid = runningPid(name);
153
+ if (!pid) return { running: false };
154
+
155
+ let uptimeSeconds;
156
+ try {
157
+ const stat = statSync(`/proc/${pid}/stat`);
158
+ uptimeSeconds = Math.round((Date.now() - stat.ctimeMs) / 1000);
159
+ } catch { /* /proc not available on macOS — leave undefined */ }
160
+
161
+ const port = apiPortFor(name);
162
+ const out = { running: true, pid, port, ...(uptimeSeconds !== undefined ? { uptimeSeconds } : {}) };
163
+
164
+ if (opts.probeApi) {
165
+ try {
166
+ const resp = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: AbortSignal.timeout(2000) });
167
+ out.apiReachable = resp.ok;
168
+ } catch {
169
+ out.apiReachable = false;
170
+ }
171
+ }
172
+ return out;
173
+ }
package/lib/resolve.js CHANGED
@@ -10,14 +10,22 @@
10
10
  // identity from the operator's filesystem position). The new model is
11
11
  // position-independent — operator can be anywhere on disk.
12
12
 
13
- import { existsSync, readFileSync, mkdirSync, readdirSync } from 'fs';
13
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
14
14
  import { spawnSync } from 'child_process';
15
- import { join } from 'path';
15
+ import { dirname, join } from 'path';
16
+ import { fileURLToPath } from 'url';
16
17
  import { homedir } from 'os';
17
18
 
18
19
  export const DEFAULT_CONTAINER_NAME = 'crosstalk';
19
20
  export const DEFAULT_API_PORT = 7000;
20
- export const CROSSTALK_LABEL = 'crosstalk.transport=true';
21
+
22
+ // Client version exported for the skew-warn check in api-client.js. The
23
+ // v7 DEFAULT_IMAGE export (resolved CROSSTALK_IMAGE env or
24
+ // ghcr.io/cordfuse/crosstalk-server:<CLIENT_VERSION>) was removed in
25
+ // v8-native — no container image to pull.
26
+ const _thisDir = dirname(fileURLToPath(import.meta.url));
27
+ const _pkg = JSON.parse(readFileSync(join(_thisDir, '..', 'package.json'), 'utf-8'));
28
+ export const CLIENT_VERSION = _pkg.version;
21
29
 
22
30
  // Per-OS storage bases. User-mode (default) keeps everything under the
23
31
  // operator's home — no sudo/UAC, no Docker Desktop file-sharing allowlist
@@ -42,36 +50,83 @@ export function resolveBase(mode) {
42
50
  };
43
51
 
44
52
  if (mode !== 'user' && mode !== 'system') {
45
- throw new Error(`CROSSTALK_STORAGE_MODE='${mode}' invalid — must be 'user' or 'system'.`);
53
+ throw new Error(`storage mode '${mode}' invalid — must be 'user' or 'system' (see CROSSTALK_USER_MODE).`);
46
54
  }
47
55
  return bases[mode];
48
56
  }
49
57
 
58
+ /**
59
+ * Resolve the runtime mode: 'user' or 'system'.
60
+ *
61
+ * v8-native env-var convention: `CROSSTALK_USER_MODE=1` opts into user
62
+ * mode. Without it, the default is 'system' (matches @cordfuse/llmux's
63
+ * `LLMUX_USER_MODE` pattern — boolean opt-in to user mode, system is
64
+ * the production default).
65
+ *
66
+ * v7 used `CROSSTALK_STORAGE_MODE=user|system` and defaulted to 'user'.
67
+ * That string form is still honored for one transition release so
68
+ * operator scripts don't break instantly — a one-time stderr deprecation
69
+ * notice fires when it's read. v9 cuts the back-compat path.
70
+ *
71
+ * Truthy values for CROSSTALK_USER_MODE: '1', 'true', 'yes' (case
72
+ * insensitive). Anything else (including '0', 'false', empty) → false.
73
+ */
50
74
  export function storageMode() {
51
- return (process.env.CROSSTALK_STORAGE_MODE ?? 'user').toLowerCase();
75
+ // v7 back-compat path (one transition release).
76
+ const legacy = process.env.CROSSTALK_STORAGE_MODE;
77
+ if (legacy !== undefined) {
78
+ const v = legacy.toLowerCase();
79
+ if (v === 'user' || v === 'system') {
80
+ if (!process.env._CROSSTALK_LEGACY_MODE_WARNED) {
81
+ process.stderr.write(
82
+ `crosstalk: CROSSTALK_STORAGE_MODE='${v}' is deprecated.\n` +
83
+ ` Use CROSSTALK_USER_MODE=1 to opt into user mode; otherwise system mode is the default.\n`,
84
+ );
85
+ process.env._CROSSTALK_LEGACY_MODE_WARNED = '1';
86
+ }
87
+ return v;
88
+ }
89
+ throw new Error(`CROSSTALK_STORAGE_MODE='${legacy}' invalid — must be 'user' or 'system'.`);
90
+ }
91
+ // v8 path: boolean opt-in to user mode, default system.
92
+ const userVar = (process.env.CROSSTALK_USER_MODE ?? '').toLowerCase();
93
+ const userMode = userVar === '1' || userVar === 'true' || userVar === 'yes';
94
+ return userMode ? 'user' : 'system';
52
95
  }
53
96
 
54
- // All storage paths for a given container name, derived from the resolved
97
+ // All storage paths for a given transport name, derived from the resolved
55
98
  // base. None of these are validated for existence here — callers check
56
- // based on context (init pre-creates, up verifies, etc).
99
+ // based on context (init pre-creates, server start verifies, etc).
100
+ //
101
+ // v8-native: `composeFile` retained for transitional reads of legacy
102
+ // state directories (compose-mode upgrades), but no new compose files
103
+ // are generated. `pidFile` and `logFile` are written by the host-spawned
104
+ // engine (now `crosstalk daemon dispatch`) so `crosstalk server status`
105
+ // and `crosstalk server stop` have a single source of truth without
106
+ // querying docker.
57
107
  export function storagePaths(name, mode = storageMode()) {
58
108
  const base = resolveBase(mode);
59
109
  const root = join(base, name);
110
+ const state = join(root, 'crosstalk-state');
60
111
  return {
61
112
  base,
62
113
  mode,
63
114
  storageRoot: root,
64
115
  transportDir: join(root, 'transport'),
65
116
  crosstalkRoot: join(root, 'crosstalk-root'),
66
- crosstalkState: join(root, 'crosstalk-state'),
67
- composeFile: join(root, 'docker-compose.yml'),
117
+ crosstalkState: state,
118
+ composeFile: join(root, 'docker-compose.yml'), // legacy; not generated by v8
68
119
  portFile: join(root, 'api-port'),
120
+ pidFile: join(state, 'crosstalk.pid'),
121
+ logFile: join(state, 'crosstalk.log'),
69
122
  };
70
123
  }
71
124
 
72
- // Container name from --containername flag, defaulting to `crosstalk`.
73
- // Validates the name is a sane Docker container name + filesystem dir
74
- // name: lowercase alphanumeric, `_.-`, no leading dot/hyphen.
125
+ // Transport name from --containername (preferred: --name in v8) flag,
126
+ // defaulting to `crosstalk`. The flag is still --containername for now
127
+ // to keep operator muscle memory + scripted invocations working through
128
+ // the v7→v8 transition. Validates the name is sane filesystem dir name:
129
+ // lowercase alphanumeric, `_.-`, no leading dot/hyphen.
75
130
  const NAME_RE = /^[a-z0-9][a-z0-9_.-]*$/;
76
131
  export function parseContainerName(argv) {
77
132
  let name = DEFAULT_CONTAINER_NAME;
@@ -139,30 +194,42 @@ export function isInitialized(name) {
139
194
  return existsSync(join(paths.transportDir, '.git'));
140
195
  }
141
196
 
142
- // True if a running container exists for this name. Uses Docker as the
143
- // source of truth, filtered by our label to avoid confusing
144
- // operator-named-`crosstalk` containers from other workloads.
197
+ // True if the crosstalk engine is running for this transport. Source of truth:
198
+ // the PID file in crosstalk-state/. PID is verified alive with kill(0)
199
+ // (no signal sent, just an EPERM/ESRCH probe). Stale PID files (e.g.,
200
+ // after an unclean shutdown) are treated as not-running and the file is
201
+ // best-effort removed.
202
+ //
203
+ // v8-native: replaces the docker-ps query. The legacy isRunning that
204
+ // shelled out to docker for v7 lifecycle has been removed; if you find
205
+ // a caller that still expects container semantics, it's a leftover from
206
+ // the docker era and should be migrated to PID semantics.
145
207
  export function isRunning(name) {
146
- const r = spawnSync(
147
- 'docker',
148
- ['ps', '--filter', `label=${CROSSTALK_LABEL}`, '--filter', `name=^${name}$`, '--format', '{{.Names}}'],
149
- { encoding: 'utf-8' },
150
- );
151
- if (r.status !== 0) return false;
152
- return r.stdout.trim().split('\n').includes(name);
208
+ const paths = storagePaths(name);
209
+ if (!existsSync(paths.pidFile)) return false;
210
+ let pid;
211
+ try { pid = Number(readFileSync(paths.pidFile, 'utf-8').trim()); }
212
+ catch { return false; }
213
+ if (!Number.isInteger(pid) || pid <= 0) return false;
214
+ try {
215
+ process.kill(pid, 0); // no-op signal; throws if not alive / not ours
216
+ return true;
217
+ } catch {
218
+ // Stale PID file — sweep it so subsequent start doesn't get confused.
219
+ try { writeFileSync(paths.pidFile, ''); } catch { /* best effort */ }
220
+ return false;
221
+ }
153
222
  }
154
223
 
155
- // True if a stopped container exists (less common path; usually `down`
156
- // removes the container). Checked separately so `up` can decide whether
157
- // to `docker rm` and recreate.
158
- export function containerExists(name) {
159
- const r = spawnSync(
160
- 'docker',
161
- ['ps', '-a', '--filter', `label=${CROSSTALK_LABEL}`, '--filter', `name=^${name}$`, '--format', '{{.Names}}'],
162
- { encoding: 'utf-8' },
163
- );
164
- if (r.status !== 0) return false;
165
- return r.stdout.trim().split('\n').includes(name);
224
+ /** Read the recorded PID for a running transport, or undefined. */
225
+ export function runningPid(name) {
226
+ const paths = storagePaths(name);
227
+ if (!existsSync(paths.pidFile)) return undefined;
228
+ let pid;
229
+ try { pid = Number(readFileSync(paths.pidFile, 'utf-8').trim()); }
230
+ catch { return undefined; }
231
+ if (!Number.isInteger(pid) || pid <= 0) return undefined;
232
+ try { process.kill(pid, 0); return pid; } catch { return undefined; }
166
233
  }
167
234
 
168
235
  // Look up the resolved name+paths from argv, validate that init has run,
@@ -183,7 +250,7 @@ export function requireInitialized(argv) {
183
250
  if (!isInitialized(name)) {
184
251
  process.stderr.write(
185
252
  `crosstalk: no transport '${name}' on this machine.\n` +
186
- ` Run 'crosstalk init${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}' to set one up.\n`,
253
+ ` Run 'crosstalk transport init${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}' to set one up.\n`,
187
254
  );
188
255
  process.exit(2);
189
256
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cordfuse/crosstalk",
3
- "version": "7.0.0-alpha.8",
4
- "description": "Crosstalk client host-side CLI that talks to the crosstalkd daemon running inside the crosstalk-server container.",
3
+ "version": "7.0.0-beta.1",
4
+ "description": "Crosstalk — agent-agnostic swarm communication protocol built on git. Operator CLI + daemon in one binary.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/cordfuse/crosstalk",
@@ -14,10 +14,33 @@
14
14
  },
15
15
  "files": [
16
16
  "bin/",
17
- "lib/",
18
17
  "commands/",
19
- "README.md"
18
+ "lib/",
19
+ "src/",
20
+ "template/",
21
+ "deploy/",
22
+ "README.md",
23
+ "GUIDE-CLI.md",
24
+ "GUIDE-PROMPTS.md"
20
25
  ],
26
+ "scripts": {
27
+ "build": "tsc --noEmit",
28
+ "lint": "tsc --noEmit",
29
+ "test": "bun test",
30
+ "prepack": "cp -r transport template",
31
+ "postpack": "rm -rf template"
32
+ },
33
+ "dependencies": {
34
+ "@types/ws": "^8.18.1",
35
+ "node-pty": "^1.1.0",
36
+ "tsx": "^4.20.0",
37
+ "ws": "^8.21.0",
38
+ "yaml": "^2.8.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^22.10.0",
42
+ "typescript": "^5.7.0"
43
+ },
21
44
  "engines": {
22
45
  "node": ">=20"
23
46
  },