@cordfuse/crosstalk 5.0.0-alpha.7 → 6.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 (58) hide show
  1. package/GUIDE-CLI.md +298 -0
  2. package/GUIDE-PROMPTS.md +132 -0
  3. package/README.md +139 -0
  4. package/bin/crosstalk.js +51 -80
  5. package/package.json +9 -5
  6. package/src/activation.ts +104 -0
  7. package/src/actor.ts +29 -4
  8. package/src/attach.ts +1 -1
  9. package/src/channel.ts +8 -21
  10. package/src/chat.ts +52 -115
  11. package/src/dispatch.ts +288 -660
  12. package/src/dlq.ts +89 -136
  13. package/src/init.ts +23 -42
  14. package/src/open.ts +55 -31
  15. package/src/replies.ts +59 -0
  16. package/src/send.ts +87 -72
  17. package/src/state.ts +173 -0
  18. package/src/status.ts +18 -57
  19. package/src/stop.ts +37 -0
  20. package/src/transport.ts +81 -198
  21. package/src/turnq.ts +64 -32
  22. package/src/upgrade.ts +9 -11
  23. package/src/wake.ts +5 -6
  24. package/template/CLAUDE.md +12 -2
  25. package/template/gitignore +4 -0
  26. package/template/upstream/CROSSTALK-VERSION +1 -1
  27. package/template/upstream/CROSSTALK.md +172 -463
  28. package/template/upstream/OPERATOR.md +9 -9
  29. package/template/upstream/PROTOCOL.md +64 -244
  30. package/template/upstream/actors/concierge.md +24 -118
  31. package/src/cursor.ts +0 -48
  32. package/template/.amazonq/rules/crosstalk.md +0 -2
  33. package/template/.continue/rules/crosstalk.md +0 -7
  34. package/template/.cursor/rules/crosstalk.mdc +0 -7
  35. package/template/.github/copilot-instructions.md +0 -2
  36. package/template/.windsurfrules +0 -2
  37. package/template/AGENTS.md +0 -2
  38. package/template/ANTIGRAVITY.md +0 -2
  39. package/template/GEMINI.md +0 -2
  40. package/template/OPENCODE.md +0 -2
  41. package/template/QWEN.md +0 -2
  42. package/template/README.md +0 -22
  43. package/template/local/CROSSTALK.md +0 -4
  44. package/template/upstream/JITTER.md +0 -24
  45. package/template/upstream/actors/cloud-architect.md +0 -83
  46. package/template/upstream/actors/devops-engineer.md +0 -83
  47. package/template/upstream/actors/documentation-engineer.md +0 -107
  48. package/template/upstream/actors/infrastructure-engineer.md +0 -83
  49. package/template/upstream/actors/junior-developer.md +0 -83
  50. package/template/upstream/actors/precise-generalist.md +0 -48
  51. package/template/upstream/actors/product-manager.md +0 -83
  52. package/template/upstream/actors/qa-engineer.md +0 -83
  53. package/template/upstream/actors/security-engineer.md +0 -92
  54. package/template/upstream/actors/senior-generalist-engineer.md +0 -111
  55. package/template/upstream/actors/senior-software-engineer.md +0 -94
  56. package/template/upstream/actors/skeptic.md +0 -89
  57. package/template/upstream/actors/technical-writer.md +0 -89
  58. package/template/upstream/actors/ux-designer.md +0 -83
package/bin/crosstalk.js CHANGED
@@ -2,40 +2,28 @@
2
2
  // crosstalk — subcommand dispatcher
3
3
  //
4
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.
5
+ // upstream/CROSSTALK-VERSION), then runs the requested subcommand from the
6
+ // runtime's installed source via tsx. All args after the subcommand are
7
+ // forwarded. `init` is the one subcommand that runs outside a transport
8
+ // (it creates one).
17
9
 
18
10
  import { existsSync, statSync } from 'fs';
19
11
  import { resolve, join, dirname } from 'path';
20
- import { spawnSync } from 'child_process';
12
+ import { spawnSync, spawn } from 'child_process';
21
13
  import { fileURLToPath } from 'url';
14
+ import { createRequire } from 'module';
22
15
 
23
16
  const SUBCOMMANDS = [
24
- 'dispatch', 'send', 'wake', 'status', 'init', 'dlq',
25
- 'open', 'channel', 'chat', 'attach', 'upgrade',
17
+ 'dispatch', 'stop', 'send', 'replies', 'wake', 'status', 'init', 'dlq', 'channel',
18
+ 'chat', 'open', 'attach', 'upgrade',
26
19
  ];
27
-
28
- // Subcommands that operate outside an existing transport (they create or
29
- // inspect from anywhere).
30
20
  const STANDALONE_SUBCOMMANDS = new Set(['init']);
31
21
 
32
22
  function findTransportRoot(startDir) {
33
23
  let dir = resolve(startDir);
34
24
  while (true) {
35
25
  const versionFile = join(dir, 'upstream', 'CROSSTALK-VERSION');
36
- if (existsSync(versionFile) && statSync(versionFile).isFile()) {
37
- return dir;
38
- }
26
+ if (existsSync(versionFile) && statSync(versionFile).isFile()) return dir;
39
27
  const parent = dirname(dir);
40
28
  if (parent === dir) return null;
41
29
  dir = parent;
@@ -44,8 +32,7 @@ function findTransportRoot(startDir) {
44
32
 
45
33
  function printUsage(exitCode = 0) {
46
34
  process.stdout.write(
47
- `Usage: crosstalk <subcommand> [args...]\n\n` +
48
- `Subcommands:\n` +
35
+ `Usage: crosstalk <subcommand> [args...]\n\nSubcommands:\n` +
49
36
  SUBCOMMANDS.map((s) => ` ${s}`).join('\n') +
50
37
  `\n\nMost subcommands require you to be inside a Crosstalk transport\n` +
51
38
  `(a directory containing upstream/CROSSTALK-VERSION). 'init' can run\n` +
@@ -55,72 +42,56 @@ function printUsage(exitCode = 0) {
55
42
  }
56
43
 
57
44
  const argv = process.argv.slice(2);
45
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') printUsage(0);
58
46
 
59
- if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
60
- printUsage(0);
47
+ if (argv[0] === '--version' || argv[0] === 'version') {
48
+ const require = createRequire(import.meta.url);
49
+ const { version } = require('../package.json');
50
+ process.stdout.write(`${version}\n`);
51
+ process.exit(0);
61
52
  }
62
53
 
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);
54
+ const cmd = argv[0];
55
+ if (!SUBCOMMANDS.includes(cmd)) {
56
+ console.error(`crosstalk: unknown subcommand '${cmd}'\n`);
57
+ printUsage(1);
72
58
  }
73
59
 
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
- }
60
+ const thisDir = dirname(fileURLToPath(import.meta.url));
61
+ const srcFile = join(thisDir, '..', 'src', `${cmd}.ts`);
87
62
 
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`,
63
+ let cwd = process.cwd();
64
+ if (!STANDALONE_SUBCOMMANDS.has(cmd)) {
65
+ const root = findTransportRoot(cwd);
66
+ if (!root) {
67
+ console.error(
68
+ `crosstalk ${cmd}: not inside a Crosstalk transport ` +
69
+ `(no upstream/CROSSTALK-VERSION found from ${cwd} upward).`,
97
70
  );
98
71
  process.exit(2);
99
72
  }
100
- workingDir = transportRoot;
73
+ cwd = root;
101
74
  }
102
75
 
103
- // Strip a single leading `--` separator if present (legacy npm run X -- ergonomics).
104
- const cleanedArgs = forwardArgs[0] === '--' ? forwardArgs.slice(1) : forwardArgs;
105
-
106
- // Resolve tsx directly from the runtime's own bundled node_modules. Going
107
- // through `npx tsx` was fragile in containers where the entrypoint changes
108
- // npm's global prefix (the bin shim would lstat the new prefix's lib/
109
- // directory and fail with ENOENT if tsx wasn't installed there too). Direct
110
- // invocation of the bundled tsx binary sidesteps the entire npm resolution
111
- // dance.
112
- const tsxBin = join(runtimeRoot, 'node_modules', '.bin', 'tsx');
113
- if (!existsSync(tsxBin)) {
114
- process.stderr.write(
115
- `crosstalk: bundled tsx not found at ${tsxBin}.\n` +
116
- `The @cordfuse/crosstalk installation may be corrupted; try reinstalling.\n`,
117
- );
118
- process.exit(2);
76
+ const require = createRequire(import.meta.url);
77
+ const tsxCli = require.resolve('tsx/cli');
78
+
79
+ // dispatch is long-running: use async spawn so SIGTERM/SIGINT forwarded to
80
+ // the tsx child kills the whole chain cleanly. All other subcommands are
81
+ // short-lived and spawnSync is fine.
82
+ if (cmd === 'dispatch') {
83
+ const child = spawn(process.execPath, [tsxCli, srcFile, ...argv.slice(1)], {
84
+ cwd,
85
+ stdio: 'inherit',
86
+ });
87
+ const forward = (sig) => { try { child.kill(sig); } catch {} };
88
+ process.on('SIGTERM', () => forward('SIGTERM'));
89
+ process.on('SIGINT', () => forward('SIGTERM'));
90
+ child.on('exit', (code, signal) => process.exit(signal ? 1 : (code ?? 0)));
91
+ } else {
92
+ const r = spawnSync(process.execPath, [tsxCli, srcFile, ...argv.slice(1)], {
93
+ cwd,
94
+ stdio: 'inherit',
95
+ });
96
+ process.exit(r.status ?? 1);
119
97
  }
120
-
121
- const result = spawnSync(tsxBin, [toolPath, ...cleanedArgs], {
122
- cwd: workingDir,
123
- stdio: 'inherit',
124
- });
125
-
126
- process.exit(result.status ?? 1);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cordfuse/crosstalk",
3
- "version": "5.0.0-alpha.7",
4
- "description": "Crosstalk runtime — async messaging between agents over git. The crosstalk CLI plus dispatch, send, attach, chat, and supporting tools.",
3
+ "version": "6.0.0-alpha.10",
4
+ "description": "Crosstalk runtime — async messaging between agents over git, across machines.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/cordfuse/crosstalk",
@@ -15,15 +15,19 @@
15
15
  "files": [
16
16
  "bin/",
17
17
  "src/",
18
- "template/"
18
+ "template/",
19
+ "GUIDE-CLI.md",
20
+ "GUIDE-PROMPTS.md"
19
21
  ],
20
22
  "scripts": {
21
23
  "build": "tsc --noEmit",
22
24
  "lint": "tsc --noEmit",
23
- "test": "echo 'No tests yet'"
25
+ "test": "bun test",
26
+ "prepack": "cp -r ../transport template && cp ../README.md ../GUIDE-CLI.md ../GUIDE-PROMPTS.md .",
27
+ "postpack": "rm -rf template README.md GUIDE-CLI.md GUIDE-PROMPTS.md"
24
28
  },
25
29
  "dependencies": {
26
- "@cordfuse/turnq": "^0.3.4",
30
+ "@cordfuse/turnq": "^0.4.2",
27
31
  "@lydell/node-pty": "^1.2.0-beta.12",
28
32
  "tsx": "^4.20.0",
29
33
  "yaml": "^2.8.0"
@@ -0,0 +1,104 @@
1
+ // The activation rule — pure functions, no I/O, exhaustively unit-tested.
2
+ //
3
+ // One rule (CROSSTALK.md "Activation"):
4
+ //
5
+ // A message wakes its addressee if it has no `re:` (a new task), or its
6
+ // `re:` points at a message the addressee sent.
7
+ //
8
+ // `re:` is written by the runtime at send time, never inferred at read
9
+ // time. It is a string or a list: a reply that answers a batch of N
10
+ // messages records ALL N relPaths, so no answered message is ever lost to
11
+ // batching.
12
+
13
+ export interface ActivationMessage {
14
+ from: string;
15
+ to: string[];
16
+ re?: string | string[];
17
+ }
18
+
19
+ export function recipients(toField: unknown): string[] {
20
+ if (Array.isArray(toField)) return toField.map(String);
21
+ if (typeof toField === 'string') return [toField];
22
+ return [];
23
+ }
24
+
25
+ export function reList(reField: unknown): string[] {
26
+ if (Array.isArray(reField)) return reField.map(String);
27
+ if (typeof reField === 'string') return [reField];
28
+ return [];
29
+ }
30
+
31
+ // A recipient is `actor` or `actor@host`.
32
+ export function extractActor(recipient: string): string {
33
+ const at = recipient.indexOf('@');
34
+ return at === -1 ? recipient : recipient.slice(0, at);
35
+ }
36
+
37
+ export function targetHost(recipient: string): string | null {
38
+ const at = recipient.indexOf('@');
39
+ return at === -1 ? null : recipient.slice(at + 1);
40
+ }
41
+
42
+ export interface RoutingResult {
43
+ addressed: boolean;
44
+ // Actor was named, but every instance targeted a different host — logged
45
+ // by the dispatcher so wrong-host routes are visible, never silent.
46
+ wrongHost: boolean;
47
+ }
48
+
49
+ export function matchRouting(
50
+ recipientList: string[],
51
+ actorName: string,
52
+ thisHost: string,
53
+ ): RoutingResult {
54
+ let actorNamedAtAll = false;
55
+ for (const r of recipientList) {
56
+ if (r === 'all') return { addressed: true, wrongHost: false };
57
+ if (extractActor(r) !== actorName) continue;
58
+ actorNamedAtAll = true;
59
+ const host = targetHost(r);
60
+ if (host === null || host === thisHost) return { addressed: true, wrongHost: false };
61
+ }
62
+ return { addressed: false, wrongHost: actorNamedAtAll };
63
+ }
64
+
65
+ export type WakeDecision = 'wake' | 'skip' | 'wrong-host';
66
+
67
+ // `senderOf` resolves a channel relPath to the `from:` of the message there,
68
+ // or undefined if no such message exists. A dangling `re:` entry (target
69
+ // missing) wakes the addressee — fail open so a message is never silently
70
+ // dropped; no loop is possible because the reply to it carries a
71
+ // resolvable `re:`.
72
+ export function decideWake(
73
+ msg: ActivationMessage,
74
+ actorName: string,
75
+ thisHost: string,
76
+ senderOf: (relPath: string) => string | undefined,
77
+ ): WakeDecision {
78
+ const routing = matchRouting(msg.to, actorName, thisHost);
79
+ if (!routing.addressed) return routing.wrongHost ? 'wrong-host' : 'skip';
80
+ if (msg.from === actorName) return 'skip';
81
+ const targets = reList(msg.re);
82
+ if (targets.length === 0) return 'wake';
83
+ for (const target of targets) {
84
+ const asker = senderOf(target);
85
+ if (asker === undefined || asker === actorName) return 'wake';
86
+ }
87
+ return 'skip';
88
+ }
89
+
90
+ // Split a channel's pending messages (already sorted by relPath) into
91
+ // contiguous batches sized for the actor's concurrency. Contiguous so each
92
+ // batch's highest relPath is monotone across batches — the cursor advances
93
+ // safely per batch. When pending fits within concurrency, every batch is a
94
+ // single message (parallel fan-out); when it exceeds, batches collapse into
95
+ // ~concurrency invocations (fan-in stays O(1) per actor).
96
+ export function splitForConcurrency<T>(msgs: T[], concurrency: number): T[][] {
97
+ if (concurrency <= 1 || msgs.length <= 1) return [msgs];
98
+ const chunkSize = Math.max(1, Math.ceil(msgs.length / concurrency));
99
+ const out: T[][] = [];
100
+ for (let i = 0; i < msgs.length; i += chunkSize) {
101
+ out.push(msgs.slice(i, i + chunkSize));
102
+ }
103
+ return out;
104
+ }
package/src/actor.ts CHANGED
@@ -1,8 +1,31 @@
1
1
  import { existsSync, readFileSync, readdirSync } from 'fs';
2
2
  import { join } from 'path';
3
- import { hostname as osHostname } from 'os';
3
+ import { hostname as osHostname, platform } from 'os';
4
+ import { spawnSync } from 'child_process';
4
5
  import { parseFrontmatter } from './frontmatter.js';
5
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
+
6
29
  export interface HostActorTier {
7
30
  cli: string;
8
31
  count?: number;
@@ -36,13 +59,15 @@ export function findHostFile(transportRoot: string, override?: string): HostFile
36
59
  if (!target) throw new Error(`Host file '${override}' not found in ${dir}`);
37
60
  return parseHostFile(join(dir, target));
38
61
  }
39
- const hostName = osHostname();
62
+ const names = candidateHostNames();
40
63
  for (const f of files) {
41
64
  const parsed = parseHostFile(join(dir, f));
42
- if (parsed.hostname === hostName || parsed.alias === hostName) return parsed;
65
+ for (const n of names) {
66
+ if (parsed.hostname === n || parsed.alias === n) return parsed;
67
+ }
43
68
  }
44
69
  throw new Error(
45
- `No host file matches hostname '${hostName}' in ${dir}. ` +
70
+ `No host file matches any of [${names.join(', ')}] in ${dir}. ` +
46
71
  `Pass --host <alias> to override.`,
47
72
  );
48
73
  }
package/src/attach.ts CHANGED
@@ -52,7 +52,7 @@ process.stdout.write(`crosstalk: attaching to ${bin} (operator mode)\n`);
52
52
 
53
53
  let term;
54
54
  try {
55
- term = ptySpawn(bin, args, {
55
+ term = ptySpawn(bin!, args, {
56
56
  name: process.env['TERM'] ?? 'xterm-256color',
57
57
  cols,
58
58
  rows,
package/src/channel.ts CHANGED
@@ -1,6 +1,9 @@
1
+ // crosstalk channel — create a new channel directory with its CHANNEL.md.
2
+
1
3
  import { mkdirSync, writeFileSync, existsSync } from 'fs';
2
4
  import { resolve, join } from 'path';
3
5
  import { randomUUID } from 'crypto';
6
+ import { serializeFrontmatter } from './frontmatter.js';
4
7
 
5
8
  const transportRoot = resolve(process.cwd());
6
9
  const argv = process.argv.slice(2);
@@ -13,12 +16,10 @@ function flag(name: string): string | undefined {
13
16
 
14
17
  const name = flag('--name');
15
18
  const parent = flag('--parent');
16
- const createdBy = flag('--created-by') ?? process.env.USER ?? 'operator';
19
+ const createdBy = flag('--created-by') ?? process.env['USER'] ?? 'operator';
17
20
 
18
21
  if (!name) {
19
- console.error(
20
- 'Usage: npm run channel -- --name <name> [--parent <parent-uuid>] [--created-by <name>]',
21
- );
22
+ console.error('Usage: crosstalk channel --name <name> [--parent <parent-uuid>] [--created-by <name>]');
22
23
  process.exit(1);
23
24
  }
24
25
 
@@ -37,24 +38,10 @@ const frontmatter: Record<string, unknown> = {
37
38
  created_by: createdBy,
38
39
  created_at: new Date().toISOString(),
39
40
  };
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('');
41
+ if (parent) frontmatter['parent'] = parent;
56
42
 
57
- writeFileSync(join(dir, 'CHANNEL.md'), lines.join('\n'));
43
+ const body = `# ${name}\n\n${parent ? `Subchannel of \`${parent}\`.` : 'Channel description goes here.'}\n`;
44
+ writeFileSync(join(dir, 'CHANNEL.md'), serializeFrontmatter(frontmatter, body));
58
45
 
59
46
  console.log(`Created channel: ${uuid}`);
60
47
  console.log(` name: ${name}`);
package/src/chat.ts CHANGED
@@ -1,32 +1,32 @@
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.
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
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.
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
10
  //
11
11
  // Difference from `open`:
12
12
  // - `open` spawns the actor's CLI LOCALLY — needs claude/etc. installed,
13
13
  // needs auth, doesn't use any remote dispatcher.
14
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.
15
+ // replies. Whatever's processing the channel handles the work.
17
16
  //
18
17
  // Usage:
19
- // npm run chat -- --channel <uuid> --to <actor> [--as <name>] [--tier <name>]
20
- // npm run chat -- --channel <uuid> --to concierge --as steve
18
+ // crosstalk chat --channel <uuid> --to <actor> [--as <name>] [--tier <name>]
19
+ // crosstalk chat --channel <uuid> --to concierge --as steve
21
20
 
22
21
  import { resolve, join } from 'path';
23
- import { mkdirSync, writeFileSync, readdirSync, readFileSync, statSync, existsSync } from 'fs';
22
+ import { mkdirSync, writeFileSync } from 'fs';
24
23
  import { createInterface } from 'readline/promises';
25
- import { spawnSync } from 'child_process';
26
24
  import { now, messageFilename } from './filenames.js';
27
- import { serializeFrontmatter, parseFrontmatter } from './frontmatter.js';
28
- import { gitCommitAndPush } from './transport.js';
25
+ import { serializeFrontmatter } from './frontmatter.js';
26
+ import { gitPull, gitCommitAndPush, listChannelMessages, ChannelMessage } from './transport.js';
27
+ import { reList } from './activation.js';
29
28
  import { withLock } from './turnq.js';
29
+ import { sendWakeSignal } from './state.js';
30
30
 
31
31
  const transportRoot = resolve(process.cwd());
32
32
  const argv = process.argv.slice(2);
@@ -39,119 +39,55 @@ function flag(name: string): string | undefined {
39
39
 
40
40
  const channelUuid = flag('--channel');
41
41
  const toActor = flag('--to');
42
- const fromName = flag('--as') ?? process.env.USER ?? 'steve';
42
+ const fromName = flag('--as') ?? process.env['USER'] ?? 'operator';
43
43
  const tier = flag('--tier');
44
44
  const pollSeconds = Number(flag('--poll')) || 5;
45
45
  const replyTimeoutSeconds = Number(flag('--timeout')) || 600;
46
46
 
47
47
  if (!channelUuid || !toActor) {
48
48
  console.error(
49
- 'Usage: npm run chat -- --channel <uuid> --to <actor> [--as <name>] [--tier <name>] [--poll <seconds>] [--timeout <seconds>]',
49
+ 'Usage: crosstalk chat --channel <uuid> --to <actor> [--as <name>] [--tier <name>] [--poll <seconds>] [--timeout <seconds>]',
50
50
  );
51
51
  process.exit(1);
52
52
  }
53
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
- }
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,
102
63
  };
103
- walk(channelDir, '');
104
- return results.sort((a, b) => a.ts - b.ts);
105
- }
64
+ if (tier) frontmatter['tier'] = tier;
65
+ const filename = messageFilename(ts);
66
+ writeFileSync(join(dir, filename), serializeFrontmatter(frontmatter, body));
106
67
 
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(
68
+ const r = await withLock(transportRoot, 'git', async () =>
69
+ gitCommitAndPush(
131
70
  transportRoot,
132
71
  `chat: ${fromName} -> ${toActor} in ${channelUuid!.slice(0, 8)}`,
133
- );
134
- if (!r.ok && r.error) {
135
- // Same anti-pattern as send.ts: writing to errors/ from an operator-
136
- // side command dirties the working tree and breaks subsequent
137
- // git pull --rebase. Stay on stderr only.
138
- const kind = r.committed ? 'push' : 'commit';
139
- console.error(`(${kind} failed: ${r.error.slice(0, 200)} — message is local-only)`);
140
- }
141
- });
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}`;
142
80
  }
143
81
 
144
- async function waitForReplyAfter(thresholdTs: number): Promise<ScannedMessage | null> {
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> {
145
86
  const deadline = Date.now() + replyTimeoutSeconds * 1_000;
146
87
  while (Date.now() < deadline) {
147
- gitPullQuiet();
148
- const messages = scanChannelMessages();
149
- const reply = messages.find(
150
- (m) =>
151
- m.ts > thresholdTs &&
152
- m.type === 'text' &&
153
- m.from === toActor &&
154
- m.to.includes(fromName),
88
+ gitPull(transportRoot);
89
+ const reply = listChannelMessages(transportRoot, channelUuid!).find((m) =>
90
+ reList(m.data['re']).includes(sentRelPath),
155
91
  );
156
92
  if (reply) return reply;
157
93
  await new Promise((r) => setTimeout(r, pollSeconds * 1_000));
@@ -183,11 +119,10 @@ async function main(): Promise<void> {
183
119
  const trimmed = userMsg.trim();
184
120
  if (!trimmed) continue;
185
121
 
186
- const sentAt = Date.now();
187
- await sendMessage(trimmed);
122
+ const sentRelPath = await sendMessage(trimmed);
188
123
  process.stdout.write(`(waiting for ${toActor}…) `);
189
124
 
190
- const reply = await waitForReplyAfter(sentAt);
125
+ const reply = await waitForReplyTo(sentRelPath);
191
126
  if (!reply) {
192
127
  console.log(
193
128
  `\n(no reply within ${replyTimeoutSeconds}s — give up and try again, or check dispatch state)\n`,
@@ -195,11 +130,13 @@ async function main(): Promise<void> {
195
130
  continue;
196
131
  }
197
132
  process.stdout.write('\r' + ' '.repeat(40) + '\r');
198
- console.log(`${toActor}> ${reply.body}\n`);
133
+ const replyFrom = typeof reply.data['from'] === 'string' ? reply.data['from'] : toActor;
134
+ console.log(`${replyFrom}> ${reply.body}\n`);
199
135
  }
200
136
  } finally {
201
137
  rl.close();
202
138
  }
139
+ process.exit(0);
203
140
  }
204
141
 
205
142
  main();