@cordfuse/crosstalk 7.0.0-alpha.1 → 7.0.0-alpha.3

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 CHANGED
@@ -1,26 +1,92 @@
1
1
  # @cordfuse/crosstalk — host client
2
2
 
3
- > **README is a stub.** Full operator-facing docs land in the docs-consolidation pass. The body below is intentionally short; it covers what's actually implemented today.
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
- The host-side CLI for the Crosstalk protocol. Pairs with `@cordfuse/crosstalkd`, the daemon that runs inside the `crosstalk-server` container.
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
- - Operator installs this package: `npm install -g @cordfuse/crosstalk`
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
- This split mirrors `docker` (host CLI) / `dockerd` (daemon). The client never reads or writes transport files directly — all protocol operations go through the API.
9
+ ## Install
12
10
 
13
- ## Status (v7.0.0-dev.0)
11
+ ```sh
12
+ npm install -g @cordfuse/crosstalk
13
+ ```
14
14
 
15
- - `crosstalk version` works
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
- ## Install (post-publish; today this package is unpublished)
17
+ ---
18
+
19
+ ## Quickstart
19
20
 
20
21
  ```sh
21
- npm install -g @cordfuse/crosstalk
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.
@@ -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 <name> # create a channel
16
- crosstalk channel list # list channels
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
- // Otherwise: create a channel with the given name.
54
- if (has(argv, '--rename') || has(argv, '--delete')) {
55
- process.stderr.write('crosstalk channel: --rename and --delete are not implemented yet (engine API needs PATCH/DELETE).\n');
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
- const name = pos[0];
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
@@ -1,10 +1,13 @@
1
1
  // crosstalk chat — the single interactive entry point into the container.
2
2
  //
3
3
  // Modes:
4
- // crosstalk chat # default agent (claude), interactive
5
- // crosstalk chat --agent <name> # interactive with a specific agent
6
- // crosstalk chat --login [--agent X] # OAuth setup flow with browser-open
7
- // crosstalk chat --shell # bash escape hatch (sysadmin / debug)
4
+ // crosstalk chat --agent <name> # interactive with a specific agent
5
+ // crosstalk chat --agent <name> --login # OAuth setup flow with browser-open
6
+ // crosstalk chat --shell # bash escape hatch (sysadmin / debug)
7
+ //
8
+ // --agent is required for non-shell modes. There's no implicit default
9
+ // (containers may have multiple agent CLIs installed; the operator
10
+ // must say which one to invoke).
8
11
  //
9
12
  // Login flow design (root-cause fix, not a bandaid):
10
13
  // The container has no DISPLAY / no browser / no xdg-open. So the agent
@@ -21,6 +24,7 @@
21
24
 
22
25
  import { spawn, spawnSync } from 'child_process';
23
26
  import { requireTransportRoot, containerName } from '../lib/transport.js';
27
+ import { api, ConnectError } from '../lib/api-client.js';
24
28
  import { flag, has } from '../lib/argv.js';
25
29
 
26
30
  const KNOWN_AGENTS = ['claude', 'codex', 'gemini', 'qwen', 'opencode', 'agy'];
@@ -46,29 +50,33 @@ function openInBrowser(url) {
46
50
  function usage(exit = 0) {
47
51
  const w = exit === 0 ? process.stdout : process.stderr;
48
52
  w.write(
49
- `Usage: crosstalk chat [--agent <name>] [--login] [--shell]
53
+ `Usage: crosstalk chat --agent <name> [--login]
54
+ crosstalk chat --shell
50
55
 
51
56
  Opens an interactive session inside the engine container — the single
52
57
  PTY-wrapped entry point for everything you'd do inside the container
53
58
  (daily chat, OAuth setup, sysadmin).
54
59
 
60
+ --agent is REQUIRED for any chat mode. Containers can have multiple
61
+ agent CLIs installed; the operator must say which one to invoke.
62
+
63
+ Supported agents: ${KNOWN_AGENTS.join(', ')}.
64
+
55
65
  Modes:
56
- (default) Interactive with the default agent (claude)
57
- --agent <name> Use a specific agent. Supported:
58
- ${KNOWN_AGENTS.join(', ')}
59
- --login First-time auth flow. Intercepts the OAuth URL on
60
- the agent's stdout and opens it in your default
61
- browser via the OS-native launcher. Falls back to
62
- printing the URL unwrapped if no browser is
63
- reachable (headless / SSH session).
66
+ --agent <name> Interactive with the named agent.
67
+ --agent <name> --login First-time auth flow. Intercepts the OAuth
68
+ URL on the agent's stdout and opens it in your
69
+ default browser via the OS-native launcher.
70
+ Falls back to printing the URL unwrapped if no
71
+ browser is reachable (headless / SSH session).
64
72
  --shell Drop into bash (install agent CLIs, debug,
65
- inspect state).
73
+ inspect state). No --agent needed.
66
74
 
67
75
  Examples:
68
- crosstalk chat # interactive claude
76
+ crosstalk chat --agent claude # interactive claude
69
77
  crosstalk chat --agent gemini # interactive gemini
70
- crosstalk chat --login # OAuth, claude
71
- crosstalk chat --login --agent codex # OAuth, codex
78
+ crosstalk chat --agent claude --login # OAuth, claude
79
+ crosstalk chat --agent codex --login # OAuth, codex
72
80
  crosstalk chat --shell # bash
73
81
  `,
74
82
  );
@@ -143,7 +151,38 @@ export async function run(argv) {
143
151
  return runInteractive(name, 'bash');
144
152
  }
145
153
 
146
- const agent = flag(argv, '--agent') ?? 'claude';
154
+ const agent = flag(argv, '--agent');
155
+ if (!agent) {
156
+ // Query the engine for what's actually installed (vs the theoretical
157
+ // supported set). Gives a smarter error than "supported: claude,
158
+ // codex, gemini, qwen, opencode, agy" when only 1-2 are actually
159
+ // present in the container.
160
+ let installed = null;
161
+ try {
162
+ const r = await api.get('/agents/installed');
163
+ installed = r.installed;
164
+ } catch (err) {
165
+ if (!(err instanceof ConnectError)) {
166
+ // engine unreachable — fall through with no introspection
167
+ }
168
+ }
169
+ if (installed && installed.length === 0) {
170
+ process.stderr.write(
171
+ `crosstalk chat: no agent CLIs installed in this container.\n` +
172
+ ` Install one first:\n` +
173
+ ` crosstalk chat --shell\n` +
174
+ ` npm install -g @anthropic-ai/claude-code # or your agent's installer\n`,
175
+ );
176
+ return 1;
177
+ }
178
+ const list = installed && installed.length > 0
179
+ ? `Installed in this container: ${installed.join(', ')}.`
180
+ : `Supported agents: ${KNOWN_AGENTS.join(', ')}.`;
181
+ process.stderr.write(
182
+ `crosstalk chat: --agent <name> is required.\n ${list}\n`,
183
+ );
184
+ return 1;
185
+ }
147
186
  if (!KNOWN_AGENTS.includes(agent)) {
148
187
  process.stderr.write(
149
188
  `crosstalk chat: unknown agent '${agent}'. Supported: ${KNOWN_AGENTS.join(', ')}\n`,
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.1';
24
+ ?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.3';
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.1 -f server/Dockerfile .
37
+ docker build -t crosstalk-server:7.0.0-alpha.3 -f server/Dockerfile .
38
38
  Then run:
39
- CROSSTALK_IMAGE=crosstalk-server:7.0.0-alpha.1 crosstalk init mytransport
39
+ CROSSTALK_IMAGE=crosstalk-server:7.0.0-alpha.3 crosstalk init mytransport
40
40
  `,
41
41
  );
42
42
  process.exit(exit);
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.1';
19
+ ?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.3';
20
20
  const DEFAULT_API_PORT = Number(process.env.CROSSTALK_API_PORT) || 7000;
21
21
 
22
22
  function usage(exit = 0) {
@@ -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: (path, opts) => call('GET', path, undefined, opts),
79
- post: (path, body, opts) => call('POST', path, body, opts),
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.1",
3
+ "version": "7.0.0-alpha.3",
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",