@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.
Files changed (72) hide show
  1. package/GUIDE-CLI.md +322 -0
  2. package/GUIDE-PROMPTS.md +118 -0
  3. package/LICENSE +21 -0
  4. package/README.md +422 -61
  5. package/bin/crosstalk.js +88 -54
  6. package/commands/agent.js +69 -0
  7. package/commands/auth.js +273 -0
  8. package/commands/channel.js +54 -44
  9. package/commands/chat.js +107 -71
  10. package/commands/daemon.js +120 -0
  11. package/commands/logs.js +108 -19
  12. package/commands/message.js +125 -0
  13. package/commands/server.js +185 -0
  14. package/commands/settings.js +49 -0
  15. package/commands/status.js +37 -13
  16. package/commands/token.js +136 -0
  17. package/commands/transport.js +299 -0
  18. package/commands/version.js +3 -3
  19. package/commands/workflow.js +234 -0
  20. package/deploy/crosstalk@.service +62 -0
  21. package/deploy/install.sh +82 -0
  22. package/lib/api-client.js +77 -22
  23. package/lib/credentials.js +207 -0
  24. package/lib/nativeServer.js +182 -0
  25. package/lib/resolve.js +106 -37
  26. package/package.json +27 -4
  27. package/src/activation.ts +104 -0
  28. package/src/api.ts +1717 -0
  29. package/src/auth/enforce.ts +68 -0
  30. package/src/auth/handlers.ts +266 -0
  31. package/src/auth/middleware.ts +132 -0
  32. package/src/auth/setup.ts +263 -0
  33. package/src/auth/tokens.ts +285 -0
  34. package/src/auth/users.ts +267 -0
  35. package/src/dispatch.ts +530 -0
  36. package/src/dispatchers.ts +91 -0
  37. package/src/filenames.ts +28 -0
  38. package/src/frontmatter.ts +26 -0
  39. package/src/init.ts +116 -0
  40. package/src/invoke.ts +201 -0
  41. package/src/log-buffer.ts +67 -0
  42. package/src/models.ts +300 -0
  43. package/src/resolve.ts +100 -0
  44. package/src/state.ts +191 -0
  45. package/src/stop.ts +37 -0
  46. package/src/transport.ts +353 -0
  47. package/src/web/auth-pages.ts +160 -0
  48. package/src/web/channels.ts +395 -0
  49. package/src/web/chat-page.ts +636 -0
  50. package/src/web/chat-pty.ts +254 -0
  51. package/src/web/dashboard.ts +130 -0
  52. package/src/web/layout.ts +237 -0
  53. package/src/web/stubs.ts +510 -0
  54. package/src/web/workflows.ts +490 -0
  55. package/src/workflow.ts +470 -0
  56. package/template/CLAUDE.md +10 -0
  57. package/template/CROSSTALK-VERSION +1 -0
  58. package/template/CROSSTALK.md +262 -0
  59. package/template/PROTOCOL.md +70 -0
  60. package/template/README.md +64 -0
  61. package/template/auth/.gitkeep +0 -0
  62. package/template/auth/README.md +224 -0
  63. package/template/data/crosstalk.yaml +196 -0
  64. package/template/gitignore +4 -0
  65. package/commands/down.js +0 -40
  66. package/commands/init.js +0 -243
  67. package/commands/pull.js +0 -22
  68. package/commands/replies.js +0 -40
  69. package/commands/restart.js +0 -29
  70. package/commands/rm.js +0 -109
  71. package/commands/run.js +0 -115
  72. 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
- export const CROSSTALK_LABEL = 'crosstalk.transport=true';
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(`CROSSTALK_STORAGE_MODE='${mode}' invalid — must be 'user' or 'system'.`);
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
- return (process.env.CROSSTALK_STORAGE_MODE ?? 'user').toLowerCase();
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 container name, derived from the resolved
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, up verifies, etc).
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: join(root, 'crosstalk-state'),
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
- // Container name from --containername flag, defaulting to `crosstalk`.
73
- // Validates the name is a sane Docker container name + filesystem dir
74
- // name: lowercase alphanumeric, `_.-`, no leading dot/hyphen.
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 `<base>/<name>/transport/.git` exists. The cheapest signal that
136
- // a transport has been initialized for this name.
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, '.git'));
196
+ return existsSync(join(paths.transportDir, 'CROSSTALK-VERSION'));
140
197
  }
141
198
 
142
- // True if a running container exists for this name. Uses Docker as the
143
- // source of truth, filtered by our label to avoid confusing
144
- // operator-named-`crosstalk` containers from other workloads.
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 r = spawnSync(
147
- 'docker',
148
- ['ps', '--filter', `label=${CROSSTALK_LABEL}`, '--filter', `name=^${name}$`, '--format', '{{.Names}}'],
149
- { encoding: 'utf-8' },
150
- );
151
- if (r.status !== 0) return false;
152
- return r.stdout.trim().split('\n').includes(name);
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
- // True if a stopped container exists (less common path; usually `down`
156
- // removes the container). Checked separately so `up` can decide whether
157
- // to `docker rm` and recreate.
158
- export function containerExists(name) {
159
- const r = spawnSync(
160
- 'docker',
161
- ['ps', '-a', '--filter', `label=${CROSSTALK_LABEL}`, '--filter', `name=^${name}$`, '--format', '{{.Names}}'],
162
- { encoding: 'utf-8' },
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": "7.0.0-alpha.9",
4
- "description": "Crosstalk client host-side CLI that talks to the crosstalkd daemon running inside the crosstalk-server container.",
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
- "README.md"
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
+ }