@cordfuse/crosstalk 7.0.0-beta.1 → 8.1.0

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/dispatch.ts CHANGED
@@ -20,16 +20,8 @@ import { readFileSync, existsSync, appendFileSync } from 'fs';
20
20
  import { watch } from 'fs/promises';
21
21
  import { fileURLToPath } from 'url';
22
22
 
23
- import { loadRegistry, type ModelEntry, type ModelsRegistry } from './models.js';
24
- import {
25
- discoverChannels,
26
- listChannelMessages,
27
- gitPull,
28
- gitCommitAndPush,
29
- cursorBaseline,
30
- newFilesSince,
31
- type ChannelMessage,
32
- } from './transport.js';
23
+ import { loadRegistry, loadTransportKind, type ModelEntry, type ModelsRegistry } from './models.js';
24
+ import { GitTransport, LocalFsTransport, type Transport, type ChannelMessage } from './transport.js';
33
25
  import {
34
26
  stateDir,
35
27
  readCursor,
@@ -72,7 +64,10 @@ const RUNTIME_VERSION: string = (() => {
72
64
  }
73
65
  })();
74
66
 
75
- const transportRoot = resolve(process.cwd());
67
+ // CROSSTALK_TRANSPORT_PATH points the engine at a transport directory
68
+ // directly (defaults to cwd) — pairs with CROSSTALK_TRANSPORT_TYPE to run the
69
+ // bus on a given directory (e.g. a mounted share) without a cd.
70
+ const transportRoot = resolve(process.env['CROSSTALK_TRANSPORT_PATH'] ?? process.cwd());
76
71
  const argv = process.argv.slice(2);
77
72
 
78
73
  function flag(name: string): string | undefined {
@@ -87,6 +82,21 @@ const jsonMode = argv.includes('--json');
87
82
  const pollSeconds = Number(flag('--poll')) || 30;
88
83
  const logFile = flag('--log-file');
89
84
 
85
+ // v8: the dispatch loop drives the bus through a Transport, not git calls.
86
+ // Selection precedence: --transport flag > CROSSTALK_TRANSPORT_TYPE env >
87
+ // `transport:` in crosstalk.yaml > git default. local = plain-fs, incl. a
88
+ // mounted SMB/NFS share. The fs transport drops in with zero changes below.
89
+ const transportOverride = flag('--transport') ?? process.env['CROSSTALK_TRANSPORT_TYPE'];
90
+ if (transportOverride !== undefined && transportOverride !== 'git' && transportOverride !== 'local') {
91
+ console.error(`crosstalk daemon dispatch: unknown transport '${transportOverride}' (expected git|local); falling back to config/default.`);
92
+ }
93
+ const transportKind = (transportOverride === 'git' || transportOverride === 'local')
94
+ ? transportOverride
95
+ : loadTransportKind(transportRoot);
96
+ const transport: Transport = transportKind === 'local'
97
+ ? new LocalFsTransport(transportRoot)
98
+ : new GitTransport(transportRoot);
99
+
90
100
  if (!alias) {
91
101
  console.error('crosstalk daemon dispatch: --alias <name> is required (machine identity in the bus).');
92
102
  process.exit(1);
@@ -208,14 +218,14 @@ async function dispatchTick(registry: ModelsRegistry, protocolPrompt: string): P
208
218
  const claimed = registry.claimed;
209
219
  writeHeartbeat(transportRoot, RUNTIME_VERSION, alias!);
210
220
 
211
- const pullResult = gitPull(transportRoot);
221
+ const pullResult = transport.sync();
212
222
  if (!pullResult.ok) {
213
223
  logError(transportRoot, `git pull failed: ${pullResult.error}`, 'warn');
214
224
  log('git_pull_failed', { error: (pullResult.error ?? '').slice(0, 200) });
215
225
  return false;
216
226
  }
217
227
 
218
- const head = cursorBaseline(transportRoot);
228
+ const head = transport.head();
219
229
  if (!head) {
220
230
  logError(transportRoot, 'git rev-parse failed for origin/HEAD and HEAD — skipping tick');
221
231
  return false;
@@ -231,7 +241,7 @@ async function dispatchTick(registry: ModelsRegistry, protocolPrompt: string): P
231
241
  cursor = head;
232
242
  log('cursor_seeded', { commit: head.slice(0, 12) });
233
243
  }
234
- const channels = discoverChannels(transportRoot);
244
+ const channels = transport.discoverChannels();
235
245
  const activationDue = cursor !== head;
236
246
 
237
247
  let didWork = false;
@@ -252,7 +262,7 @@ async function dispatchTick(registry: ModelsRegistry, protocolPrompt: string): P
252
262
 
253
263
  const writeNeeded = didWork || workflowProgressed;
254
264
  if (writeNeeded) {
255
- const push = gitCommitAndPush(transportRoot, `dispatch(${alias}): replies ${new Date().toISOString()}`);
265
+ const push = transport.publish(`dispatch(${alias}): replies ${new Date().toISOString()}`);
256
266
  if (!push.ok && push.error) {
257
267
  // Commit failed is a real error (local state out of sync); push
258
268
  // failed with the commit landing is a warning (push will retry).
@@ -271,17 +281,15 @@ async function runActivationPass(
271
281
  cursor: string,
272
282
  head: string,
273
283
  ): Promise<boolean> {
274
- const addedList = newFilesSince(transportRoot, cursor);
284
+ const addedList = transport.newFilesSince(cursor, head);
275
285
  if (addedList === null) {
276
286
  logError(transportRoot, `cursor ${cursor.slice(0, 12)} unknown — re-scanning all channels`, 'warn');
277
287
  }
278
288
  const added: Set<string> | null = addedList === null ? null : new Set(addedList);
279
-
280
- void head; // head is the diff's upper bound implicitly (newFilesSince uses HEAD).
281
289
  const pending: PendingDispatch[] = [];
282
290
 
283
291
  for (const channelUuid of channels) {
284
- const messages = listChannelMessages(transportRoot, channelUuid);
292
+ const messages = transport.listChannelMessages(channelUuid);
285
293
  const senderByRelPath = new Map(messages.map((m) => [m.relPath, messageSender(m)]));
286
294
  // Normalize asker identity the same way self-suppression does: strip
287
295
  // the @machine suffix before comparing against actorName. Without
@@ -336,18 +344,46 @@ async function runActivationPass(
336
344
  return results.some(Boolean);
337
345
  }
338
346
 
347
+ // Block until something worth a tick happens, or `ms` elapses (the poll
348
+ // fallback). Two wake sources race the timeout:
349
+ // • the state dir's `wake.signal` — written by sendWakeSignal on a
350
+ // same-host API post (channel/message create).
351
+ // • the transport's inbound dir (local-fs: data/channels, watched
352
+ // recursively) — zero-lag pickup when a writer drops a message straight
353
+ // onto the bus (a peer on a shared mount, a file drop). git returns null
354
+ // (a remote isn't watchable); fs.watch also won't fire over SMB/NFS — so
355
+ // the poll timeout stays the always-on backstop.
339
356
  async function waitForWakeOrTimeout(ms: number): Promise<void> {
340
- const dir = stateDir(transportRoot);
341
357
  const ac = new AbortController();
342
358
  const timer = setTimeout(() => ac.abort(), ms);
343
- try {
344
- const watcher = watch(dir, { signal: ac.signal });
345
- for await (const ev of watcher) {
346
- if (ev.filename === 'wake.signal') return;
359
+
360
+ const watchFor = async (dir: string, recursive: boolean): Promise<void> => {
361
+ try {
362
+ const watcher = watch(dir, { signal: ac.signal, recursive });
363
+ for await (const ev of watcher) {
364
+ // state dir: only wake.signal. bus dir: any new .md is a candidate
365
+ // message — wake and let the tick's cursor decide if it's new.
366
+ if (recursive ? (ev.filename?.endsWith('.md') ?? false) : ev.filename === 'wake.signal') return;
367
+ }
368
+ } catch {
369
+ if (ac.signal.aborted) return; // timeout — expected
370
+ // watch unavailable (recursive unsupported, network FS, missing dir):
371
+ // don't spin — wait out the poll interval via the abort signal.
372
+ await new Promise<void>((res) => {
373
+ if (ac.signal.aborted) res();
374
+ else ac.signal.addEventListener('abort', () => res(), { once: true });
375
+ });
347
376
  }
348
- } catch {
349
- /* abort = timeout */
377
+ };
378
+
379
+ const waits: Promise<void>[] = [watchFor(stateDir(transportRoot), false)];
380
+ const busDir = transport.watchDir();
381
+ if (busDir && existsSync(busDir)) waits.push(watchFor(busDir, true));
382
+
383
+ try {
384
+ await Promise.race(waits);
350
385
  } finally {
386
+ ac.abort();
351
387
  clearTimeout(timer);
352
388
  }
353
389
  }
@@ -371,7 +407,7 @@ async function main(): Promise<void> {
371
407
  }
372
408
  removePidfile(transportRoot);
373
409
  if (alias) removeRegistryEntry(transportRoot, alias);
374
- const push = gitCommitAndPush(transportRoot, `dispatch(${alias}): deregister entry`);
410
+ const push = transport.publish(`dispatch(${alias}): deregister entry`);
375
411
  if (!push.ok && push.error) {
376
412
  logError(transportRoot, `registry deregister failed: ${push.error}`, 'warn');
377
413
  }
@@ -414,13 +450,14 @@ async function main(): Promise<void> {
414
450
  // races to duplicate. Commit+push immediately so cross-machine
415
451
  // visibility doesn't wait for first reply traffic.
416
452
  writeRegistryEntry(transportRoot, alias!, [...registry.claimed.keys()], RUNTIME_VERSION);
417
- const registryPush = gitCommitAndPush(transportRoot, `dispatch(${alias}): register entry`);
453
+ const registryPush = transport.publish(`dispatch(${alias}): register entry`);
418
454
  if (!registryPush.ok && registryPush.error) {
419
455
  logError(transportRoot, `registry publish failed: ${registryPush.error}`, 'warn');
420
456
  }
421
457
 
422
458
  log('dispatch_start', {
423
459
  transport: transportRoot,
460
+ transport_kind: transport.kind,
424
461
  alias,
425
462
  version: RUNTIME_VERSION,
426
463
  state_dir: stateDir(transportRoot),
@@ -454,6 +491,7 @@ async function main(): Promise<void> {
454
491
  apiServer = startApi(
455
492
  {
456
493
  transportRoot,
494
+ transport,
457
495
  alias: alias!,
458
496
  version: RUNTIME_VERSION,
459
497
  registry,
@@ -466,6 +504,35 @@ async function main(): Promise<void> {
466
504
  );
467
505
  }
468
506
 
507
+ // Hot-reload the model registry on SIGHUP, no restart. The dispatcher
508
+ // loads crosstalk.yaml once at startup; the onboarding wizard (and any
509
+ // operator who edits the yaml) raises SIGHUP so newly-added providers/
510
+ // models are claimed live. Mutate the EXISTING registry maps in place —
511
+ // the running API context captured these map references at startApi(), so
512
+ // reassigning `registry` would leave /status + the dashboard stale.
513
+ process.on('SIGHUP', () => {
514
+ try {
515
+ const fresh = loadRegistry(transportRoot);
516
+ registry.all.clear();
517
+ for (const [k, v] of fresh.all) registry.all.set(k, v);
518
+ registry.byBareName.clear();
519
+ for (const [k, v] of fresh.byBareName) registry.byBareName.set(k, v);
520
+ registry.claimed.clear();
521
+ for (const [k, v] of fresh.claimed) registry.claimed.set(k, v);
522
+ writeRegistryEntry(transportRoot, alias!, [...registry.claimed.keys()], RUNTIME_VERSION);
523
+ const push = transport.publish(`dispatch(${alias}): reload registry (SIGHUP)`);
524
+ if (!push.ok && push.error) {
525
+ logError(transportRoot, `registry publish after reload failed: ${push.error}`, 'warn');
526
+ }
527
+ log('registry_reloaded', {
528
+ claimed_models: [...registry.claimed.keys()],
529
+ total_models: registry.all.size,
530
+ });
531
+ } catch (err) {
532
+ logError(transportRoot, `registry reload (SIGHUP) failed — keeping current set: ${(err as Error).message}`, 'warn');
533
+ }
534
+ });
535
+
469
536
  if (onceMode) {
470
537
  await dispatchTick(registry, protocolPrompt);
471
538
  process.exit(0);
package/src/models.ts CHANGED
@@ -102,6 +102,23 @@ function parseInlineEnv(
102
102
  return Object.keys(out).length > 0 ? out : null;
103
103
  }
104
104
 
105
+ export type TransportKind = 'git' | 'local';
106
+
107
+ // The optional top-level `transport:` key in data/crosstalk.yaml selects the
108
+ // bus medium. 'local' = the filesystem transport (a plain directory, incl. a
109
+ // mounted SMB/NFS share); absent / anything else / unreadable → 'git'. A
110
+ // --transport flag at dispatch boot overrides this.
111
+ export function loadTransportKind(transportRoot: string): TransportKind {
112
+ const path = crosstalkYamlPath(transportRoot);
113
+ if (!existsSync(path)) return 'git';
114
+ try {
115
+ const parsed = parseYaml(readFileSync(path, 'utf8')) as Record<string, unknown> | null;
116
+ return parsed && parsed['transport'] === 'local' ? 'local' : 'git';
117
+ } catch {
118
+ return 'git';
119
+ }
120
+ }
121
+
105
122
  export function readModels(transportRoot: string): {
106
123
  all: Map<string, ModelEntry>;
107
124
  byBareName: Map<string, ModelEntry[]>;
@@ -0,0 +1,136 @@
1
+ // onboarding.ts — agent catalog + config writers backing the first-run
2
+ // wizard. The wizard turns "0 agents connected" into a working agent by
3
+ // writing a provider block to data/crosstalk.yaml (and, optionally, an API
4
+ // key to auth/<provider>.env) and raising SIGHUP so the dispatcher claims
5
+ // the new model live. See src/dispatch.ts SIGHUP handler + src/web/wizard.ts.
6
+
7
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs';
8
+ import { join, dirname } from 'path';
9
+ import { parseDocument, YAMLMap } from 'yaml';
10
+ import { crosstalkYamlPath } from './models.js';
11
+
12
+ export interface AgentCatalogEntry {
13
+ cli: string; // binary name (matches KNOWN_AGENT_BINARIES in api.ts)
14
+ label: string; // display name for the wizard
15
+ provider: string; // provider key written to the yaml
16
+ model: string; // model name written under the provider
17
+ command: string; // command string the dispatcher runs
18
+ keyEnv: string | null; // env var the key goes in, or null if host-authed
19
+ keyHint: string; // guidance shown next to the (optional) key field
20
+ }
21
+
22
+ // Default command strings mirror the documented examples in the template
23
+ // crosstalk.yaml. Most of these CLIs authenticate via their own host
24
+ // credential store (codex ~/.codex/auth.json, claude/gemini interactive
25
+ // login), so the key field is optional — on a host where the CLI is already
26
+ // logged in, just writing the model entry yields a working agent.
27
+ export const AGENT_CATALOG: Record<string, AgentCatalogEntry> = {
28
+ claude: {
29
+ cli: 'claude', label: 'Claude (Anthropic)', provider: 'anthropic', model: 'sonnet',
30
+ command: 'claude --print --dangerously-skip-permissions --model sonnet',
31
+ keyEnv: 'ANTHROPIC_API_KEY',
32
+ keyHint: 'Leave blank if `claude` is already logged in on this host; otherwise paste an Anthropic API key.',
33
+ },
34
+ codex: {
35
+ cli: 'codex', label: 'Codex (OpenAI)', provider: 'openai-codex', model: 'codex',
36
+ command: 'codex --skip-git-repo-check',
37
+ keyEnv: null,
38
+ keyHint: 'Codex uses its own login (`codex login`) — no key needed here.',
39
+ },
40
+ gemini: {
41
+ cli: 'gemini', label: 'Gemini (Google)', provider: 'google', model: 'gemini',
42
+ command: 'gemini --skip-trust --yolo -p',
43
+ keyEnv: 'GEMINI_API_KEY',
44
+ keyHint: 'Leave blank if `gemini` is already logged in; otherwise paste a Google AI Studio key.',
45
+ },
46
+ agy: {
47
+ cli: 'agy', label: 'Antigravity (Google)', provider: 'google-agy', model: 'agy',
48
+ command: 'agy --print',
49
+ keyEnv: 'GEMINI_API_KEY',
50
+ keyHint: 'Leave blank if already authenticated; otherwise paste a Google AI Studio key.',
51
+ },
52
+ qwen: {
53
+ cli: 'qwen', label: 'Qwen', provider: 'qwen', model: 'qwen',
54
+ command: 'qwen --yolo -p',
55
+ keyEnv: null,
56
+ keyHint: 'Qwen uses its own login — no key needed here.',
57
+ },
58
+ opencode: {
59
+ cli: 'opencode', label: 'OpenCode', provider: 'opencode', model: 'opencode',
60
+ command: 'opencode run',
61
+ keyEnv: null,
62
+ keyHint: 'OpenCode uses its own provider config — no key needed here.',
63
+ },
64
+ };
65
+
66
+ export interface ConnectResult {
67
+ provider: string;
68
+ model: string;
69
+ qualified: string;
70
+ wroteKey: boolean;
71
+ }
72
+
73
+ /**
74
+ * Write a provider/model block to data/crosstalk.yaml (preserving the file's
75
+ * comments via the YAML Document API) and, when an apiKey is supplied for a
76
+ * key-based provider, an auth/<provider>.env file (0600). Idempotent: adding
77
+ * a provider/model that already exists just overwrites that leaf.
78
+ *
79
+ * Does NOT reload the dispatcher — the caller raises SIGHUP after this returns.
80
+ */
81
+ export function connectAgent(transportRoot: string, cli: string, apiKey?: string): ConnectResult {
82
+ const entry = AGENT_CATALOG[cli];
83
+ if (!entry) throw new Error(`unknown agent CLI '${cli}'`);
84
+
85
+ let envFile: string | null = null;
86
+ const key = (apiKey ?? '').trim();
87
+ if (entry.keyEnv && key.length > 0) {
88
+ envFile = `auth/${entry.provider}.env`;
89
+ const envPath = join(transportRoot, envFile);
90
+ mkdirSync(dirname(envPath), { recursive: true });
91
+ // 0600 — secrets file, owner-only. env_file (a relpath) is what lands in
92
+ // the committable yaml; the raw key stays out of it.
93
+ writeFileSync(envPath, `${entry.keyEnv}=${key}\n`, { mode: 0o600 });
94
+ }
95
+
96
+ const yamlPath = crosstalkYamlPath(transportRoot);
97
+ const raw = readFileSync(yamlPath, 'utf-8');
98
+ const doc = parseDocument(raw);
99
+ const providers = doc.get('providers');
100
+ const provExists = providers instanceof YAMLMap && providers.has(entry.provider);
101
+
102
+ if (provExists) {
103
+ // Provider already present (re-run, or adding a second model) — merge
104
+ // via the Document API so the existing structure's comments survive.
105
+ const prov = (providers as YAMLMap).get(entry.provider) as YAMLMap;
106
+ let models = prov.get('models');
107
+ if (!(models instanceof YAMLMap)) { models = new YAMLMap(); prov.set('models', models); }
108
+ (models as YAMLMap).set(entry.model, entry.command);
109
+ if (envFile) prov.set('env_file', envFile);
110
+ writeFileSync(yamlPath, doc.toString());
111
+ } else {
112
+ // New provider — append as text rather than restructuring, so the
113
+ // template's commented schema docs + example blocks are preserved
114
+ // verbatim (rewriting the Document drops comments attached to the empty
115
+ // `providers:` node). The file ends inside the `providers:` mapping, so
116
+ // a two-space-indented block becomes its child.
117
+ const block = envFile
118
+ ? ` ${entry.provider}:\n env_file: ${envFile}\n models:\n ${entry.model}: ${entry.command}\n`
119
+ : ` ${entry.provider}:\n models:\n ${entry.model}: ${entry.command}\n`;
120
+ // Only synthesize a top-level `providers:` if the file genuinely lacks
121
+ // one — checked against the raw text so a present-but-null `providers:`
122
+ // (the template default) doesn't get a duplicate key appended.
123
+ const hasProvidersKey = /^providers\s*:/m.test(raw);
124
+ let out = raw.replace(/\s*$/, '') + '\n';
125
+ if (!hasProvidersKey) out += '\nproviders:\n';
126
+ out += block;
127
+ writeFileSync(yamlPath, out);
128
+ }
129
+
130
+ return {
131
+ provider: entry.provider,
132
+ model: entry.model,
133
+ qualified: `${entry.provider}/${entry.model}`,
134
+ wroteKey: envFile !== null,
135
+ };
136
+ }
package/src/state.ts CHANGED
@@ -44,18 +44,23 @@ export function stateDir(transportRoot: string): string {
44
44
 
45
45
  // ── cursor (single, machine-global) ──
46
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.
47
+ // The cursor is an opaque, transport-OWNED token marking where this machine
48
+ // last scanned. Storage here is format-agnostic: any non-empty string round-
49
+ // trips, and each transport validates + interprets its own token. An
50
+ // unrecognized token (e.g. after switching transports) makes the transport's
51
+ // newFilesSince return null, and the loop full-scans to recover.
52
+ //
53
+ // Git's token is a commit SHA, and that choice is deliberate: filenames order
54
+ // by sender timestamp, but messages reach origin in PUSH order — a message
55
+ // that loses a push race can land with a timestamp earlier than one already
56
+ // processed, and a relPath/time cursor would skip it forever. Commit cursors
57
+ // can't. The local-fs transport uses an mtime watermark instead, which is
58
+ // safe ONLY because a single local machine has one clock and no push races.
52
59
  //
53
60
  // v6 kept one cursor per (actor × channel). v7 collapses to a single
54
61
  // machine-global cursor: a dispatcher processes everything new since the
55
62
  // cursor in one tick, regardless of channel or claimed model.
56
63
 
57
- const VALID_CURSOR = /^[0-9a-f]{40}$/;
58
-
59
64
  export function cursorPath(transportRoot: string): string {
60
65
  return join(stateDir(transportRoot), 'cursor');
61
66
  }
@@ -71,15 +76,11 @@ export function readCursor(transportRoot: string): string | null {
71
76
  return null;
72
77
  }
73
78
  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`, 'warn');
76
- return null;
77
- }
78
79
  return raw;
79
80
  }
80
81
 
81
- export function writeCursor(transportRoot: string, commit: string): void {
82
- writeFileSync(cursorPath(transportRoot), commit + '\n');
82
+ export function writeCursor(transportRoot: string, token: string): void {
83
+ writeFileSync(cursorPath(transportRoot), token + '\n');
83
84
  }
84
85
 
85
86
  // ── pidfile ──
package/src/transport.ts CHANGED
@@ -1,4 +1,6 @@
1
- // Git transport layer. The dispatcher's commits contain ONLY data/
1
+ // Transport layer. The git transport functions are here; the Transport
2
+ // interface + GitTransport/LocalFsTransport live at the foot of this file.
3
+ // The git dispatcher's commits contain ONLY data/ —
2
4
  // machine-local state lives in the state dir (state.ts), so there is
3
5
  // nothing to exclude, untrack, or heal. Push rejection means another
4
6
  // machine won the race: pull --rebase and retry at the call site.
@@ -78,9 +80,9 @@ export function cursorBaseline(transportRoot: string): string | null {
78
80
  // HEAD. Returns null when the commit is unknown to this clone (state dir
79
81
  // copied across transports, history rewritten) — caller falls back to a
80
82
  // full channel scan.
81
- export function newFilesSince(transportRoot: string, sinceCommit: string): string[] | null {
83
+ export function newFilesSince(transportRoot: string, sinceCommit: string, head: string): string[] | null {
82
84
  const r = captureGit(transportRoot, [
83
- 'diff', '--name-only', '--diff-filter=A', `${sinceCommit}..HEAD`, '--', 'data/channels/',
85
+ 'diff', '--name-only', '--diff-filter=A', `${sinceCommit}..${head}`, '--', 'data/channels/',
84
86
  ]);
85
87
  if (r.status !== 0) return null;
86
88
  return r.stdout.split('\n').filter(Boolean);
@@ -241,3 +243,111 @@ export function listChannelMessages(transportRoot: string, channelUuid: string):
241
243
  walk(channelDir, '');
242
244
  return results.sort((a, b) => a.relPath.localeCompare(b.relPath));
243
245
  }
246
+
247
+ // ── Transport abstraction (v8) ──
248
+ //
249
+ // The dispatch loop talks to a medium through this interface, never to git
250
+ // directly. The CURSOR is an opaque, transport-owned token: dispatch.ts
251
+ // persists whatever string a transport hands back and round-trips it to
252
+ // `newFilesSince` next tick without interpreting it. Git's token is a
253
+ // 40-hex commit SHA (see state.ts on why commit cursors, not relPaths);
254
+ // a future fs transport is free to use a different token shape that is
255
+ // correct for its medium. `head()` returns the upper bound for this tick;
256
+ // `newFilesSince(cursor)` returns repo-relative message paths added in
257
+ // (cursor, head], or null when the cursor is unrecognizable (caller then
258
+ // full-scans). `sync()` pulls remote state in; `publish()` flushes locally
259
+ // written replies out. Message reads (discoverChannels/listChannelMessages)
260
+ // are already plain-fs and identical across every transport.
261
+ export interface Transport {
262
+ readonly kind: string;
263
+ sync(): GitResult;
264
+ head(): string | null;
265
+ newFilesSince(cursor: string, head: string): string[] | null;
266
+ publish(message: string): GitPushResult;
267
+ discoverChannels(): string[];
268
+ listChannelMessages(channelUuid: string): ChannelMessage[];
269
+ /** A directory to fs.watch for inbound messages (zero-lag wake), or null
270
+ * if the medium can't be watched (git: a remote isn't watchable — the
271
+ * poll loop covers it). fs.watch also won't fire over SMB/NFS, so the
272
+ * poll stays the backstop even when this is non-null. */
273
+ watchDir(): string | null;
274
+ }
275
+
276
+ // Git transport — the original and, until the fs transport lands, the only
277
+ // implementation. A thin pass-through to the module functions above; it
278
+ // adds no behavior, it only moves them behind the interface so dispatch.ts
279
+ // stops importing git operations by name.
280
+ export class GitTransport implements Transport {
281
+ readonly kind = 'git';
282
+ constructor(private readonly root: string) {}
283
+ sync(): GitResult { return gitPull(this.root); }
284
+ head(): string | null { return cursorBaseline(this.root); }
285
+ newFilesSince(cursor: string, head: string): string[] | null { return newFilesSince(this.root, cursor, head); }
286
+ publish(message: string): GitPushResult { return gitCommitAndPush(this.root, message); }
287
+ discoverChannels(): string[] { return discoverChannels(this.root); }
288
+ listChannelMessages(channelUuid: string): ChannelMessage[] { return listChannelMessages(this.root, channelUuid); }
289
+ watchDir(): string | null { return null; } // a git remote isn't fs.watchable — poll covers inbound
290
+ }
291
+
292
+ // Local filesystem transport — single machine, no remote, no git. The bus is
293
+ // just a directory tree (it can sit on an SMB/NFS mount; from here it's all
294
+ // the same plain fs). Mount this on /mnt/share and two daemons on the same
295
+ // host, or on hosts sharing the mount, see each other's messages with zero
296
+ // infrastructure.
297
+ //
298
+ // sync()/publish() are no-ops: there's no remote to pull from, and replies
299
+ // are written straight to disk by writeReply — the write IS the publish.
300
+ //
301
+ // The cursor is an epoch-millis mtime watermark. head() is "now" at tick
302
+ // start; newFilesSince returns message files with mtime in (cursor, head].
303
+ // mtime is a sound ordering key HERE and ONLY here: a single local machine
304
+ // has one monotonic clock and no push-race reordering — the exact failure
305
+ // mode state.ts forbids relPath/time cursors over for git/remote media. Do
306
+ // NOT lift this watermark onto SMB/NFS spanning multiple writers without
307
+ // revisiting that guarantee.
308
+ export class LocalFsTransport implements Transport {
309
+ readonly kind = 'local';
310
+ constructor(private readonly root: string) {}
311
+
312
+ sync(): GitResult { return { ok: true }; }
313
+ publish(_message: string): GitPushResult { return { ok: true, committed: true, pushed: false }; }
314
+ head(): string | null { return String(Date.now()); }
315
+ discoverChannels(): string[] { return discoverChannels(this.root); }
316
+ listChannelMessages(channelUuid: string): ChannelMessage[] { return listChannelMessages(this.root, channelUuid); }
317
+ // Watch the channels tree for inbound message drops — event-driven, zero
318
+ // lag, the v1.1.0 "real-time local" property. Fires only on a true local
319
+ // FS; a share-mounted dir falls back to the dispatch loop's poll.
320
+ watchDir(): string | null { return join(this.root, 'data', 'channels'); }
321
+
322
+ newFilesSince(cursor: string, head: string): string[] | null {
323
+ const since = Number(cursor);
324
+ const until = Number(head);
325
+ // Unrecognized token (e.g. a git SHA left over from a transport switch)
326
+ // → null tells the loop to full-scan, after which we write our own.
327
+ if (!Number.isFinite(since) || !Number.isFinite(until)) return null;
328
+ const channelsDir = join(this.root, 'data', 'channels');
329
+ if (!existsSync(channelsDir)) return [];
330
+ const out: string[] = [];
331
+ for (const channelUuid of this.discoverChannels()) {
332
+ const walk = (dir: string, prefix: string): void => {
333
+ let entries: string[];
334
+ try { entries = readdirSync(dir); } catch { return; }
335
+ for (const entry of entries) {
336
+ const full = join(dir, entry);
337
+ let stat;
338
+ try { stat = statSync(full); } catch { continue; }
339
+ if (stat.isDirectory()) {
340
+ walk(full, prefix ? `${prefix}/${entry}` : entry);
341
+ } else if (entry.endsWith('.md') && entry !== 'CHANNEL.md') {
342
+ if (stat.mtimeMs > since && stat.mtimeMs <= until) {
343
+ const rel = prefix ? `${prefix}/${entry}` : entry;
344
+ out.push(`data/channels/${channelUuid}/${rel}`);
345
+ }
346
+ }
347
+ }
348
+ };
349
+ walk(join(channelsDir, channelUuid), '');
350
+ }
351
+ return out;
352
+ }
353
+ }
@@ -21,7 +21,7 @@ export function channelListPage(d: ChannelListData): string {
21
21
  <span>Channels (${d.channels.length})</span>
22
22
  <button id="open-new-channel" class="primary" type="button" style="font-size:11px;font-weight:600">+ new channel</button>
23
23
  </h3>
24
- <p class="sub">Each channel is a directory under <code>data/channels/&lt;uuid&gt;/</code>. Messages and replies live as committed markdown files.</p>
24
+ <p class="sub">A channel is a conversation thread. Post a message addressed to an agent and the dispatcher delivers its reply here. (Under the hood, each channel is a folder of markdown files on the bus.)</p>
25
25
  <div id="new-channel-form" style="display:none;background:#0b0c10;border:1px solid #1f2329;border-radius:6px;padding:12px;margin-bottom:14px">
26
26
  <div class="field" style="margin-bottom:8px">
27
27
  <label for="new-channel-name">Channel name</label>
@@ -352,36 +352,38 @@ export interface AgentsPageData {
352
352
  }
353
353
 
354
354
  export function agentsPage(d: AgentsPageData): string {
355
- function chips(list: string[], highlightedList: string[], emptyMsg: string): string {
356
- if (list.length === 0) return `<p class="empty">${escapeHtml(emptyMsg)}</p>`;
357
- return list.map(a => {
358
- const cls = highlightedList.includes(a) ? 'chip brand' : 'chip';
359
- return `<span class="${cls}">${escapeHtml(a)}</span>`;
360
- }).join('');
355
+ // One row per supported CLI with its resolved status, rather than four
356
+ // cards of look-alike chips (the catalog and the installed list were
357
+ // identical whenever every supported CLI happened to be on PATH, and the
358
+ // chips read as tappable buttons when they're just labels). Iterate the
359
+ // union of the catalog + anything the yaml references, so a stray
360
+ // reference to an unknown CLI still surfaces.
361
+ const allClis = Array.from(new Set([...d.known, ...d.yamlReferenced, ...d.installed])).sort();
362
+ function statusFor(cli: string): { label: string; cls: string } {
363
+ const installed = d.installed.includes(cli);
364
+ const referenced = d.yamlReferenced.includes(cli);
365
+ const claimed = d.claimed.includes(cli);
366
+ if (claimed) return { label: 'claimed · ready to dispatch', cls: 'ok' };
367
+ if (installed && referenced) return { label: 'configured · restart engine to claim', cls: 'warn' };
368
+ if (installed) return { label: 'installed · add to crosstalk.yaml to use', cls: 'warn' };
369
+ if (referenced) return { label: 'in crosstalk.yaml · binary not on PATH', cls: 'warn' };
370
+ return { label: 'not installed', cls: 'dim' };
361
371
  }
372
+ const rows = allClis.length === 0
373
+ ? '<p class="empty">No supported agent CLIs found.</p>'
374
+ : allClis.map(cli => {
375
+ const s = statusFor(cli);
376
+ return `<div class="kv"><span class="key">${escapeHtml(cli)}</span><span class="val ${s.cls}">${s.label}</span></div>`;
377
+ }).join('');
378
+ const cta = d.claimed.length === 0
379
+ ? '<section class="card" style="border-left:3px solid #d29922"><h3 style="color:#d29922">Nothing connected yet</h3><p class="sub" style="margin-bottom:0">Run the guided setup to wire one of these up — Crosstalk writes the config and claims it live. <a href="/welcome"><strong>Set up an agent →</strong></a></p></section>'
380
+ : '';
362
381
  const body = `
382
+ ${cta}
363
383
  <section class="card">
364
- <h3>Installed agent CLIs</h3>
365
- <p class="sub">CLI binaries crosstalk knows how to PTY-wrap, detected on this engine's PATH.</p>
366
- ${chips(d.installed, d.claimed, 'No supported agent CLIs found on PATH.')}
367
- </section>
368
-
369
- <section class="card">
370
- <h3>Referenced in data/crosstalk.yaml</h3>
371
- <p class="sub">CLIs that have at least one model entry in this transport's registry (whether claimed or not).</p>
372
- ${chips(d.yamlReferenced, d.claimed, 'No model entries reference any known agent CLI yet.')}
373
- </section>
374
-
375
- <section class="card">
376
- <h3>Claimed by this dispatcher</h3>
377
- <p class="sub">CLIs with at least one entry whose binary was on PATH at dispatcher startup. Restart after installing a new CLI to claim its models.</p>
378
- ${chips(d.claimed, d.claimed, 'No CLIs claimed yet — restart the engine after installing.')}
379
- </section>
380
-
381
- <section class="card">
382
- <h3>Supported (catalog)</h3>
383
- <p class="sub">The full set of agent CLIs crosstalk has built-in support for.</p>
384
- ${chips(d.known, d.installed, '')}
384
+ <h3>Agent CLIs</h3>
385
+ <p class="sub">Every agent CLI crosstalk supports and its status on this engine. The <a href="/welcome">guided setup</a> wires one up live; to do it by hand, add a model entry under <code>providers:</code> in <code>data/crosstalk.yaml</code> and restart the engine.</p>
386
+ ${rows}
385
387
  </section>
386
388
  `;
387
389
  return page({