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