@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.
- package/GUIDE-CLI.md +322 -0
- package/GUIDE-PROMPTS.md +118 -0
- package/LICENSE +21 -0
- package/README.md +402 -61
- package/bin/crosstalk.js +88 -54
- package/commands/agent.js +69 -0
- package/commands/auth.js +273 -0
- package/commands/channel.js +54 -44
- package/commands/chat.js +107 -71
- package/commands/daemon.js +120 -0
- package/commands/logs.js +108 -19
- package/commands/message.js +125 -0
- package/commands/server.js +153 -0
- package/commands/settings.js +49 -0
- package/commands/status.js +37 -13
- package/commands/token.js +136 -0
- package/commands/transport.js +270 -0
- package/commands/version.js +3 -3
- package/commands/workflow.js +234 -0
- package/deploy/crosstalk@.service +62 -0
- package/deploy/install.sh +82 -0
- package/lib/api-client.js +77 -22
- package/lib/credentials.js +207 -0
- package/lib/nativeServer.js +173 -0
- package/lib/resolve.js +101 -34
- package/package.json +27 -4
- package/src/activation.ts +104 -0
- package/src/api.ts +1716 -0
- package/src/auth/enforce.ts +68 -0
- package/src/auth/handlers.ts +266 -0
- package/src/auth/middleware.ts +132 -0
- package/src/auth/setup.ts +263 -0
- package/src/auth/tokens.ts +285 -0
- package/src/auth/users.ts +267 -0
- package/src/dispatch.ts +492 -0
- package/src/dispatchers.ts +91 -0
- package/src/filenames.ts +28 -0
- package/src/frontmatter.ts +26 -0
- package/src/init.ts +116 -0
- package/src/invoke.ts +201 -0
- package/src/log-buffer.ts +67 -0
- package/src/models.ts +283 -0
- package/src/resolve.ts +100 -0
- package/src/state.ts +190 -0
- package/src/stop.ts +37 -0
- package/src/transport.ts +243 -0
- package/src/web/auth-pages.ts +160 -0
- package/src/web/channels.ts +395 -0
- package/src/web/chat-page.ts +636 -0
- package/src/web/chat-pty.ts +254 -0
- package/src/web/dashboard.ts +129 -0
- package/src/web/layout.ts +237 -0
- package/src/web/stubs.ts +510 -0
- package/src/web/workflows.ts +490 -0
- package/src/workflow.ts +470 -0
- package/template/CLAUDE.md +10 -0
- package/template/CROSSTALK-VERSION +1 -0
- package/template/CROSSTALK.md +262 -0
- package/template/PROTOCOL.md +70 -0
- package/template/README.md +64 -0
- package/template/auth/.gitkeep +0 -0
- package/template/auth/README.md +224 -0
- package/template/data/crosstalk.yaml +196 -0
- package/template/gitignore +4 -0
- package/commands/down.js +0 -40
- package/commands/init.js +0 -243
- package/commands/pull.js +0 -22
- package/commands/replies.js +0 -40
- package/commands/restart.js +0 -29
- package/commands/rm.js +0 -109
- package/commands/run.js +0 -122
- 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
|
-
|
|
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(`
|
|
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
|
-
|
|
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
|
|
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,
|
|
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:
|
|
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
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
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
|
|
143
|
-
//
|
|
144
|
-
//
|
|
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
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (
|
|
152
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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-
|
|
4
|
-
"description": "Crosstalk
|
|
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
|
-
"
|
|
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
|
},
|