@cordfuse/crosstalk 7.0.0-alpha.2 → 7.0.0-alpha.4
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/README.md +77 -11
- package/commands/channel.js +61 -13
- package/commands/chat.js +94 -19
- package/commands/init.js +3 -3
- package/commands/status.js +16 -1
- package/commands/up.js +1 -1
- package/commands/version.js +2 -0
- package/lib/api-client.js +37 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,26 +1,92 @@
|
|
|
1
1
|
# @cordfuse/crosstalk — host client
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The host-side CLI for the Crosstalk protocol. Pairs with [`@cordfuse/crosstalkd`](https://www.npmjs.com/package/@cordfuse/crosstalkd) — the daemon running inside the [`crosstalk-server`](https://github.com/cordfuse/crosstalk/tree/main/server) container — and the [transport template](https://github.com/cordfuse/crosstalk/tree/main/transport) it scaffolds.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
> **What Crosstalk is.** An agent-agnostic swarm communication protocol built on git. A git repo is the message bus. Full background: **[github.com/cordfuse/crosstalk](https://github.com/cordfuse/crosstalk#why-crosstalk-exists)**.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- Operator runs `docker compose up -d` to bring up the `crosstalk-server` container (which runs `crosstalkd`)
|
|
9
|
-
- Operator runs `crosstalk <subcommand>` — the client talks to the daemon's HTTP API on `127.0.0.1:7000`
|
|
7
|
+
---
|
|
10
8
|
|
|
11
|
-
|
|
9
|
+
## Install
|
|
12
10
|
|
|
13
|
-
|
|
11
|
+
```sh
|
|
12
|
+
npm install -g @cordfuse/crosstalk
|
|
13
|
+
```
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
- All other subcommands — landing in P4–P6
|
|
15
|
+
Requires: Node.js ≥ 20 + Docker on PATH + git on PATH. Works on Linux, macOS, Windows (Docker Desktop).
|
|
17
16
|
|
|
18
|
-
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Quickstart
|
|
19
20
|
|
|
20
21
|
```sh
|
|
21
|
-
|
|
22
|
+
# 1. Scaffold a transport.
|
|
23
|
+
crosstalk init mytransport
|
|
24
|
+
cd mytransport
|
|
25
|
+
|
|
26
|
+
# 2. Bring up the engine container for this transport.
|
|
27
|
+
crosstalk up
|
|
28
|
+
# Generates docker-compose.yml in the transport root and runs
|
|
29
|
+
# `docker compose up -d` for ghcr.io/cordfuse/crosstalk-server:<latest>.
|
|
30
|
+
# Bind-mounts $PWD as the transport — any `git remote add` from the
|
|
31
|
+
# host is immediately visible to the engine.
|
|
32
|
+
|
|
33
|
+
# 3. Install an agent CLI inside the container, then authenticate.
|
|
34
|
+
crosstalk chat --shell
|
|
35
|
+
npm install -g @anthropic-ai/claude-code
|
|
36
|
+
exit
|
|
37
|
+
crosstalk chat --agent claude --login
|
|
38
|
+
# Auth URL opens in your default browser via xdg-open / open / start.
|
|
39
|
+
# Operator never has to copy-paste it. Token saved in the /root
|
|
40
|
+
# volume so subsequent dispatched workers reuse it.
|
|
41
|
+
|
|
42
|
+
# 4. Send your first message.
|
|
43
|
+
crosstalk channel general
|
|
44
|
+
crosstalk run --type primitive --to sonnet "What is the capital of France?"
|
|
45
|
+
crosstalk replies <relPath printed by step above>
|
|
22
46
|
```
|
|
23
47
|
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Subcommand surface
|
|
51
|
+
|
|
52
|
+
| Group | Subcommand | What it does |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| Meta | `crosstalk version` | Print client + engine versions (warns on skew) |
|
|
55
|
+
| Protocol | `crosstalk init <dir>` | Scaffold a transport + `git init` |
|
|
56
|
+
| Protocol | `crosstalk run --type primitive\|workflow ...` | Dispatch a primitive or workflow |
|
|
57
|
+
| Protocol | `crosstalk replies <relPath>...` | Poll reply status |
|
|
58
|
+
| Protocol | `crosstalk status` | Engine heartbeat, claimed models, channels |
|
|
59
|
+
| Protocol | `crosstalk channel <name> [--rename\|--delete]` | Channel operations |
|
|
60
|
+
| Lifecycle | `crosstalk up` | Generate docker-compose, start engine container |
|
|
61
|
+
| Lifecycle | `crosstalk down [--volumes]` | Stop the engine container |
|
|
62
|
+
| Lifecycle | `crosstalk restart` | Down + up |
|
|
63
|
+
| Lifecycle | `crosstalk pull` | Refresh the engine image |
|
|
64
|
+
| Lifecycle | `crosstalk logs [-f]` | Stream the engine container's logs |
|
|
65
|
+
| Interactive | `crosstalk chat --agent <name>` | Interactive session with an agent CLI inside the container |
|
|
66
|
+
| Interactive | `crosstalk chat --agent <name> --login` | OAuth flow with browser-open URL handling |
|
|
67
|
+
| Interactive | `crosstalk chat --shell` | Drop into bash inside the container (debug, install CLIs) |
|
|
68
|
+
|
|
69
|
+
`--agent` is required for any non-shell chat — containers can have multiple installed CLIs, and the operator must pick explicitly. The error message names which ones are actually installed (queried from the engine's `/agents/installed` endpoint).
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## What lives where
|
|
74
|
+
|
|
75
|
+
- **Host** — `crosstalk` binary on PATH, `~/path/to/mytransport/` (the operator's transport directory, bind-mounted).
|
|
76
|
+
- **Container** (`crosstalk-server`, image `ghcr.io/cordfuse/crosstalk-server`) — `crosstalkd` daemon, dispatch state in named volume `crosstalk-state:/var/lib/crosstalkd-state`, operator-installed agent CLIs + auth tokens in named volume `crosstalk-root:/root`.
|
|
77
|
+
- **Engine HTTP API** — `127.0.0.1:7000` (compose `127.0.0.1:HOST:CONTAINER` port mapping is the actual security boundary; engine binds 0.0.0.0 inside the container).
|
|
78
|
+
- **Transport bind-mount** — `$PWD:/var/lib/crosstalk-transport`. Operator git commands on the host are seen by the engine immediately.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Architecture
|
|
83
|
+
|
|
84
|
+
Mirrors `docker` (host CLI) / `dockerd` (daemon). The split keeps the protocol-layer logic in one place (the engine) and exposes a clean operator surface on the host without forcing operators to manage Node/agent CLIs/OAuth tokens on their bare host.
|
|
85
|
+
|
|
86
|
+
Client makes plain HTTP requests to the engine over loopback. No auth (same model as ollama, postgres-on-localhost, dockerd's local socket; the loopback bind is what gates exposure). Version skew detected via `X-Crosstalk-Engine-Version` response header — client warns once per process when the operator forgot to `crosstalk pull && crosstalk restart` after upgrading the client (or vice versa).
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
24
90
|
## License
|
|
25
91
|
|
|
26
92
|
MIT.
|
package/commands/channel.js
CHANGED
|
@@ -6,19 +6,17 @@
|
|
|
6
6
|
|
|
7
7
|
import { api } from '../lib/api-client.js';
|
|
8
8
|
import { reportAndExit } from '../lib/errors.js';
|
|
9
|
-
import { positionals, has } from '../lib/argv.js';
|
|
9
|
+
import { positionals, has, flag } from '../lib/argv.js';
|
|
10
10
|
|
|
11
11
|
function usage(exit = 0) {
|
|
12
12
|
const w = exit === 0 ? process.stdout : process.stderr;
|
|
13
13
|
w.write(
|
|
14
14
|
`Usage:
|
|
15
|
-
crosstalk channel
|
|
16
|
-
crosstalk channel
|
|
15
|
+
crosstalk channel list # list channels
|
|
16
|
+
crosstalk channel <name> # create a channel
|
|
17
|
+
crosstalk channel <name-or-uuid> --rename <new> # rename
|
|
18
|
+
crosstalk channel <name-or-uuid> --delete # delete (with confirmation)
|
|
17
19
|
crosstalk channel --help
|
|
18
|
-
|
|
19
|
-
Not yet implemented (engine API needs PATCH/DELETE first):
|
|
20
|
-
crosstalk channel <name-or-uuid> --rename <new>
|
|
21
|
-
crosstalk channel <name-or-uuid> --delete
|
|
22
20
|
`,
|
|
23
21
|
);
|
|
24
22
|
process.exit(exit);
|
|
@@ -27,7 +25,7 @@ Not yet implemented (engine API needs PATCH/DELETE first):
|
|
|
27
25
|
export async function run(argv) {
|
|
28
26
|
if (has(argv, '--help') || has(argv, '-h')) usage(0);
|
|
29
27
|
|
|
30
|
-
const pos = positionals(argv, []);
|
|
28
|
+
const pos = positionals(argv, ['--rename']);
|
|
31
29
|
if (pos.length === 0) usage(1);
|
|
32
30
|
|
|
33
31
|
// `crosstalk channel list` → list channels
|
|
@@ -50,16 +48,66 @@ export async function run(argv) {
|
|
|
50
48
|
return 0;
|
|
51
49
|
}
|
|
52
50
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
const handle = pos[0];
|
|
52
|
+
const renameTo = flag(argv, '--rename');
|
|
53
|
+
const wantDelete = has(argv, '--delete');
|
|
54
|
+
|
|
55
|
+
if (renameTo && wantDelete) {
|
|
56
|
+
process.stderr.write('crosstalk channel: --rename and --delete are mutually exclusive.\n');
|
|
56
57
|
return 1;
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
|
|
60
|
+
// Rename: PATCH /channels/<handle> { name: <new> }
|
|
61
|
+
if (renameTo) {
|
|
62
|
+
let r;
|
|
63
|
+
try {
|
|
64
|
+
r = await api.patch(`/channels/${encodeURIComponent(handle)}`, { name: renameTo });
|
|
65
|
+
} catch (err) {
|
|
66
|
+
reportAndExit(err, 'crosstalk channel');
|
|
67
|
+
}
|
|
68
|
+
if (r.noop) {
|
|
69
|
+
process.stdout.write(`No-op: ${handle} already named ${renameTo}.\n`);
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
process.stdout.write(`Renamed: ${handle} -> ${r.name} (${r.uuid})\n`);
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Delete: DELETE /channels/<handle>?confirm=<name>
|
|
77
|
+
// Engine returns 400 if confirm is missing/wrong, with the expected
|
|
78
|
+
// value in the error message. Client reads the channel's name first
|
|
79
|
+
// and passes it as confirm — operator's explicit --delete flag IS the
|
|
80
|
+
// confirmation for now. Interactive prompt deferred (would block on
|
|
81
|
+
// stdin in scripts).
|
|
82
|
+
if (wantDelete) {
|
|
83
|
+
let target;
|
|
84
|
+
try {
|
|
85
|
+
const all = await api.get('/channels');
|
|
86
|
+
target = all.channels.find((c) => c.uuid === handle || c.name === handle);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
reportAndExit(err, 'crosstalk channel');
|
|
89
|
+
}
|
|
90
|
+
if (!target) {
|
|
91
|
+
process.stderr.write(`crosstalk channel: '${handle}' not found.\n`);
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
const confirm = target.name ?? target.uuid;
|
|
95
|
+
let r;
|
|
96
|
+
try {
|
|
97
|
+
r = await api.delete(
|
|
98
|
+
`/channels/${encodeURIComponent(handle)}?confirm=${encodeURIComponent(confirm)}`,
|
|
99
|
+
);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
reportAndExit(err, 'crosstalk channel');
|
|
102
|
+
}
|
|
103
|
+
process.stdout.write(`Deleted channel: ${r.name ?? '(unnamed)'} (${r.uuid})\n`);
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// No flags → create a channel with the given name.
|
|
60
108
|
let r;
|
|
61
109
|
try {
|
|
62
|
-
r = await api.post('/channels', { name });
|
|
110
|
+
r = await api.post('/channels', { name: handle });
|
|
63
111
|
} catch (err) {
|
|
64
112
|
reportAndExit(err, 'crosstalk channel');
|
|
65
113
|
}
|
package/commands/chat.js
CHANGED
|
@@ -24,13 +24,63 @@
|
|
|
24
24
|
|
|
25
25
|
import { spawn, spawnSync } from 'child_process';
|
|
26
26
|
import { requireTransportRoot, containerName } from '../lib/transport.js';
|
|
27
|
+
import { api, ConnectError } from '../lib/api-client.js';
|
|
27
28
|
import { flag, has } from '../lib/argv.js';
|
|
28
29
|
|
|
29
30
|
const KNOWN_AGENTS = ['claude', 'codex', 'gemini', 'qwen', 'opencode', 'agy'];
|
|
30
|
-
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
31
|
+
|
|
32
|
+
// URL extraction that survives agent TUI rendering. Naive regex against
|
|
33
|
+
// raw stdout fails because agent CLIs (Claude in particular) draw a box
|
|
34
|
+
// around the URL with line wraps every ~N chars; the captured URL is
|
|
35
|
+
// truncated at the first wrap, with ANSI control chars leaking in at
|
|
36
|
+
// the cut point. Real example from alpha.3 macOS test: extracted URL
|
|
37
|
+
// ended with `%18%6b…` (CAN char + box-drawing artifact) instead of
|
|
38
|
+
// the `redirect_uri=…` param, breaking OAuth.
|
|
39
|
+
//
|
|
40
|
+
// Approach:
|
|
41
|
+
// 1. ANSI-strip everything (CSI / OSC / SS3 escapes).
|
|
42
|
+
// 2. Strip control chars (0x00-0x1F except LF, plus DEL).
|
|
43
|
+
// 3. Join line wraps that occur INSIDE a URL-safe character run —
|
|
44
|
+
// `<url-char>\n<url-char>` → `<url-char><url-char>`. Preserves real
|
|
45
|
+
// `<url-char>\n<non-url-char>` boundaries.
|
|
46
|
+
// 4. Match `https?://<url-chars>+` against the joined buffer.
|
|
47
|
+
// 5. Only fire when the character AFTER the matched URL is a true
|
|
48
|
+
// terminator (whitespace, end-of-buffer) — guards against acting
|
|
49
|
+
// on a partial URL still being written.
|
|
50
|
+
|
|
51
|
+
const URL_SAFE_CLASS = "a-zA-Z0-9%/?#&=+\\-._~:@!$()*,;";
|
|
52
|
+
const URL_RE = new RegExp(`https?://[${URL_SAFE_CLASS}]+`);
|
|
53
|
+
|
|
54
|
+
function extractCompleteUrl(rawBuffer) {
|
|
55
|
+
// Strip ANSI CSI escapes (\x1B[ ... terminator).
|
|
56
|
+
let clean = rawBuffer.replace(/\x1B\[[0-9;?]*[a-zA-Z]/g, '');
|
|
57
|
+
// Strip ANSI OSC escapes (\x1B] ... BEL or ST).
|
|
58
|
+
clean = clean.replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)/g, '');
|
|
59
|
+
// Strip SS3 escapes (\x1BO + one char).
|
|
60
|
+
clean = clean.replace(/\x1BO./g, '');
|
|
61
|
+
// Strip remaining single-char escapes that slipped through.
|
|
62
|
+
clean = clean.replace(/\x1B[^a-zA-Z0-9]/g, '');
|
|
63
|
+
// Strip control chars except LF (0x0A); we use LF for line-wrap rejoin.
|
|
64
|
+
clean = clean.replace(/[\x00-\x09\x0B-\x1F\x7F]/g, '');
|
|
65
|
+
// Rejoin URL-character wrap points: `<url-char>\n<url-char>` becomes
|
|
66
|
+
// `<url-char><url-char>`. Doesn't merge `<url-char>\n<space>` etc.
|
|
67
|
+
const safeRe = new RegExp(`[${URL_SAFE_CLASS}]\\n[${URL_SAFE_CLASS}]`, 'g');
|
|
68
|
+
let prev;
|
|
69
|
+
do {
|
|
70
|
+
prev = clean;
|
|
71
|
+
clean = clean.replace(safeRe, (m) => m[0] + m[2]);
|
|
72
|
+
} while (clean !== prev);
|
|
73
|
+
|
|
74
|
+
const match = clean.match(URL_RE);
|
|
75
|
+
if (!match) return null;
|
|
76
|
+
// Require a terminator after the URL — without it, the URL may still
|
|
77
|
+
// be growing in subsequent stdout chunks. Terminator = whitespace,
|
|
78
|
+
// quote/angle, or end-of-buffer-with-trailing-newline.
|
|
79
|
+
const after = clean.slice(match.index + match[0].length);
|
|
80
|
+
if (after.length === 0) return null; // EOF-as-grow case, wait
|
|
81
|
+
if (!/^[\s<>"']/.test(after)) return null; // URL still continuing
|
|
82
|
+
return match[0];
|
|
83
|
+
}
|
|
34
84
|
|
|
35
85
|
function openInBrowser(url) {
|
|
36
86
|
const cmd =
|
|
@@ -106,27 +156,29 @@ function runLogin(container, agent) {
|
|
|
106
156
|
//
|
|
107
157
|
// No agent-specific "login" subcommand — we just run the agent's
|
|
108
158
|
// default interactive entry point. First-run auth flow happens
|
|
109
|
-
// automatically for every agent we support.
|
|
110
|
-
//
|
|
159
|
+
// automatically for every agent we support.
|
|
160
|
+
//
|
|
161
|
+
// No COLUMNS=1000 env: making the agent render the URL "unwrapped" via
|
|
162
|
+
// a wide terminal claim doesn't reliably propagate to the in-container
|
|
163
|
+
// PTY, and even when it does Claude's TUI hard-wraps anyway. The
|
|
164
|
+
// extractCompleteUrl() function joins wrapped URL fragments on the
|
|
165
|
+
// host side, so the agent's rendering width is irrelevant. As bonus,
|
|
166
|
+
// dropping COLUMNS=1000 fixes daily-chat soft-wrap weirdness in
|
|
167
|
+
// long-form prose (agents see the real host terminal width).
|
|
111
168
|
return new Promise((resolve) => {
|
|
112
169
|
const child = spawn(
|
|
113
170
|
'docker',
|
|
114
|
-
[
|
|
115
|
-
'exec', '-it',
|
|
116
|
-
'--env', 'COLUMNS=1000', // fallback for browser-open failure
|
|
117
|
-
'--env', 'LINES=40',
|
|
118
|
-
container,
|
|
119
|
-
agent,
|
|
120
|
-
],
|
|
171
|
+
['exec', '-it', container, agent],
|
|
121
172
|
{ stdio: ['inherit', 'pipe', 'inherit'] },
|
|
122
173
|
);
|
|
123
174
|
let opened = false;
|
|
175
|
+
let buffer = '';
|
|
124
176
|
child.stdout.on('data', (chunk) => {
|
|
125
177
|
process.stdout.write(chunk);
|
|
126
178
|
if (opened) return;
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
179
|
+
buffer += chunk.toString();
|
|
180
|
+
const url = extractCompleteUrl(buffer);
|
|
181
|
+
if (!url) return;
|
|
130
182
|
opened = true;
|
|
131
183
|
const ok = openInBrowser(url);
|
|
132
184
|
process.stdout.write(
|
|
@@ -152,10 +204,33 @@ export async function run(argv) {
|
|
|
152
204
|
|
|
153
205
|
const agent = flag(argv, '--agent');
|
|
154
206
|
if (!agent) {
|
|
207
|
+
// Query the engine for what's actually installed (vs the theoretical
|
|
208
|
+
// supported set). Gives a smarter error than "supported: claude,
|
|
209
|
+
// codex, gemini, qwen, opencode, agy" when only 1-2 are actually
|
|
210
|
+
// present in the container.
|
|
211
|
+
let installed = null;
|
|
212
|
+
try {
|
|
213
|
+
const r = await api.get('/agents/installed');
|
|
214
|
+
installed = r.installed;
|
|
215
|
+
} catch (err) {
|
|
216
|
+
if (!(err instanceof ConnectError)) {
|
|
217
|
+
// engine unreachable — fall through with no introspection
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (installed && installed.length === 0) {
|
|
221
|
+
process.stderr.write(
|
|
222
|
+
`crosstalk chat: no agent CLIs installed in this container.\n` +
|
|
223
|
+
` Install one first:\n` +
|
|
224
|
+
` crosstalk chat --shell\n` +
|
|
225
|
+
` npm install -g @anthropic-ai/claude-code # or your agent's installer\n`,
|
|
226
|
+
);
|
|
227
|
+
return 1;
|
|
228
|
+
}
|
|
229
|
+
const list = installed && installed.length > 0
|
|
230
|
+
? `Installed in this container: ${installed.join(', ')}.`
|
|
231
|
+
: `Supported agents: ${KNOWN_AGENTS.join(', ')}.`;
|
|
155
232
|
process.stderr.write(
|
|
156
|
-
`crosstalk chat: --agent <name> is required
|
|
157
|
-
`multiple agent CLIs installed; pick one explicitly).\n` +
|
|
158
|
-
`Supported: ${KNOWN_AGENTS.join(', ')}\n`,
|
|
233
|
+
`crosstalk chat: --agent <name> is required.\n ${list}\n`,
|
|
159
234
|
);
|
|
160
235
|
return 1;
|
|
161
236
|
}
|
package/commands/init.js
CHANGED
|
@@ -21,7 +21,7 @@ import { spawnSync } from 'child_process';
|
|
|
21
21
|
import { has, positionals } from '../lib/argv.js';
|
|
22
22
|
|
|
23
23
|
const DEFAULT_IMAGE = process.env.CROSSTALK_IMAGE
|
|
24
|
-
?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.
|
|
24
|
+
?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.4';
|
|
25
25
|
|
|
26
26
|
function usage(exit = 0) {
|
|
27
27
|
const w = exit === 0 ? process.stdout : process.stderr;
|
|
@@ -34,9 +34,9 @@ function usage(exit = 0) {
|
|
|
34
34
|
|
|
35
35
|
The image must be pullable (or already present locally). For local dev
|
|
36
36
|
before the GHCR publish pipeline ships, build the image first:
|
|
37
|
-
docker build -t crosstalk-server:7.0.0-alpha.
|
|
37
|
+
docker build -t crosstalk-server:7.0.0-alpha.4 -f server/Dockerfile .
|
|
38
38
|
Then run:
|
|
39
|
-
CROSSTALK_IMAGE=crosstalk-server:7.0.0-alpha.
|
|
39
|
+
CROSSTALK_IMAGE=crosstalk-server:7.0.0-alpha.4 crosstalk init mytransport
|
|
40
40
|
`,
|
|
41
41
|
);
|
|
42
42
|
process.exit(exit);
|
package/commands/status.js
CHANGED
|
@@ -18,7 +18,22 @@ export async function run(_argv) {
|
|
|
18
18
|
|
|
19
19
|
process.stdout.write(`Claimed models: ${s.claimed_models.length}\n`);
|
|
20
20
|
if (s.claimed_models.length === 0) {
|
|
21
|
-
|
|
21
|
+
// Two failure shapes look the same in claimed_models=0: (a) no CLIs
|
|
22
|
+
// installed at all, (b) CLIs installed but data/models.yaml has no
|
|
23
|
+
// entries referencing them. Ask /agents/installed to distinguish so
|
|
24
|
+
// the operator gets a directly-actionable hint.
|
|
25
|
+
let installed = null;
|
|
26
|
+
try {
|
|
27
|
+
const r = await api.get('/agents/installed');
|
|
28
|
+
installed = r.installed;
|
|
29
|
+
} catch { /* ignore — fall back to generic message */ }
|
|
30
|
+
if (installed && installed.length > 0) {
|
|
31
|
+
process.stdout.write(
|
|
32
|
+
` (${installed.join(', ')} installed in the container but no data/models.yaml entry references them — add one to claim)\n`,
|
|
33
|
+
);
|
|
34
|
+
} else {
|
|
35
|
+
process.stdout.write(' (no model CLIs found inside the container — install one via `crosstalk chat --shell` and add an entry to data/models.yaml)\n');
|
|
36
|
+
}
|
|
22
37
|
} else {
|
|
23
38
|
for (const m of s.claimed_models) process.stdout.write(` - ${m}\n`);
|
|
24
39
|
}
|
package/commands/up.js
CHANGED
|
@@ -16,7 +16,7 @@ import { requireTransportRoot, transportName, composeFile } from '../lib/transpo
|
|
|
16
16
|
import { has } from '../lib/argv.js';
|
|
17
17
|
|
|
18
18
|
const DEFAULT_IMAGE = process.env.CROSSTALK_IMAGE
|
|
19
|
-
?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.
|
|
19
|
+
?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.4';
|
|
20
20
|
const DEFAULT_API_PORT = Number(process.env.CROSSTALK_API_PORT) || 7000;
|
|
21
21
|
|
|
22
22
|
function usage(exit = 0) {
|
package/commands/version.js
CHANGED
|
@@ -16,6 +16,8 @@ const clientPkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
|
16
16
|
export async function run(_argv) {
|
|
17
17
|
process.stdout.write(`crosstalk client: ${clientPkg.version}\n`);
|
|
18
18
|
try {
|
|
19
|
+
// Skew detection happens passively in api-client.js via the
|
|
20
|
+
// X-Crosstalk-Engine-Version response header — no duplication here.
|
|
19
21
|
const v = await api.get('/version');
|
|
20
22
|
process.stdout.write(`crosstalkd engine: ${v.version} (alias: ${v.alias})\n`);
|
|
21
23
|
} catch (err) {
|
package/lib/api-client.js
CHANGED
|
@@ -11,6 +11,38 @@
|
|
|
11
11
|
//
|
|
12
12
|
// No auth: the engine binds 127.0.0.1 only and there's no token. Same
|
|
13
13
|
// model as ollama / postgres-on-localhost.
|
|
14
|
+
//
|
|
15
|
+
// Version skew: engine sets X-Crosstalk-Engine-Version on every
|
|
16
|
+
// response. We compare it to the client's package.json version on each
|
|
17
|
+
// call and emit one warning per process if they don't match. Pre-1.0
|
|
18
|
+
// client and engine ship in lockstep; mismatches usually mean the
|
|
19
|
+
// operator upgraded one without the other.
|
|
20
|
+
|
|
21
|
+
import { readFileSync } from 'fs';
|
|
22
|
+
import { dirname, join } from 'path';
|
|
23
|
+
import { fileURLToPath } from 'url';
|
|
24
|
+
|
|
25
|
+
const CLIENT_VERSION = (() => {
|
|
26
|
+
try {
|
|
27
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
return JSON.parse(readFileSync(join(thisDir, '..', 'package.json'), 'utf-8')).version;
|
|
29
|
+
} catch {
|
|
30
|
+
return 'unknown';
|
|
31
|
+
}
|
|
32
|
+
})();
|
|
33
|
+
|
|
34
|
+
let skewWarned = false;
|
|
35
|
+
|
|
36
|
+
function checkSkew(engineVersion) {
|
|
37
|
+
if (skewWarned || !engineVersion || engineVersion === CLIENT_VERSION) return;
|
|
38
|
+
skewWarned = true;
|
|
39
|
+
process.stderr.write(
|
|
40
|
+
`[WARN] Version skew: client ${CLIENT_VERSION}, engine ${engineVersion}.\n` +
|
|
41
|
+
` Pre-1.0 client and engine ship in lockstep. Likely fix:\n` +
|
|
42
|
+
` crosstalk pull && crosstalk restart # if engine is behind\n` +
|
|
43
|
+
` npm install -g @cordfuse/crosstalk # if client is behind\n\n`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
14
46
|
|
|
15
47
|
export const DEFAULT_API_PORT = 7000;
|
|
16
48
|
|
|
@@ -71,10 +103,13 @@ async function call(method, path, body, opts = {}) {
|
|
|
71
103
|
} catch (err) {
|
|
72
104
|
throw new ConnectError(port, err.message || String(err));
|
|
73
105
|
}
|
|
106
|
+
checkSkew(res.headers.get('x-crosstalk-engine-version'));
|
|
74
107
|
return parseJsonOrThrow(res);
|
|
75
108
|
}
|
|
76
109
|
|
|
77
110
|
export const api = {
|
|
78
|
-
get:
|
|
79
|
-
post:
|
|
111
|
+
get: (path, opts) => call('GET', path, undefined, opts),
|
|
112
|
+
post: (path, body, opts) => call('POST', path, body, opts),
|
|
113
|
+
patch: (path, body, opts) => call('PATCH', path, body, opts),
|
|
114
|
+
delete: (path, opts) => call('DELETE', path, undefined, opts),
|
|
80
115
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cordfuse/crosstalk",
|
|
3
|
-
"version": "7.0.0-alpha.
|
|
3
|
+
"version": "7.0.0-alpha.4",
|
|
4
4
|
"description": "Crosstalk client — host-side CLI that talks to the crosstalkd daemon running inside the crosstalk-server container.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|