@cordfuse/crosstalk 6.0.0-alpha.9 → 7.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.
- package/README.md +26 -0
- package/bin/crosstalk.js +60 -74
- package/commands/channel.js +69 -0
- package/commands/chat.js +174 -0
- package/commands/down.js +37 -0
- package/commands/init.js +105 -0
- package/commands/logs.js +39 -0
- package/commands/pull.js +24 -0
- package/commands/replies.js +39 -0
- package/commands/restart.js +24 -0
- package/commands/run.js +121 -0
- package/commands/status.js +52 -0
- package/commands/up.js +129 -0
- package/commands/version.js +30 -0
- package/lib/api-client.js +80 -0
- package/lib/argv.js +28 -0
- package/lib/errors.js +19 -0
- package/lib/transport.js +51 -0
- package/package.json +5 -21
- package/src/activation.ts +0 -104
- package/src/actor.ts +0 -131
- package/src/attach.ts +0 -118
- package/src/channel.ts +0 -49
- package/src/chat.ts +0 -142
- package/src/dispatch.ts +0 -531
- package/src/dlq.ts +0 -216
- package/src/filenames.ts +0 -28
- package/src/frontmatter.ts +0 -26
- package/src/init.ts +0 -138
- package/src/open.ts +0 -207
- package/src/replies.ts +0 -59
- package/src/send.ts +0 -122
- package/src/state.ts +0 -173
- package/src/status.ts +0 -75
- package/src/stop.ts +0 -37
- package/src/transport.ts +0 -213
- package/src/turnq.ts +0 -91
- package/src/upgrade.ts +0 -211
- package/src/wake.ts +0 -7
- package/template/CLAUDE.md +0 -12
- package/template/gitignore +0 -4
- package/template/upstream/CROSSTALK-VERSION +0 -1
- package/template/upstream/CROSSTALK.md +0 -298
- package/template/upstream/OPERATOR.md +0 -60
- package/template/upstream/PROTOCOL.md +0 -80
- 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();
|