@cordfuse/crosstalk 6.0.0-alpha.8 → 7.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +26 -0
  2. package/bin/crosstalk.js +60 -74
  3. package/commands/channel.js +69 -0
  4. package/commands/chat.js +159 -0
  5. package/commands/down.js +37 -0
  6. package/commands/init.js +105 -0
  7. package/commands/logs.js +39 -0
  8. package/commands/pull.js +24 -0
  9. package/commands/replies.js +39 -0
  10. package/commands/restart.js +24 -0
  11. package/commands/run.js +121 -0
  12. package/commands/status.js +52 -0
  13. package/commands/up.js +129 -0
  14. package/commands/version.js +30 -0
  15. package/lib/api-client.js +80 -0
  16. package/lib/argv.js +28 -0
  17. package/lib/errors.js +19 -0
  18. package/lib/transport.js +51 -0
  19. package/package.json +5 -21
  20. package/src/activation.ts +0 -104
  21. package/src/actor.ts +0 -131
  22. package/src/attach.ts +0 -118
  23. package/src/channel.ts +0 -49
  24. package/src/chat.ts +0 -142
  25. package/src/dispatch.ts +0 -531
  26. package/src/dlq.ts +0 -216
  27. package/src/filenames.ts +0 -28
  28. package/src/frontmatter.ts +0 -26
  29. package/src/init.ts +0 -138
  30. package/src/open.ts +0 -207
  31. package/src/replies.ts +0 -59
  32. package/src/send.ts +0 -102
  33. package/src/state.ts +0 -173
  34. package/src/status.ts +0 -75
  35. package/src/stop.ts +0 -37
  36. package/src/transport.ts +0 -213
  37. package/src/turnq.ts +0 -91
  38. package/src/upgrade.ts +0 -211
  39. package/src/wake.ts +0 -7
  40. package/template/CLAUDE.md +0 -12
  41. package/template/gitignore +0 -4
  42. package/template/upstream/CROSSTALK-VERSION +0 -1
  43. package/template/upstream/CROSSTALK.md +0 -298
  44. package/template/upstream/OPERATOR.md +0 -60
  45. package/template/upstream/PROTOCOL.md +0 -80
  46. package/template/upstream/actors/concierge.md +0 -36
package/src/actor.ts DELETED
@@ -1,131 +0,0 @@
1
- import { existsSync, readFileSync, readdirSync } from 'fs';
2
- import { join } from 'path';
3
- import { hostname as osHostname, platform } from 'os';
4
- import { spawnSync } from 'child_process';
5
- import { parseFrontmatter } from './frontmatter.js';
6
-
7
- // Collect the names this machine might be known by. On macOS, the kernel
8
- // hostname (`os.hostname()`) drifts with DHCP/VPN/Tailscale when the static
9
- // HostName is unset — `scutil --get LocalHostName` is the stable Bonjour
10
- // name (e.g. `Steves-MacBook-Air`), and host files commonly use the `.local`
11
- // form. Trying all variants makes auto-detect deterministic across network
12
- // state without forcing every Mac operator to pin `--host`.
13
- function candidateHostNames(): string[] {
14
- const names = new Set<string>();
15
- names.add(osHostname());
16
- if (platform() === 'darwin') {
17
- const r = spawnSync('scutil', ['--get', 'LocalHostName'], { encoding: 'utf-8' });
18
- if (r.status === 0) {
19
- const local = r.stdout.trim();
20
- if (local) {
21
- names.add(local);
22
- names.add(`${local}.local`);
23
- }
24
- }
25
- }
26
- return [...names];
27
- }
28
-
29
- export interface HostActorTier {
30
- cli: string;
31
- count?: number;
32
- systemPromptFile?: string;
33
- }
34
-
35
- export type HostActorTiers = Record<string, HostActorTier | string>;
36
-
37
- export interface HostFile {
38
- alias: string;
39
- hostname?: string;
40
- actors: Record<string, HostActorTiers>;
41
- }
42
-
43
- export interface ActorProfile {
44
- name: string;
45
- systemPrompt: string;
46
- }
47
-
48
- export function findHostFile(transportRoot: string, override?: string): HostFile {
49
- const dir = join(transportRoot, 'hosts');
50
- if (!existsSync(dir)) {
51
- throw new Error(`No host directory at ${dir}`);
52
- }
53
- const files = readdirSync(dir).filter((f) => f.endsWith('.md'));
54
- if (files.length === 0) {
55
- throw new Error(`No host files in ${dir}`);
56
- }
57
- if (override) {
58
- const target = files.find((f) => f.replace(/\.md$/, '') === override);
59
- if (!target) throw new Error(`Host file '${override}' not found in ${dir}`);
60
- return parseHostFile(join(dir, target));
61
- }
62
- const names = candidateHostNames();
63
- for (const f of files) {
64
- const parsed = parseHostFile(join(dir, f));
65
- for (const n of names) {
66
- if (parsed.hostname === n || parsed.alias === n) return parsed;
67
- }
68
- }
69
- throw new Error(
70
- `No host file matches any of [${names.join(', ')}] in ${dir}. ` +
71
- `Pass --host <alias> to override.`,
72
- );
73
- }
74
-
75
- function parseHostFile(path: string): HostFile {
76
- const raw = readFileSync(path, 'utf-8');
77
- const { data } = parseFrontmatter<HostFile>(raw);
78
- if (!data.alias) throw new Error(`Host file ${path} missing 'alias' field`);
79
- if (!data.actors) throw new Error(`Host file ${path} missing 'actors' field`);
80
- return data;
81
- }
82
-
83
- export function loadActorProfile(transportRoot: string, name: string): ActorProfile {
84
- const candidates = [
85
- join(transportRoot, 'local', 'actors', `${name}.md`),
86
- join(transportRoot, 'upstream', 'actors', `${name}.md`),
87
- ];
88
- const path = candidates.find((p) => existsSync(p));
89
- if (!path) {
90
- throw new Error(
91
- `Actor profile not found for '${name}'. Looked in:\n ${candidates.join('\n ')}`,
92
- );
93
- }
94
- const raw = readFileSync(path, 'utf-8');
95
- const { body } = parseFrontmatter(raw);
96
- return { name, systemPrompt: body.trim() };
97
- }
98
-
99
- // Quote-aware tokenizer for CLI strings. Splits on whitespace but treats
100
- // "double-quoted" and 'single-quoted' segments as single tokens. Adequate
101
- // for `bash -c "cmd; cmd"` and similar; does not implement shell escape
102
- // sequences (operators who need those can use cli as a string[] in YAML
103
- // once we support that, or write a wrapper script).
104
- export function tokenizeCli(cli: string): string[] {
105
- const tokens: string[] = [];
106
- const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
107
- let m: RegExpExecArray | null;
108
- while ((m = re.exec(cli)) !== null) {
109
- const tok = m[1] ?? m[2] ?? m[3] ?? '';
110
- tokens.push(tok);
111
- }
112
- return tokens;
113
- }
114
-
115
- export function pickTier(
116
- tiers: HostActorTiers,
117
- preferred?: string,
118
- ): { tier: string; cli: string } {
119
- if (preferred && tiers[preferred] !== undefined) {
120
- const value = tiers[preferred];
121
- const cli = typeof value === 'string' ? value : value.cli;
122
- if (cli) return { tier: preferred, cli };
123
- // declared but cli missing — fall through to first-tier fallback
124
- }
125
- const entries = Object.entries(tiers);
126
- if (entries.length === 0) throw new Error('No tiers defined for actor');
127
- const [tier, value] = entries[0];
128
- const cli = typeof value === 'string' ? value : value.cli;
129
- if (!cli) throw new Error(`Tier '${tier}' has no cli command`);
130
- return { tier, cli };
131
- }
package/src/attach.ts DELETED
@@ -1,118 +0,0 @@
1
- // crosstalk attach — pty-wrap an agent CLI in operator mode
2
- //
3
- // Spawns the operator's preferred agent CLI inside a pseudo-terminal,
4
- // preloading OPERATOR.md as system context so the agent acts as the
5
- // operator's natural-language interface to this Crosstalk transport.
6
- //
7
- // The CLI thinks it's running natively — its TUI features (slash commands,
8
- // autocompletion, syntax highlighting) all work because it has a real
9
- // (virtual) terminal underneath. The wrapper just forwards bytes both ways
10
- // and handles terminal resize.
11
- //
12
- // Usage:
13
- // crosstalk attach
14
- // CROSSTALK_OPERATOR_CLI="claude --append-system-prompt" crosstalk attach
15
- // CROSSTALK_OPERATOR_CLI="gemini -i" crosstalk attach
16
- // CROSSTALK_OPERATOR_CLI="agy -i" crosstalk attach
17
- //
18
- // CROSSTALK_OPERATOR_CLI is the full agent invocation including the
19
- // system-prompt-equivalent flag. OPERATOR.md content is appended as the
20
- // final argument. Defaults to `claude --append-system-prompt`.
21
-
22
- import { existsSync, readFileSync } from 'fs';
23
- import { resolve, join } from 'path';
24
- import { spawn as ptySpawn } from '@lydell/node-pty';
25
- import { tokenizeCli } from './actor.js';
26
-
27
- const transportRoot = resolve(process.cwd());
28
- const operatorMdPath = join(transportRoot, 'upstream', 'OPERATOR.md');
29
-
30
- if (!existsSync(operatorMdPath)) {
31
- console.error(`crosstalk attach: OPERATOR.md not found at ${operatorMdPath}`);
32
- console.error(`This tool requires upstream/OPERATOR.md to be present.`);
33
- process.exit(1);
34
- }
35
-
36
- const operatorContent = readFileSync(operatorMdPath, 'utf-8').trim();
37
- const cliCommand = process.env['CROSSTALK_OPERATOR_CLI'] ?? 'claude --append-system-prompt';
38
- const parts = tokenizeCli(cliCommand);
39
-
40
- if (parts.length === 0) {
41
- console.error('crosstalk attach: CROSSTALK_OPERATOR_CLI is empty');
42
- process.exit(1);
43
- }
44
-
45
- const [bin, ...flagArgs] = parts;
46
- const args = [...flagArgs, operatorContent];
47
-
48
- const cols = process.stdout.columns ?? 80;
49
- const rows = process.stdout.rows ?? 24;
50
-
51
- process.stdout.write(`crosstalk: attaching to ${bin} (operator mode)\n`);
52
-
53
- let term;
54
- try {
55
- term = ptySpawn(bin!, args, {
56
- name: process.env['TERM'] ?? 'xterm-256color',
57
- cols,
58
- rows,
59
- cwd: transportRoot,
60
- env: process.env as Record<string, string>,
61
- });
62
- } catch (err) {
63
- console.error(
64
- `crosstalk attach: failed to launch '${bin}': ${(err as Error).message}`,
65
- );
66
- console.error(
67
- `Check that '${bin}' is installed and on your PATH. Override with` +
68
- ` CROSSTALK_OPERATOR_CLI to use a different CLI.`,
69
- );
70
- process.exit(1);
71
- }
72
-
73
- const startedAt = Date.now();
74
- let turnCount = 0;
75
-
76
- if (process.stdin.isTTY) {
77
- process.stdin.setRawMode(true);
78
- }
79
- process.stdin.resume();
80
-
81
- process.stdin.on('data', (data) => {
82
- term.write(data.toString());
83
- if (data.includes(0x0d) || data.includes(0x0a)) turnCount++;
84
- });
85
-
86
- term.onData((data) => {
87
- process.stdout.write(data);
88
- });
89
-
90
- const handleResize = () => {
91
- try {
92
- term.resize(process.stdout.columns ?? cols, process.stdout.rows ?? rows);
93
- } catch {
94
- /* term may have exited */
95
- }
96
- };
97
- process.stdout.on('resize', handleResize);
98
-
99
- const cleanup = (exitCode: number) => {
100
- if (process.stdin.isTTY) {
101
- try { process.stdin.setRawMode(false); } catch { /* ignore */ }
102
- }
103
- process.stdin.pause();
104
- const seconds = ((Date.now() - startedAt) / 1000).toFixed(1);
105
- process.stdout.write(
106
- `\ncrosstalk: session ended (${turnCount} turns, ${seconds}s active)\n`,
107
- );
108
- process.exit(exitCode);
109
- };
110
-
111
- term.onExit(({ exitCode }) => cleanup(exitCode ?? 0));
112
-
113
- const forwardSignal = (sig: 'SIGINT' | 'SIGTERM' | 'SIGHUP') => {
114
- try { term.kill(sig); } catch { /* term may already be gone */ }
115
- };
116
- process.on('SIGINT', () => forwardSignal('SIGINT'));
117
- process.on('SIGTERM', () => forwardSignal('SIGTERM'));
118
- process.on('SIGHUP', () => forwardSignal('SIGHUP'));
package/src/channel.ts DELETED
@@ -1,49 +0,0 @@
1
- // crosstalk channel — create a new channel directory with its CHANNEL.md.
2
-
3
- import { mkdirSync, writeFileSync, existsSync } from 'fs';
4
- import { resolve, join } from 'path';
5
- import { randomUUID } from 'crypto';
6
- import { serializeFrontmatter } from './frontmatter.js';
7
-
8
- const transportRoot = resolve(process.cwd());
9
- const argv = process.argv.slice(2);
10
-
11
- function flag(name: string): string | undefined {
12
- const i = argv.indexOf(name);
13
- if (i === -1 || i === argv.length - 1) return undefined;
14
- return argv[i + 1];
15
- }
16
-
17
- const name = flag('--name');
18
- const parent = flag('--parent');
19
- const createdBy = flag('--created-by') ?? process.env['USER'] ?? 'operator';
20
-
21
- if (!name) {
22
- console.error('Usage: crosstalk channel --name <name> [--parent <parent-uuid>] [--created-by <name>]');
23
- process.exit(1);
24
- }
25
-
26
- const uuid = randomUUID();
27
- const dir = join(transportRoot, 'data', 'channels', uuid);
28
-
29
- if (existsSync(dir)) {
30
- console.error(`Directory already exists: ${dir}`);
31
- process.exit(1);
32
- }
33
-
34
- mkdirSync(dir, { recursive: true });
35
-
36
- const frontmatter: Record<string, unknown> = {
37
- name,
38
- created_by: createdBy,
39
- created_at: new Date().toISOString(),
40
- };
41
- if (parent) frontmatter['parent'] = parent;
42
-
43
- const body = `# ${name}\n\n${parent ? `Subchannel of \`${parent}\`.` : 'Channel description goes here.'}\n`;
44
- writeFileSync(join(dir, 'CHANNEL.md'), serializeFrontmatter(frontmatter, body));
45
-
46
- console.log(`Created channel: ${uuid}`);
47
- console.log(` name: ${name}`);
48
- if (parent) console.log(` parent: ${parent}`);
49
- console.log(` path: data/channels/${uuid}/CHANNEL.md`);
package/src/chat.ts DELETED
@@ -1,142 +0,0 @@
1
- // crosstalk chat — participate in a channel as a human, without invoking
2
- // any agent CLI locally. Sends messages by writing + committing + pushing
3
- // to the channel; polls for the reply whose re: points back at the sent
4
- // message; displays it.
5
- //
6
- // Use this when a dispatcher (local daemon, deployed crosstalk-server,
7
- // a peer's machine) is running against the transport and you want to chat
8
- // with the dispatched actors from a clean local clone. No agent CLI
9
- // needed locally, no auth.
10
- //
11
- // Difference from `open`:
12
- // - `open` spawns the actor's CLI LOCALLY — needs claude/etc. installed,
13
- // needs auth, doesn't use any remote dispatcher.
14
- // - `chat` just produces messages and waits for the channel to fill in
15
- // replies. Whatever's processing the channel handles the work.
16
- //
17
- // Usage:
18
- // crosstalk chat --channel <uuid> --to <actor> [--as <name>] [--tier <name>]
19
- // crosstalk chat --channel <uuid> --to concierge --as steve
20
-
21
- import { resolve, join } from 'path';
22
- import { mkdirSync, writeFileSync } from 'fs';
23
- import { createInterface } from 'readline/promises';
24
- import { now, messageFilename } from './filenames.js';
25
- import { serializeFrontmatter } from './frontmatter.js';
26
- import { gitPull, gitCommitAndPush, listChannelMessages, ChannelMessage } from './transport.js';
27
- import { reList } from './activation.js';
28
- import { withLock } from './turnq.js';
29
- import { sendWakeSignal } from './state.js';
30
-
31
- const transportRoot = resolve(process.cwd());
32
- const argv = process.argv.slice(2);
33
-
34
- function flag(name: string): string | undefined {
35
- const i = argv.indexOf(name);
36
- if (i === -1 || i === argv.length - 1) return undefined;
37
- return argv[i + 1];
38
- }
39
-
40
- const channelUuid = flag('--channel');
41
- const toActor = flag('--to');
42
- const fromName = flag('--as') ?? process.env['USER'] ?? 'operator';
43
- const tier = flag('--tier');
44
- const pollSeconds = Number(flag('--poll')) || 5;
45
- const replyTimeoutSeconds = Number(flag('--timeout')) || 600;
46
-
47
- if (!channelUuid || !toActor) {
48
- console.error(
49
- 'Usage: crosstalk chat --channel <uuid> --to <actor> [--as <name>] [--tier <name>] [--poll <seconds>] [--timeout <seconds>]',
50
- );
51
- process.exit(1);
52
- }
53
-
54
- async function sendMessage(body: string): Promise<string> {
55
- const ts = now();
56
- const dir = join(transportRoot, 'data', 'channels', channelUuid!, ts.pathDate);
57
- mkdirSync(dir, { recursive: true });
58
- const frontmatter: Record<string, unknown> = {
59
- from: fromName,
60
- to: toActor!,
61
- type: 'text',
62
- timestamp: ts.iso,
63
- };
64
- if (tier) frontmatter['tier'] = tier;
65
- const filename = messageFilename(ts);
66
- writeFileSync(join(dir, filename), serializeFrontmatter(frontmatter, body));
67
-
68
- const r = await withLock(transportRoot, 'git', async () =>
69
- gitCommitAndPush(
70
- transportRoot,
71
- `chat: ${fromName} -> ${toActor} in ${channelUuid!.slice(0, 8)}`,
72
- ),
73
- );
74
- sendWakeSignal(transportRoot);
75
- if (!r.ok && r.error) {
76
- const kind = r.committed ? 'push' : 'commit';
77
- console.error(`(${kind} failed: ${r.error.slice(0, 200)} — message is local-only)`);
78
- }
79
- return `${ts.pathDate}/${filename}`;
80
- }
81
-
82
- // A reply is the message whose re: list contains the relPath we sent —
83
- // recorded by the dispatcher at write time, so the match is exact, not a
84
- // timestamp/addressing heuristic.
85
- async function waitForReplyTo(sentRelPath: string): Promise<ChannelMessage | null> {
86
- const deadline = Date.now() + replyTimeoutSeconds * 1_000;
87
- while (Date.now() < deadline) {
88
- gitPull(transportRoot);
89
- const reply = listChannelMessages(transportRoot, channelUuid!).find((m) =>
90
- reList(m.data['re']).includes(sentRelPath),
91
- );
92
- if (reply) return reply;
93
- await new Promise((r) => setTimeout(r, pollSeconds * 1_000));
94
- }
95
- return null;
96
- }
97
-
98
- async function main(): Promise<void> {
99
- console.log(
100
- `crosstalk chat — channel=${channelUuid!.slice(0, 8)} from=${fromName} to=${toActor}`,
101
- );
102
- console.log(
103
- 'Type a message and press Enter. Replies will appear after the dispatcher processes them.',
104
- );
105
- console.log('Ctrl-C or Ctrl-D to exit.');
106
- console.log('');
107
-
108
- const rl = createInterface({ input: process.stdin, output: process.stdout });
109
-
110
- try {
111
- while (true) {
112
- let userMsg: string;
113
- try {
114
- userMsg = await rl.question(`${fromName}> `);
115
- } catch {
116
- break;
117
- }
118
- if (userMsg === undefined || userMsg === null) break;
119
- const trimmed = userMsg.trim();
120
- if (!trimmed) continue;
121
-
122
- const sentRelPath = await sendMessage(trimmed);
123
- process.stdout.write(`(waiting for ${toActor}…) `);
124
-
125
- const reply = await waitForReplyTo(sentRelPath);
126
- if (!reply) {
127
- console.log(
128
- `\n(no reply within ${replyTimeoutSeconds}s — give up and try again, or check dispatch state)\n`,
129
- );
130
- continue;
131
- }
132
- process.stdout.write('\r' + ' '.repeat(40) + '\r');
133
- const replyFrom = typeof reply.data['from'] === 'string' ? reply.data['from'] : toActor;
134
- console.log(`${replyFrom}> ${reply.body}\n`);
135
- }
136
- } finally {
137
- rl.close();
138
- }
139
- process.exit(0);
140
- }
141
-
142
- main();