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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +97 -0
  2. package/bin/crosstalk.js +61 -74
  3. package/commands/channel.js +118 -0
  4. package/commands/chat.js +119 -0
  5. package/commands/down.js +40 -0
  6. package/commands/init.js +243 -0
  7. package/commands/logs.js +37 -0
  8. package/commands/pull.js +22 -0
  9. package/commands/replies.js +40 -0
  10. package/commands/restart.js +29 -0
  11. package/commands/rm.js +109 -0
  12. package/commands/run.js +115 -0
  13. package/commands/status.js +90 -0
  14. package/commands/up.js +135 -0
  15. package/commands/version.js +33 -0
  16. package/lib/api-client.js +141 -0
  17. package/lib/argv.js +30 -0
  18. package/lib/errors.js +19 -0
  19. package/lib/resolve.js +191 -0
  20. package/package.json +5 -21
  21. package/src/activation.ts +0 -104
  22. package/src/actor.ts +0 -131
  23. package/src/attach.ts +0 -118
  24. package/src/channel.ts +0 -49
  25. package/src/chat.ts +0 -142
  26. package/src/dispatch.ts +0 -531
  27. package/src/dlq.ts +0 -216
  28. package/src/filenames.ts +0 -28
  29. package/src/frontmatter.ts +0 -26
  30. package/src/init.ts +0 -138
  31. package/src/open.ts +0 -207
  32. package/src/replies.ts +0 -59
  33. package/src/send.ts +0 -122
  34. package/src/state.ts +0 -173
  35. package/src/status.ts +0 -75
  36. package/src/stop.ts +0 -37
  37. package/src/transport.ts +0 -213
  38. package/src/turnq.ts +0 -91
  39. package/src/upgrade.ts +0 -211
  40. package/src/wake.ts +0 -7
  41. package/template/CLAUDE.md +0 -12
  42. package/template/gitignore +0 -4
  43. package/template/upstream/CROSSTALK-VERSION +0 -1
  44. package/template/upstream/CROSSTALK.md +0 -298
  45. package/template/upstream/OPERATOR.md +0 -60
  46. package/template/upstream/PROTOCOL.md +0 -80
  47. package/template/upstream/actors/concierge.md +0 -36
package/src/activation.ts DELETED
@@ -1,104 +0,0 @@
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 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();