@cordfuse/crosstalk 6.0.0-alpha.1 → 6.0.0-alpha.3

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 CHANGED
@@ -9,12 +9,12 @@
9
9
 
10
10
  import { existsSync, statSync } from 'fs';
11
11
  import { resolve, join, dirname } from 'path';
12
- import { spawnSync } from 'child_process';
12
+ import { spawnSync, spawn } from 'child_process';
13
13
  import { fileURLToPath } from 'url';
14
14
  import { createRequire } from 'module';
15
15
 
16
16
  const SUBCOMMANDS = [
17
- 'dispatch', 'send', 'replies', 'wake', 'status', 'init', 'dlq', 'channel',
17
+ 'dispatch', 'stop', 'send', 'replies', 'wake', 'status', 'init', 'dlq', 'channel',
18
18
  'chat', 'open', 'attach', 'upgrade',
19
19
  ];
20
20
  const STANDALONE_SUBCOMMANDS = new Set(['init']);
@@ -75,8 +75,23 @@ if (!STANDALONE_SUBCOMMANDS.has(cmd)) {
75
75
 
76
76
  const require = createRequire(import.meta.url);
77
77
  const tsxCli = require.resolve('tsx/cli');
78
- const r = spawnSync(process.execPath, [tsxCli, srcFile, ...argv.slice(1)], {
79
- cwd,
80
- stdio: 'inherit',
81
- });
82
- process.exit(r.status ?? 1);
78
+
79
+ // dispatch is long-running: use async spawn so SIGTERM/SIGINT forwarded to
80
+ // the tsx child kills the whole chain cleanly. All other subcommands are
81
+ // short-lived and spawnSync is fine.
82
+ if (cmd === 'dispatch') {
83
+ const child = spawn(process.execPath, [tsxCli, srcFile, ...argv.slice(1)], {
84
+ cwd,
85
+ stdio: 'inherit',
86
+ });
87
+ const forward = (sig) => { try { child.kill(sig); } catch {} };
88
+ process.on('SIGTERM', () => forward('SIGTERM'));
89
+ process.on('SIGINT', () => forward('SIGTERM'));
90
+ child.on('exit', (code, signal) => process.exit(signal ? 1 : (code ?? 0)));
91
+ } else {
92
+ const r = spawnSync(process.execPath, [tsxCli, srcFile, ...argv.slice(1)], {
93
+ cwd,
94
+ stdio: 'inherit',
95
+ });
96
+ process.exit(r.status ?? 1);
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cordfuse/crosstalk",
3
- "version": "6.0.0-alpha.1",
3
+ "version": "6.0.0-alpha.3",
4
4
  "description": "Crosstalk runtime — async messaging between agents over git, across machines.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,7 +20,9 @@
20
20
  "scripts": {
21
21
  "build": "tsc --noEmit",
22
22
  "lint": "tsc --noEmit",
23
- "test": "bun test"
23
+ "test": "bun test",
24
+ "prepack": "cp -r ../transport template",
25
+ "postpack": "rm -rf template"
24
26
  },
25
27
  "dependencies": {
26
28
  "@cordfuse/turnq": "^0.4.1",
package/src/dispatch.ts CHANGED
@@ -36,6 +36,8 @@ import {
36
36
  readCursor,
37
37
  writeCursor,
38
38
  writeHeartbeat,
39
+ writePidfile,
40
+ removePidfile,
39
41
  logError,
40
42
  } from './state.js';
41
43
  import { recipients, reList, decideWake, splitForConcurrency } from './activation.js';
@@ -289,6 +291,7 @@ async function dispatchOne(p: PendingDispatch): Promise<boolean> {
289
291
  // auto-links re:). If it truly did nothing, the asker's `crosstalk
290
292
  // replies` stays PENDING — visible, not silently lost.
291
293
  log('dispatch_silent', { actor: p.actorName, channel: p.channelUuid.slice(0, 8), batch_size: p.msgs.length });
294
+ log('dispatch_done', { actor: p.actorName, channel: p.channelUuid.slice(0, 8), batch_size: p.msgs.length, replied: false });
292
295
  return true;
293
296
  }
294
297
 
@@ -305,6 +308,7 @@ async function dispatchOne(p: PendingDispatch): Promise<boolean> {
305
308
  for (const [sender, relPaths] of bySender) {
306
309
  writeReply(p.channelUuid, p.actorName, sender, relPaths.length === 1 ? relPaths[0]! : relPaths, reply);
307
310
  }
311
+ log('dispatch_done', { actor: p.actorName, channel: p.channelUuid.slice(0, 8), batch_size: p.msgs.length, replied: true });
308
312
  return true;
309
313
  }
310
314
 
@@ -360,26 +364,32 @@ async function dispatchTick(): Promise<TickResult> {
360
364
  const cursor = readCursor(transportRoot, actorName, channelUuid);
361
365
  if (cursor === head) continue;
362
366
 
367
+ // First encounter: seed to HEAD so only future messages are dispatched.
368
+ // Without this, a null cursor falls through to `post = messages` and
369
+ // replays the full channel history on every fresh-state boot.
370
+ if (cursor === null) {
371
+ writeCursor(transportRoot, actorName, channelUuid, head);
372
+ continue;
373
+ }
374
+
363
375
  const messages = listChannelMessages(transportRoot, channelUuid);
364
376
  const senderByRelPath = new Map(messages.map((m) => [m.relPath, messageSender(m)]));
365
377
  const senderOf = (relPath: string) => senderByRelPath.get(relPath);
366
378
 
367
- let post = messages;
368
- if (cursor) {
369
- let added = addedSince.get(cursor);
370
- if (added === undefined) {
371
- const files = newFilesSince(transportRoot, cursor);
372
- added = files === null ? null : new Set(files);
373
- addedSince.set(cursor, added);
374
- if (added === null) {
375
- logError(transportRoot, 'other', `cursor commit ${cursor.slice(0, 12)} unknown to this clone — full channel re-scan`);
376
- }
377
- }
378
- if (added !== null) {
379
- const prefix = `data/channels/${channelUuid}/`;
380
- post = messages.filter((m) => added.has(prefix + m.relPath));
379
+ let added = addedSince.get(cursor);
380
+ if (added === undefined) {
381
+ const files = newFilesSince(transportRoot, cursor);
382
+ added = files === null ? null : new Set(files);
383
+ addedSince.set(cursor, added);
384
+ if (added === null) {
385
+ logError(transportRoot, 'other', `cursor commit ${cursor.slice(0, 12)} unknown to this clone — full channel re-scan`);
381
386
  }
382
387
  }
388
+ let post = messages;
389
+ if (added !== null) {
390
+ const prefix = `data/channels/${channelUuid}/`;
391
+ post = messages.filter((m) => added.has(prefix + m.relPath));
392
+ }
383
393
  if (post.length === 0) {
384
394
  writeCursor(transportRoot, actorName, channelUuid, head);
385
395
  continue;
@@ -466,6 +476,12 @@ async function waitForWakeOrTimeout(ms: number): Promise<void> {
466
476
  }
467
477
 
468
478
  async function main(): Promise<void> {
479
+ writePidfile(transportRoot);
480
+ const cleanup = () => removePidfile(transportRoot);
481
+ process.on('exit', cleanup);
482
+ process.on('SIGTERM', () => { cleanup(); process.exit(0); });
483
+ process.on('SIGINT', () => { cleanup(); process.exit(0); });
484
+
469
485
  log('dispatch_start', { transport: transportRoot, version: RUNTIME_VERSION, state_dir: stateDir(transportRoot) });
470
486
  if (onceMode) {
471
487
  await dispatchTick();
package/src/send.ts CHANGED
@@ -11,7 +11,7 @@ import { resolve, join } from 'path';
11
11
  import { mkdirSync, writeFileSync } from 'fs';
12
12
  import { now, messageFilename } from './filenames.js';
13
13
  import { serializeFrontmatter } from './frontmatter.js';
14
- import { gitCommitAndPush } from './transport.js';
14
+ import { gitCommitAndPush, discoverChannels } from './transport.js';
15
15
  import { withLock } from './turnq.js';
16
16
  import { sendWakeSignal } from './state.js';
17
17
 
@@ -25,7 +25,7 @@ function flag(name: string): string | undefined {
25
25
  }
26
26
 
27
27
  async function main(): Promise<void> {
28
- const channelUuid = flag('--channel') ?? process.env['CROSSTALK_DISPATCH_CHANNEL'];
28
+ let channelUuid = flag('--channel') ?? process.env['CROSSTALK_DISPATCH_CHANNEL'];
29
29
  const to = flag('--to');
30
30
  const from = flag('--from')
31
31
  ?? process.env['CROSSTALK_DISPATCH_ACTOR']
@@ -35,13 +35,27 @@ async function main(): Promise<void> {
35
35
  const isNew = argv.includes('--new');
36
36
  const body = argv[argv.length - 1];
37
37
 
38
- if (!channelUuid || !to || !body || body.startsWith('--')) {
38
+ if (!to || !body || body.startsWith('--')) {
39
39
  console.error(
40
40
  'Usage: crosstalk send --to <actor[,actor...]> [--channel <uuid>] [--from <actor>] [--tier <name>] [--new] "<message body>"',
41
41
  );
42
42
  process.exit(1);
43
43
  }
44
44
 
45
+ if (!channelUuid) {
46
+ const channels = discoverChannels(transportRoot);
47
+ if (channels.length === 1) {
48
+ channelUuid = channels[0]!;
49
+ } else if (channels.length === 0) {
50
+ console.error('crosstalk send: no channels found. Create one with: crosstalk channel create <name>');
51
+ process.exit(1);
52
+ } else {
53
+ console.error(`crosstalk send: multiple channels found — specify one with --channel <uuid>`);
54
+ console.error(` Run 'crosstalk status' to list channel UUIDs.`);
55
+ process.exit(1);
56
+ }
57
+ }
58
+
45
59
  const reTargets = (isNew ? '' : process.env['CROSSTALK_DISPATCH_RE'] ?? '')
46
60
  .split(',')
47
61
  .map((s) => s.trim())
@@ -77,7 +91,7 @@ async function main(): Promise<void> {
77
91
  console.error(`but git ${pushResult.committed ? 'push' : 'commit'} FAILED:`);
78
92
  console.error(` ${pushResult.error.slice(0, 300)}`);
79
93
  console.error('\nYour message is in the local clone but not on origin. Recover with:');
80
- console.error(' git pull --rebase && git push');
94
+ console.error(' git fetch origin && git rebase origin/main && git push');
81
95
  process.exit(3);
82
96
  }
83
97
 
package/src/state.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  // dlq/<id>.md — failed-dispatch entries
9
9
  // errors.log — infra failures, JSONL, append-only
10
10
  // heartbeat — last tick timestamp + pid + version
11
+ // dispatcher.pid — PID of the running dispatcher process
11
12
  // wake.signal — touched to wake the dispatch loop
12
13
  //
13
14
  // Location: $CROSSTALK_STATE_DIR if set (exact dir — container-friendly),
@@ -19,6 +20,7 @@ import {
19
20
  mkdirSync,
20
21
  readFileSync,
21
22
  writeFileSync,
23
+ unlinkSync,
22
24
  appendFileSync,
23
25
  } from 'fs';
24
26
  import { join, dirname } from 'path';
@@ -95,6 +97,34 @@ export function writeCursor(
95
97
  writeFileSync(p, commit + '\n');
96
98
  }
97
99
 
100
+ // ── pidfile ──
101
+
102
+ export function pidfilePath(transportRoot: string): string {
103
+ return join(stateDir(transportRoot), 'dispatcher.pid');
104
+ }
105
+
106
+ export function writePidfile(transportRoot: string): void {
107
+ try {
108
+ writeFileSync(pidfilePath(transportRoot), `${process.pid}\n`);
109
+ } catch { /* best-effort */ }
110
+ }
111
+
112
+ export function removePidfile(transportRoot: string): void {
113
+ try {
114
+ unlinkSync(pidfilePath(transportRoot));
115
+ } catch { /* already gone */ }
116
+ }
117
+
118
+ export function readPidfile(transportRoot: string): number | null {
119
+ try {
120
+ const raw = readFileSync(pidfilePath(transportRoot), 'utf-8').trim();
121
+ const n = parseInt(raw, 10);
122
+ return Number.isFinite(n) && n > 0 ? n : null;
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
98
128
  // ── heartbeat + wake ──
99
129
 
100
130
  export function writeHeartbeat(transportRoot: string, version: string): void {
package/src/stop.ts ADDED
@@ -0,0 +1,37 @@
1
+ // crosstalk 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('crosstalk stop: no dispatcher.pid found — is dispatch running?');
16
+ process.exit(1);
17
+ }
18
+
19
+ if (!processRunning(pid)) {
20
+ console.error(`crosstalk 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(`crosstalk stop: dispatcher (pid ${pid}) stopped`);
31
+ process.exit(0);
32
+ }
33
+ spawnSync('sleep', ['0.1']);
34
+ }
35
+
36
+ console.error(`crosstalk stop: pid ${pid} did not exit within 5s — try: kill -9 ${pid}`);
37
+ process.exit(1);
@@ -0,0 +1,12 @@
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.
@@ -0,0 +1 @@
1
+ 6.0
@@ -0,0 +1,298 @@
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.
@@ -0,0 +1,60 @@
1
+ # Operator interface mode
2
+
3
+ You are the human operator's natural-language interface to this Crosstalk transport. The human is the operator who deployed this transport — they are not a Crosstalk actor, and neither are you. Your job is to translate their requests into Crosstalk tool calls, watch for results, and surface them conversationally.
4
+
5
+ ## What you can do
6
+
7
+ You have shell-execution capability. All operator commands are available via the `crosstalk` binary on your PATH.
8
+
9
+ | When the human says... | You run... |
10
+ |---|---|
11
+ | "ask <actor> <question>" | `crosstalk send --to <actor> "<question>"` then `crosstalk replies <relPath>` (printed by send) until it reports REPLIED; surface the reply body |
12
+ | "ask <actor> in <channel> ..." | same, but pass `--channel <uuid>` |
13
+ | "check status" / "is dispatch alive" | `crosstalk status`, render conversationally |
14
+ | "what's in the dlq" | `crosstalk dlq`, summarize entries |
15
+ | "show me dlq entry <id>" | `crosstalk dlq --show <id>` |
16
+ | "retry that" / "retry dlq <id>" | `crosstalk dlq --retry <id>` |
17
+ | "clear the dlq" | `crosstalk dlq --clear` (confirm first) |
18
+ | "create a channel called X" | `crosstalk channel --name X` |
19
+ | "list channels" | read directories under `data/channels/`, render each `CHANNEL.md` name conversationally |
20
+ | "edit the <name> actor" | edit `local/actors/<name>.md`, commit + push |
21
+ | "pull latest" | `git pull --rebase` |
22
+
23
+ ## What you must not do
24
+
25
+ - **Never run `crosstalk dispatch`** — it competes with the dispatcher already running against this transport
26
+ - **Never run `crosstalk open --actor <x>`** — it spawns a local actor that races the dispatcher
27
+ - **Never touch the state directory** (`$CROSSTALK_STATE_DIR`, default `~/.local/state/crosstalk/`) — cursors, dlq, and error logs are dispatcher-owned; mutations from outside dispatch corrupt routing
28
+ - **Never impersonate an actor** — you are not concierge, you are not any actor; you are the operator's interface to them
29
+
30
+ ## Default channel resolution
31
+
32
+ If the operator doesn't name a channel, list the directories under `data/channels/` and read each `CHANNEL.md` for human-readable names and recent activity. If there is exactly one channel, use it silently. If there are multiple, surface options and ask. Do not invent a channel.
33
+
34
+ ## Latency communication
35
+
36
+ Each round-trip between operator and actor is ~5 to 30 seconds (commit + push + dispatcher poll interval + actor execution time + git pull). When the operator asks you to ask an actor something:
37
+
38
+ 1. Run the `send` command and note the relPath it prints
39
+ 2. Tell the operator: "(waiting for <actor>…)"
40
+ 3. Every few seconds, run `git pull --rebase` then `crosstalk replies <relPath>`
41
+ 4. When it reports REPLIED, surface the reply body conversationally
42
+
43
+ If no reply arrives within ~10 minutes, tell the operator and offer to check `crosstalk status` and `crosstalk dlq` for clues.
44
+
45
+ ## Conversational tone
46
+
47
+ Talk like a competent assistant, not a CLI. The operator should not need to know UUIDs, file paths, or git commands. Hide that machinery. Surface the actor's reply as if it came directly from the actor.
48
+
49
+ Bad:
50
+ > Wrote message to `data/channels/a3f9c1d4-.../2026/06/04/...md`, ran `git commit`, ran `git push`. Now polling. Reply found in `2026/06/04/...md`. Body: "Deployment is green."
51
+
52
+ Good:
53
+ > (waiting for concierge…)
54
+ > concierge says: Deployment is green.
55
+
56
+ ## You are NOT a dispatched actor
57
+
58
+ If you have seen this file via `crosstalk attach` (pty operator launcher) or because the operator launched their CLI interactively in this transport, you are in operator mode. You do NOT process incoming messages. You do NOT have an actor identity. You are not concierge, junior-developer, or any other actor declared in this transport. Your single role is to be the operator's smart interface.
59
+
60
+ Dispatched actors receive their identity via a structured system prompt from the dispatcher. If you find yourself with such an identity, ignore everything above and follow that system prompt instead.
@@ -0,0 +1,80 @@
1
+ # Crosstalk Protocol — Agent Orientation
2
+
3
+ You are an actor in a Crosstalk transport. This file is prepended to your actor
4
+ profile whenever you are invoked — by the dispatcher (a message addressed you) or
5
+ by an operator opening an interactive session. Behave the same either way.
6
+
7
+ ## How to reply
8
+
9
+ **Just answer.** Your stdout becomes the body of a reply message. The runtime writes
10
+ the YAML frontmatter (`from`, `to`, `type`, `timestamp`, `re`) for you — never write
11
+ frontmatter yourself, and never write files into `data/` directly.
12
+
13
+ Your reply is automatically addressed to whoever messaged you and marked as a reply
14
+ to their message (the `re:` field). Replies wake only the participant who asked —
15
+ so answering is always safe and can never start a message loop.
16
+
17
+ ## Batched delivery
18
+
19
+ If several messages were waiting for you in a channel, you receive them all in one
20
+ prompt, delimited by `--- Message K of N (from: ..., ref: ...) ---`. Process them
21
+ collectively and reply once. If you routed all your output via `crosstalk send` and
22
+ have nothing to add, an empty stdout is fine for a multi-message batch. For a
23
+ single message, an empty reply is a protocol violation — you were addressed; respond.
24
+
25
+ ## Tools
26
+
27
+ You have shell access from the transport root.
28
+
29
+ - `crosstalk send --channel <uuid> --to <actor> "<body>"` — proactively message
30
+ someone (replying to your prompt needs no tool — just answer). Sends are
31
+ automatically linked (`re:`) to the message you are currently processing; pass
32
+ `--new` to start unrelated work instead. `--tier <name>` requests a model tier.
33
+ Prints `Sent: <relPath>` — keep that relPath if you are orchestrating (see below).
34
+ - `crosstalk replies --re <relPath>[,<relPath>...]` — shows which of your dispatched
35
+ messages have replies. This is ground truth: replies are matched by the
36
+ runtime-written `re:` field, not by anything a peer claims in its body.
37
+ - `crosstalk status` — host file, channels, cursors, DLQ count, dispatcher heartbeat.
38
+ - `crosstalk dlq [--list|--show <id>|--retry <id>]` — inspect or retry failed
39
+ dispatches.
40
+ - `crosstalk channel --name <name> [--parent <uuid>]` — create a channel; prints
41
+ its UUID.
42
+ - `crosstalk wake` — poke the dispatcher to tick now (rarely needed; `send` already
43
+ does it).
44
+
45
+ ## Orchestrating peers (fan-out / fan-in)
46
+
47
+ 1. Dispatch each peer with `crosstalk send` in the SAME channel; record the printed
48
+ relPaths. Tell peers to reply to YOU.
49
+ 2. Reply briefly to your requester ("dispatched N tasks") and **exit immediately**.
50
+ Never poll or wait — the runtime re-wakes you when replies arrive, and the
51
+ channel is your state.
52
+ 3. On later wakes, run `crosstalk replies` on your recorded relPaths. Until all are
53
+ covered, exit again. When all are covered, aggregate once.
54
+ 4. Send the final answer to the original requester with `crosstalk send` — your
55
+ stdout reply goes to whoever last messaged you, which is the peers, not the
56
+ requester.
57
+
58
+ ## Addressing
59
+
60
+ `to: actor` reaches that actor on every host that runs it; `to: actor@host` narrows
61
+ to one machine. Use `@host` when it matters where the work runs.
62
+
63
+ ## Delivery semantics
64
+
65
+ At-least-once. You may occasionally see a message twice; for anything non-idempotent
66
+ (sending email, destructive actions), check the channel for evidence of prior
67
+ completion before acting.
68
+
69
+ ## If something looks wrong
70
+
71
+ `crosstalk status` shows the dispatcher heartbeat and failure counts. Failed
72
+ dispatches land in the DLQ (local to each machine) with an attempts count; repeated
73
+ failures are quarantined until retried with `crosstalk dlq --retry`.
74
+
75
+ ## Do not
76
+
77
+ - Write YAML frontmatter or files under `data/` by hand — use stdout or `send`.
78
+ - Reply to messages addressed to other actors.
79
+ - Fabricate channel UUIDs — list `data/channels/` or run `crosstalk status`.
80
+ - Use your model-native memory system — shared notes live in `data/memories/`.
@@ -0,0 +1,36 @@
1
+ ---
2
+ name: concierge
3
+ ---
4
+
5
+ You are **concierge** — the front door of this transport. The operator
6
+ talks to you; you coordinate everyone else.
7
+
8
+ ## Your job
9
+
10
+ 1. **Understand the ask.** The operator's message states a goal. If it is
11
+ trivially answerable, answer it yourself via stdout and stop.
12
+ 2. **Decompose and fan out.** For larger work, split the goal into
13
+ independent pieces and send one message per piece to the right actor:
14
+
15
+ crosstalk send --to <actor> "<task description>"
16
+
17
+ Check `hosts/*.md` to see which actors exist and where they run. Use
18
+ `--to actor@host` only when the host matters; bare names reach every
19
+ host that declares the actor.
20
+ 3. **Collect.** Note the relPath each send prints. Check progress with:
21
+
22
+ crosstalk replies --re <relPath1>,<relPath2>
23
+
24
+ PENDING means not answered yet — end your turn; the dispatcher wakes
25
+ you automatically when each answer arrives (it carries `re:` pointing
26
+ at your message).
27
+ 4. **Summarize.** When the answers are in, reply to the operator via
28
+ stdout with a concise synthesis. Do not forward raw worker output.
29
+
30
+ ## Rules
31
+
32
+ - Never do a worker's job yourself when an actor exists for it.
33
+ - Never message yourself.
34
+ - One send per task per actor — duplicates create duplicate work.
35
+ - Your stdout reply is delivered automatically with the right `re:`;
36
+ use `crosstalk send` only to start NEW work, not to answer.