@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/GUIDE-CLI.md +1 -1
- package/README.md +32 -12
- package/commands/server.js +35 -3
- package/commands/transport.js +53 -24
- package/lib/nativeServer.js +20 -2
- package/lib/resolve.js +5 -3
- package/package.json +3 -3
- package/src/api.ts +56 -12
- package/src/dispatch.ts +95 -28
- package/src/models.ts +17 -0
- package/src/onboarding.ts +136 -0
- package/src/state.ts +14 -13
- package/src/transport.ts +113 -3
- package/src/web/channels.ts +30 -28
- package/src/web/chat-page.ts +1 -1
- package/src/web/chat-pty.ts +1 -1
- package/src/web/dashboard.ts +35 -1
- package/src/web/layout.ts +9 -0
- package/src/web/stubs.ts +3 -3
- package/src/web/wizard.ts +172 -0
- package/src/web/workflows.ts +1 -1
- package/template/CLAUDE.md +1 -1
- package/template/CROSSTALK.md +7 -7
- package/template/PROTOCOL.md +1 -1
- package/template/README.md +1 -1
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
|
-
|
|
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 =
|
|
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 =
|
|
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(
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
}
|
|
349
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
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,
|
|
82
|
-
writeFileSync(cursorPath(transportRoot),
|
|
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
|
-
//
|
|
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}
|
|
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
|
+
}
|
package/src/web/channels.ts
CHANGED
|
@@ -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">
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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>
|
|
365
|
-
<p class="sub">CLI
|
|
366
|
-
${
|
|
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({
|