@cordfuse/crosstalk 8.0.0-alpha.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/lib/nativeServer.js +10 -1
- package/package.json +1 -1
- package/src/api.ts +44 -1
- package/src/dispatch.ts +29 -0
- package/src/onboarding.ts +136 -0
- 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 +34 -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/lib/nativeServer.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "8.1.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
|
+
}
|
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({
|
package/src/web/chat-page.ts
CHANGED
|
@@ -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">
|
|
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>
|
package/src/web/chat-pty.ts
CHANGED
|
@@ -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
|
|
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';
|
package/src/web/dashboard.ts
CHANGED
|
@@ -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)}
|
|
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
|
@@ -154,6 +154,15 @@ function commonStyles(): string {
|
|
|
154
154
|
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
|
|
155
155
|
.toast.ok{color:#7ee787;border-color:#1f4528}
|
|
156
156
|
.toast.err{color:#f85149;border-color:#4a2329}
|
|
157
|
+
/* Persistent sidebar on wide screens: the drawer is always open and the
|
|
158
|
+
content is inset to the right of it, so every nav item is visible
|
|
159
|
+
without opening the hamburger. Phones keep the slide-in drawer. */
|
|
160
|
+
@media (min-width:1024px){
|
|
161
|
+
body{margin-left:300px;max-width:1180px}
|
|
162
|
+
#nav-drawer{left:0;transition:none;box-shadow:none}
|
|
163
|
+
#nav-backdrop{display:none}
|
|
164
|
+
#nav-toggle{display:none}
|
|
165
|
+
}
|
|
157
166
|
`;
|
|
158
167
|
}
|
|
159
168
|
|
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
|
|
479
|
-
<p class="sub">
|
|
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
|
|
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
|
+
}
|
package/src/web/workflows.ts
CHANGED
|
@@ -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.
|
|
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>
|