@cordfuse/crosstalk 6.0.0-alpha.9 → 7.0.0-alpha.10

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 (47) hide show
  1. package/README.md +97 -0
  2. package/bin/crosstalk.js +61 -74
  3. package/commands/channel.js +118 -0
  4. package/commands/chat.js +119 -0
  5. package/commands/down.js +40 -0
  6. package/commands/init.js +243 -0
  7. package/commands/logs.js +37 -0
  8. package/commands/pull.js +22 -0
  9. package/commands/replies.js +40 -0
  10. package/commands/restart.js +29 -0
  11. package/commands/rm.js +109 -0
  12. package/commands/run.js +115 -0
  13. package/commands/status.js +90 -0
  14. package/commands/up.js +135 -0
  15. package/commands/version.js +33 -0
  16. package/lib/api-client.js +141 -0
  17. package/lib/argv.js +30 -0
  18. package/lib/errors.js +19 -0
  19. package/lib/resolve.js +191 -0
  20. package/package.json +5 -21
  21. package/src/activation.ts +0 -104
  22. package/src/actor.ts +0 -131
  23. package/src/attach.ts +0 -118
  24. package/src/channel.ts +0 -49
  25. package/src/chat.ts +0 -142
  26. package/src/dispatch.ts +0 -531
  27. package/src/dlq.ts +0 -216
  28. package/src/filenames.ts +0 -28
  29. package/src/frontmatter.ts +0 -26
  30. package/src/init.ts +0 -138
  31. package/src/open.ts +0 -207
  32. package/src/replies.ts +0 -59
  33. package/src/send.ts +0 -122
  34. package/src/state.ts +0 -173
  35. package/src/status.ts +0 -75
  36. package/src/stop.ts +0 -37
  37. package/src/transport.ts +0 -213
  38. package/src/turnq.ts +0 -91
  39. package/src/upgrade.ts +0 -211
  40. package/src/wake.ts +0 -7
  41. package/template/CLAUDE.md +0 -12
  42. package/template/gitignore +0 -4
  43. package/template/upstream/CROSSTALK-VERSION +0 -1
  44. package/template/upstream/CROSSTALK.md +0 -298
  45. package/template/upstream/OPERATOR.md +0 -60
  46. package/template/upstream/PROTOCOL.md +0 -80
  47. package/template/upstream/actors/concierge.md +0 -36
@@ -0,0 +1,243 @@
1
+ // crosstalk init — scaffold a transport into <base>/<name>/.
2
+ //
3
+ // alpha.7 rewrite: phased + idempotent. Each phase is independent and
4
+ // safe to re-run; re-init detects missing markers and repairs them.
5
+ // Mac caught bug A in alpha.6 where the api-port file was written at
6
+ // the end of the pipeline, so any disruption (image pull, git failure)
7
+ // left storage existing but unstartable — and re-init silently
8
+ // no-op-ed instead of fixing it.
9
+ //
10
+ // Phases:
11
+ // 1. Storage layout — mkdir subdirs + write api-port. Pure FS, fast,
12
+ // runs first so the resolver-visible marker exists before anything
13
+ // else can go wrong.
14
+ // 2. Transport scaffold — `crosstalkd init` in a one-shot container.
15
+ // Skipped if transport/.git already exists. stdout/stderr captured
16
+ // (mac's bug B — in-container scaffold output was leaking).
17
+ // 3. Git init + initial commit. Skipped if .git exists.
18
+ // 4. Remote setup if --remote given (errors if a different origin
19
+ // is already set; idempotent on same-value).
20
+ // 5. Summary.
21
+ //
22
+ // Each phase reports created/skipped/repaired so operators can tell
23
+ // what just happened.
24
+
25
+ import { mkdirSync, existsSync, writeFileSync, readFileSync } from 'fs';
26
+ import { spawnSync } from 'child_process';
27
+ import { join } from 'path';
28
+ import { has, flag } from '../lib/argv.js';
29
+ import {
30
+ parseContainerName,
31
+ storagePaths,
32
+ pickFreePort,
33
+ DEFAULT_CONTAINER_NAME,
34
+ } from '../lib/resolve.js';
35
+
36
+ const DEFAULT_IMAGE = process.env.CROSSTALK_IMAGE
37
+ ?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.8';
38
+
39
+ function usage(exit = 0) {
40
+ const w = exit === 0 ? process.stdout : process.stderr;
41
+ w.write(
42
+ `Usage:
43
+ crosstalk init [--containername <name>] [--remote <url>]
44
+
45
+ Creates a new transport on this machine, or repairs an existing
46
+ incomplete one. Idempotent — re-running fills in any missing markers
47
+ without disturbing existing state.
48
+
49
+ The transport git repo lives at <base>/<name>/transport/ where <base> is:
50
+ Linux: $XDG_DATA_HOME/crosstalk/ (or ~/.local/share/crosstalk/)
51
+ macOS: ~/Library/Application Support/crosstalk/
52
+ Windows: %LOCALAPPDATA%\\crosstalk\\
53
+
54
+ Set CROSSTALK_STORAGE_MODE=system to use the system-wide path (sudo/UAC
55
+ required; intended for headless multi-operator hosts).
56
+
57
+ Options:
58
+ --containername <name> Container name (default: 'crosstalk'). Must be
59
+ lowercase alphanumeric + ._- (no leading . or -).
60
+ One default + N named containers per machine.
61
+ --remote <url> Set git upstream. If a different remote is
62
+ already set, errors instead of overwriting.
63
+ --image <tag> Override the engine image (default: bundled
64
+ GHCR tag; or CROSSTALK_IMAGE env).
65
+ -c <name> Shorthand for --containername.
66
+
67
+ Examples:
68
+ crosstalk init # default container
69
+ crosstalk init --containername uat # second container
70
+ crosstalk init --remote git\\@github.com:me/t.git # with upstream
71
+ `,
72
+ );
73
+ process.exit(exit);
74
+ }
75
+
76
+ export async function run(argv) {
77
+ if (has(argv, '--help') || has(argv, '-h')) usage(0);
78
+
79
+ let name;
80
+ try {
81
+ name = parseContainerName(argv);
82
+ } catch (err) {
83
+ process.stderr.write(`crosstalk init: ${err.message}\n`);
84
+ return 1;
85
+ }
86
+
87
+ const remote = flag(argv, '--remote');
88
+ const image = flag(argv, '--image') ?? DEFAULT_IMAGE;
89
+ const paths = storagePaths(name);
90
+ const report = [];
91
+
92
+ // ── Phase 1: Storage layout ────────────────────────────────────────
93
+ // mkdir is idempotent (recursive: true). Write api-port if absent.
94
+ // This is the load-bearing fix for bug A — port file lands FIRST,
95
+ // before anything else can go wrong (image pull, git init, etc.).
96
+ try {
97
+ mkdirSync(paths.transportDir, { recursive: true });
98
+ mkdirSync(paths.crosstalkRoot, { recursive: true });
99
+ mkdirSync(paths.crosstalkState, { recursive: true });
100
+ } catch (err) {
101
+ process.stderr.write(
102
+ `crosstalk init: failed to create storage at ${paths.storageRoot} — ${err.message}\n`,
103
+ );
104
+ if (paths.mode === 'system') {
105
+ process.stderr.write(
106
+ ` System-mode storage requires write access to ${paths.base}.\n` +
107
+ ` Try: sudo mkdir -p ${paths.storageRoot} && sudo chown -R $USER ${paths.storageRoot}\n`,
108
+ );
109
+ }
110
+ return 1;
111
+ }
112
+
113
+ let port;
114
+ if (existsSync(paths.portFile)) {
115
+ const v = Number(readFileSync(paths.portFile, 'utf-8').trim());
116
+ if (Number.isInteger(v) && v > 0 && v < 65536) {
117
+ port = v;
118
+ report.push(` api port: ${port} (existing)`);
119
+ }
120
+ }
121
+ if (!port) {
122
+ try {
123
+ port = pickFreePort(name);
124
+ writeFileSync(paths.portFile, `${port}\n`);
125
+ report.push(` api port: ${port} (allocated)`);
126
+ } catch (err) {
127
+ process.stderr.write(`crosstalk init: port allocation failed — ${err.message}\n`);
128
+ return 1;
129
+ }
130
+ }
131
+
132
+ // ── Phase 2: Transport scaffold ────────────────────────────────────
133
+ // The template-scaffold check is "does CROSSTALK-VERSION exist?" NOT
134
+ // ".git exists" — git init is a separate phase, and the engine refuses
135
+ // to re-scaffold over an existing CROSSTALK-VERSION (without --force).
136
+ // Decoupling means a disrupted init (template written, git init never
137
+ // ran) re-enters Phase 3 cleanly on re-run.
138
+ const transportGitDir = join(paths.transportDir, '.git');
139
+ const crosstalkVersionFile = join(paths.transportDir, 'CROSSTALK-VERSION');
140
+ if (!existsSync(crosstalkVersionFile)) {
141
+ const uid = typeof process.getuid === 'function' ? process.getuid() : null;
142
+ const gid = typeof process.getgid === 'function' ? process.getgid() : null;
143
+ const userArgs = (uid != null && gid != null) ? ['--user', `${uid}:${gid}`] : [];
144
+
145
+ const dockerArgs = [
146
+ 'run', '--rm',
147
+ '-v', `${paths.transportDir}:/init-target`,
148
+ ...userArgs,
149
+ '--entrypoint', 'crosstalkd',
150
+ image,
151
+ 'init', '/init-target',
152
+ ];
153
+
154
+ // Bug B fix: don't inherit stdio — the engine prints next-steps that
155
+ // confuse operators ("Transport initialized at /init-target" with
156
+ // container-internal paths). Capture, show only on failure.
157
+ const initRun = spawnSync('docker', dockerArgs, { stdio: 'pipe' });
158
+ if (initRun.status !== 0) {
159
+ const stderr = initRun.stderr?.toString() ?? '';
160
+ const stdout = initRun.stdout?.toString() ?? '';
161
+ process.stderr.write(`crosstalk init: docker run exited ${initRun.status}\n`);
162
+ if (stderr.trim()) process.stderr.write(` stderr: ${stderr.trim()}\n`);
163
+ if (stdout.trim()) process.stderr.write(` stdout: ${stdout.trim()}\n`);
164
+ process.stderr.write(` command was: docker ${dockerArgs.join(' ')}\n`);
165
+ return initRun.status ?? 1;
166
+ }
167
+ report.push(` template: scaffolded via ${image}`);
168
+ } else {
169
+ report.push(` template: present (skipped scaffold)`);
170
+ }
171
+
172
+ // ── Phase 3: Git init + initial commit ─────────────────────────────
173
+ if (!existsSync(crosstalkVersionFile)) {
174
+ // Engine init succeeded but didn't leave files. Bail clearly — we
175
+ // won't proceed to git init on an empty dir.
176
+ process.stderr.write(
177
+ `crosstalk init: transport template missing at ${paths.transportDir}\n` +
178
+ ` Engine init didn't write template files. Try 'crosstalk rm' then re-init.\n`,
179
+ );
180
+ return 1;
181
+ }
182
+
183
+ const gitSteps = [
184
+ ['init', '--quiet', '--initial-branch=main'],
185
+ ['add', '-A'],
186
+ ['commit', '--quiet', '-m', 'initial transport'],
187
+ ];
188
+ let gitOk = true;
189
+ for (const args of gitSteps) {
190
+ const gr = spawnSync('git', args, { cwd: paths.transportDir, stdio: 'pipe' });
191
+ if (gr.status !== 0) {
192
+ // 'git init' on existing repo is fine (it re-runs idempotently);
193
+ // 'add -A' is also idempotent. 'commit' fails when there's
194
+ // nothing to commit — that means a previous run already committed.
195
+ // Only the first call (git init) is load-bearing; the rest are
196
+ // best-effort to populate the initial state.
197
+ gitOk = false;
198
+ break;
199
+ }
200
+ }
201
+ report.push(gitOk ? ` git: initialized + committed` : ` git: already present (skipped)`);
202
+
203
+ // ── Phase 4: Remote setup ──────────────────────────────────────────
204
+ if (remote) {
205
+ const cur = spawnSync('git', ['remote', 'get-url', 'origin'], {
206
+ cwd: paths.transportDir,
207
+ stdio: 'pipe',
208
+ });
209
+ const currentUrl = cur.status === 0 ? cur.stdout.toString().trim() : null;
210
+ if (!currentUrl) {
211
+ const rr = spawnSync('git', ['remote', 'add', 'origin', remote], {
212
+ cwd: paths.transportDir,
213
+ stdio: 'pipe',
214
+ });
215
+ if (rr.status !== 0) {
216
+ process.stderr.write(
217
+ `crosstalk init: 'git remote add origin ${remote}' failed — ${rr.stderr?.toString().trim()}\n`,
218
+ );
219
+ } else {
220
+ report.push(` remote: ${remote} (added)`);
221
+ }
222
+ } else if (currentUrl === remote) {
223
+ report.push(` remote: ${remote} (already set)`);
224
+ } else {
225
+ process.stderr.write(
226
+ `crosstalk init: remote already set to '${currentUrl}'; refusing to overwrite with '${remote}'.\n` +
227
+ ` Use 'crosstalk status' to see the transport path; cd there and adjust git config manually if intended.\n`,
228
+ );
229
+ return 1;
230
+ }
231
+ }
232
+
233
+ // ── Phase 5: Summary ───────────────────────────────────────────────
234
+ process.stdout.write(
235
+ `\nTransport '${name}' ready:\n` +
236
+ ` storage: ${paths.storageRoot}\n` +
237
+ ` transport: ${paths.transportDir}\n` +
238
+ ` image: ${image}\n` +
239
+ report.join('\n') + '\n' +
240
+ `\nNext: crosstalk up${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}\n`,
241
+ );
242
+ return 0;
243
+ }
@@ -0,0 +1,37 @@
1
+ // crosstalk logs — tail a transport's engine logs.
2
+
3
+ import { existsSync } from 'fs';
4
+ import { spawnSync } from 'child_process';
5
+ import { has } from '../lib/argv.js';
6
+ import { requireInitialized } from '../lib/resolve.js';
7
+
8
+ function usage(exit = 0) {
9
+ const w = exit === 0 ? process.stdout : process.stderr;
10
+ w.write(
11
+ `Usage: crosstalk logs [--containername <name>] [-f|--follow] [-n <lines>]
12
+
13
+ Streams the engine container's stdout/stderr. -f to follow (Ctrl-C to stop).
14
+ `,
15
+ );
16
+ process.exit(exit);
17
+ }
18
+
19
+ export async function run(argv) {
20
+ if (has(argv, '--help') || has(argv, '-h')) usage(0);
21
+
22
+ const { name, paths } = requireInitialized(argv);
23
+ if (!existsSync(paths.composeFile)) {
24
+ process.stderr.write(`crosstalk logs: '${name}' has no compose file — run 'crosstalk up' first.\n`);
25
+ return 1;
26
+ }
27
+ const args = ['compose', '-f', paths.composeFile, 'logs'];
28
+ if (has(argv, '-f') || has(argv, '--follow')) args.push('--follow');
29
+ for (let i = 0; i < argv.length; i++) {
30
+ if (argv[i] === '-n' || argv[i] === '--tail') {
31
+ args.push('--tail', argv[i + 1] ?? '100');
32
+ i++;
33
+ }
34
+ }
35
+ const r = spawnSync('docker', args, { stdio: 'inherit' });
36
+ return r.status ?? 1;
37
+ }
@@ -0,0 +1,22 @@
1
+ // crosstalk pull — refresh the engine image for a transport.
2
+
3
+ import { existsSync } from 'fs';
4
+ import { spawnSync } from 'child_process';
5
+ import { has } from '../lib/argv.js';
6
+ import { requireInitialized } from '../lib/resolve.js';
7
+
8
+ export async function run(argv) {
9
+ if (has(argv, '--help') || has(argv, '-h')) {
10
+ process.stdout.write('Usage: crosstalk pull [--containername <name>]\n');
11
+ return 0;
12
+ }
13
+ const { name, paths } = requireInitialized(argv);
14
+ if (!existsSync(paths.composeFile)) {
15
+ process.stderr.write(`crosstalk pull: '${name}' has no compose file — run 'crosstalk up' first.\n`);
16
+ return 1;
17
+ }
18
+ const r = spawnSync('docker', ['compose', '-f', paths.composeFile, 'pull'], {
19
+ stdio: 'inherit',
20
+ });
21
+ return r.status ?? 1;
22
+ }
@@ -0,0 +1,40 @@
1
+ // crosstalk replies <relPath> [<relPath>...] — poll reply status.
2
+ //
3
+ // Exit 0 if all targets have REPLIED or FAILED, exit 2 while any PENDING
4
+ // — agents can poll cheaply in a loop. Mirrors crosstalkd replies.
5
+
6
+ import { apiFor } from '../lib/api-client.js';
7
+ import { reportAndExit } from '../lib/errors.js';
8
+ import { positionals, has } from '../lib/argv.js';
9
+
10
+ function usage(exit = 0) {
11
+ const w = exit === 0 ? process.stdout : process.stderr;
12
+ w.write('Usage: crosstalk replies [--containername <name>] <relPath> [<relPath>...]\n');
13
+ process.exit(exit);
14
+ }
15
+
16
+ export async function run(argv) {
17
+ if (has(argv, '--help') || has(argv, '-h')) usage(0);
18
+ const api = apiFor(argv);
19
+ const targets = positionals(argv, ['--containername', '-c']);
20
+ if (targets.length === 0) usage(1);
21
+
22
+ let resp;
23
+ try {
24
+ resp = await api.get(`/replies?relPaths=${encodeURIComponent(targets.join(','))}`);
25
+ } catch (err) {
26
+ reportAndExit(err, 'crosstalk replies');
27
+ }
28
+
29
+ let pending = 0;
30
+ for (const r of resp.replies) {
31
+ if (r.status === 'PENDING') {
32
+ process.stdout.write(`PENDING ${r.target}\n`);
33
+ pending++;
34
+ } else {
35
+ const tag = r.status === 'FAILED' ? 'FAILED ' : 'REPLIED ';
36
+ process.stdout.write(`${tag} ${r.target} <- ${r.from} (${r.replyRelPath})\n`);
37
+ }
38
+ }
39
+ return pending > 0 ? 2 : 0;
40
+ }
@@ -0,0 +1,29 @@
1
+ // crosstalk restart — restart a transport's engine container.
2
+ //
3
+ // Uses `compose up -d --force-recreate` (not `compose restart`) so
4
+ // changes to env_file (e.g. tokens added via `crosstalk auth paste`)
5
+ // are picked up. Plain `compose restart` just stops + starts the same
6
+ // container without re-reading config.
7
+
8
+ import { existsSync } from 'fs';
9
+ import { spawnSync } from 'child_process';
10
+ import { has } from '../lib/argv.js';
11
+ import { requireInitialized } from '../lib/resolve.js';
12
+
13
+ export async function run(argv) {
14
+ if (has(argv, '--help') || has(argv, '-h')) {
15
+ process.stdout.write('Usage: crosstalk restart [--containername <name>]\n');
16
+ return 0;
17
+ }
18
+ const { name, paths } = requireInitialized(argv);
19
+ if (!existsSync(paths.composeFile)) {
20
+ process.stderr.write(`crosstalk restart: '${name}' has no compose file — run 'crosstalk up' first.\n`);
21
+ return 1;
22
+ }
23
+ const r = spawnSync(
24
+ 'docker',
25
+ ['compose', '-f', paths.composeFile, 'up', '-d', '--force-recreate'],
26
+ { stdio: 'inherit' },
27
+ );
28
+ return r.status ?? 1;
29
+ }
package/commands/rm.js ADDED
@@ -0,0 +1,109 @@
1
+ // crosstalk rm — remove a transport completely.
2
+ //
3
+ // Stops the container if running, removes the container, deletes the
4
+ // entire <base>/<name>/ tree (transport git repo, installed CLIs, auth
5
+ // state, dispatcher state, compose file, port file). Confirmation
6
+ // prompt by default; --force / -f to skip for scripted use.
7
+ //
8
+ // Cannot undo. All message history is lost.
9
+
10
+ import { existsSync, rmSync } from 'fs';
11
+ import { spawnSync } from 'child_process';
12
+ import { createInterface } from 'readline';
13
+ import { has } from '../lib/argv.js';
14
+ import {
15
+ parseContainerName,
16
+ storagePaths,
17
+ isInitialized,
18
+ isRunning,
19
+ containerExists,
20
+ DEFAULT_CONTAINER_NAME,
21
+ } from '../lib/resolve.js';
22
+
23
+ function usage(exit = 0) {
24
+ const w = exit === 0 ? process.stdout : process.stderr;
25
+ w.write(
26
+ `Usage: crosstalk rm [--containername <name>] [--force]
27
+
28
+ Stops and removes the engine container, then deletes the entire
29
+ storage tree under <base>/<name>/. Includes the transport git repo
30
+ (all message history), installed agent CLIs + auth tokens, and
31
+ dispatcher state. CANNOT BE UNDONE.
32
+
33
+ Options:
34
+ --containername <name> Container to remove (default: 'crosstalk').
35
+ --force, -f Skip the confirmation prompt.
36
+ `,
37
+ );
38
+ process.exit(exit);
39
+ }
40
+
41
+ function confirm(promptText) {
42
+ return new Promise((resolve) => {
43
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
44
+ rl.question(promptText, (ans) => {
45
+ rl.close();
46
+ const yes = /^y(es)?$/i.test(ans.trim());
47
+ resolve(yes);
48
+ });
49
+ });
50
+ }
51
+
52
+ export async function run(argv) {
53
+ if (has(argv, '--help') || has(argv, '-h')) usage(0);
54
+
55
+ let name;
56
+ try {
57
+ name = parseContainerName(argv);
58
+ } catch (err) {
59
+ process.stderr.write(`crosstalk rm: ${err.message}\n`);
60
+ return 1;
61
+ }
62
+
63
+ const paths = storagePaths(name);
64
+ const haveStorage = isInitialized(name) || existsSync(paths.storageRoot);
65
+ const haveContainer = containerExists(name);
66
+
67
+ if (!haveStorage && !haveContainer) {
68
+ process.stderr.write(`crosstalk rm: transport '${name}' not found.\n`);
69
+ return 1;
70
+ }
71
+
72
+ if (!has(argv, '--force') && !has(argv, '-f')) {
73
+ process.stderr.write(
74
+ `Remove '${name}'?\n` +
75
+ ` This deletes:\n` +
76
+ ` - transport git repo at ${paths.transportDir}\n` +
77
+ ` (all message history, channels, replies)\n` +
78
+ ` - installed agent CLIs + auth tokens (claude, codex, ...)\n` +
79
+ ` - dispatcher state (cursor, heartbeat, errors.log)\n` +
80
+ ` Cannot undo.\n`,
81
+ );
82
+ const ok = await confirm('[y/N]: ');
83
+ if (!ok) {
84
+ process.stderr.write('crosstalk rm: cancelled.\n');
85
+ return 1;
86
+ }
87
+ }
88
+
89
+ // Stop + remove the container (best-effort).
90
+ if (isRunning(name) && existsSync(paths.composeFile)) {
91
+ spawnSync('docker', ['compose', '-f', paths.composeFile, 'down'], { stdio: 'inherit' });
92
+ } else if (haveContainer) {
93
+ spawnSync('docker', ['rm', '-f', name], { stdio: 'ignore' });
94
+ }
95
+
96
+ // Wipe storage.
97
+ try {
98
+ rmSync(paths.storageRoot, { recursive: true, force: true });
99
+ } catch (err) {
100
+ process.stderr.write(
101
+ `crosstalk rm: failed to remove ${paths.storageRoot} — ${err.message}\n` +
102
+ ` (container is removed, but storage cleanup needs intervention)\n`,
103
+ );
104
+ return 1;
105
+ }
106
+
107
+ process.stdout.write(`Removed '${name}'.\n`);
108
+ return 0;
109
+ }
@@ -0,0 +1,115 @@
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 { apiFor } from '../lib/api-client.js';
12
+ import { reportAndExit } from '../lib/errors.js';
13
+ import { flag, has, positionals } from '../lib/argv.js';
14
+
15
+ const FLAGS_WITH_VALUE = new Set(['--type', '--to', '--as', '--fanout', '--channel', '--from', '--containername', '-c']);
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 api = apiFor(argv);
56
+ const type = flag(argv, '--type');
57
+ if (type !== 'primitive' && type !== 'workflow') usage(1);
58
+
59
+ // Body is the last positional. positionals() skips flag values so the
60
+ // body resolves regardless of flag order — `--containername a8 hello`
61
+ // and `hello --containername a8` both work.
62
+ const pos = positionals(argv, [...FLAGS_WITH_VALUE]);
63
+ const bodyArg = pos[pos.length - 1];
64
+
65
+ const body = resolveBody(bodyArg);
66
+ if (body == null || body.length === 0) {
67
+ process.stderr.write('crosstalk run: missing body (file path, inline string, or `-` for stdin)\n');
68
+ return 1;
69
+ }
70
+
71
+ const channel = flag(argv, '--channel');
72
+ const from = flag(argv, '--from') ?? process.env.USER ?? 'operator';
73
+
74
+ let payload;
75
+ if (type === 'primitive') {
76
+ const to = flag(argv, '--to');
77
+ if (!to) {
78
+ process.stderr.write('crosstalk run --type primitive: --to <model> is required\n');
79
+ return 1;
80
+ }
81
+ const fanoutRaw = flag(argv, '--fanout');
82
+ const fanout = fanoutRaw ? Math.max(1, parseInt(fanoutRaw, 10)) : 1;
83
+ payload = {
84
+ type: 'primitive',
85
+ to,
86
+ as: flag(argv, '--as'),
87
+ channel,
88
+ from,
89
+ fanout,
90
+ body,
91
+ };
92
+ } else {
93
+ payload = {
94
+ type: 'workflow',
95
+ channel,
96
+ from,
97
+ body,
98
+ };
99
+ }
100
+
101
+ let resp;
102
+ try {
103
+ resp = await api.post('/messages', payload);
104
+ } catch (err) {
105
+ reportAndExit(err, 'crosstalk run');
106
+ }
107
+
108
+ if (resp.type === 'primitive') {
109
+ for (const p of resp.relPaths) process.stdout.write(`Sent: ${p}\n`);
110
+ } else {
111
+ process.stdout.write(`Workflow dispatched: ${resp.relPath}\n`);
112
+ process.stdout.write(`Child channel: ${resp.childChannel} (parent: ${resp.channel.slice(0, 8)})\n`);
113
+ }
114
+ return 0;
115
+ }
@@ -0,0 +1,90 @@
1
+ // crosstalk status — mirror of crosstalkd status output, fetched via API.
2
+ //
3
+ // alpha.6: also prints the host path of the transport git repo as an
4
+ // escape hatch for operators who want to poke around with raw git.
5
+
6
+ import { apiFor } from '../lib/api-client.js';
7
+ import { reportAndExit } from '../lib/errors.js';
8
+ import { requireInitialized } from '../lib/resolve.js';
9
+
10
+ export async function run(argv) {
11
+ const { name, paths } = requireInitialized(argv);
12
+ const api = apiFor(argv);
13
+
14
+ let s;
15
+ try {
16
+ s = await api.get('/status');
17
+ } catch (err) {
18
+ reportAndExit(err, 'crosstalk status');
19
+ }
20
+
21
+ process.stdout.write(`Container: ${name}\n`);
22
+ process.stdout.write(`Transport host path: ${paths.transportDir}\n`);
23
+ process.stdout.write(`Engine alias: ${s.alias}\n`);
24
+ process.stdout.write(`Engine version: ${s.version}\n`);
25
+ process.stdout.write('\n');
26
+
27
+ process.stdout.write(`Claimed models: ${s.claimed_models.length}\n`);
28
+ if (s.claimed_models.length === 0) {
29
+ let installed = null;
30
+ let yamlReferenced = null;
31
+ try {
32
+ const r = await api.get('/agents/installed');
33
+ installed = r.installed;
34
+ yamlReferenced = r.yaml_referenced;
35
+ } catch { /* ignore — fall back to generic message */ }
36
+ if (installed && installed.length > 0) {
37
+ // M2 (alpha.10): differentiate "yaml references it but the claim
38
+ // list is stale" from "yaml has no entry yet". The dispatcher
39
+ // claims at startup; a CLI installed after `crosstalk up` doesn't
40
+ // surface until `crosstalk restart`.
41
+ const referencedSet = new Set(yamlReferenced ?? []);
42
+ const installedAndReferenced = installed.filter((b) => referencedSet.has(b));
43
+ const installedNotReferenced = installed.filter((b) => !referencedSet.has(b));
44
+ if (installedAndReferenced.length > 0) {
45
+ const verb = installedAndReferenced.length === 1 ? 'is' : 'are';
46
+ process.stdout.write(
47
+ ` (${installedAndReferenced.join(', ')} ${verb} installed and referenced in data/crosstalk.yaml ` +
48
+ `but not yet claimed — run 'crosstalk restart' to refresh the dispatcher's claim list)\n`,
49
+ );
50
+ }
51
+ if (installedNotReferenced.length > 0) {
52
+ const verb = installedNotReferenced.length === 1 ? 'references it' : 'references them';
53
+ process.stdout.write(
54
+ ` (${installedNotReferenced.join(', ')} installed in the container but no data/crosstalk.yaml entry ${verb} — add one to claim)\n`,
55
+ );
56
+ }
57
+ } else {
58
+ process.stdout.write(' (no model CLIs found inside the container — install one via `crosstalk chat --shell` and add an entry to data/crosstalk.yaml)\n');
59
+ }
60
+ } else {
61
+ for (const m of s.claimed_models) process.stdout.write(` - ${m}\n`);
62
+ }
63
+ process.stdout.write('\n');
64
+
65
+ if (s.heartbeat) {
66
+ const ageS = Math.floor((Date.now() - new Date(s.heartbeat.ts).getTime()) / 1000);
67
+ const label = ageS < 120 ? 'LIVE' : ageS > 300 ? 'STALE' : 'idle';
68
+ process.stdout.write(`Dispatch heartbeat: ${label} (pid ${s.heartbeat.pid}, last tick ${ageS}s ago, ${s.heartbeat.version})\n`);
69
+ } else {
70
+ process.stdout.write('Dispatch heartbeat: never\n');
71
+ }
72
+
73
+ if (s.cursor) {
74
+ process.stdout.write(`Cursor: ${s.cursor.slice(0, 12)} (global, machine-local)\n`);
75
+ } else {
76
+ process.stdout.write('Cursor: (none yet — first tick will seed to HEAD)\n');
77
+ }
78
+ process.stdout.write('\n');
79
+
80
+ process.stdout.write(`Channels: ${s.channels.length}\n`);
81
+ for (const ch of s.channels) {
82
+ const chName = ch.name ?? '(unnamed)';
83
+ const parentSuffix = ch.parent ? ` [child of ${ch.parent.slice(0, 8)}]` : '';
84
+ process.stdout.write(` ${ch.uuid.slice(0, 8)}... — ${chName}${parentSuffix}\n`);
85
+ }
86
+ process.stdout.write('\n');
87
+
88
+ process.stdout.write(`Infrastructure errors logged: ${s.errors_logged}\n`);
89
+ return 0;
90
+ }