@cordfuse/crosstalk 7.0.0-beta.1 → 8.1.0
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 +1 -1
- package/README.md +32 -12
- package/commands/server.js +35 -3
- package/commands/transport.js +53 -24
- package/lib/nativeServer.js +20 -2
- package/lib/resolve.js +5 -3
- package/package.json +3 -3
- package/src/api.ts +56 -12
- package/src/dispatch.ts +95 -28
- package/src/models.ts +17 -0
- package/src/onboarding.ts +136 -0
- package/src/state.ts +14 -13
- package/src/transport.ts +113 -3
- package/src/web/channels.ts +30 -28
- package/src/web/chat-page.ts +1 -1
- package/src/web/chat-pty.ts +1 -1
- package/src/web/dashboard.ts +35 -1
- package/src/web/layout.ts +9 -0
- package/src/web/stubs.ts +3 -3
- package/src/web/wizard.ts +172 -0
- package/src/web/workflows.ts +1 -1
- package/template/CLAUDE.md +1 -1
- package/template/CROSSTALK.md +7 -7
- package/template/PROTOCOL.md +1 -1
- package/template/README.md +1 -1
package/GUIDE-CLI.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Reference for the `crosstalk` CLI. Run `crosstalk <noun> --help` (or `crosstalk <noun> <verb> --help`) for inline flags.
|
|
4
4
|
|
|
5
|
-
Requires: Node.js ≥ 20 + git on PATH.
|
|
5
|
+
Requires: Node.js ≥ 20 + git on PATH. Single-package install: `npm install -g @cordfuse/crosstalk` (current: beta.1). The daemon lives as `crosstalk daemon …` inside the same package; there's no separate `crosstalkd` binary anymore (collapse landed in alpha.18; `@cordfuse/crosstalkd` is deprecated on npm).
|
|
6
6
|
|
|
7
7
|
The surface is strict **noun-verb**, mirroring [`@cordfuse/llmux`](https://www.npmjs.com/package/@cordfuse/llmux):
|
|
8
8
|
|
package/README.md
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
# Crosstalk
|
|
2
2
|
|
|
3
|
-
**A shared message bus for humans and AI agents,
|
|
3
|
+
**A shared message bus for humans and AI agents. The bus is a directory — carried by git, or a plain/shared filesystem.**
|
|
4
4
|
|
|
5
|
-
> **
|
|
5
|
+
> **Status (8.x).** Crosstalk is a single npm package — `@cordfuse/crosstalk` (the separate `@cordfuse/crosstalkd` daemon package is deprecated; the daemon lives as `crosstalk daemon dispatch` inside this same package). CLI surface is strict noun-verb, mirroring `@cordfuse/llmux`. **As of 8.x the bus is transport-pluggable:** git (distributed, versioned, auditable) is the default; a local or shared filesystem — `transport: local`, including a mounted SMB/NFS share — needs no git and no remote. Select per transport with the `transport:` key in `data/crosstalk.yaml`.
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
> ### Six words that come up everywhere on this page
|
|
10
10
|
>
|
|
11
|
-
> - **Transport** —
|
|
11
|
+
> - **Transport** — the message bus, scaffolded by `crosstalk transport init`. A directory: a git repo (default — distributed, versioned) or a plain/shared filesystem (`transport: local`). It IS the bus.
|
|
12
12
|
> - **Machine** — a host running one crosstalk engine. Identifies itself via the dispatcher `--alias` (defaulting to the transport name).
|
|
13
|
-
> - **Message** — a markdown file with YAML frontmatter,
|
|
13
|
+
> - **Message** — a markdown file with YAML frontmatter, written to a channel. The unit of work.
|
|
14
14
|
> - **Model** — a named agent invocation declared in `data/crosstalk.yaml` (e.g. `sonnet`, `codex-o3`).
|
|
15
15
|
> - **Actor** — an optional persona file (`local/actors/<name>.md`) that prepends to a model's prompt as system context.
|
|
16
16
|
> - **Channel** — a UUID directory under `data/channels/`. A conversation thread. Optionally parented.
|
|
@@ -32,15 +32,15 @@ The missing piece: a way to make **any** agent CLI talk to **any** other agent C
|
|
|
32
32
|
|
|
33
33
|
## The solution
|
|
34
34
|
|
|
35
|
-
Crosstalk is that missing layer. The whole protocol is one idea — **a
|
|
35
|
+
Crosstalk is that missing layer. The whole protocol is one idea — **a directory is the message bus.** Carry that directory with git (distributed, versioned, auditable) or a plain/shared filesystem (local, LAN, a mounted share) — same protocol either way.
|
|
36
36
|
|
|
37
37
|
- **Any CLI participates.** If an agent's CLI runs one prompt non-interactively and prints a reply, it's a valid model. Mix Claude Code, Codex, Gemini CLI, Qwen Code, opencode, Antigravity — or any future CLI that follows the same shape — in one transport.
|
|
38
|
-
- **Messages are
|
|
38
|
+
- **Messages are files.** Every send is a markdown file with YAML frontmatter dropped into a channel. On the git transport each is a commit, so the whole conversation is git history — nothing to lose, nothing hidden, nothing to back up separately. On the filesystem transport it's just the files on disk.
|
|
39
39
|
- **Peer-to-peer.** Each machine runs its own crosstalk engine. No broker, no central runtime, no registry.
|
|
40
|
-
- **Multi-machine for free.** Git already solves "synchronize this across hosts
|
|
41
|
-
- **Self-coordinating.** Collision-free filenames
|
|
40
|
+
- **Multi-machine for free.** Git already solves "synchronize this across hosts"; a shared mount (SMB/NFS) does the same on a LAN. Crosstalk inherits whichever you point it at.
|
|
41
|
+
- **Self-coordinating.** Collision-free filenames mean concurrent writers never clash; the git transport adds rebase-retry on push, the filesystem transport relies on the unique filenames alone. Either way the bus works correctly with no central coordinator.
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
No infrastructure to provision, no central server holding your conversation — just a directory, on git or a filesystem.
|
|
44
44
|
|
|
45
45
|
---
|
|
46
46
|
|
|
@@ -235,6 +235,26 @@ Same flow works for self-hosted Gitea or GitLab. Crosstalk doesn't care; it only
|
|
|
235
235
|
|
|
236
236
|
---
|
|
237
237
|
|
|
238
|
+
## Local filesystem transport (no git)
|
|
239
|
+
|
|
240
|
+
Don't want a git remote? Point Crosstalk at a plain directory instead — a local folder for a single host, or a **mounted SMB/NFS share** for several hosts on a LAN. No git, no remote, no infrastructure.
|
|
241
|
+
|
|
242
|
+
```sh
|
|
243
|
+
# Scaffold a local-fs transport straight onto the directory (e.g. a NAS mount):
|
|
244
|
+
crosstalk transport init --transport local --path /mnt/share/crosstalk
|
|
245
|
+
|
|
246
|
+
# Run the engine on it — the bus lives on the share, state stays machine-local:
|
|
247
|
+
crosstalk server start --path /mnt/share/crosstalk
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
`--transport local` writes `transport: local` into `data/crosstalk.yaml` (no git init), and the dispatcher self-selects the filesystem transport: it reads/writes message files directly and picks up new ones via an mtime cursor — event-driven `fs.watch` on a true local FS, polling over a network mount. Each host that mounts the share runs its own engine and keeps its own machine-local cursor/heartbeat, so concurrent writers never clash (collision-free filenames).
|
|
251
|
+
|
|
252
|
+
Tradeoffs vs. git: no version history, no audit trail, no conflict resolution beyond the unique filenames — but zero setup and no SSH/credentials. Reach for git when *what was said* matters; reach for the filesystem transport for quick local/LAN swarms.
|
|
253
|
+
|
|
254
|
+
> **SFTP?** Mount it with `sshfs` and use `transport: local` — Crosstalk just sees a directory.
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
238
258
|
## Adding a second machine
|
|
239
259
|
|
|
240
260
|
Multi-machine adds exactly one new idea: **routing by alias.**
|
|
@@ -424,8 +444,8 @@ Transport git repos are portable — the storage path is unchanged (`~/.local/sh
|
|
|
424
444
|
If you have a v7 Docker install:
|
|
425
445
|
|
|
426
446
|
1. `docker compose -f <your-compose-file> down` first.
|
|
427
|
-
2. `npm install -g @cordfuse/crosstalk@latest` — pulls
|
|
428
|
-
3. If you previously installed `@cordfuse/crosstalkd`: `npm uninstall -g @cordfuse/crosstalkd` (deprecated, no longer published
|
|
447
|
+
2. `npm install -g @cordfuse/crosstalk@latest` — pulls the latest 8.x.
|
|
448
|
+
3. If you previously installed `@cordfuse/crosstalkd`: `npm uninstall -g @cordfuse/crosstalkd` (deprecated, no longer published since alpha.18).
|
|
429
449
|
4. In user mode: `export CROSSTALK_USER_MODE=1 && crosstalk server start --containername <name>`.
|
|
430
450
|
5. In system mode: rerun `sudo ./deploy/install.sh` to lay down the new `crosstalk@.service` unit, then `sudo systemctl daemon-reload && sudo systemctl enable --now crosstalk@<name>`. The old `crosstalkd@<name>.service` files can be removed.
|
|
431
451
|
|
|
@@ -435,4 +455,4 @@ The v7 GHCR image `ghcr.io/cordfuse/crosstalk-server:*` is no longer being updat
|
|
|
435
455
|
|
|
436
456
|
## Status
|
|
437
457
|
|
|
438
|
-
Crosstalk
|
|
458
|
+
Crosstalk 8.x — single-package monorepo + strict noun-verb CLI, transport-pluggable (git or a local/shared filesystem). The protocol version lives at `CROSSTALK-VERSION` (single integer) at the transport root. Published as [`@cordfuse/crosstalk`](https://www.npmjs.com/package/@cordfuse/crosstalk).
|
package/commands/server.js
CHANGED
|
@@ -20,9 +20,10 @@
|
|
|
20
20
|
|
|
21
21
|
import { spawn } from 'child_process';
|
|
22
22
|
import { existsSync } from 'fs';
|
|
23
|
-
import {
|
|
23
|
+
import { join, resolve } from 'path';
|
|
24
|
+
import { has, flag } from '../lib/argv.js';
|
|
24
25
|
import {
|
|
25
|
-
requireInitialized, storageMode,
|
|
26
|
+
requireInitialized, storageMode, parseContainerName,
|
|
26
27
|
DEFAULT_CONTAINER_NAME,
|
|
27
28
|
} from '../lib/resolve.js';
|
|
28
29
|
import { startEngine, stopEngine, engineStatus } from '../lib/nativeServer.js';
|
|
@@ -56,7 +57,9 @@ function usage(exit = 0) {
|
|
|
56
57
|
`Usage: crosstalk server <subverb> [--containername <name>]
|
|
57
58
|
|
|
58
59
|
Subverbs:
|
|
59
|
-
start
|
|
60
|
+
start [--path <dir>] start the engine; --path runs it on a transport at an
|
|
61
|
+
arbitrary directory (e.g. a mounted share), state stays
|
|
62
|
+
machine-local (user mode only)
|
|
60
63
|
stop SIGTERM the running engine; SIGKILL after 5s grace
|
|
61
64
|
restart stop + start
|
|
62
65
|
status [--probe] report pid + uptime; --probe also pings /healthz
|
|
@@ -68,6 +71,35 @@ Storage mode defaults to 'system'; set CROSSTALK_USER_MODE=1 for user mode.
|
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
async function cmdStart(argv) {
|
|
74
|
+
// --path <dir>: run the engine on a transport at an arbitrary directory
|
|
75
|
+
// (e.g. a mounted SMB/NFS share) instead of the managed storage layout.
|
|
76
|
+
// State (cursor/heartbeat/pid/port) stays machine-local, keyed by name.
|
|
77
|
+
const transportPath = flag(argv, '--path');
|
|
78
|
+
if (transportPath) {
|
|
79
|
+
if (storageMode() === 'system') {
|
|
80
|
+
process.stderr.write(`crosstalk server start: --path is user-mode only (set CROSSTALK_USER_MODE=1).\n`);
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
if (!existsSync(join(transportPath, 'CROSSTALK-VERSION'))) {
|
|
84
|
+
process.stderr.write(
|
|
85
|
+
`crosstalk server start: '${transportPath}' is not a crosstalk transport (no CROSSTALK-VERSION).\n` +
|
|
86
|
+
` Scaffold it first: crosstalk daemon init ${transportPath}\n`,
|
|
87
|
+
);
|
|
88
|
+
return 1;
|
|
89
|
+
}
|
|
90
|
+
const name = parseContainerName(argv);
|
|
91
|
+
const r = startEngine(name, { transportPath });
|
|
92
|
+
if (!r.ok) {
|
|
93
|
+
process.stderr.write(`crosstalk server start: ${r.error}\n`);
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
process.stdout.write(
|
|
97
|
+
`'${name}' started on ${resolve(transportPath)} (pid ${r.pid}, api 127.0.0.1:${r.port}).\n` +
|
|
98
|
+
` state stays machine-local; logs: crosstalk server logs${nameSuffix(name)} -f\n`,
|
|
99
|
+
);
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
71
103
|
const { name } = requireInitialized(argv);
|
|
72
104
|
if (storageMode() === 'system') return redirectToSystemctl('start', name);
|
|
73
105
|
const r = startEngine(name);
|
package/commands/transport.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs';
|
|
15
15
|
import { spawnSync } from 'child_process';
|
|
16
16
|
import { createInterface } from 'readline';
|
|
17
|
-
import { join } from 'path';
|
|
17
|
+
import { join, resolve } from 'path';
|
|
18
18
|
import { has, flag } from '../lib/argv.js';
|
|
19
19
|
import {
|
|
20
20
|
parseContainerName,
|
|
@@ -29,7 +29,7 @@ function usage(exit = 0) {
|
|
|
29
29
|
const w = exit === 0 ? process.stdout : process.stderr;
|
|
30
30
|
w.write(
|
|
31
31
|
`Usage:
|
|
32
|
-
crosstalk transport init [--containername <name>] [--remote <url>]
|
|
32
|
+
crosstalk transport init [--containername <name>] [--transport git|local] [--path <dir>] [--remote <url>]
|
|
33
33
|
crosstalk transport rm [--containername <name>] [--force]
|
|
34
34
|
crosstalk transport --help
|
|
35
35
|
|
|
@@ -75,12 +75,22 @@ async function runInit(argv) {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
const remote = flag(argv, '--remote');
|
|
78
|
+
const transportKind = flag(argv, '--transport') === 'local' ? 'local' : 'git';
|
|
79
|
+
if (transportKind === 'local' && remote) {
|
|
80
|
+
process.stderr.write(`crosstalk transport init: --remote is ignored for --transport local (no git, no upstream).\n`);
|
|
81
|
+
}
|
|
78
82
|
const paths = storagePaths(name);
|
|
83
|
+
// --path <dir>: scaffold the transport at an arbitrary directory (e.g. a
|
|
84
|
+
// mounted share) instead of the managed storage layout. Port + state stay
|
|
85
|
+
// machine-local under the name-keyed storage root; only the transport
|
|
86
|
+
// (template + channels) lives at the path. Pairs with `server start --path`.
|
|
87
|
+
const customPath = flag(argv, '--path');
|
|
88
|
+
const transportDir = customPath ? resolve(customPath) : paths.transportDir;
|
|
79
89
|
const report = [];
|
|
80
90
|
|
|
81
91
|
// ── Phase 1: Storage layout ────────────────────────────────────────
|
|
82
92
|
try {
|
|
83
|
-
mkdirSync(
|
|
93
|
+
mkdirSync(transportDir, { recursive: true });
|
|
84
94
|
mkdirSync(paths.crosstalkRoot, { recursive: true });
|
|
85
95
|
mkdirSync(paths.crosstalkState, { recursive: true });
|
|
86
96
|
} catch (err) {
|
|
@@ -116,7 +126,7 @@ async function runInit(argv) {
|
|
|
116
126
|
}
|
|
117
127
|
|
|
118
128
|
// ── Phase 2: Transport scaffold ────────────────────────────────────
|
|
119
|
-
const crosstalkVersionFile = join(
|
|
129
|
+
const crosstalkVersionFile = join(transportDir, 'CROSSTALK-VERSION');
|
|
120
130
|
if (!existsSync(crosstalkVersionFile)) {
|
|
121
131
|
// Scaffold via `crosstalk daemon init <path>` — the same self-bin
|
|
122
132
|
// call pattern startEngine() uses.
|
|
@@ -129,7 +139,7 @@ async function runInit(argv) {
|
|
|
129
139
|
}
|
|
130
140
|
const initRun = spawnSync(
|
|
131
141
|
process.execPath,
|
|
132
|
-
[selfBin, 'daemon', 'init',
|
|
142
|
+
[selfBin, 'daemon', 'init', transportDir],
|
|
133
143
|
{ stdio: 'pipe' },
|
|
134
144
|
);
|
|
135
145
|
if (initRun.status !== 0) {
|
|
@@ -145,40 +155,59 @@ async function runInit(argv) {
|
|
|
145
155
|
report.push(` template: present (skipped scaffold)`);
|
|
146
156
|
}
|
|
147
157
|
|
|
158
|
+
// ── Phase 2.5: local transport marker ──────────────────────────────
|
|
159
|
+
// Local (fs) transports carry no git history; persist the medium in the
|
|
160
|
+
// config so the dispatcher self-selects it (no --transport flag needed).
|
|
161
|
+
if (transportKind === 'local') {
|
|
162
|
+
const yamlPath = join(transportDir, 'data', 'crosstalk.yaml');
|
|
163
|
+
try {
|
|
164
|
+
const cur = existsSync(yamlPath) ? readFileSync(yamlPath, 'utf8') : '';
|
|
165
|
+
if (!/^\s*transport\s*:/m.test(cur)) {
|
|
166
|
+
writeFileSync(yamlPath, `transport: local\n${cur}`);
|
|
167
|
+
}
|
|
168
|
+
report.push(` medium: local (fs — no git)`);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
process.stderr.write(`crosstalk transport init: failed to write transport key to ${yamlPath} — ${err.message}\n`);
|
|
171
|
+
return 1;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
148
175
|
// ── Phase 3: Git init + initial commit ─────────────────────────────
|
|
149
176
|
if (!existsSync(crosstalkVersionFile)) {
|
|
150
177
|
process.stderr.write(
|
|
151
|
-
`crosstalk transport init: transport template missing at ${
|
|
178
|
+
`crosstalk transport init: transport template missing at ${transportDir}\n` +
|
|
152
179
|
` Engine init didn't write template files. Try 'crosstalk transport rm' then re-init.\n`,
|
|
153
180
|
);
|
|
154
181
|
return 1;
|
|
155
182
|
}
|
|
156
183
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
184
|
+
if (transportKind === 'git') {
|
|
185
|
+
const gitSteps = [
|
|
186
|
+
['init', '--quiet', '--initial-branch=main'],
|
|
187
|
+
['add', '-A'],
|
|
188
|
+
['commit', '--quiet', '-m', 'initial transport'],
|
|
189
|
+
];
|
|
190
|
+
let gitOk = true;
|
|
191
|
+
for (const args of gitSteps) {
|
|
192
|
+
const gr = spawnSync('git', args, { cwd: transportDir, stdio: 'pipe' });
|
|
193
|
+
if (gr.status !== 0) {
|
|
194
|
+
gitOk = false;
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
168
197
|
}
|
|
198
|
+
report.push(gitOk ? ` git: initialized + committed` : ` git: already present (skipped)`);
|
|
169
199
|
}
|
|
170
|
-
report.push(gitOk ? ` git: initialized + committed` : ` git: already present (skipped)`);
|
|
171
200
|
|
|
172
|
-
// ── Phase 4: Remote setup
|
|
173
|
-
if (remote) {
|
|
201
|
+
// ── Phase 4: Remote setup (git only) ───────────────────────────────
|
|
202
|
+
if (remote && transportKind === 'git') {
|
|
174
203
|
const cur = spawnSync('git', ['remote', 'get-url', 'origin'], {
|
|
175
|
-
cwd:
|
|
204
|
+
cwd: transportDir,
|
|
176
205
|
stdio: 'pipe',
|
|
177
206
|
});
|
|
178
207
|
const currentUrl = cur.status === 0 ? cur.stdout.toString().trim() : null;
|
|
179
208
|
if (!currentUrl) {
|
|
180
209
|
const rr = spawnSync('git', ['remote', 'add', 'origin', remote], {
|
|
181
|
-
cwd:
|
|
210
|
+
cwd: transportDir,
|
|
182
211
|
stdio: 'pipe',
|
|
183
212
|
});
|
|
184
213
|
if (rr.status !== 0) {
|
|
@@ -202,9 +231,9 @@ async function runInit(argv) {
|
|
|
202
231
|
process.stdout.write(
|
|
203
232
|
`\nTransport '${name}' ready:\n` +
|
|
204
233
|
` storage: ${paths.storageRoot}\n` +
|
|
205
|
-
` transport: ${
|
|
234
|
+
` transport: ${transportDir}\n` +
|
|
206
235
|
report.join('\n') + '\n' +
|
|
207
|
-
`\nNext: crosstalk server start${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}\n`,
|
|
236
|
+
`\nNext: crosstalk server start${customPath ? ` --path ${transportDir}` : (name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`)}\n`,
|
|
208
237
|
);
|
|
209
238
|
return 0;
|
|
210
239
|
}
|
package/lib/nativeServer.js
CHANGED
|
@@ -67,18 +67,36 @@ export function startEngine(name, opts = {}) {
|
|
|
67
67
|
const poll = String(opts.pollSeconds ?? process.env.DISPATCH_POLL_SECONDS ?? 30);
|
|
68
68
|
const port = String(apiPortFor(name));
|
|
69
69
|
|
|
70
|
+
// --path: the transport lives at an arbitrary directory (e.g. a mounted
|
|
71
|
+
// share). Point the engine there via CROSSTALK_TRANSPORT_PATH, and pin
|
|
72
|
+
// state machine-local (by name) so cursor/heartbeat never land on the
|
|
73
|
+
// share — each machine sharing the dir keeps its own local state.
|
|
74
|
+
const transportDir = opts.transportPath ? resolve(opts.transportPath) : paths.transportDir;
|
|
70
75
|
const env = {
|
|
71
76
|
...process.env,
|
|
72
77
|
CROSSTALK_API_PORT: port,
|
|
73
|
-
TRANSPORT_DIR:
|
|
78
|
+
TRANSPORT_DIR: transportDir,
|
|
74
79
|
};
|
|
80
|
+
// Default state to per-instance (keyed by name). Without this the
|
|
81
|
+
// dispatcher falls back to stateDir()'s default, keyed on
|
|
82
|
+
// basename(transportRoot) — always "transport" for a local-fs transport —
|
|
83
|
+
// so every named local instance would share one cursor/heartbeat/errors
|
|
84
|
+
// dir and clobber each other. Honor an explicit CROSSTALK_STATE_DIR if the
|
|
85
|
+
// caller already set one (the MC harness pins its heartbeat/cursor/errors
|
|
86
|
+
// there to isolate its run) — only fill in the default when it's absent.
|
|
87
|
+
if (!env.CROSSTALK_STATE_DIR) {
|
|
88
|
+
env.CROSSTALK_STATE_DIR = paths.crosstalkState;
|
|
89
|
+
}
|
|
90
|
+
if (opts.transportPath) {
|
|
91
|
+
env.CROSSTALK_TRANSPORT_PATH = transportDir;
|
|
92
|
+
}
|
|
75
93
|
|
|
76
94
|
const logFd = openSync(paths.logFile, 'a');
|
|
77
95
|
const child = spawn(
|
|
78
96
|
process.execPath,
|
|
79
97
|
[selfBin, 'daemon', 'dispatch', '--alias', alias, '--poll', poll, '--json'],
|
|
80
98
|
{
|
|
81
|
-
cwd:
|
|
99
|
+
cwd: transportDir,
|
|
82
100
|
env,
|
|
83
101
|
stdio: ['ignore', logFd, logFd],
|
|
84
102
|
detached: true,
|
package/lib/resolve.js
CHANGED
|
@@ -187,11 +187,13 @@ export function pickFreePort(name, mode = storageMode()) {
|
|
|
187
187
|
throw new Error('Could not find a free port between 7001 and 7999.');
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
// True if
|
|
191
|
-
//
|
|
190
|
+
// True if the transport has been scaffolded for this name. Checks the
|
|
191
|
+
// CROSSTALK-VERSION marker that `daemon init` writes for EVERY transport —
|
|
192
|
+
// git and local-fs alike. (Was '.git', which wrongly rejected no-git local
|
|
193
|
+
// transports: `server start` reported "no transport 'mc'".)
|
|
192
194
|
export function isInitialized(name) {
|
|
193
195
|
const paths = storagePaths(name);
|
|
194
|
-
return existsSync(join(paths.transportDir, '
|
|
196
|
+
return existsSync(join(paths.transportDir, 'CROSSTALK-VERSION'));
|
|
195
197
|
}
|
|
196
198
|
|
|
197
199
|
// True if the crosstalk engine is running for this transport. Source of truth:
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cordfuse/crosstalk",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Crosstalk — agent-agnostic swarm communication protocol
|
|
3
|
+
"version": "8.1.0",
|
|
4
|
+
"description": "Crosstalk — agent-agnostic swarm communication protocol over a shared directory (git or filesystem transport). Operator CLI + daemon in one binary.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"homepage": "https://github.com/cordfuse/crosstalk",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"scripts": {
|
|
27
27
|
"build": "tsc --noEmit",
|
|
28
28
|
"lint": "tsc --noEmit",
|
|
29
|
-
"test": "
|
|
29
|
+
"test": "node --import tsx --test tests/*.test.ts",
|
|
30
30
|
"prepack": "cp -r transport template",
|
|
31
31
|
"postpack": "rm -rf template"
|
|
32
32
|
},
|
package/src/api.ts
CHANGED
|
@@ -33,14 +33,16 @@ import { now, messageFilename } from './filenames.js';
|
|
|
33
33
|
import { parseFrontmatter, serializeFrontmatter } from './frontmatter.js';
|
|
34
34
|
import {
|
|
35
35
|
discoverChannels,
|
|
36
|
-
gitCommitAndPush,
|
|
37
36
|
listChannelMessages,
|
|
37
|
+
type Transport,
|
|
38
38
|
} from './transport.js';
|
|
39
39
|
import { sendWakeSignal, readHeartbeat, readCursor, countErrors } from './state.js';
|
|
40
40
|
import { reList } from './activation.js';
|
|
41
41
|
import { isOnPath, type ModelEntry, type ModelsRegistry } from './models.js';
|
|
42
|
+
import { AGENT_CATALOG, connectAgent } from './onboarding.js';
|
|
42
43
|
import { resolveToField } from './resolve.js';
|
|
43
44
|
import { dashboardPage } from './web/dashboard.js';
|
|
45
|
+
import { wizardPage } from './web/wizard.js';
|
|
44
46
|
import { channelListPage, channelViewPage, agentsPage, type BusMessage } from './web/channels.js';
|
|
45
47
|
import { workflowsListPage, workflowDetailPage, type WorkflowSummary } from './web/workflows.js';
|
|
46
48
|
import { setupPage as authSetupPage, loginPage as authLoginPage } from './web/auth-pages.js';
|
|
@@ -66,6 +68,9 @@ export interface ApiContext {
|
|
|
66
68
|
version: string;
|
|
67
69
|
registry: ModelsRegistry;
|
|
68
70
|
claimed: Map<string, ModelEntry>; // shorthand for registry.claimed
|
|
71
|
+
/** v8: the bus medium (git default, or local-fs). Web-UI writes publish
|
|
72
|
+
* through this so a local-fs deployment never shells out to git. */
|
|
73
|
+
transport: Transport;
|
|
69
74
|
/** Auth stores; v8-native. Optional — engine still runs without auth
|
|
70
75
|
* if the operator doesn't want it (just the web UI gating turns off). */
|
|
71
76
|
userStore?: UserStore;
|
|
@@ -205,6 +210,7 @@ function handleStatus(ctx: ApiContext): JsonRes {
|
|
|
205
210
|
status: 200,
|
|
206
211
|
body: {
|
|
207
212
|
transport_root: transportRoot,
|
|
213
|
+
transport_kind: ctx.transport.kind,
|
|
208
214
|
alias: ctx.alias,
|
|
209
215
|
version: ctx.version,
|
|
210
216
|
claimed_models: [...ctx.claimed.keys()],
|
|
@@ -281,7 +287,7 @@ function handleCreateChannel(ctx: ApiContext, body: CreateChannelReq): JsonRes {
|
|
|
281
287
|
const dir = join(transportRoot, 'data', 'channels', uuid);
|
|
282
288
|
mkdirSync(dir, { recursive: true });
|
|
283
289
|
writeFileSync(join(dir, 'CHANNEL.md'), serializeFrontmatter({ name: body.name }, ''));
|
|
284
|
-
const push =
|
|
290
|
+
const push = ctx.transport.publish(`channel(create): ${body.name} (${uuid.slice(0, 8)})`);
|
|
285
291
|
if (!push.committed && push.error) {
|
|
286
292
|
// Commit itself failed (e.g. file system / git config issue).
|
|
287
293
|
throw new HttpError(500, `commit failed: ${push.error.slice(0, 300)}`);
|
|
@@ -316,8 +322,7 @@ function handleRenameChannel(ctx: ApiContext, handle: string, body: RenameChanne
|
|
|
316
322
|
const fm: Record<string, unknown> = { name: body.name };
|
|
317
323
|
if (meta.parent) fm['parent'] = meta.parent;
|
|
318
324
|
writeFileSync(chPath, serializeFrontmatter(fm, ''));
|
|
319
|
-
const push =
|
|
320
|
-
transportRoot,
|
|
325
|
+
const push = ctx.transport.publish(
|
|
321
326
|
`channel(rename): ${meta.name ?? '(unnamed)'} -> ${body.name} (${meta.uuid.slice(0, 8)})`,
|
|
322
327
|
);
|
|
323
328
|
if (!push.committed && push.error) {
|
|
@@ -348,8 +353,7 @@ function handleDeleteChannel(ctx: ApiContext, handle: string, confirm: string |
|
|
|
348
353
|
}
|
|
349
354
|
const dir = join(transportRoot, 'data', 'channels', meta.uuid);
|
|
350
355
|
rmSync(dir, { recursive: true, force: true });
|
|
351
|
-
const push =
|
|
352
|
-
transportRoot,
|
|
356
|
+
const push = ctx.transport.publish(
|
|
353
357
|
`channel(delete): ${meta.name ?? '(unnamed)'} (${meta.uuid.slice(0, 8)})`,
|
|
354
358
|
);
|
|
355
359
|
if (!push.committed && push.error) {
|
|
@@ -458,7 +462,7 @@ function handlePostMessage(ctx: ApiContext, body: PostMessageReq): JsonRes {
|
|
|
458
462
|
fanout === 1
|
|
459
463
|
? `run: ${from} -> ${toQualified} in ${channelUuid.slice(0, 8)}`
|
|
460
464
|
: `run: ${from} -> ${toQualified} x${fanout} in ${channelUuid.slice(0, 8)}`;
|
|
461
|
-
const push =
|
|
465
|
+
const push = ctx.transport.publish(commitMsg);
|
|
462
466
|
sendWakeSignal(transportRoot);
|
|
463
467
|
if (!push.committed && push.error) {
|
|
464
468
|
throw new HttpError(500, `commit failed: ${push.error.slice(0, 300)}`);
|
|
@@ -494,8 +498,7 @@ function handlePostMessage(ctx: ApiContext, body: PostMessageReq): JsonRes {
|
|
|
494
498
|
extraFrontmatter,
|
|
495
499
|
workflow: true,
|
|
496
500
|
});
|
|
497
|
-
const push =
|
|
498
|
-
transportRoot,
|
|
501
|
+
const push = ctx.transport.publish(
|
|
499
502
|
`run(workflow): ${from} child ${childUuid.slice(0, 8)}`,
|
|
500
503
|
);
|
|
501
504
|
sendWakeSignal(transportRoot);
|
|
@@ -663,7 +666,7 @@ async function tryWebAuthFlow(ctx: ApiContext, req: IncomingMessage, res: Server
|
|
|
663
666
|
// setup/api-setup path, redirect to /setup so the first browser visit
|
|
664
667
|
// bootstraps the admin account.
|
|
665
668
|
const HTML_GATED_PATHS = new Set([
|
|
666
|
-
'/', '/agents', '/chat',
|
|
669
|
+
'/', '/welcome', '/agents', '/chat',
|
|
667
670
|
'/tokens', '/logs', '/settings', '/account', '/users', '/about',
|
|
668
671
|
]);
|
|
669
672
|
if (method === 'GET' && (HTML_GATED_PATHS.has(path) || path.startsWith('/c') || path.startsWith('/w'))) {
|
|
@@ -713,6 +716,7 @@ async function tryWebApiRoute(ctx: ApiContext, req: IncomingMessage, res: Server
|
|
|
713
716
|
path === '/api/account' || path === '/api/account/passphrase' ||
|
|
714
717
|
path === '/api/users' || path.startsWith('/api/users/') ||
|
|
715
718
|
path === '/api/workflows' || path.startsWith('/api/workflows/') ||
|
|
719
|
+
path === '/api/onboarding/agents' || path === '/api/onboarding/connect' ||
|
|
716
720
|
path === '/api/logs' || path === '/api/logs/stream' ||
|
|
717
721
|
path === '/api/settings';
|
|
718
722
|
if (!isApiPath) return false;
|
|
@@ -784,6 +788,41 @@ async function tryWebApiRoute(ctx: ApiContext, req: IncomingMessage, res: Server
|
|
|
784
788
|
}
|
|
785
789
|
}
|
|
786
790
|
|
|
791
|
+
// ── Onboarding wizard ────────────────────────────────────────────
|
|
792
|
+
// GET /api/onboarding/agents → catalog entries annotated with whether
|
|
793
|
+
// each CLI is installed on this engine's PATH and already claimed.
|
|
794
|
+
if (method === 'GET' && path === '/api/onboarding/agents') {
|
|
795
|
+
const agents = Object.values(AGENT_CATALOG).map((e) => ({
|
|
796
|
+
cli: e.cli, label: e.label, provider: e.provider, model: e.model,
|
|
797
|
+
installed: isOnPath(e.cli),
|
|
798
|
+
claimed: ctx.registry.claimed.has(`${e.provider}/${e.model}`),
|
|
799
|
+
needsKey: e.keyEnv !== null, keyHint: e.keyHint,
|
|
800
|
+
}));
|
|
801
|
+
writeJson(res, 200, { agents }, ctx.version);
|
|
802
|
+
return true;
|
|
803
|
+
}
|
|
804
|
+
// POST /api/onboarding/connect {cli, apiKey?} → admin: write the provider
|
|
805
|
+
// block (+ optional key file) and SIGHUP so the dispatcher claims it live.
|
|
806
|
+
if (method === 'POST' && path === '/api/onboarding/connect') {
|
|
807
|
+
if (!me.admin) { writeJson(res, 403, { error: 'admin only' }, ctx.version); return true; }
|
|
808
|
+
const body = await readJson<{ cli?: string; apiKey?: string }>(req);
|
|
809
|
+
const cli = (body.cli ?? '').trim();
|
|
810
|
+
if (!AGENT_CATALOG[cli]) { writeJson(res, 400, { error: `unknown agent CLI '${cli}'` }, ctx.version); return true; }
|
|
811
|
+
if (!isOnPath(cli)) { writeJson(res, 400, { error: `'${cli}' is not installed on this engine's PATH` }, ctx.version); return true; }
|
|
812
|
+
let result;
|
|
813
|
+
try {
|
|
814
|
+
result = connectAgent(ctx.transportRoot, cli, body.apiKey);
|
|
815
|
+
} catch (err) {
|
|
816
|
+
writeJson(res, 500, { error: `failed to write config: ${(err as Error).message}` }, ctx.version);
|
|
817
|
+
return true;
|
|
818
|
+
}
|
|
819
|
+
// Reload the registry live — the API runs in the dispatcher process, so
|
|
820
|
+
// SIGHUP to self triggers the registry hot-reload (see src/dispatch.ts).
|
|
821
|
+
try { process.kill(process.pid, 'SIGHUP'); } catch { /* best-effort */ }
|
|
822
|
+
writeJson(res, 200, { ok: true, ...result }, ctx.version);
|
|
823
|
+
return true;
|
|
824
|
+
}
|
|
825
|
+
|
|
787
826
|
// ── Account: change passphrase ───────────────────────────────────
|
|
788
827
|
if (method === 'POST' && path === '/api/account/passphrase') {
|
|
789
828
|
const body = await readJson<{ oldPassphrase?: string; newPassphrase?: string }>(req);
|
|
@@ -1007,8 +1046,7 @@ async function tryWebApiRoute(ctx: ApiContext, req: IncomingMessage, res: Server
|
|
|
1007
1046
|
extraFrontmatter,
|
|
1008
1047
|
workflow: true,
|
|
1009
1048
|
});
|
|
1010
|
-
const push =
|
|
1011
|
-
ctx.transportRoot,
|
|
1049
|
+
const push = ctx.transport.publish(
|
|
1012
1050
|
`run(workflow): ${me.username} child ${childUuid.slice(0, 8)} (composed)`,
|
|
1013
1051
|
);
|
|
1014
1052
|
sendWakeSignal(ctx.transportRoot);
|
|
@@ -1088,6 +1126,7 @@ async function tryHtmlRoute(ctx: ApiContext, req: IncomingMessage, res: ServerRe
|
|
|
1088
1126
|
const channels = allChannelMeta(ctx.transportRoot);
|
|
1089
1127
|
writeHtml(res, 200, dashboardPage({
|
|
1090
1128
|
host, alias: ctx.alias, version: ctx.version, transportRoot: ctx.transportRoot,
|
|
1129
|
+
transportKind: ctx.transport.kind,
|
|
1091
1130
|
heartbeat: hb ? { ts: hb.ts, pid: hb.pid, version: hb.version, alias: hb.alias } : null,
|
|
1092
1131
|
cursor: cursor ?? null,
|
|
1093
1132
|
claimedModels: [...ctx.claimed.keys()],
|
|
@@ -1098,6 +1137,11 @@ async function tryHtmlRoute(ctx: ApiContext, req: IncomingMessage, res: ServerRe
|
|
|
1098
1137
|
return true;
|
|
1099
1138
|
}
|
|
1100
1139
|
|
|
1140
|
+
if (path === '/welcome') {
|
|
1141
|
+
writeHtml(res, 200, wizardPage({ host, alias: ctx.alias }), ctx.version);
|
|
1142
|
+
return true;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1101
1145
|
if (path === '/c') {
|
|
1102
1146
|
const channels = allChannelMeta(ctx.transportRoot);
|
|
1103
1147
|
writeHtml(res, 200, channelListPage({
|