@cordfuse/crosstalk 6.0.0-alpha.9 → 7.0.0-alpha.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/README.md +26 -0
- package/bin/crosstalk.js +60 -74
- package/commands/channel.js +69 -0
- package/commands/chat.js +159 -0
- package/commands/down.js +37 -0
- package/commands/init.js +105 -0
- package/commands/logs.js +39 -0
- package/commands/pull.js +24 -0
- package/commands/replies.js +39 -0
- package/commands/restart.js +24 -0
- package/commands/run.js +121 -0
- package/commands/status.js +52 -0
- package/commands/up.js +129 -0
- package/commands/version.js +30 -0
- package/lib/api-client.js +80 -0
- package/lib/argv.js +28 -0
- package/lib/errors.js +19 -0
- package/lib/transport.js +51 -0
- package/package.json +5 -21
- package/src/activation.ts +0 -104
- package/src/actor.ts +0 -131
- package/src/attach.ts +0 -118
- package/src/channel.ts +0 -49
- package/src/chat.ts +0 -142
- package/src/dispatch.ts +0 -531
- package/src/dlq.ts +0 -216
- package/src/filenames.ts +0 -28
- package/src/frontmatter.ts +0 -26
- package/src/init.ts +0 -138
- package/src/open.ts +0 -207
- package/src/replies.ts +0 -59
- package/src/send.ts +0 -122
- package/src/state.ts +0 -173
- package/src/status.ts +0 -75
- package/src/stop.ts +0 -37
- package/src/transport.ts +0 -213
- package/src/turnq.ts +0 -91
- package/src/upgrade.ts +0 -211
- package/src/wake.ts +0 -7
- package/template/CLAUDE.md +0 -12
- package/template/gitignore +0 -4
- package/template/upstream/CROSSTALK-VERSION +0 -1
- package/template/upstream/CROSSTALK.md +0 -298
- package/template/upstream/OPERATOR.md +0 -60
- package/template/upstream/PROTOCOL.md +0 -80
- package/template/upstream/actors/concierge.md +0 -36
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// crosstalk restart — restart the engine container.
|
|
2
|
+
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { spawnSync } from 'child_process';
|
|
5
|
+
import { requireTransportRoot, composeFile } from '../lib/transport.js';
|
|
6
|
+
import { has } from '../lib/argv.js';
|
|
7
|
+
|
|
8
|
+
export async function run(argv) {
|
|
9
|
+
if (has(argv, '--help') || has(argv, '-h')) {
|
|
10
|
+
process.stdout.write('Usage: crosstalk restart\n');
|
|
11
|
+
return 0;
|
|
12
|
+
}
|
|
13
|
+
const root = requireTransportRoot();
|
|
14
|
+
const composeYml = composeFile(root);
|
|
15
|
+
if (!existsSync(composeYml)) {
|
|
16
|
+
process.stderr.write(`crosstalk restart: no docker-compose.yml — run 'crosstalk up' first.\n`);
|
|
17
|
+
return 1;
|
|
18
|
+
}
|
|
19
|
+
const r = spawnSync('docker', ['compose', '-f', composeYml, 'restart'], {
|
|
20
|
+
cwd: root,
|
|
21
|
+
stdio: 'inherit',
|
|
22
|
+
});
|
|
23
|
+
return r.status ?? 1;
|
|
24
|
+
}
|
package/commands/run.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// crosstalk run — dispatch a primitive or workflow via POST /messages.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors crosstalkd's run surface:
|
|
4
|
+
// crosstalk run --type primitive --to <model>[@<machine>] [--as <persona>]
|
|
5
|
+
// [--fanout <n>] [--channel <name|uuid>] [--from <name>]
|
|
6
|
+
// [--new] <body|file|->
|
|
7
|
+
// crosstalk run --type workflow [--channel <name|uuid>] [--from <name>]
|
|
8
|
+
// [--new] <file|->
|
|
9
|
+
|
|
10
|
+
import { readFileSync, existsSync, statSync } from 'fs';
|
|
11
|
+
import { api } from '../lib/api-client.js';
|
|
12
|
+
import { reportAndExit } from '../lib/errors.js';
|
|
13
|
+
import { flag, has } from '../lib/argv.js';
|
|
14
|
+
|
|
15
|
+
const FLAGS_WITH_VALUE = new Set(['--type', '--to', '--as', '--fanout', '--channel', '--from']);
|
|
16
|
+
|
|
17
|
+
function usage(exit = 0) {
|
|
18
|
+
const w = exit === 0 ? process.stdout : process.stderr;
|
|
19
|
+
w.write(
|
|
20
|
+
`Usage:
|
|
21
|
+
crosstalk run --type primitive --to <model>[@<machine>] \\
|
|
22
|
+
[--as <persona>] [--fanout <n>] \\
|
|
23
|
+
[--channel <name|uuid>] [--from <name>] [--new] \\
|
|
24
|
+
<body|file|->
|
|
25
|
+
|
|
26
|
+
crosstalk run --type workflow [--channel <name|uuid>] \\
|
|
27
|
+
[--from <name>] [--new] <file|->
|
|
28
|
+
`,
|
|
29
|
+
);
|
|
30
|
+
process.exit(exit);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readStdin() {
|
|
34
|
+
try {
|
|
35
|
+
return readFileSync(0, 'utf-8');
|
|
36
|
+
} catch {
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resolveBody(arg) {
|
|
42
|
+
if (arg == null) return null;
|
|
43
|
+
if (arg === '-') return readStdin();
|
|
44
|
+
if (existsSync(arg)) {
|
|
45
|
+
try {
|
|
46
|
+
if (statSync(arg).isFile()) return readFileSync(arg, 'utf-8');
|
|
47
|
+
} catch { /* fall through to inline */ }
|
|
48
|
+
}
|
|
49
|
+
return arg;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function run(argv) {
|
|
53
|
+
if (has(argv, '--help') || has(argv, '-h')) usage(0);
|
|
54
|
+
|
|
55
|
+
const type = flag(argv, '--type');
|
|
56
|
+
if (type !== 'primitive' && type !== 'workflow') usage(1);
|
|
57
|
+
|
|
58
|
+
// The body is the last non-flag arg. Walk argv backwards skipping
|
|
59
|
+
// flag values.
|
|
60
|
+
let bodyArg;
|
|
61
|
+
for (let i = argv.length - 1; i >= 0; i--) {
|
|
62
|
+
const a = argv[i];
|
|
63
|
+
if (a.startsWith('--')) break;
|
|
64
|
+
// Make sure we're not the value of a flag like `--to <value>`
|
|
65
|
+
const prev = argv[i - 1];
|
|
66
|
+
if (prev && FLAGS_WITH_VALUE.has(prev)) continue;
|
|
67
|
+
bodyArg = a;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const body = resolveBody(bodyArg);
|
|
72
|
+
if (body == null || body.length === 0) {
|
|
73
|
+
process.stderr.write('crosstalk run: missing body (file path, inline string, or `-` for stdin)\n');
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const channel = flag(argv, '--channel');
|
|
78
|
+
const from = flag(argv, '--from') ?? process.env.USER ?? 'operator';
|
|
79
|
+
|
|
80
|
+
let payload;
|
|
81
|
+
if (type === 'primitive') {
|
|
82
|
+
const to = flag(argv, '--to');
|
|
83
|
+
if (!to) {
|
|
84
|
+
process.stderr.write('crosstalk run --type primitive: --to <model> is required\n');
|
|
85
|
+
return 1;
|
|
86
|
+
}
|
|
87
|
+
const fanoutRaw = flag(argv, '--fanout');
|
|
88
|
+
const fanout = fanoutRaw ? Math.max(1, parseInt(fanoutRaw, 10)) : 1;
|
|
89
|
+
payload = {
|
|
90
|
+
type: 'primitive',
|
|
91
|
+
to,
|
|
92
|
+
as: flag(argv, '--as'),
|
|
93
|
+
channel,
|
|
94
|
+
from,
|
|
95
|
+
fanout,
|
|
96
|
+
body,
|
|
97
|
+
};
|
|
98
|
+
} else {
|
|
99
|
+
payload = {
|
|
100
|
+
type: 'workflow',
|
|
101
|
+
channel,
|
|
102
|
+
from,
|
|
103
|
+
body,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let resp;
|
|
108
|
+
try {
|
|
109
|
+
resp = await api.post('/messages', payload);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
reportAndExit(err, 'crosstalk run');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (resp.type === 'primitive') {
|
|
115
|
+
for (const p of resp.relPaths) process.stdout.write(`Sent: ${p}\n`);
|
|
116
|
+
} else {
|
|
117
|
+
process.stdout.write(`Workflow dispatched: ${resp.relPath}\n`);
|
|
118
|
+
process.stdout.write(`Child channel: ${resp.childChannel} (parent: ${resp.channel.slice(0, 8)})\n`);
|
|
119
|
+
}
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// crosstalk status — mirror of crosstalkd status output, fetched via API.
|
|
2
|
+
|
|
3
|
+
import { api } from '../lib/api-client.js';
|
|
4
|
+
import { reportAndExit } from '../lib/errors.js';
|
|
5
|
+
|
|
6
|
+
export async function run(_argv) {
|
|
7
|
+
let s;
|
|
8
|
+
try {
|
|
9
|
+
s = await api.get('/status');
|
|
10
|
+
} catch (err) {
|
|
11
|
+
reportAndExit(err, 'crosstalk status');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
process.stdout.write(`Crosstalk transport: ${s.transport_root}\n`);
|
|
15
|
+
process.stdout.write(`Engine alias: ${s.alias}\n`);
|
|
16
|
+
process.stdout.write(`Engine version: ${s.version}\n`);
|
|
17
|
+
process.stdout.write('\n');
|
|
18
|
+
|
|
19
|
+
process.stdout.write(`Claimed models: ${s.claimed_models.length}\n`);
|
|
20
|
+
if (s.claimed_models.length === 0) {
|
|
21
|
+
process.stdout.write(' (no model CLIs found inside the container — install one or edit data/models.yaml)\n');
|
|
22
|
+
} else {
|
|
23
|
+
for (const m of s.claimed_models) process.stdout.write(` - ${m}\n`);
|
|
24
|
+
}
|
|
25
|
+
process.stdout.write('\n');
|
|
26
|
+
|
|
27
|
+
if (s.heartbeat) {
|
|
28
|
+
const ageS = Math.floor((Date.now() - new Date(s.heartbeat.ts).getTime()) / 1000);
|
|
29
|
+
const label = ageS < 120 ? 'LIVE' : ageS > 300 ? 'STALE' : 'idle';
|
|
30
|
+
process.stdout.write(`Dispatch heartbeat: ${label} (pid ${s.heartbeat.pid}, last tick ${ageS}s ago, ${s.heartbeat.version})\n`);
|
|
31
|
+
} else {
|
|
32
|
+
process.stdout.write('Dispatch heartbeat: never\n');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (s.cursor) {
|
|
36
|
+
process.stdout.write(`Cursor: ${s.cursor.slice(0, 12)} (global, machine-local)\n`);
|
|
37
|
+
} else {
|
|
38
|
+
process.stdout.write('Cursor: (none yet — first tick will seed to HEAD)\n');
|
|
39
|
+
}
|
|
40
|
+
process.stdout.write('\n');
|
|
41
|
+
|
|
42
|
+
process.stdout.write(`Channels: ${s.channels.length}\n`);
|
|
43
|
+
for (const ch of s.channels) {
|
|
44
|
+
const name = ch.name ?? '(unnamed)';
|
|
45
|
+
const parentSuffix = ch.parent ? ` [child of ${ch.parent.slice(0, 8)}]` : '';
|
|
46
|
+
process.stdout.write(` ${ch.uuid.slice(0, 8)}... — ${name}${parentSuffix}\n`);
|
|
47
|
+
}
|
|
48
|
+
process.stdout.write('\n');
|
|
49
|
+
|
|
50
|
+
process.stdout.write(`Infrastructure errors logged: ${s.errors_logged}\n`);
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
package/commands/up.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// crosstalk up — bring up the engine container for this transport.
|
|
2
|
+
//
|
|
3
|
+
// First-time use: generates docker-compose.yml in the transport root from
|
|
4
|
+
// a template (operator can edit freely, subsequent `up`s won't clobber).
|
|
5
|
+
// The compose file bind-mounts the transport root as the engine's working
|
|
6
|
+
// tree so git operations work both ways — engine sees operator's local
|
|
7
|
+
// commits and configured remotes immediately, operator sees engine
|
|
8
|
+
// commits (replies, cursor advances) immediately.
|
|
9
|
+
//
|
|
10
|
+
// Subsequent runs: just shell out to `docker compose up -d`.
|
|
11
|
+
|
|
12
|
+
import { writeFileSync, existsSync, appendFileSync, readFileSync } from 'fs';
|
|
13
|
+
import { spawnSync } from 'child_process';
|
|
14
|
+
import { join } from 'path';
|
|
15
|
+
import { requireTransportRoot, transportName, composeFile } from '../lib/transport.js';
|
|
16
|
+
import { has } from '../lib/argv.js';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_IMAGE = process.env.CROSSTALK_IMAGE
|
|
19
|
+
?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.1';
|
|
20
|
+
const DEFAULT_API_PORT = Number(process.env.CROSSTALK_API_PORT) || 7000;
|
|
21
|
+
|
|
22
|
+
function usage(exit = 0) {
|
|
23
|
+
const w = exit === 0 ? process.stdout : process.stderr;
|
|
24
|
+
w.write(
|
|
25
|
+
`Usage: crosstalk up
|
|
26
|
+
|
|
27
|
+
Brings up the engine container for the transport in the current directory
|
|
28
|
+
(or any ancestor containing CROSSTALK-VERSION). First-time use generates
|
|
29
|
+
docker-compose.yml in the transport root. Override the image with
|
|
30
|
+
CROSSTALK_IMAGE, the API port with CROSSTALK_API_PORT.
|
|
31
|
+
`,
|
|
32
|
+
);
|
|
33
|
+
process.exit(exit);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function renderCompose({ name, image, apiPort, alias, uid, gid }) {
|
|
37
|
+
// Entrypoint runs as root for setup (apt-installed CLIs in /root,
|
|
38
|
+
// npm prefix, SSH key import). It then adjusts a 'crosstalkd' user
|
|
39
|
+
// to match CROSSTALK_UID/GID (operator's host UID), chowns the
|
|
40
|
+
// bind-mounted transport + state dir to that user, and drops via
|
|
41
|
+
// setpriv before exec'ing the dispatcher. Net effect: bind-mount
|
|
42
|
+
// files written by the engine are operator-owned on the host, so
|
|
43
|
+
// \`git remote add\` and friends work without sudo. /root stays
|
|
44
|
+
// root-owned for CLI installs.
|
|
45
|
+
return `# Generated by \`crosstalk up\`. Safe to edit; subsequent \`up\`s
|
|
46
|
+
# only regenerate when this file is missing. Compose config is machine-
|
|
47
|
+
# local — kept out of git via .gitignore by default.
|
|
48
|
+
|
|
49
|
+
services:
|
|
50
|
+
crosstalkd:
|
|
51
|
+
image: ${image}
|
|
52
|
+
container_name: crosstalk-${name}
|
|
53
|
+
restart: unless-stopped
|
|
54
|
+
ports:
|
|
55
|
+
- "127.0.0.1:${apiPort}:7000"
|
|
56
|
+
volumes:
|
|
57
|
+
# Transport bind-mount: \`git remote add\` from the host is visible
|
|
58
|
+
# to the engine on the next tick. No clone-on-startup needed.
|
|
59
|
+
- .:/var/lib/crosstalk-transport
|
|
60
|
+
# Operator-installed agent CLIs (claude, codex, agy, ...) + auth
|
|
61
|
+
# state. Root-owned, dispatcher reads only.
|
|
62
|
+
- crosstalk-root:/root
|
|
63
|
+
# Dispatcher's machine-local state: cursor, heartbeat, errors.log.
|
|
64
|
+
# Operator-UID-owned (chowned by entrypoint to CROSSTALK_UID/GID).
|
|
65
|
+
- crosstalk-state:/var/lib/crosstalkd-state
|
|
66
|
+
environment:
|
|
67
|
+
CROSSTALK_ALIAS: ${alias}
|
|
68
|
+
CROSSTALK_UID: "${uid}"
|
|
69
|
+
CROSSTALK_GID: "${gid}"
|
|
70
|
+
CROSSTALKD_API_PORT: "7000"
|
|
71
|
+
DISPATCH_JSON: "true"
|
|
72
|
+
DISPATCH_POLL_SECONDS: "30"
|
|
73
|
+
|
|
74
|
+
volumes:
|
|
75
|
+
crosstalk-root:
|
|
76
|
+
crosstalk-state:
|
|
77
|
+
`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function ensureGitignored(transportRoot) {
|
|
81
|
+
const path = join(transportRoot, '.gitignore');
|
|
82
|
+
const entry = 'docker-compose.yml\n';
|
|
83
|
+
if (!existsSync(path)) {
|
|
84
|
+
writeFileSync(path, entry);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const current = readFileSync(path, 'utf-8');
|
|
88
|
+
if (!current.split('\n').includes('docker-compose.yml')) {
|
|
89
|
+
appendFileSync(path, (current.endsWith('\n') ? '' : '\n') + entry);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function run(argv) {
|
|
94
|
+
if (has(argv, '--help') || has(argv, '-h')) usage(0);
|
|
95
|
+
|
|
96
|
+
const root = requireTransportRoot();
|
|
97
|
+
const composeYml = composeFile(root);
|
|
98
|
+
const generated = !existsSync(composeYml);
|
|
99
|
+
|
|
100
|
+
if (generated) {
|
|
101
|
+
const alias = process.env.CROSSTALK_ALIAS ?? process.env.HOSTNAME ?? transportName(root);
|
|
102
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : 1000;
|
|
103
|
+
const gid = typeof process.getgid === 'function' ? process.getgid() : 1000;
|
|
104
|
+
const content = renderCompose({
|
|
105
|
+
name: transportName(root),
|
|
106
|
+
image: DEFAULT_IMAGE,
|
|
107
|
+
apiPort: DEFAULT_API_PORT,
|
|
108
|
+
alias,
|
|
109
|
+
uid,
|
|
110
|
+
gid,
|
|
111
|
+
});
|
|
112
|
+
writeFileSync(composeYml, content);
|
|
113
|
+
ensureGitignored(root);
|
|
114
|
+
process.stdout.write(`Generated ${composeYml}\n`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const r = spawnSync('docker', ['compose', '-f', composeYml, 'up', '-d'], {
|
|
118
|
+
cwd: root,
|
|
119
|
+
stdio: 'inherit',
|
|
120
|
+
});
|
|
121
|
+
if (r.status !== 0) {
|
|
122
|
+
process.stderr.write(`crosstalk up: docker compose exited ${r.status}\n`);
|
|
123
|
+
return r.status ?? 1;
|
|
124
|
+
}
|
|
125
|
+
process.stdout.write(
|
|
126
|
+
`\nEngine starting. Check status with 'crosstalk status' (give it ~5s to settle).\n`,
|
|
127
|
+
);
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// crosstalk version — print client and (if reachable) engine versions.
|
|
2
|
+
//
|
|
3
|
+
// Doesn't fail if the engine isn't reachable; just prints "(engine
|
|
4
|
+
// unreachable)" since you might be running `version` before bringing
|
|
5
|
+
// the container up.
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from 'fs';
|
|
8
|
+
import { dirname, join } from 'path';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
10
|
+
import { api, ConnectError } from '../lib/api-client.js';
|
|
11
|
+
|
|
12
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const pkgPath = join(thisDir, '..', 'package.json');
|
|
14
|
+
const clientPkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
15
|
+
|
|
16
|
+
export async function run(_argv) {
|
|
17
|
+
process.stdout.write(`crosstalk client: ${clientPkg.version}\n`);
|
|
18
|
+
try {
|
|
19
|
+
const v = await api.get('/version');
|
|
20
|
+
process.stdout.write(`crosstalkd engine: ${v.version} (alias: ${v.alias})\n`);
|
|
21
|
+
} catch (err) {
|
|
22
|
+
if (err instanceof ConnectError) {
|
|
23
|
+
process.stdout.write(`crosstalkd engine: (unreachable on 127.0.0.1:${err.port})\n`);
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
process.stdout.write(`crosstalkd engine: (error: ${err.message})\n`);
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// api-client.js — HTTP client over loopback to crosstalkd's local API.
|
|
2
|
+
//
|
|
3
|
+
// All operator commands route through this. The engine binds 127.0.0.1
|
|
4
|
+
// inside the container; docker-compose maps that to the host loopback
|
|
5
|
+
// at the same port. Client connects to http://127.0.0.1:<port>/<path>.
|
|
6
|
+
//
|
|
7
|
+
// Port resolution (in order):
|
|
8
|
+
// 1. --port <n> CLI flag (handled by individual commands)
|
|
9
|
+
// 2. CROSSTALK_API_PORT env var
|
|
10
|
+
// 3. Default 7000
|
|
11
|
+
//
|
|
12
|
+
// No auth: the engine binds 127.0.0.1 only and there's no token. Same
|
|
13
|
+
// model as ollama / postgres-on-localhost.
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_API_PORT = 7000;
|
|
16
|
+
|
|
17
|
+
export function resolvePort(explicit) {
|
|
18
|
+
if (typeof explicit === 'number' && Number.isInteger(explicit)) return explicit;
|
|
19
|
+
const fromEnv = Number(process.env.CROSSTALK_API_PORT);
|
|
20
|
+
if (Number.isInteger(fromEnv) && fromEnv > 0) return fromEnv;
|
|
21
|
+
return DEFAULT_API_PORT;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function apiUrl(path, port) {
|
|
25
|
+
return `http://127.0.0.1:${port}${path}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function parseJsonOrThrow(res) {
|
|
29
|
+
let body;
|
|
30
|
+
try {
|
|
31
|
+
body = await res.json();
|
|
32
|
+
} catch {
|
|
33
|
+
throw new ApiError(res.status, 'invalid JSON in response');
|
|
34
|
+
}
|
|
35
|
+
if (!res.ok) {
|
|
36
|
+
const msg = body && typeof body.error === 'string' ? body.error : `HTTP ${res.status}`;
|
|
37
|
+
throw new ApiError(res.status, msg);
|
|
38
|
+
}
|
|
39
|
+
return body;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class ApiError extends Error {
|
|
43
|
+
constructor(status, message) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = 'ApiError';
|
|
46
|
+
this.status = status;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class ConnectError extends Error {
|
|
51
|
+
constructor(port, cause) {
|
|
52
|
+
super(
|
|
53
|
+
`cannot reach crosstalkd on 127.0.0.1:${port} — is the container running? ` +
|
|
54
|
+
`Try 'crosstalk up' to start it. (underlying: ${cause})`,
|
|
55
|
+
);
|
|
56
|
+
this.name = 'ConnectError';
|
|
57
|
+
this.port = port;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function call(method, path, body, opts = {}) {
|
|
62
|
+
const port = resolvePort(opts.port);
|
|
63
|
+
const init = {
|
|
64
|
+
method,
|
|
65
|
+
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
|
66
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
67
|
+
};
|
|
68
|
+
let res;
|
|
69
|
+
try {
|
|
70
|
+
res = await fetch(apiUrl(path, port), init);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
throw new ConnectError(port, err.message || String(err));
|
|
73
|
+
}
|
|
74
|
+
return parseJsonOrThrow(res);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const api = {
|
|
78
|
+
get: (path, opts) => call('GET', path, undefined, opts),
|
|
79
|
+
post: (path, body, opts) => call('POST', path, body, opts),
|
|
80
|
+
};
|
package/lib/argv.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Tiny argv helpers shared across commands. No yargs / commander
|
|
2
|
+
// dependency — the surfaces are simple enough that hand-parsing is
|
|
3
|
+
// shorter than wiring a framework.
|
|
4
|
+
|
|
5
|
+
export function flag(argv, name) {
|
|
6
|
+
const i = argv.indexOf(name);
|
|
7
|
+
if (i === -1 || i === argv.length - 1) return undefined;
|
|
8
|
+
return argv[i + 1];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function has(argv, name) {
|
|
12
|
+
return argv.includes(name);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// All positional (non-flag) args, skipping flag values.
|
|
16
|
+
export function positionals(argv, flagsWithValue) {
|
|
17
|
+
const valueFlags = new Set(flagsWithValue);
|
|
18
|
+
const out = [];
|
|
19
|
+
for (let i = 0; i < argv.length; i++) {
|
|
20
|
+
const a = argv[i];
|
|
21
|
+
if (a.startsWith('--')) {
|
|
22
|
+
if (valueFlags.has(a)) i++;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
out.push(a);
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
package/lib/errors.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Shared error→stderr formatting for client commands.
|
|
2
|
+
//
|
|
3
|
+
// Pattern: catch known error types (ConnectError, ApiError) and print
|
|
4
|
+
// operator-readable messages with appropriate exit codes. Unknown errors
|
|
5
|
+
// rethrow so they show stack traces — those are real bugs to fix.
|
|
6
|
+
|
|
7
|
+
import { ApiError, ConnectError } from './api-client.js';
|
|
8
|
+
|
|
9
|
+
export function reportAndExit(err, contextHint) {
|
|
10
|
+
if (err instanceof ConnectError) {
|
|
11
|
+
process.stderr.write(`${contextHint}: ${err.message}\n`);
|
|
12
|
+
process.exit(4);
|
|
13
|
+
}
|
|
14
|
+
if (err instanceof ApiError) {
|
|
15
|
+
process.stderr.write(`${contextHint}: ${err.message} (HTTP ${err.status})\n`);
|
|
16
|
+
process.exit(err.status === 400 ? 1 : 3);
|
|
17
|
+
}
|
|
18
|
+
throw err;
|
|
19
|
+
}
|
package/lib/transport.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// transport.js — locate the current Crosstalk transport on the host,
|
|
2
|
+
// derive its conventional names (compose project, container name, etc.).
|
|
3
|
+
//
|
|
4
|
+
// A transport is identified by a CROSSTALK-VERSION file at its root.
|
|
5
|
+
// `crosstalk` walks up from the operator's cwd to find it — same pattern
|
|
6
|
+
// as `git`, `npm`, etc. discovering their root directories.
|
|
7
|
+
|
|
8
|
+
import { existsSync, statSync } from 'fs';
|
|
9
|
+
import { resolve, basename, dirname, join } from 'path';
|
|
10
|
+
|
|
11
|
+
export function findTransportRoot(startDir = process.cwd()) {
|
|
12
|
+
let dir = resolve(startDir);
|
|
13
|
+
for (;;) {
|
|
14
|
+
if (existsSync(join(dir, 'CROSSTALK-VERSION'))) {
|
|
15
|
+
try {
|
|
16
|
+
if (statSync(join(dir, 'CROSSTALK-VERSION')).isFile()) return dir;
|
|
17
|
+
} catch { /* fall through */ }
|
|
18
|
+
}
|
|
19
|
+
const parent = dirname(dir);
|
|
20
|
+
if (parent === dir) return null;
|
|
21
|
+
dir = parent;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function requireTransportRoot(startDir = process.cwd()) {
|
|
26
|
+
const root = findTransportRoot(startDir);
|
|
27
|
+
if (!root) {
|
|
28
|
+
process.stderr.write(
|
|
29
|
+
`crosstalk: not inside a Crosstalk transport ` +
|
|
30
|
+
`(no CROSSTALK-VERSION found from ${resolve(startDir)} upward).\n` +
|
|
31
|
+
`Create one with 'crosstalk init <dir>' or cd into an existing transport.\n`,
|
|
32
|
+
);
|
|
33
|
+
process.exit(2);
|
|
34
|
+
}
|
|
35
|
+
return root;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// transport-name is the basename of the transport root. Used for the
|
|
39
|
+
// compose project, container name, volume names — gives each transport
|
|
40
|
+
// on a host distinct docker objects without an explicit registry.
|
|
41
|
+
export function transportName(transportRoot) {
|
|
42
|
+
return basename(transportRoot);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function containerName(transportRoot) {
|
|
46
|
+
return `crosstalk-${transportName(transportRoot)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function composeFile(transportRoot) {
|
|
50
|
+
return join(transportRoot, 'docker-compose.yml');
|
|
51
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cordfuse/crosstalk",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Crosstalk
|
|
3
|
+
"version": "7.0.0-alpha.1",
|
|
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",
|
|
7
7
|
"homepage": "https://github.com/cordfuse/crosstalk",
|
|
@@ -14,26 +14,10 @@
|
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"bin/",
|
|
17
|
-
"
|
|
18
|
-
"
|
|
17
|
+
"lib/",
|
|
18
|
+
"commands/",
|
|
19
|
+
"README.md"
|
|
19
20
|
],
|
|
20
|
-
"scripts": {
|
|
21
|
-
"build": "tsc --noEmit",
|
|
22
|
-
"lint": "tsc --noEmit",
|
|
23
|
-
"test": "bun test",
|
|
24
|
-
"prepack": "cp -r ../transport template",
|
|
25
|
-
"postpack": "rm -rf template"
|
|
26
|
-
},
|
|
27
|
-
"dependencies": {
|
|
28
|
-
"@cordfuse/turnq": "^0.4.2",
|
|
29
|
-
"@lydell/node-pty": "^1.2.0-beta.12",
|
|
30
|
-
"tsx": "^4.20.0",
|
|
31
|
-
"yaml": "^2.8.0"
|
|
32
|
-
},
|
|
33
|
-
"devDependencies": {
|
|
34
|
-
"@types/node": "^22.10.0",
|
|
35
|
-
"typescript": "^5.7.0"
|
|
36
|
-
},
|
|
37
21
|
"engines": {
|
|
38
22
|
"node": ">=20"
|
|
39
23
|
},
|
package/src/activation.ts
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
// The activation rule — pure functions, no I/O, exhaustively unit-tested.
|
|
2
|
-
//
|
|
3
|
-
// One rule (CROSSTALK.md "Activation"):
|
|
4
|
-
//
|
|
5
|
-
// A message wakes its addressee if it has no `re:` (a new task), or its
|
|
6
|
-
// `re:` points at a message the addressee sent.
|
|
7
|
-
//
|
|
8
|
-
// `re:` is written by the runtime at send time, never inferred at read
|
|
9
|
-
// time. It is a string or a list: a reply that answers a batch of N
|
|
10
|
-
// messages records ALL N relPaths, so no answered message is ever lost to
|
|
11
|
-
// batching.
|
|
12
|
-
|
|
13
|
-
export interface ActivationMessage {
|
|
14
|
-
from: string;
|
|
15
|
-
to: string[];
|
|
16
|
-
re?: string | string[];
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function recipients(toField: unknown): string[] {
|
|
20
|
-
if (Array.isArray(toField)) return toField.map(String);
|
|
21
|
-
if (typeof toField === 'string') return [toField];
|
|
22
|
-
return [];
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function reList(reField: unknown): string[] {
|
|
26
|
-
if (Array.isArray(reField)) return reField.map(String);
|
|
27
|
-
if (typeof reField === 'string') return [reField];
|
|
28
|
-
return [];
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// A recipient is `actor` or `actor@host`.
|
|
32
|
-
export function extractActor(recipient: string): string {
|
|
33
|
-
const at = recipient.indexOf('@');
|
|
34
|
-
return at === -1 ? recipient : recipient.slice(0, at);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function targetHost(recipient: string): string | null {
|
|
38
|
-
const at = recipient.indexOf('@');
|
|
39
|
-
return at === -1 ? null : recipient.slice(at + 1);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export interface RoutingResult {
|
|
43
|
-
addressed: boolean;
|
|
44
|
-
// Actor was named, but every instance targeted a different host — logged
|
|
45
|
-
// by the dispatcher so wrong-host routes are visible, never silent.
|
|
46
|
-
wrongHost: boolean;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function matchRouting(
|
|
50
|
-
recipientList: string[],
|
|
51
|
-
actorName: string,
|
|
52
|
-
thisHost: string,
|
|
53
|
-
): RoutingResult {
|
|
54
|
-
let actorNamedAtAll = false;
|
|
55
|
-
for (const r of recipientList) {
|
|
56
|
-
if (r === 'all') return { addressed: true, wrongHost: false };
|
|
57
|
-
if (extractActor(r) !== actorName) continue;
|
|
58
|
-
actorNamedAtAll = true;
|
|
59
|
-
const host = targetHost(r);
|
|
60
|
-
if (host === null || host === thisHost) return { addressed: true, wrongHost: false };
|
|
61
|
-
}
|
|
62
|
-
return { addressed: false, wrongHost: actorNamedAtAll };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export type WakeDecision = 'wake' | 'skip' | 'wrong-host';
|
|
66
|
-
|
|
67
|
-
// `senderOf` resolves a channel relPath to the `from:` of the message there,
|
|
68
|
-
// or undefined if no such message exists. A dangling `re:` entry (target
|
|
69
|
-
// missing) wakes the addressee — fail open so a message is never silently
|
|
70
|
-
// dropped; no loop is possible because the reply to it carries a
|
|
71
|
-
// resolvable `re:`.
|
|
72
|
-
export function decideWake(
|
|
73
|
-
msg: ActivationMessage,
|
|
74
|
-
actorName: string,
|
|
75
|
-
thisHost: string,
|
|
76
|
-
senderOf: (relPath: string) => string | undefined,
|
|
77
|
-
): WakeDecision {
|
|
78
|
-
const routing = matchRouting(msg.to, actorName, thisHost);
|
|
79
|
-
if (!routing.addressed) return routing.wrongHost ? 'wrong-host' : 'skip';
|
|
80
|
-
if (msg.from === actorName) return 'skip';
|
|
81
|
-
const targets = reList(msg.re);
|
|
82
|
-
if (targets.length === 0) return 'wake';
|
|
83
|
-
for (const target of targets) {
|
|
84
|
-
const asker = senderOf(target);
|
|
85
|
-
if (asker === undefined || asker === actorName) return 'wake';
|
|
86
|
-
}
|
|
87
|
-
return 'skip';
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Split a channel's pending messages (already sorted by relPath) into
|
|
91
|
-
// contiguous batches sized for the actor's concurrency. Contiguous so each
|
|
92
|
-
// batch's highest relPath is monotone across batches — the cursor advances
|
|
93
|
-
// safely per batch. When pending fits within concurrency, every batch is a
|
|
94
|
-
// single message (parallel fan-out); when it exceeds, batches collapse into
|
|
95
|
-
// ~concurrency invocations (fan-in stays O(1) per actor).
|
|
96
|
-
export function splitForConcurrency<T>(msgs: T[], concurrency: number): T[][] {
|
|
97
|
-
if (concurrency <= 1 || msgs.length <= 1) return [msgs];
|
|
98
|
-
const chunkSize = Math.max(1, Math.ceil(msgs.length / concurrency));
|
|
99
|
-
const out: T[][] = [];
|
|
100
|
-
for (let i = 0; i < msgs.length; i += chunkSize) {
|
|
101
|
-
out.push(msgs.slice(i, i + chunkSize));
|
|
102
|
-
}
|
|
103
|
-
return out;
|
|
104
|
-
}
|