@cordfuse/crosstalk 8.0.0-alpha.1 → 8.2.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.
@@ -77,9 +77,18 @@ export function startEngine(name, opts = {}) {
77
77
  CROSSTALK_API_PORT: port,
78
78
  TRANSPORT_DIR: transportDir,
79
79
  };
80
+ // Default state to per-instance (keyed by name). Without this the
81
+ // dispatcher falls back to stateDir()'s default, keyed on
82
+ // basename(transportRoot) — always "transport" for a local-fs transport —
83
+ // so every named local instance would share one cursor/heartbeat/errors
84
+ // dir and clobber each other. Honor an explicit CROSSTALK_STATE_DIR if the
85
+ // caller already set one (the MC harness pins its heartbeat/cursor/errors
86
+ // there to isolate its run) — only fill in the default when it's absent.
87
+ if (!env.CROSSTALK_STATE_DIR) {
88
+ env.CROSSTALK_STATE_DIR = paths.crosstalkState;
89
+ }
80
90
  if (opts.transportPath) {
81
91
  env.CROSSTALK_TRANSPORT_PATH = transportDir;
82
- env.CROSSTALK_STATE_DIR = paths.crosstalkState;
83
92
  }
84
93
 
85
94
  const logFd = openSync(paths.logFile, 'a');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cordfuse/crosstalk",
3
- "version": "8.0.0-alpha.1",
3
+ "version": "8.2.0",
4
4
  "description": "Crosstalk — agent-agnostic swarm communication protocol over a shared directory (git or filesystem transport). Operator CLI + daemon in one binary.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/api.ts CHANGED
@@ -39,8 +39,10 @@ import {
39
39
  import { sendWakeSignal, readHeartbeat, readCursor, countErrors } from './state.js';
40
40
  import { reList } from './activation.js';
41
41
  import { isOnPath, type ModelEntry, type ModelsRegistry } from './models.js';
42
+ import { AGENT_CATALOG, connectAgent } from './onboarding.js';
42
43
  import { resolveToField } from './resolve.js';
43
44
  import { dashboardPage } from './web/dashboard.js';
45
+ import { wizardPage } from './web/wizard.js';
44
46
  import { channelListPage, channelViewPage, agentsPage, type BusMessage } from './web/channels.js';
45
47
  import { workflowsListPage, workflowDetailPage, type WorkflowSummary } from './web/workflows.js';
46
48
  import { setupPage as authSetupPage, loginPage as authLoginPage } from './web/auth-pages.js';
@@ -664,7 +666,7 @@ async function tryWebAuthFlow(ctx: ApiContext, req: IncomingMessage, res: Server
664
666
  // setup/api-setup path, redirect to /setup so the first browser visit
665
667
  // bootstraps the admin account.
666
668
  const HTML_GATED_PATHS = new Set([
667
- '/', '/agents', '/chat',
669
+ '/', '/welcome', '/agents', '/chat',
668
670
  '/tokens', '/logs', '/settings', '/account', '/users', '/about',
669
671
  ]);
670
672
  if (method === 'GET' && (HTML_GATED_PATHS.has(path) || path.startsWith('/c') || path.startsWith('/w'))) {
@@ -714,6 +716,7 @@ async function tryWebApiRoute(ctx: ApiContext, req: IncomingMessage, res: Server
714
716
  path === '/api/account' || path === '/api/account/passphrase' ||
715
717
  path === '/api/users' || path.startsWith('/api/users/') ||
716
718
  path === '/api/workflows' || path.startsWith('/api/workflows/') ||
719
+ path === '/api/onboarding/agents' || path === '/api/onboarding/connect' ||
717
720
  path === '/api/logs' || path === '/api/logs/stream' ||
718
721
  path === '/api/settings';
719
722
  if (!isApiPath) return false;
@@ -785,6 +788,41 @@ async function tryWebApiRoute(ctx: ApiContext, req: IncomingMessage, res: Server
785
788
  }
786
789
  }
787
790
 
791
+ // ── Onboarding wizard ────────────────────────────────────────────
792
+ // GET /api/onboarding/agents → catalog entries annotated with whether
793
+ // each CLI is installed on this engine's PATH and already claimed.
794
+ if (method === 'GET' && path === '/api/onboarding/agents') {
795
+ const agents = Object.values(AGENT_CATALOG).map((e) => ({
796
+ cli: e.cli, label: e.label, provider: e.provider, model: e.model,
797
+ installed: isOnPath(e.cli),
798
+ claimed: ctx.registry.claimed.has(`${e.provider}/${e.model}`),
799
+ needsKey: e.keyEnv !== null, keyHint: e.keyHint,
800
+ }));
801
+ writeJson(res, 200, { agents }, ctx.version);
802
+ return true;
803
+ }
804
+ // POST /api/onboarding/connect {cli, apiKey?} → admin: write the provider
805
+ // block (+ optional key file) and SIGHUP so the dispatcher claims it live.
806
+ if (method === 'POST' && path === '/api/onboarding/connect') {
807
+ if (!me.admin) { writeJson(res, 403, { error: 'admin only' }, ctx.version); return true; }
808
+ const body = await readJson<{ cli?: string; apiKey?: string }>(req);
809
+ const cli = (body.cli ?? '').trim();
810
+ if (!AGENT_CATALOG[cli]) { writeJson(res, 400, { error: `unknown agent CLI '${cli}'` }, ctx.version); return true; }
811
+ if (!isOnPath(cli)) { writeJson(res, 400, { error: `'${cli}' is not installed on this engine's PATH` }, ctx.version); return true; }
812
+ let result;
813
+ try {
814
+ result = connectAgent(ctx.transportRoot, cli, body.apiKey);
815
+ } catch (err) {
816
+ writeJson(res, 500, { error: `failed to write config: ${(err as Error).message}` }, ctx.version);
817
+ return true;
818
+ }
819
+ // Reload the registry live — the API runs in the dispatcher process, so
820
+ // SIGHUP to self triggers the registry hot-reload (see src/dispatch.ts).
821
+ try { process.kill(process.pid, 'SIGHUP'); } catch { /* best-effort */ }
822
+ writeJson(res, 200, { ok: true, ...result }, ctx.version);
823
+ return true;
824
+ }
825
+
788
826
  // ── Account: change passphrase ───────────────────────────────────
789
827
  if (method === 'POST' && path === '/api/account/passphrase') {
790
828
  const body = await readJson<{ oldPassphrase?: string; newPassphrase?: string }>(req);
@@ -1099,6 +1137,11 @@ async function tryHtmlRoute(ctx: ApiContext, req: IncomingMessage, res: ServerRe
1099
1137
  return true;
1100
1138
  }
1101
1139
 
1140
+ if (path === '/welcome') {
1141
+ writeHtml(res, 200, wizardPage({ host, alias: ctx.alias }), ctx.version);
1142
+ return true;
1143
+ }
1144
+
1102
1145
  if (path === '/c') {
1103
1146
  const channels = allChannelMeta(ctx.transportRoot);
1104
1147
  writeHtml(res, 200, channelListPage({
package/src/dispatch.ts CHANGED
@@ -504,6 +504,35 @@ async function main(): Promise<void> {
504
504
  );
505
505
  }
506
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
+
507
536
  if (onceMode) {
508
537
  await dispatchTick(registry, protocolPrompt);
509
538
  process.exit(0);
@@ -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
+ }
@@ -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({
@@ -155,7 +155,7 @@ export function chatPage(d: ChatPageData): string {
155
155
 
156
156
  <section class="card picker-card">
157
157
  <h3>Interactive chat</h3>
158
- <p class="sub">Spawns a fresh agent process on the host attached to your browser via WebSocket. Close the tab, hit the back arrow, or type <code>/exit</code> to terminate gracefully. Hit the red <code>×</code> in the topbar for an immediate SIGKILL when an agent is wedged. Async-by-design: no persistent sessions for durable work, use the orch bus.</p>
158
+ <p class="sub">Opens a live terminal session with an agent, right in your browser. Type to interact; close the tab or type <code>/exit</code> to end it. Sessions aren't saved for work you want to keep, send a message on a channel and let the dispatcher reply. (To force-stop a stuck agent, the red <code>×</code> in the top bar kills it immediately.)</p>
159
159
 
160
160
  <div class="pick-row">
161
161
  <label for="chat-agent">agent</label>
@@ -17,7 +17,7 @@
17
17
  //
18
18
  // Async-by-design contract: NO persistent session. Each WS attach
19
19
  // spawns a fresh agent. There's no detach + reattach. If you want
20
- // durable state, send messages through the orch bus.
20
+ // durable state, send messages on a channel (the dispatcher replies).
21
21
 
22
22
  import type { Server as HttpServer } from 'http';
23
23
  import type { Duplex } from 'stream';
@@ -29,13 +29,46 @@ export function dashboardPage(d: DashboardData): string {
29
29
  const namedChannels = d.channels.filter(c => c.name).map(c => c.name as string);
30
30
  const cursorShort = d.cursor ? d.cursor.slice(0, 8) + '…' : '—';
31
31
 
32
+ // Plain-language status banner so the first thing a newcomer reads is
33
+ // "what state am I in / what do I do next", not raw telemetry. Derived
34
+ // entirely from existing fields — no new backend.
35
+ const claimed = d.claimedModels.length;
36
+ const pendingTotal = d.pendingWork.reduce((sum, p) => sum + p.count, 0);
37
+ const hbStale = hbAge === null || hbAge >= 300_000;
38
+ let stColor: string, stLabel: string, stHint: string;
39
+ if (hbStale) {
40
+ stColor = '#f85149';
41
+ stLabel = 'Dispatcher not running';
42
+ stHint = 'No recent heartbeat — the engine isn\'t processing the bus. Start it with <code>crosstalk server start</code>.';
43
+ } else if (claimed === 0) {
44
+ stColor = '#d29922';
45
+ stLabel = 'No agents connected yet';
46
+ stHint = 'Connect your first agent in a guided setup — Crosstalk detects your installed CLIs and wires one up live. <a href="/welcome"><strong>Set up an agent →</strong></a>';
47
+ } else if (d.channels.length === 0) {
48
+ stColor = '#7ee787';
49
+ stLabel = `Ready · ${claimed} agent${claimed === 1 ? '' : 's'} connected`;
50
+ stHint = 'Create a channel to start a conversation, or open an interactive <a href="/chat">Chat</a>.';
51
+ } else {
52
+ stColor = '#7ee787';
53
+ stLabel = `Live · ${claimed} agent${claimed === 1 ? '' : 's'}, ${d.channels.length} channel${d.channels.length === 1 ? '' : 's'}`;
54
+ stHint = pendingTotal > 0
55
+ ? `${pendingTotal} message${pendingTotal === 1 ? '' : 's'} awaiting a reply — the dispatcher picks them up on its next tick.`
56
+ : 'Every message on the bus has a reply.';
57
+ }
58
+
32
59
  const body = `
60
+ <section class="card" style="border-left:3px solid ${stColor}">
61
+ <h3 style="color:${stColor}">${escapeHtml(stLabel)}</h3>
62
+ <p class="sub" style="margin-bottom:0">${stHint}</p>
63
+ </section>
64
+
33
65
  <div class="grid cols-2">
34
66
  <section class="card">
35
67
  <h3>Engine</h3>
36
68
  <div class="kv"><span class="key">alias</span><span class="val brand">${escapeHtml(d.alias)}</span></div>
37
69
  <div class="kv"><span class="key">version</span><span class="val">${escapeHtml(d.version)}</span></div>
38
- <div class="kv"><span class="key">transport</span><span class="val">${escapeHtml(d.transportKind)}<span class="dim"> · ${escapeHtml(d.transportRoot)}</span></span></div>
70
+ <div class="kv"><span class="key">transport</span><span class="val">${escapeHtml(d.transportKind)}</span></div>
71
+ <div style="font-size:11px;color:#7a7f87;word-break:break-all;margin:-2px 0 10px">${escapeHtml(d.transportRoot)}</div>
39
72
  <div class="kv"><span class="key">cursor</span><span class="val">${escapeHtml(cursorShort)}</span></div>
40
73
  </section>
41
74
 
package/src/web/layout.ts CHANGED
@@ -74,18 +74,15 @@ function commonStyles(): string {
74
74
  body{padding:18px 16px 80px;max-width:980px;margin:0 auto;box-sizing:border-box}
75
75
  a{color:#7cc4ff;text-decoration:none}
76
76
  a:hover{text-decoration:underline}
77
- header{display:flex;flex-direction:column;align-items:stretch;gap:8px;margin-bottom:18px}
78
- header .header-controls{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap}
79
- h1{font-size:18px;margin:0}
77
+ header{display:flex;align-items:center;gap:10px;margin-bottom:18px;background:#1c2128;border:1px solid #262c34;border-radius:8px;padding:10px 14px}
78
+ h1{font-size:16px;margin:0;min-width:0;flex:1 1 auto;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
80
79
  h1 .brand{color:#7cc4ff;letter-spacing:.08em;font-weight:600}
81
- h1 .host{color:#a371f7;font-weight:500;margin-left:8px}
82
80
  h1 .page-title{color:#a371f7;font-weight:500;margin-left:8px}
83
- #meta{color:#7a7f87;font-size:11px;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
84
- #meta .dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#7ee787}
85
- #meta .dot.stale{background:#d29922}
86
- #meta .dot.dead{background:#f85149}
87
- #nav-toggle{background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;width:34px;height:34px;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0;font-size:18px;line-height:1;margin-right:10px;flex:0 0 auto;transition:background 150ms ease,border-color 150ms ease}
88
- #nav-toggle:hover{background:#252b34;border-color:#3a414b}
81
+ .dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#7ee787;flex:0 0 auto}
82
+ .dot.stale{background:#d29922}
83
+ .dot.dead{background:#f85149}
84
+ #nav-toggle{background:#1c2128;color:#e6e8eb;border:none;border-radius:6px;width:34px;height:34px;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0;font-size:18px;line-height:1;margin-right:10px;flex:0 0 auto;transition:background 150ms ease}
85
+ #nav-toggle:hover{background:#252b34}
89
86
  #nav-toggle:active{transform:scale(.94)}
90
87
  #nav-drawer{position:fixed;top:0;left:-300px;width:280px;height:100dvh;background:#0e1116;border-right:1px solid #1f2329;transition:left 220ms ease;z-index:55;padding:18px 0;box-sizing:border-box;display:flex;flex-direction:column}
91
88
  #nav-drawer.open{left:0}
@@ -154,6 +151,15 @@ function commonStyles(): string {
154
151
  .toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
155
152
  .toast.ok{color:#7ee787;border-color:#1f4528}
156
153
  .toast.err{color:#f85149;border-color:#4a2329}
154
+ /* Persistent sidebar on wide screens: the drawer is always open and the
155
+ content is inset to the right of it, so every nav item is visible
156
+ without opening the hamburger. Phones keep the slide-in drawer. */
157
+ @media (min-width:1024px){
158
+ body{margin-left:300px;max-width:1180px}
159
+ #nav-drawer{left:0;transition:none;box-shadow:none}
160
+ #nav-backdrop{display:none}
161
+ #nav-toggle{display:none}
162
+ }
157
163
  `;
158
164
  }
159
165
 
@@ -217,15 +223,9 @@ export function page(opts: PageOpts): string {
217
223
  </head><body>
218
224
  ${navDrawer({ host: opts.host, alias: opts.alias, ...(opts.activeNav ? { activeId: opts.activeNav } : {}) })}
219
225
  <header>
220
- <div class="header-controls">
221
- <h1><button id="nav-toggle" aria-label="open navigation" type="button">≡</button><span class="brand">CROSSTALK</span> <span class="host">${escapeHtml(opts.host)}</span>${titleSuffix}</h1>
222
- <div id="meta">
223
- <span class="dot" id="liveness-dot"></span>
224
- <span id="liveness-label">live · 5s</span>
225
- <span>·</span>
226
- <span>alias ${escapeHtml(opts.alias)}</span>
227
- </div>
228
- </div>
226
+ <button id="nav-toggle" aria-label="open navigation" type="button">≡</button>
227
+ <h1><span class="brand">CROSSTALK</span>${titleSuffix}</h1>
228
+ <span class="dot" id="liveness-dot" title="${escapeHtml(opts.host)} · alias ${escapeHtml(opts.alias)}"></span>
229
229
  </header>
230
230
  <main>
231
231
  ${opts.body}
package/src/web/stubs.ts CHANGED
@@ -475,8 +475,8 @@ export function settingsPage(d: SettingsPageData): string {
475
475
  host: d.host, alias: d.alias, activeNav: 'settings', pageTitle: 'Settings',
476
476
  body: `
477
477
  <section class="card">
478
- <h3>Engine settings <span class="chip">read-only — backlog</span></h3>
479
- <p class="sub">Snapshot of the running engine. Editable settings are on the backlogfor now, restart the engine with different flags / env vars.</p>
478
+ <h3>Engine settings</h3>
479
+ <p class="sub">The running engine's configuration. Settings come from <code>data/crosstalk.yaml</code>, CLI flags, and environment variables edit those and restart the engine to change them. This page reflects what's currently live.</p>
480
480
  <div class="kv"><span class="key">alias</span><span class="val brand">${escapeHtml(d.alias)}</span></div>
481
481
  <div class="kv"><span class="key">version</span><span class="val">${escapeHtml(d.version)}</span></div>
482
482
  <div class="kv"><span class="key">transport root</span><span class="val dim" style="font-size:11px">${escapeHtml(d.transportRoot)}</span></div>
@@ -497,7 +497,7 @@ export function aboutPage(opts: CommonOpts & { version: string; transportRoot: s
497
497
  body: `
498
498
  <section class="card">
499
499
  <h3>Crosstalk</h3>
500
- <p class="sub">Async git-backed message bus + dispatcher for AI agent CLIs. Sibling product to <a href="https://github.com/cordfuse/llmux">@cordfuse/llmux</a> (interactive tmux-based session mux).</p>
500
+ <p class="sub">Async message bus + dispatcher for AI agent CLIs, carried by git or a plain (incl. shared) filesystem. Sibling product to <a href="https://github.com/cordfuse/llmux">@cordfuse/llmux</a> (interactive tmux-based session mux).</p>
501
501
  <div class="kv"><span class="key">engine version</span><span class="val brand">${escapeHtml(opts.version)}</span></div>
502
502
  <div class="kv"><span class="key">alias</span><span class="val">${escapeHtml(opts.alias)}</span></div>
503
503
  <div class="kv"><span class="key">host</span><span class="val">${escapeHtml(opts.host)}</span></div>
@@ -0,0 +1,172 @@
1
+ // wizard.ts — first-run onboarding, served at GET /welcome and linked from
2
+ // the dashboard when no agent is claimed yet. Three steps:
3
+ // 1. Connect an agent — POST /api/onboarding/connect (writes crosstalk.yaml
4
+ // + optional key, SIGHUPs the dispatcher so the model is claimed live).
5
+ // 2. Create a channel — POST /channels.
6
+ // 3. Send a message — POST /messages, then hand off to the channel view
7
+ // where the dispatcher's reply lands.
8
+ //
9
+ // The client JS builds DOM with string concatenation (no client-side template
10
+ // literals) so it nests cleanly inside this module's template string.
11
+
12
+ import { page, escapeHtml, TOAST_HELPER } from './layout.js';
13
+
14
+ export interface WizardPageData {
15
+ host: string;
16
+ alias: string;
17
+ }
18
+
19
+ export function wizardPage(d: WizardPageData): string {
20
+ const body = `
21
+ <section class="card">
22
+ <h3>Welcome to Crosstalk</h3>
23
+ <p class="sub">Crosstalk is a message bus where AI agent CLIs talk to each other and to you. Three quick steps to your first conversation.</p>
24
+ <div class="wiz-steps">
25
+ <span class="wiz-step active" data-step="1">1 · Connect an agent</span>
26
+ <span class="wiz-step" data-step="2">2 · Create a channel</span>
27
+ <span class="wiz-step" data-step="3">3 · Send a message</span>
28
+ </div>
29
+ </section>
30
+
31
+ <section class="card wiz-panel" id="panel-1">
32
+ <h3>Connect an agent</h3>
33
+ <p class="sub">These agent CLIs are installed on this engine. Pick one — Crosstalk writes the config and claims it live, no restart.</p>
34
+ <div id="agent-list"><p class="empty">Detecting installed agents…</p></div>
35
+ </section>
36
+
37
+ <section class="card wiz-panel" id="panel-2" style="display:none">
38
+ <h3>Create your first channel</h3>
39
+ <p class="sub">A channel is a conversation thread. Name it anything lowercase (e.g. <code>general</code>).</p>
40
+ <div class="field"><label for="ch-name">channel name</label><input id="ch-name" value="general" autocomplete="off"></div>
41
+ <div class="actions"><button id="ch-back" type="button">← back</button><button class="primary" id="ch-create" type="button">Create channel</button></div>
42
+ </section>
43
+
44
+ <section class="card wiz-panel" id="panel-3" style="display:none">
45
+ <h3>Send your first message</h3>
46
+ <p class="sub">Addressed to <span id="m-model" class="chip brand"></span> on <span id="m-channel" class="chip"></span>. The dispatcher delivers the reply to the channel.</p>
47
+ <div class="field"><label for="m-body">message</label><textarea id="m-body">Say hello and tell me one thing you can help with.</textarea></div>
48
+ <div class="actions"><button id="m-back" type="button">← back</button><button class="primary" id="m-send" type="button">Send message</button></div>
49
+ </section>
50
+
51
+ <section class="card wiz-panel" id="panel-done" style="display:none">
52
+ <h3>You're set up</h3>
53
+ <p class="sub">Your message is on the bus. Open the channel to watch your agent reply.</p>
54
+ <div class="actions"><a id="done-link" class="chip brand" href="/">View the conversation →</a></div>
55
+ </section>
56
+ `;
57
+
58
+ const js = `
59
+ ${TOAST_HELPER}
60
+ var state = { model: null, channel: null };
61
+ function show(step){
62
+ var panels = document.querySelectorAll('.wiz-panel');
63
+ for (var i=0;i<panels.length;i++) panels[i].style.display = 'none';
64
+ document.getElementById('panel-'+step).style.display = '';
65
+ var steps = document.querySelectorAll('.wiz-step');
66
+ for (var j=0;j<steps.length;j++){
67
+ var n = steps[j].getAttribute('data-step');
68
+ steps[j].classList.toggle('active', String(step) === 'done' || n <= String(step));
69
+ }
70
+ }
71
+ function esc(s){ var d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; }
72
+ async function loadAgents(){
73
+ var list = document.getElementById('agent-list');
74
+ try{
75
+ var r = await fetch('/api/onboarding/agents');
76
+ var data = await r.json();
77
+ var installed = (data.agents || []).filter(function(a){ return a.installed; });
78
+ if (!installed.length){ list.innerHTML = '<p class="empty">No supported agent CLIs found on this engine PATH. Install one (claude, codex, gemini, …) and reload.</p>'; return; }
79
+ list.innerHTML = installed.map(function(a){
80
+ var badge = a.claimed ? '<span class="chip" style="color:#7ee787">connected</span>' : '';
81
+ var key = (a.needsKey && !a.claimed) ? '<input class="agent-key" placeholder="API key — optional, blank if already logged in" style="margin-top:8px" autocomplete="off">' : '';
82
+ // Already claimed (revisit, or claimed outside the wizard): nothing to
83
+ // POST — just let the row advance the wizard with this model, rather
84
+ // than stranding the user behind a disabled "done" button.
85
+ var btn = '<button class="primary use-btn" type="button">' + (a.claimed ? 'Continue →' : 'Use this') + '</button>';
86
+ return '<div class="agent-row" data-cli="' + esc(a.cli) + '" data-qualified="' + esc(a.provider + '/' + a.model) + '" data-claimed="' + (a.claimed ? '1' : '0') + '">' +
87
+ '<div style="flex:1 1 auto;min-width:0">' +
88
+ '<div><span class="chip brand">' + esc(a.label) + '</span> ' + badge + '</div>' + key +
89
+ '<div style="font-size:11px;color:#7a7f87;margin-top:4px">' + esc(a.keyHint) + '</div>' +
90
+ '</div>' + btn + '</div>';
91
+ }).join('');
92
+ var rows = list.querySelectorAll('.agent-row');
93
+ for (var i=0;i<rows.length;i++){
94
+ (function(row){
95
+ row.querySelector('.use-btn').addEventListener('click', function(){
96
+ if (row.getAttribute('data-claimed') === '1'){
97
+ state.model = row.getAttribute('data-qualified');
98
+ document.getElementById('m-model').textContent = state.model;
99
+ show(2);
100
+ } else {
101
+ connect(row);
102
+ }
103
+ });
104
+ })(rows[i]);
105
+ }
106
+ }catch(e){ list.innerHTML = '<p class="empty">Failed to load agents: ' + esc(e.message) + '</p>'; }
107
+ }
108
+ async function connect(row){
109
+ var cli = row.getAttribute('data-cli');
110
+ var keyEl = row.querySelector('.agent-key');
111
+ var btn = row.querySelector('.use-btn');
112
+ btn.disabled = true; btn.textContent = 'Connecting…';
113
+ try{
114
+ var r = await fetch('/api/onboarding/connect', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ cli: cli, apiKey: keyEl ? keyEl.value : undefined }) });
115
+ var data = await r.json();
116
+ if (!r.ok) throw new Error(data.error || 'connect failed');
117
+ state.model = data.qualified;
118
+ document.getElementById('m-model').textContent = data.qualified;
119
+ showToast(data.qualified + ' connected', 'ok');
120
+ show(2);
121
+ }catch(e){ showToast(e.message, 'err'); btn.disabled = false; btn.textContent = 'Use this'; }
122
+ }
123
+ document.getElementById('ch-back').addEventListener('click', function(){ show(1); });
124
+ document.getElementById('m-back').addEventListener('click', function(){ show(2); });
125
+ async function createChannel(){
126
+ var name = document.getElementById('ch-name').value.trim();
127
+ if (!name){ showToast('channel name required', 'err'); return; }
128
+ try{
129
+ var r = await fetch('/channels', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ name: name }) });
130
+ var data = await r.json();
131
+ if (!r.ok) throw new Error(data.error || 'create failed');
132
+ state.channel = name;
133
+ document.getElementById('m-channel').textContent = name;
134
+ show(3);
135
+ }catch(e){ showToast(e.message, 'err'); }
136
+ }
137
+ document.getElementById('ch-create').addEventListener('click', createChannel);
138
+ document.getElementById('ch-name').addEventListener('keydown', function(e){ if (e.key === 'Enter') { e.preventDefault(); createChannel(); } });
139
+ document.getElementById('m-send').addEventListener('click', async function(){
140
+ var text = document.getElementById('m-body').value.trim();
141
+ if (!text){ showToast('message required', 'err'); return; }
142
+ var btn = document.getElementById('m-send'); btn.disabled = true; btn.textContent = 'Sending…';
143
+ try{
144
+ var r = await fetch('/messages', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ type:'primitive', channel: state.channel, to: state.model, body: text }) });
145
+ var data = await r.json();
146
+ if (!r.ok) throw new Error(data.error || 'send failed');
147
+ document.getElementById('done-link').href = '/c/' + encodeURIComponent(state.channel);
148
+ show('done');
149
+ }catch(e){ showToast(e.message, 'err'); btn.disabled = false; btn.textContent = 'Send message'; }
150
+ });
151
+ loadAgents();
152
+ `;
153
+
154
+ const css = `
155
+ .wiz-steps{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
156
+ .wiz-step{font-size:11px;color:#7a7f87;border:1px solid #1f2329;border-radius:12px;padding:4px 10px}
157
+ .wiz-step.active{color:#7cc4ff;border-color:#2d4a66}
158
+ .agent-row{display:flex;gap:12px;align-items:flex-start;padding:12px 0;border-bottom:1px solid #1f2329}
159
+ .agent-row:last-child{border-bottom:none}
160
+ .agent-key{width:100%}
161
+ `;
162
+
163
+ return page({
164
+ title: `crosstalk on ${escapeHtml(d.host)} · Welcome`,
165
+ host: d.host,
166
+ alias: d.alias,
167
+ pageTitle: 'Welcome',
168
+ extraCss: css,
169
+ body,
170
+ inlineScript: js,
171
+ });
172
+ }
@@ -100,7 +100,7 @@ export function workflowsListPage(d: WorkflowsListData): string {
100
100
  <div id="compose-step-prompt">
101
101
  <div class="field">
102
102
  <label for="cw-prompt">prompt</label>
103
- <textarea id="cw-prompt" rows="4" placeholder="e.g. compare how claude, codex, and gemini would refactor src/auth/users.ts to use bun's built-in crypto instead of node:crypto"></textarea>
103
+ <textarea id="cw-prompt" rows="4" placeholder="e.g. ask claude, codex, and gemini to each review README.md for unclear setup steps, then synthesize their notes into one list"></textarea>
104
104
  </div>
105
105
  <div class="field">
106
106
  <label for="cw-compiler">compiler agent</label>