@cordfuse/crosstalk 5.0.0-alpha.7 → 6.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/crosstalk.js +34 -78
- package/package.json +4 -4
- package/src/activation.ts +104 -0
- package/src/attach.ts +1 -1
- package/src/channel.ts +8 -21
- package/src/chat.ts +52 -115
- package/src/dispatch.ts +252 -661
- package/src/dlq.ts +68 -136
- package/src/init.ts +17 -41
- package/src/open.ts +55 -31
- package/src/replies.ts +59 -0
- package/src/send.ts +48 -67
- package/src/state.ts +143 -0
- package/src/status.ts +18 -57
- package/src/transport.ts +68 -198
- package/src/turnq.ts +64 -32
- package/src/upgrade.ts +9 -11
- package/src/wake.ts +5 -6
- package/src/cursor.ts +0 -48
- package/template/.amazonq/rules/crosstalk.md +0 -2
- package/template/.continue/rules/crosstalk.md +0 -7
- package/template/.cursor/rules/crosstalk.mdc +0 -7
- package/template/.github/copilot-instructions.md +0 -2
- package/template/.windsurfrules +0 -2
- package/template/AGENTS.md +0 -2
- package/template/ANTIGRAVITY.md +0 -2
- package/template/CLAUDE.md +0 -2
- package/template/GEMINI.md +0 -2
- package/template/OPENCODE.md +0 -2
- package/template/QWEN.md +0 -2
- package/template/README.md +0 -22
- package/template/local/CROSSTALK.md +0 -4
- package/template/upstream/CROSSTALK-VERSION +0 -1
- package/template/upstream/CROSSTALK.md +0 -589
- package/template/upstream/JITTER.md +0 -24
- package/template/upstream/OPERATOR.md +0 -60
- package/template/upstream/PROTOCOL.md +0 -260
- package/template/upstream/actors/cloud-architect.md +0 -83
- package/template/upstream/actors/concierge.md +0 -130
- package/template/upstream/actors/devops-engineer.md +0 -83
- package/template/upstream/actors/documentation-engineer.md +0 -107
- package/template/upstream/actors/infrastructure-engineer.md +0 -83
- package/template/upstream/actors/junior-developer.md +0 -83
- package/template/upstream/actors/precise-generalist.md +0 -48
- package/template/upstream/actors/product-manager.md +0 -83
- package/template/upstream/actors/qa-engineer.md +0 -83
- package/template/upstream/actors/security-engineer.md +0 -92
- package/template/upstream/actors/senior-generalist-engineer.md +0 -111
- package/template/upstream/actors/senior-software-engineer.md +0 -94
- package/template/upstream/actors/skeptic.md +0 -89
- package/template/upstream/actors/technical-writer.md +0 -89
- 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
|
|
6
|
-
//
|
|
7
|
-
// subcommand
|
|
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
12
|
import { spawnSync } 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
|
-
'
|
|
17
|
+
'dispatch', '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,41 @@ 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
|
|
60
|
-
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
const binDir = dirname(fileURLToPath(import.meta.url));
|
|
77
|
-
const runtimeRoot = dirname(binDir);
|
|
78
|
-
const toolPath = join(runtimeRoot, 'src', `${subcommand}.ts`);
|
|
60
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
61
|
+
const srcFile = join(thisDir, '..', 'src', `${cmd}.ts`);
|
|
79
62
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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`,
|
|
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
|
-
|
|
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
|
-
// 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);
|
|
73
|
+
cwd = root;
|
|
119
74
|
}
|
|
120
75
|
|
|
121
|
-
const
|
|
122
|
-
|
|
76
|
+
const require = createRequire(import.meta.url);
|
|
77
|
+
const tsxCli = require.resolve('tsx/cli');
|
|
78
|
+
const r = spawnSync(process.execPath, [tsxCli, srcFile, ...argv.slice(1)], {
|
|
79
|
+
cwd,
|
|
123
80
|
stdio: 'inherit',
|
|
124
81
|
});
|
|
125
|
-
|
|
126
|
-
process.exit(result.status ?? 1);
|
|
82
|
+
process.exit(r.status ?? 1);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cordfuse/crosstalk",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Crosstalk runtime — async messaging between agents over git
|
|
3
|
+
"version": "6.0.0-alpha.1",
|
|
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",
|
|
@@ -20,10 +20,10 @@
|
|
|
20
20
|
"scripts": {
|
|
21
21
|
"build": "tsc --noEmit",
|
|
22
22
|
"lint": "tsc --noEmit",
|
|
23
|
-
"test": "
|
|
23
|
+
"test": "bun test"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@cordfuse/turnq": "^0.
|
|
26
|
+
"@cordfuse/turnq": "^0.4.1",
|
|
27
27
|
"@lydell/node-pty": "^1.2.0-beta.12",
|
|
28
28
|
"tsx": "^4.20.0",
|
|
29
29
|
"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/attach.ts
CHANGED
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
7
|
-
// running against the transport and you want to chat
|
|
8
|
-
// actors from a clean local clone. No
|
|
9
|
-
//
|
|
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
|
|
16
|
-
// dispatcher, a peer) handles the work.
|
|
15
|
+
// replies. Whatever's processing the channel handles the work.
|
|
17
16
|
//
|
|
18
17
|
// Usage:
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
ts
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
64
|
+
if (tier) frontmatter['tier'] = tier;
|
|
65
|
+
const filename = messageFilename(ts);
|
|
66
|
+
writeFileSync(join(dir, filename), serializeFrontmatter(frontmatter, body));
|
|
106
67
|
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
const
|
|
149
|
-
|
|
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
|
|
187
|
-
await sendMessage(trimmed);
|
|
122
|
+
const sentRelPath = await sendMessage(trimmed);
|
|
188
123
|
process.stdout.write(`(waiting for ${toActor}…) `);
|
|
189
124
|
|
|
190
|
-
const reply = await
|
|
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
|
-
|
|
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();
|