@cordfuse/crosstalkd 7.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/src/run.ts ADDED
@@ -0,0 +1,236 @@
1
+ // crosstalkd run — dispatch a primitive or workflow.
2
+ //
3
+ // Primitive: write one or N (fanout) identical messages addressed to
4
+ // <model>[@<machine>] with an optional --as persona. Body comes from
5
+ // inline arg, file path, or stdin (`-`).
6
+ //
7
+ // Workflow: read a workflow document (file path or stdin `-`), parse
8
+ // its frontmatter (only `type: workflow` is required). Auto-create a
9
+ // child channel parented to the operator's current channel, then write
10
+ // the workflow doc as a marker message in the parent channel addressed
11
+ // to the reserved recipient `workflow`. The dispatcher's workflowTick
12
+ // (workflow.ts) handles execution: compile-pass → fanout → synthesize
13
+ // → route. No model is invoked through the activation loop for the
14
+ // marker itself.
15
+ //
16
+ // `re:` is the activation story (CROSSTALK.md "Activation"):
17
+ // inside a dispatched turn, CROSSTALK_DISPATCH_RE carries the relPath(s)
18
+ // of the message(s) being answered; run() links them automatically.
19
+ // `--new` suppresses that. Operators (no env) always send new tasks.
20
+
21
+ import { resolve, join, basename, extname } from 'path';
22
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync } from 'fs';
23
+ import { randomUUID } from 'crypto';
24
+ import { now, messageFilename } from './filenames.js';
25
+ import { parseFrontmatter, serializeFrontmatter } from './frontmatter.js';
26
+ import { gitCommitAndPush, discoverChannels } from './transport.js';
27
+ import { sendWakeSignal, readHeartbeat } from './state.js';
28
+
29
+ const transportRoot = resolve(process.cwd());
30
+ const argv = process.argv.slice(2);
31
+
32
+ function flag(name: string): string | undefined {
33
+ const i = argv.indexOf(name);
34
+ if (i === -1 || i === argv.length - 1) return undefined;
35
+ return argv[i + 1];
36
+ }
37
+
38
+ function readStdin(): string {
39
+ try {
40
+ return readFileSync(0, 'utf-8');
41
+ } catch {
42
+ return '';
43
+ }
44
+ }
45
+
46
+ function resolveBody(arg: string | undefined): string {
47
+ if (arg == null) {
48
+ throw new Error('crosstalkd run: missing body argument (file path, inline string, or `-` for stdin)');
49
+ }
50
+ if (arg === '-') return readStdin();
51
+ if (existsSync(arg)) {
52
+ try {
53
+ if (statSync(arg).isFile()) return readFileSync(arg, 'utf-8');
54
+ } catch { /* fall through to inline */ }
55
+ }
56
+ return arg;
57
+ }
58
+
59
+ function autoChannel(): string {
60
+ const channels = discoverChannels(transportRoot);
61
+ if (channels.length === 1) return channels[0]!;
62
+ if (channels.length === 0) {
63
+ console.error("crosstalkd run: no channels found. Create one with 'crosstalkd channel <name>'.");
64
+ process.exit(1);
65
+ }
66
+ console.error('crosstalkd run: multiple channels found — specify one with --channel <uuid>.');
67
+ console.error(" Run 'crosstalkd status' to list channels.");
68
+ process.exit(1);
69
+ }
70
+
71
+ function reTargets(isNew: boolean): string[] {
72
+ return (isNew ? '' : process.env['CROSSTALK_DISPATCH_RE'] ?? '')
73
+ .split(',')
74
+ .map((s) => s.trim())
75
+ .filter(Boolean);
76
+ }
77
+
78
+ function defaultFrom(): string {
79
+ return process.env['CROSSTALK_DISPATCH_ACTOR']
80
+ ?? process.env['USER']
81
+ ?? 'operator';
82
+ }
83
+
84
+ interface WriteOptions {
85
+ channelUuid: string;
86
+ from: string;
87
+ to: string;
88
+ as?: string;
89
+ body: string;
90
+ re: string[];
91
+ extraFrontmatter?: Record<string, unknown>;
92
+ workflow?: boolean;
93
+ }
94
+
95
+ function writeMessage(opts: WriteOptions): string {
96
+ const ts = now();
97
+ const dir = join(transportRoot, 'data', 'channels', opts.channelUuid, ts.pathDate);
98
+ mkdirSync(dir, { recursive: true });
99
+
100
+ const fm: Record<string, unknown> = {
101
+ from: opts.from,
102
+ to: opts.to,
103
+ timestamp: ts.iso,
104
+ };
105
+ if (opts.as) fm['as'] = opts.as;
106
+ if (opts.workflow) fm['type'] = 'workflow';
107
+ if (opts.re.length === 1) fm['re'] = opts.re[0];
108
+ else if (opts.re.length > 1) fm['re'] = opts.re;
109
+ if (opts.extraFrontmatter) Object.assign(fm, opts.extraFrontmatter);
110
+
111
+ const filename = messageFilename(ts);
112
+ writeFileSync(join(dir, filename), serializeFrontmatter(fm, opts.body));
113
+ return join(ts.pathDate, filename);
114
+ }
115
+
116
+ function createChildChannel(parentUuid: string, derivedName: string): string {
117
+ const childUuid = randomUUID();
118
+ const channelDir = join(transportRoot, 'data', 'channels', childUuid);
119
+ mkdirSync(channelDir, { recursive: true });
120
+ const channelMd = serializeFrontmatter(
121
+ { name: derivedName, parent: parentUuid },
122
+ '',
123
+ );
124
+ writeFileSync(join(channelDir, 'CHANNEL.md'), channelMd);
125
+ return childUuid;
126
+ }
127
+
128
+ async function runPrimitive(): Promise<void> {
129
+ const to = flag('--to');
130
+ const as = flag('--as');
131
+ const fanoutRaw = flag('--fanout');
132
+ const fanout = fanoutRaw ? Math.max(1, parseInt(fanoutRaw, 10)) : 1;
133
+ const isNew = argv.includes('--new');
134
+ const from = flag('--from') ?? defaultFrom();
135
+ const bodyArg = argv[argv.length - 1];
136
+
137
+ if (!to || !bodyArg || bodyArg.startsWith('--')) {
138
+ console.error(
139
+ 'Usage: crosstalkd run --type primitive --to <model>[@<machine>] [--as <actor>] [--fanout <n>] [--channel <uuid>] <body|file|->',
140
+ );
141
+ process.exit(1);
142
+ }
143
+
144
+ const body = resolveBody(bodyArg);
145
+ const channelUuid = flag('--channel') ?? process.env['CROSSTALK_DISPATCH_CHANNEL'] ?? autoChannel();
146
+ const re = reTargets(isNew);
147
+ const relPaths: string[] = [];
148
+ for (let i = 0; i < fanout; i++) {
149
+ relPaths.push(writeMessage({ channelUuid, from, to, as, body, re }));
150
+ }
151
+
152
+ const commitMsg = fanout === 1
153
+ ? `run: ${from} -> ${to} in ${channelUuid.slice(0, 8)}`
154
+ : `run: ${from} -> ${to} x${fanout} in ${channelUuid.slice(0, 8)}`;
155
+ const push = gitCommitAndPush(transportRoot, commitMsg);
156
+ sendWakeSignal(transportRoot);
157
+ if (!push.ok && push.error) {
158
+ console.error(`Wrote ${relPaths.length} locally:`);
159
+ for (const p of relPaths) console.error(` ${p}`);
160
+ console.error(`but git ${push.committed ? 'push' : 'commit'} FAILED: ${push.error.slice(0, 300)}`);
161
+ process.exit(3);
162
+ }
163
+ for (const p of relPaths) console.log(`Sent: ${p}${re.length ? ` (re: ${re.join(', ')})` : ''}`);
164
+ }
165
+
166
+ async function runWorkflow(): Promise<void> {
167
+ const bodyArg = argv[argv.length - 1];
168
+ if (!bodyArg || bodyArg.startsWith('--')) {
169
+ console.error('Usage: crosstalkd run --type workflow [--channel <uuid>] <file|->');
170
+ process.exit(1);
171
+ }
172
+ const raw = resolveBody(bodyArg);
173
+ const parsed = parseFrontmatter<Record<string, unknown>>(raw);
174
+ const declaredType = parsed.data['type'];
175
+
176
+ if (declaredType !== 'workflow') {
177
+ console.error(`crosstalkd run --type workflow: document must declare 'type: workflow' in frontmatter (got: ${String(declaredType)})`);
178
+ process.exit(1);
179
+ }
180
+
181
+ const parentUuid = flag('--channel') ?? process.env['CROSSTALK_DISPATCH_CHANNEL'] ?? autoChannel();
182
+ const derivedName = bodyArg === '-' ? `workflow-${Date.now()}` : basename(bodyArg, extname(bodyArg));
183
+ const childUuid = createChildChannel(parentUuid, derivedName);
184
+ const from = flag('--from') ?? defaultFrom();
185
+ const re = reTargets(argv.includes('--new'));
186
+
187
+ // The marker is addressed to the reserved recipient `workflow`. The
188
+ // dispatcher's activation loop never wakes a model for it — workflowTick
189
+ // (workflow.ts) processes it directly: compile-pass → fanout → synthesize
190
+ // → final reply routed back here with re: <markerRelPath>.
191
+ //
192
+ // Multi-host ownership: record dispatch_host so that exactly one
193
+ // dispatcher progresses this workflow's phases. Without ownership, two
194
+ // dispatchers ticking inside the same poll window would both compile,
195
+ // both fan out, and rebase-conflict on PLAN.json. Owner = the alias of
196
+ // the dispatcher whose heartbeat lives in this machine's state dir.
197
+ // Falls back to no-ownership when there's no local dispatcher running
198
+ // (single-host single-machine case — any dispatcher that joins later
199
+ // claims the workflow).
200
+ const heartbeat = readHeartbeat(transportRoot);
201
+ const dispatchHost = heartbeat?.alias;
202
+ const extraFrontmatter: Record<string, unknown> = { child_channel: childUuid };
203
+ if (dispatchHost) extraFrontmatter['dispatch_host'] = dispatchHost;
204
+ const relPath = writeMessage({
205
+ channelUuid: parentUuid,
206
+ from,
207
+ to: 'workflow',
208
+ body: parsed.body,
209
+ re,
210
+ extraFrontmatter,
211
+ workflow: true,
212
+ });
213
+
214
+ const push = gitCommitAndPush(transportRoot, `run(workflow): ${from} child ${childUuid.slice(0, 8)}`);
215
+ sendWakeSignal(transportRoot);
216
+ if (!push.ok && push.error) {
217
+ console.error(`Wrote workflow locally: ${relPath}`);
218
+ console.error(`Child channel: ${childUuid}`);
219
+ console.error(`but git ${push.committed ? 'push' : 'commit'} FAILED: ${push.error.slice(0, 300)}`);
220
+ process.exit(3);
221
+ }
222
+ console.log(`Workflow dispatched: ${relPath}`);
223
+ console.log(`Child channel: ${childUuid} (parent: ${parentUuid.slice(0, 8)})`);
224
+ }
225
+
226
+ async function main(): Promise<void> {
227
+ const type = flag('--type');
228
+ if (type !== 'primitive' && type !== 'workflow') {
229
+ console.error("Usage: crosstalkd run --type <primitive|workflow> [flags] <body|file|->");
230
+ process.exit(1);
231
+ }
232
+ if (type === 'primitive') await runPrimitive();
233
+ else await runWorkflow();
234
+ }
235
+
236
+ main();
package/src/state.ts ADDED
@@ -0,0 +1,159 @@
1
+ // Machine-local dispatcher state. NONE of this lives in the transport repo —
2
+ // the repo carries conversation; each machine carries its own progress
3
+ // through it. That separation is what makes the dispatcher's git operations
4
+ // conflict-free: its commits only ever contain data/ (messages).
5
+ //
6
+ // v7 layout under the state dir (four files, no subdirectories):
7
+ // dispatcher.pid — PID of the running dispatcher process
8
+ // cursor — last-scanned git commit hash (single global cursor)
9
+ // heartbeat — last tick timestamp + pid + version (JSON)
10
+ // wake.signal — touched to wake the dispatch loop
11
+ // errors.log — infra failures, JSONL, append-only (best-effort)
12
+ //
13
+ // Location: $CROSSTALK_STATE_DIR if set (exact dir — container-friendly),
14
+ // else ~/.config/crosstalk/state/<basename(transportRoot)>/. v6 used a
15
+ // hashed-origin-URL dir under ~/.local/state; v7 uses the working tree's
16
+ // directory name for operator readability. Operators with two clones of
17
+ // the same transport get two state dirs — acceptable; different working
18
+ // trees may have genuinely different progress through the conversation.
19
+
20
+ import {
21
+ existsSync,
22
+ mkdirSync,
23
+ readFileSync,
24
+ writeFileSync,
25
+ unlinkSync,
26
+ appendFileSync,
27
+ } from 'fs';
28
+ import { join, basename } from 'path';
29
+ import { homedir } from 'os';
30
+
31
+ const resolved = new Map<string, string>();
32
+
33
+ export function stateDir(transportRoot: string): string {
34
+ const cached = resolved.get(transportRoot);
35
+ if (cached) return cached;
36
+ let dir = process.env['CROSSTALK_STATE_DIR'];
37
+ if (!dir) {
38
+ dir = join(homedir(), '.config', 'crosstalk', 'state', basename(transportRoot));
39
+ }
40
+ mkdirSync(dir, { recursive: true });
41
+ resolved.set(transportRoot, dir);
42
+ return dir;
43
+ }
44
+
45
+ // ── cursor (single, machine-global) ──
46
+ //
47
+ // A cursor is the git commit hash the transport was last scanned at. NOT a
48
+ // message relPath: filenames order by sender timestamp, but messages reach
49
+ // origin in PUSH order — a message that loses a push race can land on
50
+ // origin with a timestamp earlier than one already processed, and a
51
+ // relPath cursor would skip it forever. Commit-based cursors can't.
52
+ //
53
+ // v6 kept one cursor per (actor × channel). v7 collapses to a single
54
+ // machine-global cursor: a dispatcher processes everything new since the
55
+ // cursor in one tick, regardless of channel or claimed model.
56
+
57
+ const VALID_CURSOR = /^[0-9a-f]{40}$/;
58
+
59
+ export function cursorPath(transportRoot: string): string {
60
+ return join(stateDir(transportRoot), 'cursor');
61
+ }
62
+
63
+ export function readCursor(transportRoot: string): string | null {
64
+ const p = cursorPath(transportRoot);
65
+ if (!existsSync(p)) return null;
66
+ let raw: string;
67
+ try {
68
+ raw = readFileSync(p, 'utf-8').trim();
69
+ } catch (err) {
70
+ logError(transportRoot, `cursor read failed: ${(err as Error).message}`);
71
+ return null;
72
+ }
73
+ if (raw.length === 0) return null;
74
+ if (!VALID_CURSOR.test(raw)) {
75
+ logError(transportRoot, `invalid cursor '${raw.slice(0, 80)}' — re-scanning from origin`);
76
+ return null;
77
+ }
78
+ return raw;
79
+ }
80
+
81
+ export function writeCursor(transportRoot: string, commit: string): void {
82
+ writeFileSync(cursorPath(transportRoot), commit + '\n');
83
+ }
84
+
85
+ // ── pidfile ──
86
+
87
+ export function pidfilePath(transportRoot: string): string {
88
+ return join(stateDir(transportRoot), 'dispatcher.pid');
89
+ }
90
+
91
+ export function writePidfile(transportRoot: string): void {
92
+ try {
93
+ writeFileSync(pidfilePath(transportRoot), `${process.pid}\n`);
94
+ } catch { /* best-effort */ }
95
+ }
96
+
97
+ export function removePidfile(transportRoot: string): void {
98
+ try {
99
+ unlinkSync(pidfilePath(transportRoot));
100
+ } catch { /* already gone */ }
101
+ }
102
+
103
+ export function readPidfile(transportRoot: string): number | null {
104
+ try {
105
+ const raw = readFileSync(pidfilePath(transportRoot), 'utf-8').trim();
106
+ const n = parseInt(raw, 10);
107
+ return Number.isFinite(n) && n > 0 ? n : null;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ // ── heartbeat + wake ──
114
+
115
+ export function writeHeartbeat(transportRoot: string, version: string, alias?: string): void {
116
+ try {
117
+ const data: Record<string, unknown> = { ts: new Date().toISOString(), pid: process.pid, version };
118
+ if (alias) data['alias'] = alias;
119
+ writeFileSync(join(stateDir(transportRoot), 'heartbeat'), JSON.stringify(data) + '\n');
120
+ } catch { /* best-effort */ }
121
+ }
122
+
123
+ export function readHeartbeat(
124
+ transportRoot: string,
125
+ ): { ts: string; pid: number; version: string; alias?: string } | null {
126
+ try {
127
+ return JSON.parse(readFileSync(join(stateDir(transportRoot), 'heartbeat'), 'utf-8'));
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ export function wakeSignalPath(transportRoot: string): string {
134
+ return join(stateDir(transportRoot), 'wake.signal');
135
+ }
136
+
137
+ export function sendWakeSignal(transportRoot: string): void {
138
+ try {
139
+ writeFileSync(wakeSignalPath(transportRoot), `${Date.now()}\n`);
140
+ } catch { /* best-effort */ }
141
+ }
142
+
143
+ // ── error log — infra failures, JSONL append, best-effort ──
144
+
145
+ export function logError(transportRoot: string, message: string): void {
146
+ try {
147
+ const line = JSON.stringify({ ts: new Date().toISOString(), message: message.slice(0, 500) });
148
+ appendFileSync(join(stateDir(transportRoot), 'errors.log'), line + '\n');
149
+ } catch { /* best-effort */ }
150
+ }
151
+
152
+ export function countErrors(transportRoot: string): number {
153
+ try {
154
+ const raw = readFileSync(join(stateDir(transportRoot), 'errors.log'), 'utf-8');
155
+ return raw.split('\n').filter((l) => l.trim().length > 0).length;
156
+ } catch {
157
+ return 0;
158
+ }
159
+ }
package/src/status.ts ADDED
@@ -0,0 +1,84 @@
1
+ // crosstalkd status — transport + dispatcher health at a glance.
2
+
3
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
4
+ import { resolve, join } from 'path';
5
+ import { discoverChannels } from './transport.js';
6
+ import { stateDir, readCursor, readHeartbeat, countErrors } from './state.js';
7
+ import { loadRegistry } from './models.js';
8
+
9
+ const transportRoot = resolve(process.cwd());
10
+
11
+ const versionFile = join(transportRoot, 'CROSSTALK-VERSION');
12
+ const protocolVersion = existsSync(versionFile)
13
+ ? readFileSync(versionFile, 'utf-8').trim()
14
+ : '(missing)';
15
+
16
+ console.log(`Crosstalk transport: ${transportRoot}`);
17
+ console.log(`Protocol version: ${protocolVersion}`);
18
+ console.log(`Machine state dir: ${stateDir(transportRoot)}`);
19
+ console.log('');
20
+
21
+ try {
22
+ const registry = loadRegistry(transportRoot);
23
+ console.log(`Models in registry: ${registry.all.size}`);
24
+ console.log(`Claimed on this machine: ${registry.claimed.size}`);
25
+ if (registry.claimed.size > 0) {
26
+ for (const name of registry.claimed.keys()) {
27
+ console.log(` - ${name}`);
28
+ }
29
+ } else {
30
+ console.log(' (no model CLIs found on PATH — install one or edit data/models.yaml)');
31
+ }
32
+ } catch (err) {
33
+ console.log(`Models: ERROR — ${(err as Error).message}`);
34
+ }
35
+
36
+ console.log('');
37
+
38
+ const hb = readHeartbeat(transportRoot);
39
+ if (hb) {
40
+ const ageS = Math.floor((Date.now() - new Date(hb.ts).getTime()) / 1000);
41
+ const label = ageS < 120 ? 'LIVE' : ageS > 300 ? 'STALE' : 'idle';
42
+ console.log(`Dispatch heartbeat: ${label} (pid ${hb.pid}, last tick ${ageS}s ago, ${hb.version})`);
43
+ } else {
44
+ console.log('Dispatch heartbeat: never (no dispatch has run on this machine)');
45
+ }
46
+
47
+ const cursor = readCursor(transportRoot);
48
+ if (cursor) {
49
+ console.log(`Cursor: ${cursor.slice(0, 12)} (global, machine-local)`);
50
+ } else {
51
+ console.log('Cursor: (none yet — first tick will seed to HEAD)');
52
+ }
53
+
54
+ console.log('');
55
+
56
+ const channels = discoverChannels(transportRoot);
57
+ console.log(`Channels: ${channels.length}`);
58
+ for (const ch of channels) {
59
+ const channelDir = join(transportRoot, 'data', 'channels', ch);
60
+ let name = '(no CHANNEL.md)';
61
+ let parent: string | null = null;
62
+ const chPath = join(channelDir, 'CHANNEL.md');
63
+ if (existsSync(chPath)) {
64
+ const raw = readFileSync(chPath, 'utf-8');
65
+ const nameMatch = raw.match(/^name:\s*(.+)$/m);
66
+ if (nameMatch) name = nameMatch[1]!.trim();
67
+ const parentMatch = raw.match(/^parent:\s*(.+)$/m);
68
+ if (parentMatch) parent = parentMatch[1]!.trim();
69
+ }
70
+ let msgCount = 0;
71
+ const walk = (d: string): void => {
72
+ for (const e of readdirSync(d)) {
73
+ const p = join(d, e);
74
+ if (statSync(p).isDirectory()) walk(p);
75
+ else if (e.endsWith('.md') && e !== 'CHANNEL.md') msgCount++;
76
+ }
77
+ };
78
+ try { walk(channelDir); } catch { /* ignore */ }
79
+ const parentSuffix = parent ? ` [child of ${parent.slice(0, 8)}]` : '';
80
+ console.log(` ${ch.slice(0, 8)}... — ${name} (${msgCount} msgs)${parentSuffix}`);
81
+ }
82
+
83
+ console.log('');
84
+ console.log(`Infrastructure errors logged: ${countErrors(transportRoot)}`);
package/src/stop.ts ADDED
@@ -0,0 +1,37 @@
1
+ // crosstalkd stop — send SIGTERM to the running dispatcher and wait for it to exit.
2
+
3
+ import { resolve } from 'path';
4
+ import { spawnSync } from 'child_process';
5
+ import { readPidfile, removePidfile } from './state.js';
6
+
7
+ const transportRoot = resolve(process.cwd());
8
+
9
+ function processRunning(pid: number): boolean {
10
+ try { process.kill(pid, 0); return true; } catch { return false; }
11
+ }
12
+
13
+ const pid = readPidfile(transportRoot);
14
+ if (pid === null) {
15
+ console.error('crosstalkd stop: no dispatcher.pid found — is dispatch running?');
16
+ process.exit(1);
17
+ }
18
+
19
+ if (!processRunning(pid)) {
20
+ console.error(`crosstalkd stop: pid ${pid} is not running — removing stale pidfile`);
21
+ removePidfile(transportRoot);
22
+ process.exit(1);
23
+ }
24
+
25
+ process.kill(pid, 'SIGTERM');
26
+
27
+ const deadline = Date.now() + 5_000;
28
+ while (Date.now() < deadline) {
29
+ if (!processRunning(pid)) {
30
+ console.log(`crosstalkd stop: dispatcher (pid ${pid}) stopped`);
31
+ process.exit(0);
32
+ }
33
+ spawnSync('sleep', ['0.1']);
34
+ }
35
+
36
+ console.error(`crosstalkd stop: pid ${pid} did not exit within 5s — try: kill -9 ${pid}`);
37
+ process.exit(1);