@cordfuse/crosstalk 7.0.0-alpha.9 → 7.0.0-beta.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 +402 -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 +153 -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 +270 -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 +173 -0
  25. package/lib/resolve.js +101 -34
  26. package/package.json +27 -4
  27. package/src/activation.ts +104 -0
  28. package/src/api.ts +1716 -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 +492 -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 +283 -0
  43. package/src/resolve.ts +100 -0
  44. package/src/state.ts +190 -0
  45. package/src/stop.ts +37 -0
  46. package/src/transport.ts +243 -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 +129 -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/commands/chat.js CHANGED
@@ -1,110 +1,147 @@
1
- // crosstalk chat — interactive entry into the container.
1
+ // crosstalk chat — interactive agent session on the host (v8-native).
2
2
  //
3
- // Modes (alpha.8):
4
- // crosstalk chat --agent <name> # interactive session with an agent CLI
5
- // crosstalk chat --shell # bash escape hatch (debug, install CLIs, run setup commands)
3
+ // Modes:
4
+ // crosstalk chat <agent> [--cwd <path>] # interactive session with an agent CLI
5
+ // crosstalk chat --shell # bash escape hatch
6
6
  //
7
- // --agent is required for non-shell mode. There's no implicit default;
8
- // containers may have multiple agent CLIs installed, the operator
9
- // picks one explicitly.
7
+ // alpha.18: positional `<agent>` replaces the v7 `--agent <name>` flag
8
+ // form. No backwards-compat alias.
10
9
  //
11
- // `--login` was removed in alpha.8. Per-agent auth is now handled via
12
- // `data/crosstalk.yaml`'s per-provider `env_file:` (env-injected at
13
- // agent spawn time inside the engine) plus operator-run setup commands
14
- // for agents whose auth is a stateful CLI act (e.g. codex). Recipes are
15
- // documented in transport/auth/README.md.
10
+ // v8-native has no container the agent CLIs are installed in the
11
+ // operator's own $HOME (user mode) or the `crosstalk` service user's
12
+ // home (system mode). We just spawn the agent process directly with
13
+ // stdio inherited.
14
+ //
15
+ // Lifecycle: agent runs as a foreground child of `crosstalk chat`. When
16
+ // the operator exits the agent (Ctrl-D / quit / agent finishes), the
17
+ // child exits and we exit with the same status code. SIGINT/SIGTERM
18
+ // received by the parent are forwarded so the agent gets a chance to
19
+ // shut down cleanly before we exit. There's no "session persists after
20
+ // detach" — crosstalk is async-by-design, no live attach model.
21
+ //
22
+ // `<agent>` is required for non-shell mode. The host may have multiple
23
+ // agent CLIs installed; the operator picks one explicitly.
16
24
 
17
- import { spawnSync } from 'child_process';
25
+ import { spawn, spawnSync } from 'child_process';
18
26
  import { requireInitialized } from '../lib/resolve.js';
19
- import { apiFor, ConnectError } from '../lib/api-client.js';
20
- import { flag, has } from '../lib/argv.js';
27
+ import { flag, has, positionals } from '../lib/argv.js';
21
28
 
22
29
  const KNOWN_AGENTS = ['claude', 'codex', 'gemini', 'qwen', 'opencode', 'agy'];
23
30
 
24
31
  function usage(exit = 0) {
25
32
  const w = exit === 0 ? process.stdout : process.stderr;
26
33
  w.write(
27
- `Usage: crosstalk chat --agent <name>
34
+ `Usage: crosstalk chat <agent> [--cwd <path>]
28
35
  crosstalk chat --shell
29
36
 
30
- Opens an interactive session inside the engine container.
37
+ Opens an interactive session with an agent CLI on the host. The agent
38
+ process is a foreground child of this command — when you exit the
39
+ agent, the process is reaped and 'crosstalk chat' returns.
31
40
 
32
- --agent is REQUIRED for any chat mode. Containers can have multiple
33
- agent CLIs installed; the operator must say which one to invoke.
41
+ The agent name is REQUIRED for any chat mode (except --shell). The host
42
+ may have multiple agent CLIs installed; the operator must say which one
43
+ to invoke.
34
44
 
35
45
  Supported agents: ${KNOWN_AGENTS.join(', ')}.
36
46
 
37
47
  Modes:
38
- --agent <name> Interactive with the named agent CLI. Use the
39
- agent's own keys/commands for first-time setup
40
- (see transport/auth/README.md for recipes).
41
- --shell Drop into bash (install agent CLIs, debug, run
42
- one-time auth setup commands like
43
- \`codex login --with-api-key\`).
48
+ <agent> Interactive with the named agent CLI. First-run OAuth
49
+ happens through the agent's own flow.
50
+ --cwd <path> Change directory before spawning the agent (handy for
51
+ pinning the session to a specific project root).
52
+ --shell Drop into bash. Useful for installing agent CLIs
53
+ (\`npm i -g @anthropic-ai/claude-code\`) or running
54
+ one-time setup commands.
44
55
 
45
56
  Examples:
46
- crosstalk chat --agent claude # interactive claude
47
- crosstalk chat --agent gemini # interactive gemini
48
- crosstalk chat --shell # bash
57
+ crosstalk chat claude
58
+ crosstalk chat codex --cwd ~/Repos/myproject
59
+ crosstalk chat --shell
49
60
  `,
50
61
  );
51
62
  process.exit(exit);
52
63
  }
53
64
 
54
- function runInteractive(container, command, env = {}) {
55
- // Full TTY passthrough docker exec -it gives the container a real
56
- // PTY, and stdio: 'inherit' forwards stdin/stdout/stderr from the
57
- // operator's terminal. Agent CLIs render their rich TUI normally and
58
- // SIGWINCH propagates from the host's tty to the container's PTY.
59
- const envFlags = [];
60
- for (const [k, v] of Object.entries(env)) {
61
- envFlags.push('--env', `${k}=${v}`);
65
+ /**
66
+ * Spawn an interactive process attached to the operator's TTY. The
67
+ * child OWNS stdin/stdout/stderr for the duration; when it exits, we
68
+ * exit with the same code. SIGINT/SIGTERM forwarded for clean shutdown.
69
+ */
70
+ function runInteractive(command, args = [], opts = {}) {
71
+ // Pre-flight: is the binary actually present on PATH? Catching this
72
+ // here gives a clearer error than the child's "command not found"
73
+ // hitting the operator's TTY.
74
+ const which = spawnSync('which', [command], { encoding: 'utf-8' });
75
+ if (which.status !== 0) {
76
+ process.stderr.write(
77
+ `crosstalk chat: '${command}' not found on PATH.\n` +
78
+ ` Install it first (e.g., npm install -g @anthropic-ai/claude-code for claude).\n`,
79
+ );
80
+ return 127;
62
81
  }
63
- const r = spawnSync('docker', ['exec', '-it', ...envFlags, container, command], {
64
- stdio: 'inherit',
82
+
83
+ return new Promise((resolve) => {
84
+ const child = spawn(command, args, {
85
+ stdio: 'inherit',
86
+ ...(opts.cwd ? { cwd: opts.cwd } : {}),
87
+ });
88
+
89
+ const forward = (sig) => {
90
+ try { child.kill(sig); } catch { /* already gone */ }
91
+ };
92
+ process.on('SIGINT', () => forward('SIGINT'));
93
+ process.on('SIGTERM', () => forward('SIGTERM'));
94
+
95
+ child.on('exit', (code, signal) => {
96
+ // Belt-and-suspenders: if the child reported clean exit but is
97
+ // somehow still alive (rare; some agent CLIs fork sub-processes
98
+ // that hold the PID), SIGKILL to be sure. The async-by-design
99
+ // contract says no live agent persists past chat exit.
100
+ if (child.pid) {
101
+ try { process.kill(child.pid, 0); process.kill(child.pid, 'SIGKILL'); }
102
+ catch { /* expected: process is gone */ }
103
+ }
104
+ if (signal) {
105
+ // Re-raise the signal so the operator's shell sees the same exit semantics.
106
+ process.exit(128 + (signalToNumber(signal) ?? 1));
107
+ }
108
+ resolve(code ?? 0);
109
+ });
110
+ child.on('error', (err) => {
111
+ process.stderr.write(`crosstalk chat: spawn failed: ${err.message}\n`);
112
+ resolve(1);
113
+ });
65
114
  });
66
- return r.status ?? 1;
115
+ }
116
+
117
+ function signalToNumber(sig) {
118
+ // Minimal map — we only forward INT/TERM, so only those need to round-trip.
119
+ return { SIGINT: 2, SIGTERM: 15, SIGHUP: 1, SIGKILL: 9 }[sig];
67
120
  }
68
121
 
69
122
  export async function run(argv) {
70
123
  if (has(argv, '--help') || has(argv, '-h')) usage(0);
71
124
 
72
- const { name } = requireInitialized(argv);
73
- const api = apiFor(argv);
125
+ // requireInitialized ensures we're being run against a known transport;
126
+ // its return value isn't used here (chat doesn't address the engine API)
127
+ // but the existence check fails fast with a clear error if the operator
128
+ // hasn't run `crosstalk transport init` for this --containername yet.
129
+ requireInitialized(argv);
130
+
131
+ const cwdFlag = flag(argv, '--cwd');
74
132
 
75
133
  if (has(argv, '--shell')) {
76
- return runInteractive(name, 'bash');
134
+ const shell = process.env.SHELL ?? 'bash';
135
+ return runInteractive(shell, [], { cwd: cwdFlag });
77
136
  }
78
137
 
79
- const agent = flag(argv, '--agent');
138
+ const pos = positionals(argv, [
139
+ '--cwd', '--containername', '-c', '--profile', '--server',
140
+ ]);
141
+ const agent = pos[0];
80
142
  if (!agent) {
81
- // Query the engine for what's actually installed (vs the theoretical
82
- // supported set). Gives a smarter error than "supported: claude,
83
- // codex, gemini, qwen, opencode, agy" when only 1-2 are actually
84
- // present in the container.
85
- let installed = null;
86
- try {
87
- const r = await api.get('/agents/installed');
88
- installed = r.installed;
89
- } catch (err) {
90
- if (!(err instanceof ConnectError)) {
91
- // engine unreachable — fall through with no introspection
92
- }
93
- }
94
- if (installed && installed.length === 0) {
95
- process.stderr.write(
96
- `crosstalk chat: no agent CLIs installed in this container.\n` +
97
- ` Install one first:\n` +
98
- ` crosstalk chat --shell\n` +
99
- ` npm install -g @anthropic-ai/claude-code # or your agent's installer\n`,
100
- );
101
- return 1;
102
- }
103
- const list = installed && installed.length > 0
104
- ? `Installed in this container: ${installed.join(', ')}.`
105
- : `Supported agents: ${KNOWN_AGENTS.join(', ')}.`;
106
143
  process.stderr.write(
107
- `crosstalk chat: --agent <name> is required.\n ${list}\n`,
144
+ `crosstalk chat: <agent> required.\n Supported: ${KNOWN_AGENTS.join(', ')}\n`,
108
145
  );
109
146
  return 1;
110
147
  }
@@ -114,6 +151,5 @@ export async function run(argv) {
114
151
  );
115
152
  return 1;
116
153
  }
117
-
118
- return runInteractive(name, agent);
154
+ return runInteractive(agent, [], { cwd: cwdFlag });
119
155
  }
@@ -0,0 +1,120 @@
1
+ // crosstalk daemon <verb> — internal: runs the engine subprocess.
2
+ //
3
+ // This is what `crosstalk server start` spawns under the hood, and
4
+ // what the systemd unit's ExecStart points at. Operators don't normally
5
+ // type `crosstalk daemon` directly; `server start/stop/restart`
6
+ // + `transport init` are the user-facing wrappers.
7
+ //
8
+ // The engine TypeScript source lives at ./src/*.ts and is executed via tsx.
9
+
10
+ import { spawn, spawnSync } from 'child_process';
11
+ import { dirname, join, resolve } from 'path';
12
+ import { fileURLToPath } from 'url';
13
+ import { existsSync, statSync } from 'fs';
14
+ import { createRequire } from 'module';
15
+
16
+ const require = createRequire(import.meta.url);
17
+
18
+ const SUBCOMMANDS = ['dispatch', 'init', 'stop'];
19
+ const STANDALONE_SUBCOMMANDS = new Set(['init']);
20
+
21
+ const thisDir = dirname(fileURLToPath(import.meta.url));
22
+ const repoRoot = resolve(thisDir, '..');
23
+
24
+ function findTransportRoot(startDir) {
25
+ let dir = resolve(startDir);
26
+ while (true) {
27
+ if (existsSync(join(dir, 'CROSSTALK-VERSION')) && statSync(join(dir, 'CROSSTALK-VERSION')).isFile()) {
28
+ return dir;
29
+ }
30
+ const parent = dirname(dir);
31
+ if (parent === dir) return null;
32
+ dir = parent;
33
+ }
34
+ }
35
+
36
+ function usage(exitCode = 0) {
37
+ const w = exitCode === 0 ? process.stdout : process.stderr;
38
+ w.write(
39
+ `Usage: crosstalk daemon <subcommand> [args...]
40
+
41
+ Subcommands (operator normally doesn't invoke these directly):
42
+ ${SUBCOMMANDS.map((s) => ` ${s}`).join('\n')}
43
+
44
+ dispatch Run the engine main loop (HTTP API + dispatch tick).
45
+ This is what 'crosstalk server start' spawns.
46
+ init Scaffold a new transport (called by 'crosstalk transport init').
47
+ stop Signal SIGTERM to the running engine via pidfile.
48
+
49
+ Most subcommands require you to be inside a Crosstalk transport (a
50
+ directory containing CROSSTALK-VERSION). 'init' can run from anywhere
51
+ to scaffold a new transport.
52
+ `,
53
+ );
54
+ process.exit(exitCode);
55
+ }
56
+
57
+ export async function run(argv) {
58
+ if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') usage(argv.length === 0 ? 1 : 0);
59
+
60
+ const sub = argv[0];
61
+ if (!SUBCOMMANDS.includes(sub)) {
62
+ process.stderr.write(`crosstalk daemon: unknown subcommand '${sub}'\n\n`);
63
+ usage(1);
64
+ }
65
+
66
+ // 'init' is allowed to run outside a transport (it scaffolds one).
67
+ // Every other subcommand walks up from cwd to find a CROSSTALK-VERSION
68
+ // marker and chdir's into the transport root.
69
+ if (!STANDALONE_SUBCOMMANDS.has(sub)) {
70
+ const transportRoot = findTransportRoot(process.cwd());
71
+ if (!transportRoot) {
72
+ process.stderr.write(
73
+ `crosstalk daemon ${sub}: not inside a Crosstalk transport ` +
74
+ `(no CROSSTALK-VERSION found from ${process.cwd()} upward).\n`,
75
+ );
76
+ process.exit(1);
77
+ }
78
+ process.chdir(transportRoot);
79
+ }
80
+
81
+ // Resolve src/<sub>.ts (engine TypeScript). tsx is a runtime dep of
82
+ // the merged package; resolve its CLI from node_modules.
83
+ const srcFile = join(repoRoot, 'src', `${sub}.ts`);
84
+ if (!existsSync(srcFile)) {
85
+ process.stderr.write(`crosstalk daemon ${sub}: source file missing — ${srcFile}\n`);
86
+ process.exit(2);
87
+ }
88
+
89
+ const tsxCli = (() => {
90
+ try { return require.resolve('tsx/cli', { paths: [repoRoot] }); }
91
+ catch { return join(repoRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs'); }
92
+ })();
93
+
94
+ // For 'dispatch' (long-running engine main loop), spawn as a child
95
+ // so we can forward signals + exit codes faithfully. For 'init' /
96
+ // 'stop' (one-shots), spawnSync is fine and faster.
97
+ const args = [tsxCli, srcFile, ...argv.slice(1)];
98
+
99
+ if (sub === 'dispatch') {
100
+ const child = spawn(process.execPath, args, {
101
+ stdio: 'inherit',
102
+ env: process.env,
103
+ });
104
+ const forward = (sig) => child.kill(sig);
105
+ process.on('SIGINT', forward);
106
+ process.on('SIGTERM', forward);
107
+ process.on('SIGHUP', forward);
108
+ return new Promise((res) => {
109
+ child.on('exit', (code, sig) => {
110
+ if (sig) process.kill(process.pid, sig);
111
+ else res(code ?? 0);
112
+ });
113
+ });
114
+ }
115
+
116
+ // Stop / init — one-shot, sync.
117
+ const r = spawnSync(process.execPath, args, { stdio: 'inherit', env: process.env });
118
+ return r.status ?? 0;
119
+ }
120
+
package/commands/logs.js CHANGED
@@ -1,37 +1,126 @@
1
- // crosstalk logs tail a transport's engine logs.
1
+ // crosstalk logs <list|tail> view engine logs.
2
+ //
3
+ // list [--limit N] [--json] Snapshot of recent entries.
4
+ // tail [--since <iso>] Live SSE stream — Ctrl-C to stop.
5
+ //
6
+ // Mirrors llmux's logs noun.
2
7
 
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';
8
+ import { apiFor } from '../lib/api-client.js';
9
+ import { ConnectError } from '../lib/api-client.js';
10
+ import { reportAndExit } from '../lib/errors.js';
11
+ import { flag, has } from '../lib/argv.js';
12
+ import { getActiveProfile } from '../lib/credentials.js';
13
+ import { DEFAULT_API_PORT } from '../lib/api-client.js';
7
14
 
8
15
  function usage(exit = 0) {
9
16
  const w = exit === 0 ? process.stdout : process.stderr;
10
17
  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).
18
+ `Usage:
19
+ crosstalk logs list [--limit N] [--json]
20
+ crosstalk logs tail [--since <iso>]
21
+ crosstalk logs --help
14
22
  `,
15
23
  );
16
24
  process.exit(exit);
17
25
  }
18
26
 
27
+ function fmtEntry(e) {
28
+ const ts = e.ts || e.timestamp || '';
29
+ const lvl = (e.level || 'info').toUpperCase().padEnd(5);
30
+ const msg = e.event || e.message || JSON.stringify(e);
31
+ const fields = e.fields && Object.keys(e.fields).length > 0
32
+ ? ' ' + Object.entries(e.fields).map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`).join(' ')
33
+ : '';
34
+ return `${ts} ${lvl} ${msg}${fields}`;
35
+ }
36
+
19
37
  export async function run(argv) {
20
38
  if (has(argv, '--help') || has(argv, '-h')) usage(0);
39
+ const sub = argv[0];
40
+ if (!sub) usage(1);
21
41
 
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`);
42
+ if (sub === 'list') return await runList(argv.slice(1));
43
+ if (sub === 'tail') return await runTail(argv.slice(1));
44
+
45
+ process.stderr.write(`crosstalk logs: unknown subcommand '${sub}'.\n`);
46
+ usage(1);
47
+ }
48
+
49
+ async function runList(argv) {
50
+ const api = apiFor(argv);
51
+ let r;
52
+ try {
53
+ r = await api.get('/api/logs');
54
+ } catch (err) {
55
+ reportAndExit(err, 'crosstalk logs list');
56
+ }
57
+ let entries = r.entries || [];
58
+ const limitStr = flag(argv, '--limit');
59
+ if (limitStr) {
60
+ const n = parseInt(limitStr, 10);
61
+ if (Number.isFinite(n) && n > 0) entries = entries.slice(-n);
62
+ }
63
+ if (has(argv, '--json')) {
64
+ process.stdout.write(JSON.stringify({ entries, currentSeq: r.currentSeq }, null, 2) + '\n');
65
+ return 0;
66
+ }
67
+ if (entries.length === 0) {
68
+ process.stdout.write('(no log entries)\n');
69
+ return 0;
70
+ }
71
+ for (const e of entries) process.stdout.write(fmtEntry(e) + '\n');
72
+ return 0;
73
+ }
74
+
75
+ async function runTail(argv) {
76
+ // SSE streaming via fetch — manual parse since our api-client wrapper
77
+ // assumes JSON response bodies. Token + server URL are read from the
78
+ // active credentials profile, matching the rest of the CLI.
79
+ const profile = getActiveProfile(argv);
80
+ const server = (profile && profile.server) || `http://127.0.0.1:${DEFAULT_API_PORT}`;
81
+ const since = flag(argv, '--since');
82
+ const url = `${server.replace(/\/+$/, '')}/api/logs/stream${since ? `?since=${encodeURIComponent(since)}` : ''}`;
83
+
84
+ const headers = {};
85
+ if (profile && profile.token) headers['Authorization'] = `Bearer ${profile.token}`;
86
+
87
+ let res;
88
+ try {
89
+ res = await fetch(url, { headers });
90
+ } catch (err) {
91
+ throw new ConnectError(server, err.message || String(err));
92
+ }
93
+ if (!res.ok || !res.body) {
94
+ process.stderr.write(`crosstalk logs tail: stream open failed (HTTP ${res.status}).\n`);
25
95
  return 1;
26
96
  }
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++;
97
+
98
+ const reader = res.body.getReader();
99
+ const decoder = new TextDecoder();
100
+ let buf = '';
101
+ const onSig = () => process.exit(0);
102
+ process.on('SIGINT', onSig);
103
+ process.on('SIGTERM', onSig);
104
+
105
+ while (true) {
106
+ const { value, done } = await reader.read();
107
+ if (done) break;
108
+ buf += decoder.decode(value, { stream: true });
109
+ // SSE frames are separated by \n\n; each `data:` line is JSON.
110
+ let nl;
111
+ while ((nl = buf.indexOf('\n\n')) !== -1) {
112
+ const frame = buf.slice(0, nl);
113
+ buf = buf.slice(nl + 2);
114
+ const dataLine = frame.split('\n').find((l) => l.startsWith('data:'));
115
+ if (!dataLine) continue;
116
+ const payload = dataLine.slice(5).trimStart();
117
+ try {
118
+ const entry = JSON.parse(payload);
119
+ process.stdout.write(fmtEntry(entry) + '\n');
120
+ } catch {
121
+ process.stdout.write(payload + '\n');
122
+ }
33
123
  }
34
124
  }
35
- const r = spawnSync('docker', args, { stdio: 'inherit' });
36
- return r.status ?? 1;
125
+ return 0;
37
126
  }
@@ -0,0 +1,125 @@
1
+ // crosstalk message <send|replies> — dispatch + poll primitive messages.
2
+ //
3
+ // Replaces v7's `crosstalk run --type primitive` (now `message send`)
4
+ // and the top-level `crosstalk replies` (now `message replies`).
5
+ // Workflows live under the `workflow` noun (compose|run|status).
6
+
7
+ import { readFileSync, existsSync, statSync } from 'fs';
8
+ import { apiFor } from '../lib/api-client.js';
9
+ import { reportAndExit } from '../lib/errors.js';
10
+ import { flag, has, positionals } from '../lib/argv.js';
11
+
12
+ const SEND_FLAGS_WITH_VALUE = new Set([
13
+ '--to', '--as', '--fanout', '--channel', '--from',
14
+ '--containername', '-c', '--profile', '--server',
15
+ ]);
16
+
17
+ function usage(exit = 0) {
18
+ const w = exit === 0 ? process.stdout : process.stderr;
19
+ w.write(
20
+ `Usage:
21
+ crosstalk message send --to <model>[@<machine>] \\
22
+ [--as <persona>] [--fanout <n>] \\
23
+ [--channel <name|uuid>] [--from <name>] \\
24
+ <body|file|->
25
+
26
+ crosstalk message replies <relPath> [<relPath>...]
27
+ `,
28
+ );
29
+ process.exit(exit);
30
+ }
31
+
32
+ function readStdin() {
33
+ try { return readFileSync(0, 'utf-8'); } catch { return ''; }
34
+ }
35
+
36
+ function resolveBody(arg) {
37
+ if (arg == null) return null;
38
+ if (arg === '-') return readStdin();
39
+ if (existsSync(arg)) {
40
+ try {
41
+ if (statSync(arg).isFile()) return readFileSync(arg, 'utf-8');
42
+ } catch { /* fall through to inline */ }
43
+ }
44
+ return arg;
45
+ }
46
+
47
+ export async function run(argv) {
48
+ if (has(argv, '--help') || has(argv, '-h')) usage(0);
49
+ const sub = argv[0];
50
+ if (!sub) usage(1);
51
+ const rest = argv.slice(1);
52
+
53
+ if (sub === 'send') return await runSend(rest);
54
+ if (sub === 'replies') return await runReplies(rest);
55
+
56
+ process.stderr.write(`crosstalk message: unknown subcommand '${sub}'.\n`);
57
+ usage(1);
58
+ }
59
+
60
+ async function runSend(argv) {
61
+ const api = apiFor(argv);
62
+
63
+ const to = flag(argv, '--to');
64
+ if (!to) {
65
+ process.stderr.write('crosstalk message send: --to <model>[@<machine>] is required\n');
66
+ return 1;
67
+ }
68
+
69
+ // Body is the last positional. positionals() skips flag values so the
70
+ // body resolves regardless of flag order — `--containername a8 hello`
71
+ // and `hello --containername a8` both work.
72
+ const pos = positionals(argv, [...SEND_FLAGS_WITH_VALUE]);
73
+ const bodyArg = pos[pos.length - 1];
74
+ const body = resolveBody(bodyArg);
75
+ if (body == null || body.length === 0) {
76
+ process.stderr.write('crosstalk message send: missing body (file path, inline string, or `-` for stdin)\n');
77
+ return 1;
78
+ }
79
+
80
+ const fanoutRaw = flag(argv, '--fanout');
81
+ const fanout = fanoutRaw ? Math.max(1, parseInt(fanoutRaw, 10)) : 1;
82
+ const payload = {
83
+ type: 'primitive',
84
+ to,
85
+ as: flag(argv, '--as'),
86
+ channel: flag(argv, '--channel'),
87
+ from: flag(argv, '--from') ?? process.env.USER ?? 'operator',
88
+ fanout,
89
+ body,
90
+ };
91
+
92
+ let resp;
93
+ try {
94
+ resp = await api.post('/messages', payload);
95
+ } catch (err) {
96
+ reportAndExit(err, 'crosstalk message send');
97
+ }
98
+ for (const p of (resp.relPaths || [])) process.stdout.write(`Sent: ${p}\n`);
99
+ return 0;
100
+ }
101
+
102
+ async function runReplies(argv) {
103
+ const api = apiFor(argv);
104
+ const targets = positionals(argv, ['--containername', '-c', '--profile', '--server']);
105
+ if (targets.length === 0) usage(1);
106
+
107
+ let resp;
108
+ try {
109
+ resp = await api.get(`/replies?relPaths=${encodeURIComponent(targets.join(','))}`);
110
+ } catch (err) {
111
+ reportAndExit(err, 'crosstalk message replies');
112
+ }
113
+
114
+ let pending = 0;
115
+ for (const r of (resp.replies || [])) {
116
+ if (r.status === 'PENDING') {
117
+ process.stdout.write(`PENDING ${r.target}\n`);
118
+ pending++;
119
+ } else {
120
+ const tag = r.status === 'FAILED' ? 'FAILED ' : 'REPLIED ';
121
+ process.stdout.write(`${tag} ${r.target} <- ${r.from} (${r.replyRelPath})\n`);
122
+ }
123
+ }
124
+ return pending > 0 ? 2 : 0;
125
+ }