@cordfuse/crosstalk 5.0.0-alpha.2

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 (52) hide show
  1. package/bin/crosstalk.js +111 -0
  2. package/package.json +46 -0
  3. package/src/actor.ts +106 -0
  4. package/src/attach.ts +118 -0
  5. package/src/channel.ts +62 -0
  6. package/src/chat.ts +203 -0
  7. package/src/cursor.ts +48 -0
  8. package/src/dispatch.ts +519 -0
  9. package/src/dlq.ts +263 -0
  10. package/src/filenames.ts +28 -0
  11. package/src/frontmatter.ts +26 -0
  12. package/src/init.ts +157 -0
  13. package/src/open.ts +183 -0
  14. package/src/send.ts +80 -0
  15. package/src/status.ts +114 -0
  16. package/src/transport.ts +303 -0
  17. package/src/turnq.ts +59 -0
  18. package/src/upgrade.ts +213 -0
  19. package/src/wake.ts +8 -0
  20. package/template/.amazonq/rules/crosstalk.md +2 -0
  21. package/template/.continue/rules/crosstalk.md +7 -0
  22. package/template/.cursor/rules/crosstalk.mdc +7 -0
  23. package/template/.github/copilot-instructions.md +2 -0
  24. package/template/.windsurfrules +2 -0
  25. package/template/AGENTS.md +2 -0
  26. package/template/ANTIGRAVITY.md +2 -0
  27. package/template/CLAUDE.md +2 -0
  28. package/template/GEMINI.md +2 -0
  29. package/template/OPENCODE.md +2 -0
  30. package/template/QWEN.md +2 -0
  31. package/template/README.md +22 -0
  32. package/template/local/CROSSTALK.md +4 -0
  33. package/template/upstream/CROSSTALK-VERSION +1 -0
  34. package/template/upstream/CROSSTALK.md +589 -0
  35. package/template/upstream/JITTER.md +24 -0
  36. package/template/upstream/OPERATOR.md +60 -0
  37. package/template/upstream/PROTOCOL.md +180 -0
  38. package/template/upstream/actors/cloud-architect.md +83 -0
  39. package/template/upstream/actors/concierge.md +105 -0
  40. package/template/upstream/actors/devops-engineer.md +83 -0
  41. package/template/upstream/actors/documentation-engineer.md +107 -0
  42. package/template/upstream/actors/infrastructure-engineer.md +83 -0
  43. package/template/upstream/actors/junior-developer.md +83 -0
  44. package/template/upstream/actors/precise-generalist.md +48 -0
  45. package/template/upstream/actors/product-manager.md +83 -0
  46. package/template/upstream/actors/qa-engineer.md +83 -0
  47. package/template/upstream/actors/security-engineer.md +92 -0
  48. package/template/upstream/actors/senior-generalist-engineer.md +111 -0
  49. package/template/upstream/actors/senior-software-engineer.md +94 -0
  50. package/template/upstream/actors/skeptic.md +89 -0
  51. package/template/upstream/actors/technical-writer.md +89 -0
  52. package/template/upstream/actors/ux-designer.md +83 -0
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ // crosstalk — subcommand dispatcher
3
+ //
4
+ // Walks up from cwd to find a Crosstalk transport (identified by
5
+ // upstream/CROSSTALK-VERSION), then execs tsx against the requested
6
+ // subcommand from the runtime's installed source. All args after the
7
+ // subcommand are forwarded.
8
+ //
9
+ // Examples:
10
+ // crosstalk attach
11
+ // crosstalk send --to concierge "hello"
12
+ // crosstalk dlq --show abc123
13
+ // crosstalk dispatch
14
+ //
15
+ // init is a special case — it can run outside any transport (it CREATES
16
+ // one). All other subcommands require a transport in the cwd tree.
17
+
18
+ import { existsSync, statSync } from 'fs';
19
+ import { resolve, join, dirname } from 'path';
20
+ import { spawnSync } from 'child_process';
21
+ import { fileURLToPath } from 'url';
22
+
23
+ const SUBCOMMANDS = [
24
+ 'dispatch', 'send', 'wake', 'status', 'init', 'dlq',
25
+ 'open', 'channel', 'chat', 'attach', 'upgrade',
26
+ ];
27
+
28
+ // Subcommands that operate outside an existing transport (they create or
29
+ // inspect from anywhere).
30
+ const STANDALONE_SUBCOMMANDS = new Set(['init']);
31
+
32
+ function findTransportRoot(startDir) {
33
+ let dir = resolve(startDir);
34
+ while (true) {
35
+ const versionFile = join(dir, 'upstream', 'CROSSTALK-VERSION');
36
+ if (existsSync(versionFile) && statSync(versionFile).isFile()) {
37
+ return dir;
38
+ }
39
+ const parent = dirname(dir);
40
+ if (parent === dir) return null;
41
+ dir = parent;
42
+ }
43
+ }
44
+
45
+ function printUsage(exitCode = 0) {
46
+ process.stdout.write(
47
+ `Usage: crosstalk <subcommand> [args...]\n\n` +
48
+ `Subcommands:\n` +
49
+ SUBCOMMANDS.map((s) => ` ${s}`).join('\n') +
50
+ `\n\nMost subcommands require you to be inside a Crosstalk transport\n` +
51
+ `(a directory containing upstream/CROSSTALK-VERSION). 'init' can run\n` +
52
+ `from anywhere to scaffold a new transport.\n`,
53
+ );
54
+ process.exit(exitCode);
55
+ }
56
+
57
+ const argv = process.argv.slice(2);
58
+
59
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
60
+ printUsage(0);
61
+ }
62
+
63
+ const subcommand = argv[0];
64
+ const forwardArgs = argv.slice(1);
65
+
66
+ if (!SUBCOMMANDS.includes(subcommand)) {
67
+ process.stderr.write(
68
+ `crosstalk: unknown subcommand '${subcommand}'.\n` +
69
+ `Available: ${SUBCOMMANDS.join(', ')}\n`,
70
+ );
71
+ process.exit(2);
72
+ }
73
+
74
+ // Resolve the runtime's source directory (sibling of bin/, both inside
75
+ // the installed @cordfuse/crosstalk package).
76
+ const binDir = dirname(fileURLToPath(import.meta.url));
77
+ const runtimeRoot = dirname(binDir);
78
+ const toolPath = join(runtimeRoot, 'src', `${subcommand}.ts`);
79
+
80
+ if (!existsSync(toolPath)) {
81
+ process.stderr.write(
82
+ `crosstalk: tool source not found at ${toolPath}.\n` +
83
+ `The @cordfuse/crosstalk installation may be corrupted; try reinstalling.\n`,
84
+ );
85
+ process.exit(2);
86
+ }
87
+
88
+ // For non-standalone subcommands, locate the transport in the cwd tree.
89
+ let workingDir = process.cwd();
90
+ if (!STANDALONE_SUBCOMMANDS.has(subcommand)) {
91
+ const transportRoot = findTransportRoot(process.cwd());
92
+ if (!transportRoot) {
93
+ process.stderr.write(
94
+ `crosstalk: no transport found in current directory tree.\n` +
95
+ `Run from inside a directory containing upstream/CROSSTALK-VERSION,\n` +
96
+ `or use 'crosstalk init <name>' to scaffold a new transport.\n`,
97
+ );
98
+ process.exit(2);
99
+ }
100
+ workingDir = transportRoot;
101
+ }
102
+
103
+ // Strip a single leading `--` separator if present (legacy npm run X -- ergonomics).
104
+ const cleanedArgs = forwardArgs[0] === '--' ? forwardArgs.slice(1) : forwardArgs;
105
+
106
+ const result = spawnSync('npx', ['tsx', toolPath, ...cleanedArgs], {
107
+ cwd: workingDir,
108
+ stdio: 'inherit',
109
+ });
110
+
111
+ process.exit(result.status ?? 1);
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@cordfuse/crosstalk",
3
+ "version": "5.0.0-alpha.2",
4
+ "description": "Crosstalk runtime — async messaging between agents over git. The crosstalk CLI plus dispatch, send, attach, chat, and supporting tools.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/cordfuse/crosstalk",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/cordfuse/crosstalk.git"
11
+ },
12
+ "bin": {
13
+ "crosstalk": "./bin/crosstalk.js"
14
+ },
15
+ "files": [
16
+ "bin/",
17
+ "src/",
18
+ "template/"
19
+ ],
20
+ "scripts": {
21
+ "build": "tsc --noEmit",
22
+ "lint": "tsc --noEmit",
23
+ "test": "echo 'No tests yet'"
24
+ },
25
+ "dependencies": {
26
+ "@cordfuse/turnq": "^0.3.4",
27
+ "@lydell/node-pty": "^1.2.0-beta.12",
28
+ "tsx": "^4.20.0",
29
+ "yaml": "^2.8.0"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^22.10.0",
33
+ "typescript": "^5.7.0"
34
+ },
35
+ "engines": {
36
+ "node": ">=20"
37
+ },
38
+ "keywords": [
39
+ "ai",
40
+ "agents",
41
+ "messaging",
42
+ "git",
43
+ "crosstalk",
44
+ "cordfuse"
45
+ ]
46
+ }
package/src/actor.ts ADDED
@@ -0,0 +1,106 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { hostname as osHostname } from 'os';
4
+ import { parseFrontmatter } from './frontmatter.js';
5
+
6
+ export interface HostActorTier {
7
+ cli: string;
8
+ count?: number;
9
+ systemPromptFile?: string;
10
+ }
11
+
12
+ export type HostActorTiers = Record<string, HostActorTier | string>;
13
+
14
+ export interface HostFile {
15
+ alias: string;
16
+ hostname?: string;
17
+ actors: Record<string, HostActorTiers>;
18
+ }
19
+
20
+ export interface ActorProfile {
21
+ name: string;
22
+ systemPrompt: string;
23
+ }
24
+
25
+ export function findHostFile(transportRoot: string, override?: string): HostFile {
26
+ const dir = join(transportRoot, 'hosts');
27
+ if (!existsSync(dir)) {
28
+ throw new Error(`No host directory at ${dir}`);
29
+ }
30
+ const files = readdirSync(dir).filter((f) => f.endsWith('.md'));
31
+ if (files.length === 0) {
32
+ throw new Error(`No host files in ${dir}`);
33
+ }
34
+ if (override) {
35
+ const target = files.find((f) => f.replace(/\.md$/, '') === override);
36
+ if (!target) throw new Error(`Host file '${override}' not found in ${dir}`);
37
+ return parseHostFile(join(dir, target));
38
+ }
39
+ const hostName = osHostname();
40
+ for (const f of files) {
41
+ const parsed = parseHostFile(join(dir, f));
42
+ if (parsed.hostname === hostName || parsed.alias === hostName) return parsed;
43
+ }
44
+ throw new Error(
45
+ `No host file matches hostname '${hostName}' in ${dir}. ` +
46
+ `Pass --host <alias> to override.`,
47
+ );
48
+ }
49
+
50
+ function parseHostFile(path: string): HostFile {
51
+ const raw = readFileSync(path, 'utf-8');
52
+ const { data } = parseFrontmatter<HostFile>(raw);
53
+ if (!data.alias) throw new Error(`Host file ${path} missing 'alias' field`);
54
+ if (!data.actors) throw new Error(`Host file ${path} missing 'actors' field`);
55
+ return data;
56
+ }
57
+
58
+ export function loadActorProfile(transportRoot: string, name: string): ActorProfile {
59
+ const candidates = [
60
+ join(transportRoot, 'local', 'actors', `${name}.md`),
61
+ join(transportRoot, 'upstream', 'actors', `${name}.md`),
62
+ ];
63
+ const path = candidates.find((p) => existsSync(p));
64
+ if (!path) {
65
+ throw new Error(
66
+ `Actor profile not found for '${name}'. Looked in:\n ${candidates.join('\n ')}`,
67
+ );
68
+ }
69
+ const raw = readFileSync(path, 'utf-8');
70
+ const { body } = parseFrontmatter(raw);
71
+ return { name, systemPrompt: body.trim() };
72
+ }
73
+
74
+ // Quote-aware tokenizer for CLI strings. Splits on whitespace but treats
75
+ // "double-quoted" and 'single-quoted' segments as single tokens. Adequate
76
+ // for `bash -c "cmd; cmd"` and similar; does not implement shell escape
77
+ // sequences (operators who need those can use cli as a string[] in YAML
78
+ // once we support that, or write a wrapper script).
79
+ export function tokenizeCli(cli: string): string[] {
80
+ const tokens: string[] = [];
81
+ const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
82
+ let m: RegExpExecArray | null;
83
+ while ((m = re.exec(cli)) !== null) {
84
+ const tok = m[1] ?? m[2] ?? m[3] ?? '';
85
+ tokens.push(tok);
86
+ }
87
+ return tokens;
88
+ }
89
+
90
+ export function pickTier(
91
+ tiers: HostActorTiers,
92
+ preferred?: string,
93
+ ): { tier: string; cli: string } {
94
+ if (preferred && tiers[preferred] !== undefined) {
95
+ const value = tiers[preferred];
96
+ const cli = typeof value === 'string' ? value : value.cli;
97
+ if (cli) return { tier: preferred, cli };
98
+ // declared but cli missing — fall through to first-tier fallback
99
+ }
100
+ const entries = Object.entries(tiers);
101
+ if (entries.length === 0) throw new Error('No tiers defined for actor');
102
+ const [tier, value] = entries[0];
103
+ const cli = typeof value === 'string' ? value : value.cli;
104
+ if (!cli) throw new Error(`Tier '${tier}' has no cli command`);
105
+ return { tier, cli };
106
+ }
package/src/attach.ts ADDED
@@ -0,0 +1,118 @@
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 ADDED
@@ -0,0 +1,62 @@
1
+ import { mkdirSync, writeFileSync, existsSync } from 'fs';
2
+ import { resolve, join } from 'path';
3
+ import { randomUUID } from 'crypto';
4
+
5
+ const transportRoot = resolve(process.cwd());
6
+ const argv = process.argv.slice(2);
7
+
8
+ function flag(name: string): string | undefined {
9
+ const i = argv.indexOf(name);
10
+ if (i === -1 || i === argv.length - 1) return undefined;
11
+ return argv[i + 1];
12
+ }
13
+
14
+ const name = flag('--name');
15
+ const parent = flag('--parent');
16
+ const createdBy = flag('--created-by') ?? process.env.USER ?? 'operator';
17
+
18
+ if (!name) {
19
+ console.error(
20
+ 'Usage: npm run channel -- --name <name> [--parent <parent-uuid>] [--created-by <name>]',
21
+ );
22
+ process.exit(1);
23
+ }
24
+
25
+ const uuid = randomUUID();
26
+ const dir = join(transportRoot, 'data', 'channels', uuid);
27
+
28
+ if (existsSync(dir)) {
29
+ console.error(`Directory already exists: ${dir}`);
30
+ process.exit(1);
31
+ }
32
+
33
+ mkdirSync(dir, { recursive: true });
34
+
35
+ const frontmatter: Record<string, unknown> = {
36
+ name,
37
+ created_by: createdBy,
38
+ created_at: new Date().toISOString(),
39
+ };
40
+ if (parent) frontmatter.parent = parent;
41
+
42
+ const lines = ['---'];
43
+ for (const [k, v] of Object.entries(frontmatter)) {
44
+ lines.push(`${k}: ${v}`);
45
+ }
46
+ lines.push('---');
47
+ lines.push('');
48
+ lines.push(`# ${name}`);
49
+ lines.push('');
50
+ if (parent) {
51
+ lines.push(`Subchannel of \`${parent}\`.`);
52
+ } else {
53
+ lines.push('Channel description goes here.');
54
+ }
55
+ lines.push('');
56
+
57
+ writeFileSync(join(dir, 'CHANNEL.md'), lines.join('\n'));
58
+
59
+ console.log(`Created channel: ${uuid}`);
60
+ console.log(` name: ${name}`);
61
+ if (parent) console.log(` parent: ${parent}`);
62
+ console.log(` path: data/channels/${uuid}/CHANNEL.md`);
package/src/chat.ts ADDED
@@ -0,0 +1,203 @@
1
+ // Participate in a crosstalk channel as a regular human, without
2
+ // invoking any agent CLI locally. Sends messages by writing + committing
3
+ // + pushing to the channel; polls the channel for replies addressed to
4
+ // the operator; displays them.
5
+ //
6
+ // Use this when a deployed crosstalk-server (or any other dispatcher) is
7
+ // running against the transport and you want to chat with the dispatched
8
+ // actors from a clean local clone. No Claude install needed locally, no
9
+ // OAuth, no docker exec.
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 (a container, a remote
16
+ // dispatcher, a peer) handles the work.
17
+ //
18
+ // Usage:
19
+ // npm run chat -- --channel <uuid> --to <actor> [--as <name>] [--tier <name>]
20
+ // npm run chat -- --channel <uuid> --to concierge --as steve
21
+
22
+ import { resolve, join } from 'path';
23
+ import { mkdirSync, writeFileSync, readdirSync, readFileSync, statSync, existsSync } from 'fs';
24
+ import { createInterface } from 'readline/promises';
25
+ import { spawnSync } from 'child_process';
26
+ import { now, messageFilename } from './filenames.js';
27
+ import { serializeFrontmatter, parseFrontmatter } from './frontmatter.js';
28
+ import { gitCommitAndPush, writeErrorLog } from './transport.js';
29
+ import { withLock } from './turnq.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 ?? 'steve';
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: npm run chat -- --channel <uuid> --to <actor> [--as <name>] [--tier <name>] [--poll <seconds>] [--timeout <seconds>]',
50
+ );
51
+ process.exit(1);
52
+ }
53
+
54
+ interface ScannedMessage {
55
+ relPath: string;
56
+ ts: number;
57
+ from: string;
58
+ to: string[];
59
+ type: string;
60
+ body: string;
61
+ }
62
+
63
+ function recipients(toField: unknown): string[] {
64
+ if (Array.isArray(toField)) return toField.map(String);
65
+ if (typeof toField === 'string') return [toField];
66
+ return [];
67
+ }
68
+
69
+ function scanChannelMessages(): ScannedMessage[] {
70
+ const channelDir = join(transportRoot, 'data', 'channels', channelUuid!);
71
+ if (!existsSync(channelDir)) return [];
72
+ const results: ScannedMessage[] = [];
73
+ const walk = (dir: string, prefix: string): void => {
74
+ let entries: string[];
75
+ try { entries = readdirSync(dir); } catch { return; }
76
+ for (const entry of entries) {
77
+ const full = join(dir, entry);
78
+ const rel = prefix ? `${prefix}/${entry}` : entry;
79
+ let stat;
80
+ try { stat = statSync(full); } catch { continue; }
81
+ if (stat.isDirectory()) {
82
+ walk(full, rel);
83
+ } else if (entry.endsWith('.md') && entry !== 'CHANNEL.md') {
84
+ try {
85
+ const raw = readFileSync(full, 'utf-8');
86
+ const { data, body } = parseFrontmatter(raw);
87
+ const from = typeof data.from === 'string' ? data.from : '';
88
+ const type = typeof data.type === 'string' ? data.type : '';
89
+ const timestamp = typeof data.timestamp === 'string' ? data.timestamp : '';
90
+ if (!from || !type || !timestamp) continue;
91
+ results.push({
92
+ relPath: rel,
93
+ ts: new Date(timestamp).getTime(),
94
+ from,
95
+ to: recipients(data.to),
96
+ type,
97
+ body,
98
+ });
99
+ } catch { /* skip unparseable */ }
100
+ }
101
+ }
102
+ };
103
+ walk(channelDir, '');
104
+ return results.sort((a, b) => a.ts - b.ts);
105
+ }
106
+
107
+ function gitPullQuiet(): boolean {
108
+ const r = spawnSync('git', ['pull', '--rebase', '--quiet'], {
109
+ cwd: transportRoot,
110
+ encoding: 'utf-8',
111
+ });
112
+ return r.status === 0;
113
+ }
114
+
115
+ async function sendMessage(body: string): Promise<void> {
116
+ await withLock('dispatch', async () => {
117
+ const ts = now();
118
+ const dir = join(transportRoot, 'data', 'channels', channelUuid!, ts.pathDate);
119
+ mkdirSync(dir, { recursive: true });
120
+ const frontmatter: Record<string, unknown> = {
121
+ from: fromName,
122
+ to: toActor!,
123
+ type: 'text',
124
+ timestamp: ts.iso,
125
+ };
126
+ if (tier) frontmatter.tier = tier;
127
+ const content = serializeFrontmatter(frontmatter, body);
128
+ writeFileSync(join(dir, messageFilename(ts)), content);
129
+
130
+ const r = gitCommitAndPush(
131
+ transportRoot,
132
+ `chat: ${fromName} -> ${toActor} in ${channelUuid!.slice(0, 8)}`,
133
+ );
134
+ if (!r.ok && r.error) {
135
+ const kind = r.committed ? 'git_push' : 'git_commit';
136
+ writeErrorLog(transportRoot, kind, r.error);
137
+ console.error(`(${kind} failed: ${r.error.slice(0, 120)} — message is local-only)`);
138
+ }
139
+ });
140
+ }
141
+
142
+ async function waitForReplyAfter(thresholdTs: number): Promise<ScannedMessage | null> {
143
+ const deadline = Date.now() + replyTimeoutSeconds * 1_000;
144
+ while (Date.now() < deadline) {
145
+ gitPullQuiet();
146
+ const messages = scanChannelMessages();
147
+ const reply = messages.find(
148
+ (m) =>
149
+ m.ts > thresholdTs &&
150
+ m.type === 'text' &&
151
+ m.from === toActor &&
152
+ m.to.includes(fromName),
153
+ );
154
+ if (reply) return reply;
155
+ await new Promise((r) => setTimeout(r, pollSeconds * 1_000));
156
+ }
157
+ return null;
158
+ }
159
+
160
+ async function main(): Promise<void> {
161
+ console.log(
162
+ `crosstalk chat — channel=${channelUuid!.slice(0, 8)} from=${fromName} to=${toActor}`,
163
+ );
164
+ console.log(
165
+ 'Type a message and press Enter. Replies will appear after the dispatcher processes them.',
166
+ );
167
+ console.log('Ctrl-C or Ctrl-D to exit.');
168
+ console.log('');
169
+
170
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
171
+
172
+ try {
173
+ while (true) {
174
+ let userMsg: string;
175
+ try {
176
+ userMsg = await rl.question(`${fromName}> `);
177
+ } catch {
178
+ break;
179
+ }
180
+ if (userMsg === undefined || userMsg === null) break;
181
+ const trimmed = userMsg.trim();
182
+ if (!trimmed) continue;
183
+
184
+ const sentAt = Date.now();
185
+ await sendMessage(trimmed);
186
+ process.stdout.write(`(waiting for ${toActor}…) `);
187
+
188
+ const reply = await waitForReplyAfter(sentAt);
189
+ if (!reply) {
190
+ console.log(
191
+ `\n(no reply within ${replyTimeoutSeconds}s — give up and try again, or check dispatch state)\n`,
192
+ );
193
+ continue;
194
+ }
195
+ process.stdout.write('\r' + ' '.repeat(40) + '\r');
196
+ console.log(`${toActor}> ${reply.body}\n`);
197
+ }
198
+ } finally {
199
+ rl.close();
200
+ }
201
+ }
202
+
203
+ main();
package/src/cursor.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { join, dirname } from 'path';
3
+ import { writeErrorLog } from './transport.js';
4
+
5
+ // Cursor format mirrors message relPath from filenames.ts:
6
+ // YYYY/MM/DD/HHMMSSmmmZ-<8hex>.md
7
+ const VALID_CURSOR = /^\d{4}\/\d{2}\/\d{2}\/\d{6}\d{3}Z-[0-9a-f]{8}\.md$/;
8
+
9
+ export function cursorPath(transportRoot: string, actor: string, channelUuid: string): string {
10
+ return join(transportRoot, 'cursors', actor, `${channelUuid}.md`);
11
+ }
12
+
13
+ export function readCursor(transportRoot: string, actor: string, channelUuid: string): string | null {
14
+ const p = cursorPath(transportRoot, actor, channelUuid);
15
+ if (!existsSync(p)) return null;
16
+ let raw: string;
17
+ try {
18
+ raw = readFileSync(p, 'utf-8').trim();
19
+ } catch (err) {
20
+ writeErrorLog(
21
+ transportRoot,
22
+ 'fs',
23
+ `cursor read failed for ${actor}@${channelUuid}: ${(err as Error).message}`,
24
+ );
25
+ return null;
26
+ }
27
+ if (raw.length === 0) return null;
28
+ if (!VALID_CURSOR.test(raw)) {
29
+ writeErrorLog(
30
+ transportRoot,
31
+ 'parse',
32
+ `cursor file for ${actor}@${channelUuid} contains invalid value '${raw.slice(0, 80)}' — treating as null (will re-scan from start)`,
33
+ );
34
+ return null;
35
+ }
36
+ return raw;
37
+ }
38
+
39
+ export function writeCursor(
40
+ transportRoot: string,
41
+ actor: string,
42
+ channelUuid: string,
43
+ relPath: string,
44
+ ): void {
45
+ const p = cursorPath(transportRoot, actor, channelUuid);
46
+ mkdirSync(dirname(p), { recursive: true });
47
+ writeFileSync(p, relPath + '\n');
48
+ }