@cordfuse/crosstalk 7.0.0-alpha.9 → 8.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/GUIDE-CLI.md +322 -0
- package/GUIDE-PROMPTS.md +118 -0
- package/LICENSE +21 -0
- package/README.md +422 -61
- package/bin/crosstalk.js +88 -54
- package/commands/agent.js +69 -0
- package/commands/auth.js +273 -0
- package/commands/channel.js +54 -44
- package/commands/chat.js +107 -71
- package/commands/daemon.js +120 -0
- package/commands/logs.js +108 -19
- package/commands/message.js +125 -0
- package/commands/server.js +185 -0
- package/commands/settings.js +49 -0
- package/commands/status.js +37 -13
- package/commands/token.js +136 -0
- package/commands/transport.js +299 -0
- package/commands/version.js +3 -3
- package/commands/workflow.js +234 -0
- package/deploy/crosstalk@.service +62 -0
- package/deploy/install.sh +82 -0
- package/lib/api-client.js +77 -22
- package/lib/credentials.js +207 -0
- package/lib/nativeServer.js +182 -0
- package/lib/resolve.js +106 -37
- package/package.json +27 -4
- package/src/activation.ts +104 -0
- package/src/api.ts +1717 -0
- package/src/auth/enforce.ts +68 -0
- package/src/auth/handlers.ts +266 -0
- package/src/auth/middleware.ts +132 -0
- package/src/auth/setup.ts +263 -0
- package/src/auth/tokens.ts +285 -0
- package/src/auth/users.ts +267 -0
- package/src/dispatch.ts +530 -0
- package/src/dispatchers.ts +91 -0
- package/src/filenames.ts +28 -0
- package/src/frontmatter.ts +26 -0
- package/src/init.ts +116 -0
- package/src/invoke.ts +201 -0
- package/src/log-buffer.ts +67 -0
- package/src/models.ts +300 -0
- package/src/resolve.ts +100 -0
- package/src/state.ts +191 -0
- package/src/stop.ts +37 -0
- package/src/transport.ts +353 -0
- package/src/web/auth-pages.ts +160 -0
- package/src/web/channels.ts +395 -0
- package/src/web/chat-page.ts +636 -0
- package/src/web/chat-pty.ts +254 -0
- package/src/web/dashboard.ts +130 -0
- package/src/web/layout.ts +237 -0
- package/src/web/stubs.ts +510 -0
- package/src/web/workflows.ts +490 -0
- package/src/workflow.ts +470 -0
- package/template/CLAUDE.md +10 -0
- package/template/CROSSTALK-VERSION +1 -0
- package/template/CROSSTALK.md +262 -0
- package/template/PROTOCOL.md +70 -0
- package/template/README.md +64 -0
- package/template/auth/.gitkeep +0 -0
- package/template/auth/README.md +224 -0
- package/template/data/crosstalk.yaml +196 -0
- package/template/gitignore +4 -0
- package/commands/down.js +0 -40
- package/commands/init.js +0 -243
- package/commands/pull.js +0 -22
- package/commands/replies.js +0 -40
- package/commands/restart.js +0 -29
- package/commands/rm.js +0 -109
- package/commands/run.js +0 -115
- package/commands/up.js +0 -135
package/lib/resolve.js
CHANGED
|
@@ -10,14 +10,22 @@
|
|
|
10
10
|
// identity from the operator's filesystem position). The new model is
|
|
11
11
|
// position-independent — operator can be anywhere on disk.
|
|
12
12
|
|
|
13
|
-
import { existsSync, readFileSync, mkdirSync, readdirSync } from 'fs';
|
|
13
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
|
|
14
14
|
import { spawnSync } from 'child_process';
|
|
15
|
-
import { join } from 'path';
|
|
15
|
+
import { dirname, join } from 'path';
|
|
16
|
+
import { fileURLToPath } from 'url';
|
|
16
17
|
import { homedir } from 'os';
|
|
17
18
|
|
|
18
19
|
export const DEFAULT_CONTAINER_NAME = 'crosstalk';
|
|
19
20
|
export const DEFAULT_API_PORT = 7000;
|
|
20
|
-
|
|
21
|
+
|
|
22
|
+
// Client version exported for the skew-warn check in api-client.js. The
|
|
23
|
+
// v7 DEFAULT_IMAGE export (resolved CROSSTALK_IMAGE env or
|
|
24
|
+
// ghcr.io/cordfuse/crosstalk-server:<CLIENT_VERSION>) was removed in
|
|
25
|
+
// v8-native — no container image to pull.
|
|
26
|
+
const _thisDir = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const _pkg = JSON.parse(readFileSync(join(_thisDir, '..', 'package.json'), 'utf-8'));
|
|
28
|
+
export const CLIENT_VERSION = _pkg.version;
|
|
21
29
|
|
|
22
30
|
// Per-OS storage bases. User-mode (default) keeps everything under the
|
|
23
31
|
// operator's home — no sudo/UAC, no Docker Desktop file-sharing allowlist
|
|
@@ -42,36 +50,83 @@ export function resolveBase(mode) {
|
|
|
42
50
|
};
|
|
43
51
|
|
|
44
52
|
if (mode !== 'user' && mode !== 'system') {
|
|
45
|
-
throw new Error(`
|
|
53
|
+
throw new Error(`storage mode '${mode}' invalid — must be 'user' or 'system' (see CROSSTALK_USER_MODE).`);
|
|
46
54
|
}
|
|
47
55
|
return bases[mode];
|
|
48
56
|
}
|
|
49
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the runtime mode: 'user' or 'system'.
|
|
60
|
+
*
|
|
61
|
+
* v8-native env-var convention: `CROSSTALK_USER_MODE=1` opts into user
|
|
62
|
+
* mode. Without it, the default is 'system' (matches @cordfuse/llmux's
|
|
63
|
+
* `LLMUX_USER_MODE` pattern — boolean opt-in to user mode, system is
|
|
64
|
+
* the production default).
|
|
65
|
+
*
|
|
66
|
+
* v7 used `CROSSTALK_STORAGE_MODE=user|system` and defaulted to 'user'.
|
|
67
|
+
* That string form is still honored for one transition release so
|
|
68
|
+
* operator scripts don't break instantly — a one-time stderr deprecation
|
|
69
|
+
* notice fires when it's read. v9 cuts the back-compat path.
|
|
70
|
+
*
|
|
71
|
+
* Truthy values for CROSSTALK_USER_MODE: '1', 'true', 'yes' (case
|
|
72
|
+
* insensitive). Anything else (including '0', 'false', empty) → false.
|
|
73
|
+
*/
|
|
50
74
|
export function storageMode() {
|
|
51
|
-
|
|
75
|
+
// v7 back-compat path (one transition release).
|
|
76
|
+
const legacy = process.env.CROSSTALK_STORAGE_MODE;
|
|
77
|
+
if (legacy !== undefined) {
|
|
78
|
+
const v = legacy.toLowerCase();
|
|
79
|
+
if (v === 'user' || v === 'system') {
|
|
80
|
+
if (!process.env._CROSSTALK_LEGACY_MODE_WARNED) {
|
|
81
|
+
process.stderr.write(
|
|
82
|
+
`crosstalk: CROSSTALK_STORAGE_MODE='${v}' is deprecated.\n` +
|
|
83
|
+
` Use CROSSTALK_USER_MODE=1 to opt into user mode; otherwise system mode is the default.\n`,
|
|
84
|
+
);
|
|
85
|
+
process.env._CROSSTALK_LEGACY_MODE_WARNED = '1';
|
|
86
|
+
}
|
|
87
|
+
return v;
|
|
88
|
+
}
|
|
89
|
+
throw new Error(`CROSSTALK_STORAGE_MODE='${legacy}' invalid — must be 'user' or 'system'.`);
|
|
90
|
+
}
|
|
91
|
+
// v8 path: boolean opt-in to user mode, default system.
|
|
92
|
+
const userVar = (process.env.CROSSTALK_USER_MODE ?? '').toLowerCase();
|
|
93
|
+
const userMode = userVar === '1' || userVar === 'true' || userVar === 'yes';
|
|
94
|
+
return userMode ? 'user' : 'system';
|
|
52
95
|
}
|
|
53
96
|
|
|
54
|
-
// All storage paths for a given
|
|
97
|
+
// All storage paths for a given transport name, derived from the resolved
|
|
55
98
|
// base. None of these are validated for existence here — callers check
|
|
56
|
-
// based on context (init pre-creates,
|
|
99
|
+
// based on context (init pre-creates, server start verifies, etc).
|
|
100
|
+
//
|
|
101
|
+
// v8-native: `composeFile` retained for transitional reads of legacy
|
|
102
|
+
// state directories (compose-mode upgrades), but no new compose files
|
|
103
|
+
// are generated. `pidFile` and `logFile` are written by the host-spawned
|
|
104
|
+
// engine (now `crosstalk daemon dispatch`) so `crosstalk server status`
|
|
105
|
+
// and `crosstalk server stop` have a single source of truth without
|
|
106
|
+
// querying docker.
|
|
57
107
|
export function storagePaths(name, mode = storageMode()) {
|
|
58
108
|
const base = resolveBase(mode);
|
|
59
109
|
const root = join(base, name);
|
|
110
|
+
const state = join(root, 'crosstalk-state');
|
|
60
111
|
return {
|
|
61
112
|
base,
|
|
62
113
|
mode,
|
|
63
114
|
storageRoot: root,
|
|
64
115
|
transportDir: join(root, 'transport'),
|
|
65
116
|
crosstalkRoot: join(root, 'crosstalk-root'),
|
|
66
|
-
crosstalkState:
|
|
67
|
-
composeFile: join(root, 'docker-compose.yml'),
|
|
117
|
+
crosstalkState: state,
|
|
118
|
+
composeFile: join(root, 'docker-compose.yml'), // legacy; not generated by v8
|
|
68
119
|
portFile: join(root, 'api-port'),
|
|
120
|
+
pidFile: join(state, 'crosstalk.pid'),
|
|
121
|
+
logFile: join(state, 'crosstalk.log'),
|
|
69
122
|
};
|
|
70
123
|
}
|
|
71
124
|
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
125
|
+
// Transport name from --containername (preferred: --name in v8) flag,
|
|
126
|
+
// defaulting to `crosstalk`. The flag is still --containername for now
|
|
127
|
+
// to keep operator muscle memory + scripted invocations working through
|
|
128
|
+
// the v7→v8 transition. Validates the name is sane filesystem dir name:
|
|
129
|
+
// lowercase alphanumeric, `_.-`, no leading dot/hyphen.
|
|
75
130
|
const NAME_RE = /^[a-z0-9][a-z0-9_.-]*$/;
|
|
76
131
|
export function parseContainerName(argv) {
|
|
77
132
|
let name = DEFAULT_CONTAINER_NAME;
|
|
@@ -132,37 +187,51 @@ export function pickFreePort(name, mode = storageMode()) {
|
|
|
132
187
|
throw new Error('Could not find a free port between 7001 and 7999.');
|
|
133
188
|
}
|
|
134
189
|
|
|
135
|
-
// True if
|
|
136
|
-
//
|
|
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'".)
|
|
137
194
|
export function isInitialized(name) {
|
|
138
195
|
const paths = storagePaths(name);
|
|
139
|
-
return existsSync(join(paths.transportDir, '
|
|
196
|
+
return existsSync(join(paths.transportDir, 'CROSSTALK-VERSION'));
|
|
140
197
|
}
|
|
141
198
|
|
|
142
|
-
// True if
|
|
143
|
-
//
|
|
144
|
-
//
|
|
199
|
+
// True if the crosstalk engine is running for this transport. Source of truth:
|
|
200
|
+
// the PID file in crosstalk-state/. PID is verified alive with kill(0)
|
|
201
|
+
// (no signal sent, just an EPERM/ESRCH probe). Stale PID files (e.g.,
|
|
202
|
+
// after an unclean shutdown) are treated as not-running and the file is
|
|
203
|
+
// best-effort removed.
|
|
204
|
+
//
|
|
205
|
+
// v8-native: replaces the docker-ps query. The legacy isRunning that
|
|
206
|
+
// shelled out to docker for v7 lifecycle has been removed; if you find
|
|
207
|
+
// a caller that still expects container semantics, it's a leftover from
|
|
208
|
+
// the docker era and should be migrated to PID semantics.
|
|
145
209
|
export function isRunning(name) {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (
|
|
152
|
-
|
|
210
|
+
const paths = storagePaths(name);
|
|
211
|
+
if (!existsSync(paths.pidFile)) return false;
|
|
212
|
+
let pid;
|
|
213
|
+
try { pid = Number(readFileSync(paths.pidFile, 'utf-8').trim()); }
|
|
214
|
+
catch { return false; }
|
|
215
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
216
|
+
try {
|
|
217
|
+
process.kill(pid, 0); // no-op signal; throws if not alive / not ours
|
|
218
|
+
return true;
|
|
219
|
+
} catch {
|
|
220
|
+
// Stale PID file — sweep it so subsequent start doesn't get confused.
|
|
221
|
+
try { writeFileSync(paths.pidFile, ''); } catch { /* best effort */ }
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
153
224
|
}
|
|
154
225
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
);
|
|
164
|
-
if (r.status !== 0) return false;
|
|
165
|
-
return r.stdout.trim().split('\n').includes(name);
|
|
226
|
+
/** Read the recorded PID for a running transport, or undefined. */
|
|
227
|
+
export function runningPid(name) {
|
|
228
|
+
const paths = storagePaths(name);
|
|
229
|
+
if (!existsSync(paths.pidFile)) return undefined;
|
|
230
|
+
let pid;
|
|
231
|
+
try { pid = Number(readFileSync(paths.pidFile, 'utf-8').trim()); }
|
|
232
|
+
catch { return undefined; }
|
|
233
|
+
if (!Number.isInteger(pid) || pid <= 0) return undefined;
|
|
234
|
+
try { process.kill(pid, 0); return pid; } catch { return undefined; }
|
|
166
235
|
}
|
|
167
236
|
|
|
168
237
|
// Look up the resolved name+paths from argv, validate that init has run,
|
|
@@ -183,7 +252,7 @@ export function requireInitialized(argv) {
|
|
|
183
252
|
if (!isInitialized(name)) {
|
|
184
253
|
process.stderr.write(
|
|
185
254
|
`crosstalk: no transport '${name}' on this machine.\n` +
|
|
186
|
-
` Run 'crosstalk init${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}' to set one up.\n`,
|
|
255
|
+
` Run 'crosstalk transport init${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}' to set one up.\n`,
|
|
187
256
|
);
|
|
188
257
|
process.exit(2);
|
|
189
258
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cordfuse/crosstalk",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Crosstalk
|
|
3
|
+
"version": "8.0.0-alpha.1",
|
|
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",
|
|
@@ -14,10 +14,33 @@
|
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"bin/",
|
|
17
|
-
"lib/",
|
|
18
17
|
"commands/",
|
|
19
|
-
"
|
|
18
|
+
"lib/",
|
|
19
|
+
"src/",
|
|
20
|
+
"template/",
|
|
21
|
+
"deploy/",
|
|
22
|
+
"README.md",
|
|
23
|
+
"GUIDE-CLI.md",
|
|
24
|
+
"GUIDE-PROMPTS.md"
|
|
20
25
|
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc --noEmit",
|
|
28
|
+
"lint": "tsc --noEmit",
|
|
29
|
+
"test": "node --import tsx --test tests/*.test.ts",
|
|
30
|
+
"prepack": "cp -r transport template",
|
|
31
|
+
"postpack": "rm -rf template"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@types/ws": "^8.18.1",
|
|
35
|
+
"node-pty": "^1.1.0",
|
|
36
|
+
"tsx": "^4.20.0",
|
|
37
|
+
"ws": "^8.21.0",
|
|
38
|
+
"yaml": "^2.8.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^22.10.0",
|
|
42
|
+
"typescript": "^5.7.0"
|
|
43
|
+
},
|
|
21
44
|
"engines": {
|
|
22
45
|
"node": ">=20"
|
|
23
46
|
},
|
|
@@ -0,0 +1,104 @@
|
|
|
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
|
+
}
|