@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/turnq.ts
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
// Advisory turn coordination via @cordfuse/turnq.
|
|
2
|
-
//
|
|
3
|
-
// The lock is an OPTIMIZATION, never a correctness requirement — git
|
|
4
|
-
// arbitrates (collision-free filenames + push rejection/rebase retry).
|
|
5
|
-
// So: wait a bounded time for the turn, then proceed without it. A turnq
|
|
6
|
-
// bug or outage can never wedge a dispatcher; it just costs an occasional
|
|
7
|
-
// rebase. Failures are loud (errors.log), never silently fallen back.
|
|
8
|
-
//
|
|
9
|
-
// Env vars:
|
|
10
|
-
// TURNQ_URL — optional, e.g. http://turnq:3003 (unset → local flock)
|
|
11
|
-
// TURNQ_API_KEY — required when TURNQ_URL is set
|
|
12
|
-
// TURNQ_CHANNEL — optional namespace prefix (default: "crosstalk")
|
|
13
|
-
|
|
14
|
-
import { createCoordinator } from '@cordfuse/turnq/coordinator';
|
|
15
|
-
import { logError } from './state.js';
|
|
16
|
-
|
|
17
|
-
const TURN_WAIT_MS = 15_000;
|
|
18
|
-
|
|
19
|
-
interface Coordinator {
|
|
20
|
-
withTurn<T>(channel: string, fn: () => Promise<T>): Promise<T>;
|
|
21
|
-
close(): void;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
let coordinatorPromise: Promise<Coordinator> | null = null;
|
|
25
|
-
|
|
26
|
-
function getCoordinator(): Promise<Coordinator> {
|
|
27
|
-
if (!coordinatorPromise) {
|
|
28
|
-
const url = process.env['TURNQ_URL'];
|
|
29
|
-
const apiKey = process.env['TURNQ_API_KEY'];
|
|
30
|
-
coordinatorPromise = createCoordinator(
|
|
31
|
-
url ? { url, apiKey, fallback: false } : {},
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
|
-
return coordinatorPromise;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function channelFor(name: string): string {
|
|
38
|
-
const prefix = process.env['TURNQ_CHANNEL'] ?? 'crosstalk';
|
|
39
|
-
return `${prefix}/${name}`;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Run `fn` while holding the named turn if it can be acquired within
|
|
43
|
-
// TURN_WAIT_MS; otherwise (timeout, coordinator error, server down) log
|
|
44
|
-
// and run `fn` anyway. `fn` runs exactly once — the `ran` guard is safe
|
|
45
|
-
// because the event loop is single-threaded.
|
|
46
|
-
export async function withLock<T>(transportRoot: string, name: string, fn: () => Promise<T>): Promise<T> {
|
|
47
|
-
let ran = false;
|
|
48
|
-
const runOnce = (): Promise<T> => {
|
|
49
|
-
ran = true;
|
|
50
|
-
return fn();
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
let coordinator: Coordinator;
|
|
54
|
-
try {
|
|
55
|
-
coordinator = await getCoordinator();
|
|
56
|
-
} catch (err) {
|
|
57
|
-
coordinatorPromise = null;
|
|
58
|
-
logError(transportRoot, 'turnq', `coordinator init failed — proceeding without lock: ${(err as Error).message}`);
|
|
59
|
-
return runOnce();
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
return new Promise<T>((resolve, reject) => {
|
|
63
|
-
const timer = setTimeout(() => {
|
|
64
|
-
if (ran) return;
|
|
65
|
-
logError(transportRoot, 'turnq', `turn '${name}' not granted within ${TURN_WAIT_MS}ms — proceeding without lock`);
|
|
66
|
-
runOnce().then(resolve, reject);
|
|
67
|
-
}, TURN_WAIT_MS);
|
|
68
|
-
timer.unref?.();
|
|
69
|
-
|
|
70
|
-
coordinator
|
|
71
|
-
.withTurn(channelFor(name), async () => {
|
|
72
|
-
clearTimeout(timer);
|
|
73
|
-
if (ran) return; // timeout path already ran fn; release the turn immediately
|
|
74
|
-
await runOnce().then(resolve, reject);
|
|
75
|
-
})
|
|
76
|
-
.catch((err) => {
|
|
77
|
-
clearTimeout(timer);
|
|
78
|
-
if (ran) return;
|
|
79
|
-
logError(transportRoot, 'turnq', `turn '${name}' failed — proceeding without lock: ${(err as Error).message}`);
|
|
80
|
-
runOnce().then(resolve, reject);
|
|
81
|
-
});
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export async function closeCoordinator(): Promise<void> {
|
|
86
|
-
if (!coordinatorPromise) return;
|
|
87
|
-
try {
|
|
88
|
-
(await coordinatorPromise).close();
|
|
89
|
-
} catch { /* best-effort */ }
|
|
90
|
-
coordinatorPromise = null;
|
|
91
|
-
}
|
package/src/upgrade.ts
DELETED
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
// crosstalk upgrade — sync the transport's upstream/ against the runtime's
|
|
2
|
-
// bundled template.
|
|
3
|
-
//
|
|
4
|
-
// The transport's upstream/ contains runtime-managed files: spec, agent
|
|
5
|
-
// orientation prompts, protocol version pin, default actor profiles. When
|
|
6
|
-
// the operator updates their runtime (via `npm update -g @cordfuse/crosstalk`),
|
|
7
|
-
// the bundled template ships with a newer spec; this command copies those
|
|
8
|
-
// files into the operator's transport so they catch up.
|
|
9
|
-
//
|
|
10
|
-
// What this command DOES touch:
|
|
11
|
-
// - upstream/CROSSTALK.md, PROTOCOL.md, OPERATOR.md
|
|
12
|
-
// - upstream/CROSSTALK-VERSION
|
|
13
|
-
// - upstream/actors/ (default actor profile starter set)
|
|
14
|
-
//
|
|
15
|
-
// What this command NEVER touches:
|
|
16
|
-
// - local/ — operator-owned actor profiles and identity
|
|
17
|
-
// - hosts/ — operator-owned host files
|
|
18
|
-
// - data/ — channels and memories
|
|
19
|
-
// - root pointer files (CLAUDE.md etc.) — only updated if a --pointers flag
|
|
20
|
-
// is passed, since they rarely change and overwriting them surprises
|
|
21
|
-
// operators who've customized them
|
|
22
|
-
// (Dispatcher state — cursors, dlq, error logs — lives outside the repo
|
|
23
|
-
// in the state dir and is never in scope.)
|
|
24
|
-
//
|
|
25
|
-
// Usage:
|
|
26
|
-
// crosstalk upgrade — sync upstream/ from runtime template
|
|
27
|
-
// crosstalk upgrade --dry-run — show what would change, no writes
|
|
28
|
-
// crosstalk upgrade --pointers — also overwrite the entry pointer files
|
|
29
|
-
|
|
30
|
-
import { existsSync, readFileSync, cpSync, statSync, readdirSync, mkdirSync } from 'fs';
|
|
31
|
-
import { resolve, join, dirname } from 'path';
|
|
32
|
-
import { fileURLToPath } from 'url';
|
|
33
|
-
|
|
34
|
-
const transportRoot = resolve(process.cwd());
|
|
35
|
-
const argv = process.argv.slice(2);
|
|
36
|
-
const dryRun = argv.includes('--dry-run');
|
|
37
|
-
const updatePointers = argv.includes('--pointers');
|
|
38
|
-
|
|
39
|
-
const POINTER_FILES = [
|
|
40
|
-
'CLAUDE.md', 'AGENTS.md', 'GEMINI.md', 'QWEN.md', 'ANTIGRAVITY.md', 'OPENCODE.md',
|
|
41
|
-
'.windsurfrules',
|
|
42
|
-
'.amazonq/rules/crosstalk.md',
|
|
43
|
-
'.continue/rules/crosstalk.md',
|
|
44
|
-
'.cursor/rules/crosstalk.mdc',
|
|
45
|
-
'.github/copilot-instructions.md',
|
|
46
|
-
];
|
|
47
|
-
|
|
48
|
-
function locateTemplate(): string {
|
|
49
|
-
const thisFileDir = dirname(fileURLToPath(import.meta.url));
|
|
50
|
-
const runtimeRoot = resolve(thisFileDir, '..');
|
|
51
|
-
const candidates = [
|
|
52
|
-
join(runtimeRoot, 'template'),
|
|
53
|
-
join(runtimeRoot, '..', 'transport'),
|
|
54
|
-
];
|
|
55
|
-
const found = candidates.find((c) => existsSync(join(c, 'upstream', 'CROSSTALK-VERSION')));
|
|
56
|
-
if (!found) {
|
|
57
|
-
console.error('crosstalk upgrade: cannot find the bundled transport template.');
|
|
58
|
-
console.error('Looked in:');
|
|
59
|
-
for (const c of candidates) console.error(` ${c}`);
|
|
60
|
-
process.exit(1);
|
|
61
|
-
}
|
|
62
|
-
return found;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function readVersion(p: string): string | null {
|
|
66
|
-
try { return readFileSync(p, 'utf-8').trim(); } catch { return null; }
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function listFilesRelative(dir: string): string[] {
|
|
70
|
-
const out: string[] = [];
|
|
71
|
-
const walk = (d: string, prefix: string): void => {
|
|
72
|
-
if (!existsSync(d)) return;
|
|
73
|
-
for (const entry of readdirSync(d)) {
|
|
74
|
-
const full = join(d, entry);
|
|
75
|
-
const rel = prefix ? `${prefix}/${entry}` : entry;
|
|
76
|
-
const st = statSync(full);
|
|
77
|
-
if (st.isDirectory()) walk(full, rel);
|
|
78
|
-
else out.push(rel);
|
|
79
|
-
}
|
|
80
|
-
};
|
|
81
|
-
walk(dir, '');
|
|
82
|
-
return out;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function classifyChange(templatePath: string, transportPath: string): 'add' | 'modify' | 'unchanged' {
|
|
86
|
-
if (!existsSync(transportPath)) return 'add';
|
|
87
|
-
const a = readFileSync(templatePath, 'utf-8');
|
|
88
|
-
const b = readFileSync(transportPath, 'utf-8');
|
|
89
|
-
return a === b ? 'unchanged' : 'modify';
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// ── Sanity checks ─────────────────────────────────────────────────────
|
|
93
|
-
|
|
94
|
-
const transportVersionPath = join(transportRoot, 'upstream', 'CROSSTALK-VERSION');
|
|
95
|
-
if (!existsSync(transportVersionPath)) {
|
|
96
|
-
console.error(`crosstalk upgrade: not a transport (no upstream/CROSSTALK-VERSION).`);
|
|
97
|
-
console.error(`Run from inside a transport directory, or use 'crosstalk init' to scaffold one.`);
|
|
98
|
-
process.exit(2);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const templateDir = locateTemplate();
|
|
102
|
-
const templateUpstream = join(templateDir, 'upstream');
|
|
103
|
-
const transportUpstream = join(transportRoot, 'upstream');
|
|
104
|
-
|
|
105
|
-
const fromVersion = readVersion(transportVersionPath) ?? '?';
|
|
106
|
-
const toVersion = readVersion(join(templateUpstream, 'CROSSTALK-VERSION')) ?? '?';
|
|
107
|
-
|
|
108
|
-
// Detect downgrade.
|
|
109
|
-
const parseMM = (v: string): [number, number] => {
|
|
110
|
-
const m = v.match(/^(\d+)\.(\d+)/);
|
|
111
|
-
return m ? [Number(m[1]), Number(m[2])] : [0, 0];
|
|
112
|
-
};
|
|
113
|
-
const [fromMaj, fromMin] = parseMM(fromVersion);
|
|
114
|
-
const [toMaj, toMin] = parseMM(toVersion);
|
|
115
|
-
const isDowngrade = toMaj < fromMaj || (toMaj === fromMaj && toMin < fromMin);
|
|
116
|
-
if (isDowngrade) {
|
|
117
|
-
console.error(`crosstalk upgrade: bundled template is OLDER than your transport.`);
|
|
118
|
-
console.error(` transport: ${fromVersion}`);
|
|
119
|
-
console.error(` template: ${toVersion}`);
|
|
120
|
-
console.error(`Your runtime is older than the runtime that scaffolded this transport.`);
|
|
121
|
-
console.error(`To upgrade your runtime: npm update -g @cordfuse/crosstalk`);
|
|
122
|
-
process.exit(3);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
console.log(`crosstalk upgrade${dryRun ? ' (dry-run)' : ''}`);
|
|
126
|
-
console.log('');
|
|
127
|
-
console.log(` transport CROSSTALK-VERSION: ${fromVersion}`);
|
|
128
|
-
console.log(` template CROSSTALK-VERSION: ${toVersion}`);
|
|
129
|
-
console.log('');
|
|
130
|
-
|
|
131
|
-
// ── Plan the changes ──────────────────────────────────────────────────
|
|
132
|
-
|
|
133
|
-
const upstreamFiles = listFilesRelative(templateUpstream);
|
|
134
|
-
const changes: { rel: string; kind: 'add' | 'modify' | 'unchanged' }[] = [];
|
|
135
|
-
|
|
136
|
-
for (const rel of upstreamFiles) {
|
|
137
|
-
const fromPath = join(templateUpstream, rel);
|
|
138
|
-
const toPath = join(transportUpstream, rel);
|
|
139
|
-
changes.push({ rel, kind: classifyChange(fromPath, toPath) });
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const adds = changes.filter((c) => c.kind === 'add');
|
|
143
|
-
const mods = changes.filter((c) => c.kind === 'modify');
|
|
144
|
-
const same = changes.filter((c) => c.kind === 'unchanged');
|
|
145
|
-
|
|
146
|
-
console.log(`upstream/ changes:`);
|
|
147
|
-
console.log(` ${adds.length} new file${adds.length === 1 ? '' : 's'}`);
|
|
148
|
-
console.log(` ${mods.length} modified`);
|
|
149
|
-
console.log(` ${same.length} unchanged`);
|
|
150
|
-
|
|
151
|
-
if (adds.length > 0 || mods.length > 0) {
|
|
152
|
-
console.log('');
|
|
153
|
-
for (const c of [...adds, ...mods].slice(0, 20)) {
|
|
154
|
-
console.log(` [${c.kind === 'add' ? '+' : '~'}] upstream/${c.rel}`);
|
|
155
|
-
}
|
|
156
|
-
if (adds.length + mods.length > 20) {
|
|
157
|
-
console.log(` … and ${adds.length + mods.length - 20} more`);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (updatePointers) {
|
|
162
|
-
console.log('');
|
|
163
|
-
console.log(`pointer files (with --pointers):`);
|
|
164
|
-
for (const pf of POINTER_FILES) {
|
|
165
|
-
const fromPath = join(templateDir, pf);
|
|
166
|
-
const toPath = join(transportRoot, pf);
|
|
167
|
-
if (!existsSync(fromPath)) continue;
|
|
168
|
-
const kind = classifyChange(fromPath, toPath);
|
|
169
|
-
if (kind !== 'unchanged') console.log(` [${kind === 'add' ? '+' : '~'}] ${pf}`);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// ── Apply or stop ─────────────────────────────────────────────────────
|
|
174
|
-
|
|
175
|
-
if (dryRun) {
|
|
176
|
-
console.log('');
|
|
177
|
-
console.log('Dry run — no changes written. Re-run without --dry-run to apply.');
|
|
178
|
-
process.exit(0);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
if (adds.length === 0 && mods.length === 0 && !updatePointers) {
|
|
182
|
-
console.log('');
|
|
183
|
-
console.log('Already in sync. Nothing to do.');
|
|
184
|
-
process.exit(0);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
console.log('');
|
|
188
|
-
console.log('Applying...');
|
|
189
|
-
|
|
190
|
-
// Copy upstream/ recursively. cpSync with force=true handles both add and modify.
|
|
191
|
-
cpSync(templateUpstream, transportUpstream, { recursive: true, force: true });
|
|
192
|
-
console.log(` ✓ upstream/ synced`);
|
|
193
|
-
|
|
194
|
-
if (updatePointers) {
|
|
195
|
-
for (const pf of POINTER_FILES) {
|
|
196
|
-
const fromPath = join(templateDir, pf);
|
|
197
|
-
const toPath = join(transportRoot, pf);
|
|
198
|
-
if (!existsSync(fromPath)) continue;
|
|
199
|
-
mkdirSync(dirname(toPath), { recursive: true });
|
|
200
|
-
cpSync(fromPath, toPath, { force: true });
|
|
201
|
-
}
|
|
202
|
-
console.log(` ✓ pointer files synced`);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
console.log('');
|
|
206
|
-
console.log(`Spec ${fromVersion} → ${toVersion}.`);
|
|
207
|
-
console.log(`Commit the upstream/ changes when ready:`);
|
|
208
|
-
console.log(` git add upstream/${updatePointers ? ' ' + POINTER_FILES.slice(0, 3).join(' ') + ' ...' : ''}`);
|
|
209
|
-
console.log(` git commit -m "spec: upgrade to ${toVersion}"`);
|
|
210
|
-
console.log('');
|
|
211
|
-
console.log('Your local/, hosts/, and data/ were not touched.');
|
package/src/wake.ts
DELETED
package/template/CLAUDE.md
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
# Crosstalk transport
|
|
2
|
-
|
|
3
|
-
You are inside a Crosstalk transport — a git repo that carries async
|
|
4
|
-
messages between AI agents across machines.
|
|
5
|
-
|
|
6
|
-
Read these before doing anything here:
|
|
7
|
-
|
|
8
|
-
1. `upstream/PROTOCOL.md` — how to behave when dispatched (start here)
|
|
9
|
-
2. `upstream/CROSSTALK.md` — the full protocol spec, if you need details
|
|
10
|
-
|
|
11
|
-
Do not edit files under `data/` by hand — messages are written only by
|
|
12
|
-
`crosstalk send` and the dispatcher.
|
package/template/gitignore
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
6.0
|
|
@@ -1,298 +0,0 @@
|
|
|
1
|
-
# Crosstalk
|
|
2
|
-
|
|
3
|
-
Version: 6.0
|
|
4
|
-
|
|
5
|
-
Crosstalk is a shared file format over git that lets humans and AI agents communicate
|
|
6
|
-
asynchronously across machines. The git repository is the message bus. No special
|
|
7
|
-
software is required to participate beyond git itself.
|
|
8
|
-
|
|
9
|
-
Design rule for this spec: every feature must be explainable in one sentence.
|
|
10
|
-
The runtime records facts at write time; it never reconstructs them by inference
|
|
11
|
-
at read time.
|
|
12
|
-
|
|
13
|
-
---
|
|
14
|
-
|
|
15
|
-
## Participants
|
|
16
|
-
|
|
17
|
-
**Humans** — operators who post messages directly or via a chat tool and read replies.
|
|
18
|
-
|
|
19
|
-
**Machines** — agents (Claude, Codex, etc.) invoked by a dispatcher to process
|
|
20
|
-
messages addressed to them and reply.
|
|
21
|
-
|
|
22
|
-
The most common machine participant is a **worker**: `to: concierge` means
|
|
23
|
-
"I need something done." The worker acts and replies. The sender may be human
|
|
24
|
-
or machine; the worker does not distinguish.
|
|
25
|
-
|
|
26
|
-
---
|
|
27
|
-
|
|
28
|
-
## Transport layout
|
|
29
|
-
|
|
30
|
-
```
|
|
31
|
-
<transport>/
|
|
32
|
-
upstream/ # runtime-managed: spec, agent orientation, defaults
|
|
33
|
-
CROSSTALK.md # this file
|
|
34
|
-
CROSSTALK-VERSION # must be exactly: 6.0
|
|
35
|
-
PROTOCOL.md # agent orientation prompt
|
|
36
|
-
actors/<name>.md # default actor profiles
|
|
37
|
-
local/ # operator-owned: never touched by the runtime
|
|
38
|
-
actors/<name>.md # custom actor profiles (override upstream by name)
|
|
39
|
-
hosts/
|
|
40
|
-
<alias>.md # one per machine running a dispatcher
|
|
41
|
-
data/
|
|
42
|
-
channels/<uuid>/
|
|
43
|
-
CHANNEL.md # optional channel metadata
|
|
44
|
-
YYYY/MM/DD/HHMMSSmmmZ-<hex>.md # messages
|
|
45
|
-
memories/<stamp>-<hex>.md # shared persistent notes
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
That is the complete committed surface. **Dispatcher bookkeeping (cursors, dead-letter
|
|
49
|
-
queue, error logs, lock state) is machine-local state and never lives in the repo** —
|
|
50
|
-
it is kept under `$CROSSTALK_STATE_DIR` (default `~/.local/state/crosstalk/<transport-id>/`,
|
|
51
|
-
where `<transport-id>` is derived from the origin URL). The repo carries conversation;
|
|
52
|
-
each machine carries its own progress through it.
|
|
53
|
-
|
|
54
|
-
---
|
|
55
|
-
|
|
56
|
-
## Channels
|
|
57
|
-
|
|
58
|
-
A channel is a UUID v4 directory under `data/channels/`. Any participant may create
|
|
59
|
-
one by creating the directory and committing. An optional `CHANNEL.md` declares
|
|
60
|
-
metadata:
|
|
61
|
-
|
|
62
|
-
```
|
|
63
|
-
---
|
|
64
|
-
name: dogfood-sprint
|
|
65
|
-
created_by: steve
|
|
66
|
-
created_at: 2026-06-09T17:00:00.000Z
|
|
67
|
-
parent: <uuid> # optional — makes this a subchannel
|
|
68
|
-
---
|
|
69
|
-
|
|
70
|
-
Free-form description. Serves as a mini system prompt scoped to the channel.
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
`name` is required and unique within the transport; the other fields are optional.
|
|
74
|
-
A subchannel is just a channel with a `parent:`; it reports back by posting to the
|
|
75
|
-
parent channel. There is no close signal — a finished channel simply goes quiet.
|
|
76
|
-
|
|
77
|
-
---
|
|
78
|
-
|
|
79
|
-
## Messages
|
|
80
|
-
|
|
81
|
-
Every message is a markdown file with YAML frontmatter:
|
|
82
|
-
|
|
83
|
-
```
|
|
84
|
-
---
|
|
85
|
-
from: alice
|
|
86
|
-
to: concierge
|
|
87
|
-
type: text
|
|
88
|
-
timestamp: 2026-06-09T19:00:00.000Z
|
|
89
|
-
---
|
|
90
|
-
|
|
91
|
-
Message body here.
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
### Frontmatter fields
|
|
95
|
-
|
|
96
|
-
| Field | Required | Notes |
|
|
97
|
-
|---|---|---|
|
|
98
|
-
| `from` | yes | logical actor name — unverified; trust boundary is repo access |
|
|
99
|
-
| `to` | yes | name, list of names, or `all`; may carry `@host` suffix (see Routing) |
|
|
100
|
-
| `type` | yes | always `text` in 6.0 |
|
|
101
|
-
| `timestamp` | yes | ISO 8601 UTC |
|
|
102
|
-
| `re` | no | relPath (or list of relPaths) of the message(s) this one answers — **written by the runtime, never by hand** |
|
|
103
|
-
| `tier` | no | requested model tier for the recipient (see Host files) |
|
|
104
|
-
|
|
105
|
-
Readers must ignore unknown fields.
|
|
106
|
-
|
|
107
|
-
### The `re:` field — causality is recorded, not inferred
|
|
108
|
-
|
|
109
|
-
A message **without** `re:` is a new task. A message **with** `re:` is a reply to the
|
|
110
|
-
message(s) at the listed relPath(s) (paths relative to the channel directory). Like
|
|
111
|
-
`to:`, the field is a string for one target and a list for several — a reply that
|
|
112
|
-
answers a batch records **every** message it answers, so batching never makes an
|
|
113
|
-
answered message look unanswered.
|
|
114
|
-
|
|
115
|
-
The runtime sets `re:` from facts it directly observes:
|
|
116
|
-
|
|
117
|
-
- When an actor answers via stdout, the runtime writes the reply with `re:` listing
|
|
118
|
-
every message in the dispatched batch from that asker.
|
|
119
|
-
- When a dispatched actor uses `crosstalk send`, the runtime injects the triggering
|
|
120
|
-
relPath(s) into the environment and `send` records them automatically. An actor can
|
|
121
|
-
suppress this (`--new`) to start genuinely new work.
|
|
122
|
-
- Messages written by operators (chat tools, hand-authored) carry no `re:` — they
|
|
123
|
-
are new tasks by definition.
|
|
124
|
-
|
|
125
|
-
Actors never compute or hand-write `re:`. Because the field is set by the machinery
|
|
126
|
-
that already knows the answer, a confused or dishonest actor cannot mislabel a reply
|
|
127
|
-
as a task or vice versa.
|
|
128
|
-
|
|
129
|
-
### Filenames
|
|
130
|
-
|
|
131
|
-
`data/channels/<uuid>/YYYY/MM/DD/HHMMSSmmmZ-<hex>.md` — current UTC time plus a
|
|
132
|
-
hex suffix of at least 8 characters from a CSPRNG. Filenames sort chronologically
|
|
133
|
-
and are collision-free by construction, so concurrent writers on different machines
|
|
134
|
-
never produce git conflicts in message files.
|
|
135
|
-
|
|
136
|
-
---
|
|
137
|
-
|
|
138
|
-
## Activation — when does a message wake its addressee?
|
|
139
|
-
|
|
140
|
-
One rule:
|
|
141
|
-
|
|
142
|
-
> **A message wakes its addressee if it has no `re:` (a new task), or any `re:`
|
|
143
|
-
> entry points at a message the addressee sent.**
|
|
144
|
-
|
|
145
|
-
Consequences:
|
|
146
|
-
|
|
147
|
-
- Tasks always wake the actor they address.
|
|
148
|
-
- A reply wakes whoever asked the question, and no one else.
|
|
149
|
-
- A reply addressed to someone who never asked (an FYI, a broadcast copy) is visible
|
|
150
|
-
in the channel but does not wake them — fan-in cannot oscillate.
|
|
151
|
-
- Self-sent messages never wake their sender.
|
|
152
|
-
|
|
153
|
-
There are no other wake conditions and no inference. The dispatcher evaluates this
|
|
154
|
-
rule with two field reads.
|
|
155
|
-
|
|
156
|
-
---
|
|
157
|
-
|
|
158
|
-
## Delivery semantics
|
|
159
|
-
|
|
160
|
-
**At-least-once.** Each dispatcher tracks a per-actor, per-channel cursor (local
|
|
161
|
-
state, not in the repo) recording the git commit the channel was last scanned at —
|
|
162
|
-
"new" means *added to git since that commit*, never "later filename timestamp",
|
|
163
|
-
because messages reach origin in push order, not timestamp order. If a machine
|
|
164
|
-
crashes mid-tick, the next tick re-dispatches anything not yet past the cursor;
|
|
165
|
-
a duplicate reply may land in the channel.
|
|
166
|
-
|
|
167
|
-
For idempotent work (lookups, computation, advice) duplicates are harmless. For
|
|
168
|
-
non-idempotent side effects, the actor must check the channel for evidence of prior
|
|
169
|
-
completion before acting. Crosstalk does not provide exactly-once semantics.
|
|
170
|
-
|
|
171
|
-
**Batched delivery.** When a dispatcher wakes an actor, it hands over ALL pending
|
|
172
|
-
messages addressed to that actor in that channel in a single invocation. One
|
|
173
|
-
activation drains the mailbox — a coordinator that fanned out to 10 peers wakes
|
|
174
|
-
once and sees all 10 replies together.
|
|
175
|
-
|
|
176
|
-
The transport is an **append-only log**. No retraction, no deletion at the protocol
|
|
177
|
-
level. Retention is the operator's concern at the git/storage layer.
|
|
178
|
-
|
|
179
|
-
---
|
|
180
|
-
|
|
181
|
-
## Actors
|
|
182
|
-
|
|
183
|
-
Each participant has a profile at `local/actors/<name>.md` (operator-owned) or
|
|
184
|
-
`upstream/actors/<name>.md` (defaults; `local/` wins on name collision). The body
|
|
185
|
-
is the actor's system prompt.
|
|
186
|
-
|
|
187
|
-
```
|
|
188
|
-
---
|
|
189
|
-
name: concierge
|
|
190
|
-
description: "General-purpose worker and coordinator."
|
|
191
|
-
---
|
|
192
|
-
|
|
193
|
-
## System Prompt
|
|
194
|
-
You are the general-purpose worker in this Crosstalk transport. ...
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
`name` (matching the filename stem) and `description` are required; the rest of the
|
|
198
|
-
frontmatter is free. Actors are added, edited, and removed by committing files.
|
|
199
|
-
|
|
200
|
-
---
|
|
201
|
-
|
|
202
|
-
## Host files
|
|
203
|
-
|
|
204
|
-
A host file at `hosts/<alias>.md` declares one machine running a dispatcher and the
|
|
205
|
-
actors it serves. Each operator commits and maintains their own.
|
|
206
|
-
|
|
207
|
-
```
|
|
208
|
-
---
|
|
209
|
-
alias: cachy
|
|
210
|
-
hostname: steve-cachyos
|
|
211
|
-
actors:
|
|
212
|
-
concierge:
|
|
213
|
-
claude: claude --print --dangerously-skip-permissions
|
|
214
|
-
junior-developer:
|
|
215
|
-
haiku:
|
|
216
|
-
cli: claude --model claude-haiku-4-5 --print --dangerously-skip-permissions
|
|
217
|
-
count: 5
|
|
218
|
-
---
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
| Field | Required | Notes |
|
|
222
|
-
|---|---|---|
|
|
223
|
-
| `alias` | yes | the host's name in `actor@host` addressing |
|
|
224
|
-
| `hostname` | no | OS hostname, used for dispatcher auto-detection |
|
|
225
|
-
| `actors` | yes | actor → tier map |
|
|
226
|
-
|
|
227
|
-
A **tier** is a named CLI slot. The bare-string shorthand means `count: 1`; the
|
|
228
|
-
object form adds `count:` (parallel invocations) per tier. Tier names are
|
|
229
|
-
operator-defined labels (`haiku`, `opus`, `flash`); senders may request one with
|
|
230
|
-
the `tier:` message field, and the dispatcher falls back to the first declared
|
|
231
|
-
tier when the requested one doesn't exist.
|
|
232
|
-
|
|
233
|
-
On startup a dispatcher finds its own host file by matching `hostname:` (or via an
|
|
234
|
-
explicit `--host <alias>` override). No match → log clearly and idle; never crash.
|
|
235
|
-
|
|
236
|
-
---
|
|
237
|
-
|
|
238
|
-
## Routing
|
|
239
|
-
|
|
240
|
-
The `to:` field accepts:
|
|
241
|
-
|
|
242
|
-
- `to: concierge` — bare name. Every host that declares the actor dispatches it.
|
|
243
|
-
- `to: junior-developer@cachy` — narrowed to the host whose `alias` is `cachy`;
|
|
244
|
-
other hosts skip it (and log the skip, so wrong-host routes are visible).
|
|
245
|
-
- `to: [a, b@mac]` — lists mix freely.
|
|
246
|
-
- `to: all` — every participant.
|
|
247
|
-
|
|
248
|
-
The actor name is everything before the `@`; the host alias is everything after.
|
|
249
|
-
The `re:` activation rule ignores host suffixes — only addressing honors them.
|
|
250
|
-
|
|
251
|
-
Use bare names for work-pool patterns where any machine will do; use `@host` when
|
|
252
|
-
the orchestration depends on which machine runs the work. This addressing is the
|
|
253
|
-
entirety of Crosstalk's multi-host model.
|
|
254
|
-
|
|
255
|
-
---
|
|
256
|
-
|
|
257
|
-
## Identity and trust
|
|
258
|
-
|
|
259
|
-
`from:` is an unverified string. The trust boundary is repository access: anyone who
|
|
260
|
-
can push can claim any name. `to:` is a routing hint, not access control — every
|
|
261
|
-
message is visible to anyone with repo access. Operators who need confidentiality
|
|
262
|
-
or verified identity must secure the repository itself.
|
|
263
|
-
|
|
264
|
-
---
|
|
265
|
-
|
|
266
|
-
## Memories
|
|
267
|
-
|
|
268
|
-
Shared persistent notes any participant may read or write, at
|
|
269
|
-
`data/memories/YYYYMMDDTHHMMSSmmmZ-<hex>.md`:
|
|
270
|
-
|
|
271
|
-
```
|
|
272
|
-
---
|
|
273
|
-
from: concierge
|
|
274
|
-
timestamp: 2026-06-09T19:00:00.000Z
|
|
275
|
-
subject: Steve prefers TypeScript for all new tooling
|
|
276
|
-
scope: global # or a channel uuid
|
|
277
|
-
supersedes: <filename> # optional — replaces an earlier memory
|
|
278
|
-
---
|
|
279
|
-
|
|
280
|
-
Body.
|
|
281
|
-
```
|
|
282
|
-
|
|
283
|
-
When loading, skip any memory named in another memory's `supersedes:`. Agents use
|
|
284
|
-
`data/memories/` instead of their model-native memory systems.
|
|
285
|
-
|
|
286
|
-
---
|
|
287
|
-
|
|
288
|
-
## Coordination
|
|
289
|
-
|
|
290
|
-
Git is self-coordinating: filenames are collision-free and non-fast-forward pushes
|
|
291
|
-
are rejected and retried with `git pull --rebase`. A transport therefore works with
|
|
292
|
-
no coordinator at all.
|
|
293
|
-
|
|
294
|
-
Dispatchers MAY use a turn coordinator (cordfuse/turnq) to reduce push contention —
|
|
295
|
-
locally via file lock, or across hosts via a shared turnq server. Coordination is
|
|
296
|
-
**advisory**: a dispatcher waits for its turn with a bounded timeout and proceeds
|
|
297
|
-
anyway on timeout or coordinator failure, letting git arbitrate. A coordinator
|
|
298
|
-
outage may cost push retries; it can never stall message processing.
|