@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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Reference for the `crosstalk` CLI. Run `crosstalk <noun> --help` (or `crosstalk <noun> <verb> --help`) for inline flags.
4
4
 
5
- Requires: Node.js ≥ 20 + git on PATH. alpha.18 is a single-package install `npm install -g @cordfuse/crosstalk`. The daemon lives as `crosstalk daemon …` inside the same package; there's no separate `crosstalkd` binary anymore.
5
+ Requires: Node.js ≥ 20 + git on PATH. Single-package install: `npm install -g @cordfuse/crosstalk` (current: beta.1). The daemon lives as `crosstalk daemon …` inside the same package; there's no separate `crosstalkd` binary anymore (collapse landed in alpha.18; `@cordfuse/crosstalkd` is deprecated on npm).
6
6
 
7
7
  The surface is strict **noun-verb**, mirroring [`@cordfuse/llmux`](https://www.npmjs.com/package/@cordfuse/llmux):
8
8
 
package/README.md CHANGED
@@ -1,16 +1,16 @@
1
1
  # Crosstalk
2
2
 
3
- **A shared message bus for humans and AI agents, built on git.**
3
+ **A shared message bus for humans and AI agents. The bus is a directory — carried by git, or a plain/shared filesystem.**
4
4
 
5
- > **alpha.18 status.** Crosstalk is now a single npm package — `@cordfuse/crosstalk`. The separate `@cordfuse/crosstalkd` daemon package is deprecated; the daemon lives as `crosstalk daemon dispatch` inside this same package. CLI surface is strict noun-verb, mirroring `@cordfuse/llmux`. Operators on v7 (Docker) or alpha.17 should follow the upgrade notes at the bottom of this file.
5
+ > **Status (8.x).** Crosstalk is a single npm package — `@cordfuse/crosstalk` (the separate `@cordfuse/crosstalkd` daemon package is deprecated; the daemon lives as `crosstalk daemon dispatch` inside this same package). CLI surface is strict noun-verb, mirroring `@cordfuse/llmux`. **As of 8.x the bus is transport-pluggable:** git (distributed, versioned, auditable) is the default; a local or shared filesystem `transport: local`, including a mounted SMB/NFS share — needs no git and no remote. Select per transport with the `transport:` key in `data/crosstalk.yaml`.
6
6
 
7
7
  ---
8
8
 
9
9
  > ### Six words that come up everywhere on this page
10
10
  >
11
- > - **Transport** — a git repo, scaffolded by `crosstalk transport init`. It IS the message bus.
11
+ > - **Transport** — the message bus, scaffolded by `crosstalk transport init`. A directory: a git repo (default — distributed, versioned) or a plain/shared filesystem (`transport: local`). It IS the bus.
12
12
  > - **Machine** — a host running one crosstalk engine. Identifies itself via the dispatcher `--alias` (defaulting to the transport name).
13
- > - **Message** — a markdown file with YAML frontmatter, committed to a channel. The unit of work.
13
+ > - **Message** — a markdown file with YAML frontmatter, written to a channel. The unit of work.
14
14
  > - **Model** — a named agent invocation declared in `data/crosstalk.yaml` (e.g. `sonnet`, `codex-o3`).
15
15
  > - **Actor** — an optional persona file (`local/actors/<name>.md`) that prepends to a model's prompt as system context.
16
16
  > - **Channel** — a UUID directory under `data/channels/`. A conversation thread. Optionally parented.
@@ -32,15 +32,15 @@ The missing piece: a way to make **any** agent CLI talk to **any** other agent C
32
32
 
33
33
  ## The solution
34
34
 
35
- Crosstalk is that missing layer. The whole protocol is one idea — **a git repo is the message bus.**
35
+ Crosstalk is that missing layer. The whole protocol is one idea — **a directory is the message bus.** Carry that directory with git (distributed, versioned, auditable) or a plain/shared filesystem (local, LAN, a mounted share) — same protocol either way.
36
36
 
37
37
  - **Any CLI participates.** If an agent's CLI runs one prompt non-interactively and prints a reply, it's a valid model. Mix Claude Code, Codex, Gemini CLI, Qwen Code, opencode, Antigravity — or any future CLI that follows the same shape — in one transport.
38
- - **Messages are commits.** Every send is a markdown file with YAML frontmatter, committed and pushed. The whole conversation is the git history: nothing to lose, nothing hidden, nothing to back up separately.
38
+ - **Messages are files.** Every send is a markdown file with YAML frontmatter dropped into a channel. On the git transport each is a commit, so the whole conversation is git history nothing to lose, nothing hidden, nothing to back up separately. On the filesystem transport it's just the files on disk.
39
39
  - **Peer-to-peer.** Each machine runs its own crosstalk engine. No broker, no central runtime, no registry.
40
- - **Multi-machine for free.** Git already solves "synchronize this across hosts." Crosstalk inherits that.
41
- - **Self-coordinating.** Collision-free filenames + rebase-retry on push mean the bus works correctly even with no central coordinator and concurrent writers.
40
+ - **Multi-machine for free.** Git already solves "synchronize this across hosts"; a shared mount (SMB/NFS) does the same on a LAN. Crosstalk inherits whichever you point it at.
41
+ - **Self-coordinating.** Collision-free filenames mean concurrent writers never clash; the git transport adds rebase-retry on push, the filesystem transport relies on the unique filenames alone. Either way the bus works correctly with no central coordinator.
42
42
 
43
- If you can `git push`, you can participate. No infrastructure to provision, no central server holding your conversation.
43
+ No infrastructure to provision, no central server holding your conversation — just a directory, on git or a filesystem.
44
44
 
45
45
  ---
46
46
 
@@ -235,6 +235,26 @@ Same flow works for self-hosted Gitea or GitLab. Crosstalk doesn't care; it only
235
235
 
236
236
  ---
237
237
 
238
+ ## Local filesystem transport (no git)
239
+
240
+ Don't want a git remote? Point Crosstalk at a plain directory instead — a local folder for a single host, or a **mounted SMB/NFS share** for several hosts on a LAN. No git, no remote, no infrastructure.
241
+
242
+ ```sh
243
+ # Scaffold a local-fs transport straight onto the directory (e.g. a NAS mount):
244
+ crosstalk transport init --transport local --path /mnt/share/crosstalk
245
+
246
+ # Run the engine on it — the bus lives on the share, state stays machine-local:
247
+ crosstalk server start --path /mnt/share/crosstalk
248
+ ```
249
+
250
+ `--transport local` writes `transport: local` into `data/crosstalk.yaml` (no git init), and the dispatcher self-selects the filesystem transport: it reads/writes message files directly and picks up new ones via an mtime cursor — event-driven `fs.watch` on a true local FS, polling over a network mount. Each host that mounts the share runs its own engine and keeps its own machine-local cursor/heartbeat, so concurrent writers never clash (collision-free filenames).
251
+
252
+ Tradeoffs vs. git: no version history, no audit trail, no conflict resolution beyond the unique filenames — but zero setup and no SSH/credentials. Reach for git when *what was said* matters; reach for the filesystem transport for quick local/LAN swarms.
253
+
254
+ > **SFTP?** Mount it with `sshfs` and use `transport: local` — Crosstalk just sees a directory.
255
+
256
+ ---
257
+
238
258
  ## Adding a second machine
239
259
 
240
260
  Multi-machine adds exactly one new idea: **routing by alias.**
@@ -424,8 +444,8 @@ Transport git repos are portable — the storage path is unchanged (`~/.local/sh
424
444
  If you have a v7 Docker install:
425
445
 
426
446
  1. `docker compose -f <your-compose-file> down` first.
427
- 2. `npm install -g @cordfuse/crosstalk@latest` — pulls alpha.18.
428
- 3. If you previously installed `@cordfuse/crosstalkd`: `npm uninstall -g @cordfuse/crosstalkd` (deprecated, no longer published from alpha.18).
447
+ 2. `npm install -g @cordfuse/crosstalk@latest` — pulls the latest 8.x.
448
+ 3. If you previously installed `@cordfuse/crosstalkd`: `npm uninstall -g @cordfuse/crosstalkd` (deprecated, no longer published since alpha.18).
429
449
  4. In user mode: `export CROSSTALK_USER_MODE=1 && crosstalk server start --containername <name>`.
430
450
  5. In system mode: rerun `sudo ./deploy/install.sh` to lay down the new `crosstalk@.service` unit, then `sudo systemctl daemon-reload && sudo systemctl enable --now crosstalk@<name>`. The old `crosstalkd@<name>.service` files can be removed.
431
451
 
@@ -435,4 +455,4 @@ The v7 GHCR image `ghcr.io/cordfuse/crosstalk-server:*` is no longer being updat
435
455
 
436
456
  ## Status
437
457
 
438
- Crosstalk alpha.18 — single-package monorepo + strict noun-verb CLI. The protocol version lives at `CROSSTALK-VERSION` (single integer) at the transport root. Published as [`@cordfuse/crosstalk`](https://www.npmjs.com/package/@cordfuse/crosstalk).
458
+ Crosstalk 8.x — single-package monorepo + strict noun-verb CLI, transport-pluggable (git or a local/shared filesystem). The protocol version lives at `CROSSTALK-VERSION` (single integer) at the transport root. Published as [`@cordfuse/crosstalk`](https://www.npmjs.com/package/@cordfuse/crosstalk).
@@ -20,9 +20,10 @@
20
20
 
21
21
  import { spawn } from 'child_process';
22
22
  import { existsSync } from 'fs';
23
- import { has } from '../lib/argv.js';
23
+ import { join, resolve } from 'path';
24
+ import { has, flag } from '../lib/argv.js';
24
25
  import {
25
- requireInitialized, storageMode,
26
+ requireInitialized, storageMode, parseContainerName,
26
27
  DEFAULT_CONTAINER_NAME,
27
28
  } from '../lib/resolve.js';
28
29
  import { startEngine, stopEngine, engineStatus } from '../lib/nativeServer.js';
@@ -56,7 +57,9 @@ function usage(exit = 0) {
56
57
  `Usage: crosstalk server <subverb> [--containername <name>]
57
58
 
58
59
  Subverbs:
59
- start start the engine as a detached host process
60
+ start [--path <dir>] start the engine; --path runs it on a transport at an
61
+ arbitrary directory (e.g. a mounted share), state stays
62
+ machine-local (user mode only)
60
63
  stop SIGTERM the running engine; SIGKILL after 5s grace
61
64
  restart stop + start
62
65
  status [--probe] report pid + uptime; --probe also pings /healthz
@@ -68,6 +71,35 @@ Storage mode defaults to 'system'; set CROSSTALK_USER_MODE=1 for user mode.
68
71
  }
69
72
 
70
73
  async function cmdStart(argv) {
74
+ // --path <dir>: run the engine on a transport at an arbitrary directory
75
+ // (e.g. a mounted SMB/NFS share) instead of the managed storage layout.
76
+ // State (cursor/heartbeat/pid/port) stays machine-local, keyed by name.
77
+ const transportPath = flag(argv, '--path');
78
+ if (transportPath) {
79
+ if (storageMode() === 'system') {
80
+ process.stderr.write(`crosstalk server start: --path is user-mode only (set CROSSTALK_USER_MODE=1).\n`);
81
+ return 1;
82
+ }
83
+ if (!existsSync(join(transportPath, 'CROSSTALK-VERSION'))) {
84
+ process.stderr.write(
85
+ `crosstalk server start: '${transportPath}' is not a crosstalk transport (no CROSSTALK-VERSION).\n` +
86
+ ` Scaffold it first: crosstalk daemon init ${transportPath}\n`,
87
+ );
88
+ return 1;
89
+ }
90
+ const name = parseContainerName(argv);
91
+ const r = startEngine(name, { transportPath });
92
+ if (!r.ok) {
93
+ process.stderr.write(`crosstalk server start: ${r.error}\n`);
94
+ return 1;
95
+ }
96
+ process.stdout.write(
97
+ `'${name}' started on ${resolve(transportPath)} (pid ${r.pid}, api 127.0.0.1:${r.port}).\n` +
98
+ ` state stays machine-local; logs: crosstalk server logs${nameSuffix(name)} -f\n`,
99
+ );
100
+ return 0;
101
+ }
102
+
71
103
  const { name } = requireInitialized(argv);
72
104
  if (storageMode() === 'system') return redirectToSystemctl('start', name);
73
105
  const r = startEngine(name);
@@ -14,7 +14,7 @@
14
14
  import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs';
15
15
  import { spawnSync } from 'child_process';
16
16
  import { createInterface } from 'readline';
17
- import { join } from 'path';
17
+ import { join, resolve } from 'path';
18
18
  import { has, flag } from '../lib/argv.js';
19
19
  import {
20
20
  parseContainerName,
@@ -29,7 +29,7 @@ function usage(exit = 0) {
29
29
  const w = exit === 0 ? process.stdout : process.stderr;
30
30
  w.write(
31
31
  `Usage:
32
- crosstalk transport init [--containername <name>] [--remote <url>]
32
+ crosstalk transport init [--containername <name>] [--transport git|local] [--path <dir>] [--remote <url>]
33
33
  crosstalk transport rm [--containername <name>] [--force]
34
34
  crosstalk transport --help
35
35
 
@@ -75,12 +75,22 @@ async function runInit(argv) {
75
75
  }
76
76
 
77
77
  const remote = flag(argv, '--remote');
78
+ const transportKind = flag(argv, '--transport') === 'local' ? 'local' : 'git';
79
+ if (transportKind === 'local' && remote) {
80
+ process.stderr.write(`crosstalk transport init: --remote is ignored for --transport local (no git, no upstream).\n`);
81
+ }
78
82
  const paths = storagePaths(name);
83
+ // --path <dir>: scaffold the transport at an arbitrary directory (e.g. a
84
+ // mounted share) instead of the managed storage layout. Port + state stay
85
+ // machine-local under the name-keyed storage root; only the transport
86
+ // (template + channels) lives at the path. Pairs with `server start --path`.
87
+ const customPath = flag(argv, '--path');
88
+ const transportDir = customPath ? resolve(customPath) : paths.transportDir;
79
89
  const report = [];
80
90
 
81
91
  // ── Phase 1: Storage layout ────────────────────────────────────────
82
92
  try {
83
- mkdirSync(paths.transportDir, { recursive: true });
93
+ mkdirSync(transportDir, { recursive: true });
84
94
  mkdirSync(paths.crosstalkRoot, { recursive: true });
85
95
  mkdirSync(paths.crosstalkState, { recursive: true });
86
96
  } catch (err) {
@@ -116,7 +126,7 @@ async function runInit(argv) {
116
126
  }
117
127
 
118
128
  // ── Phase 2: Transport scaffold ────────────────────────────────────
119
- const crosstalkVersionFile = join(paths.transportDir, 'CROSSTALK-VERSION');
129
+ const crosstalkVersionFile = join(transportDir, 'CROSSTALK-VERSION');
120
130
  if (!existsSync(crosstalkVersionFile)) {
121
131
  // Scaffold via `crosstalk daemon init <path>` — the same self-bin
122
132
  // call pattern startEngine() uses.
@@ -129,7 +139,7 @@ async function runInit(argv) {
129
139
  }
130
140
  const initRun = spawnSync(
131
141
  process.execPath,
132
- [selfBin, 'daemon', 'init', paths.transportDir],
142
+ [selfBin, 'daemon', 'init', transportDir],
133
143
  { stdio: 'pipe' },
134
144
  );
135
145
  if (initRun.status !== 0) {
@@ -145,40 +155,59 @@ async function runInit(argv) {
145
155
  report.push(` template: present (skipped scaffold)`);
146
156
  }
147
157
 
158
+ // ── Phase 2.5: local transport marker ──────────────────────────────
159
+ // Local (fs) transports carry no git history; persist the medium in the
160
+ // config so the dispatcher self-selects it (no --transport flag needed).
161
+ if (transportKind === 'local') {
162
+ const yamlPath = join(transportDir, 'data', 'crosstalk.yaml');
163
+ try {
164
+ const cur = existsSync(yamlPath) ? readFileSync(yamlPath, 'utf8') : '';
165
+ if (!/^\s*transport\s*:/m.test(cur)) {
166
+ writeFileSync(yamlPath, `transport: local\n${cur}`);
167
+ }
168
+ report.push(` medium: local (fs — no git)`);
169
+ } catch (err) {
170
+ process.stderr.write(`crosstalk transport init: failed to write transport key to ${yamlPath} — ${err.message}\n`);
171
+ return 1;
172
+ }
173
+ }
174
+
148
175
  // ── Phase 3: Git init + initial commit ─────────────────────────────
149
176
  if (!existsSync(crosstalkVersionFile)) {
150
177
  process.stderr.write(
151
- `crosstalk transport init: transport template missing at ${paths.transportDir}\n` +
178
+ `crosstalk transport init: transport template missing at ${transportDir}\n` +
152
179
  ` Engine init didn't write template files. Try 'crosstalk transport rm' then re-init.\n`,
153
180
  );
154
181
  return 1;
155
182
  }
156
183
 
157
- const gitSteps = [
158
- ['init', '--quiet', '--initial-branch=main'],
159
- ['add', '-A'],
160
- ['commit', '--quiet', '-m', 'initial transport'],
161
- ];
162
- let gitOk = true;
163
- for (const args of gitSteps) {
164
- const gr = spawnSync('git', args, { cwd: paths.transportDir, stdio: 'pipe' });
165
- if (gr.status !== 0) {
166
- gitOk = false;
167
- break;
184
+ if (transportKind === 'git') {
185
+ const gitSteps = [
186
+ ['init', '--quiet', '--initial-branch=main'],
187
+ ['add', '-A'],
188
+ ['commit', '--quiet', '-m', 'initial transport'],
189
+ ];
190
+ let gitOk = true;
191
+ for (const args of gitSteps) {
192
+ const gr = spawnSync('git', args, { cwd: transportDir, stdio: 'pipe' });
193
+ if (gr.status !== 0) {
194
+ gitOk = false;
195
+ break;
196
+ }
168
197
  }
198
+ report.push(gitOk ? ` git: initialized + committed` : ` git: already present (skipped)`);
169
199
  }
170
- report.push(gitOk ? ` git: initialized + committed` : ` git: already present (skipped)`);
171
200
 
172
- // ── Phase 4: Remote setup ──────────────────────────────────────────
173
- if (remote) {
201
+ // ── Phase 4: Remote setup (git only) ───────────────────────────────
202
+ if (remote && transportKind === 'git') {
174
203
  const cur = spawnSync('git', ['remote', 'get-url', 'origin'], {
175
- cwd: paths.transportDir,
204
+ cwd: transportDir,
176
205
  stdio: 'pipe',
177
206
  });
178
207
  const currentUrl = cur.status === 0 ? cur.stdout.toString().trim() : null;
179
208
  if (!currentUrl) {
180
209
  const rr = spawnSync('git', ['remote', 'add', 'origin', remote], {
181
- cwd: paths.transportDir,
210
+ cwd: transportDir,
182
211
  stdio: 'pipe',
183
212
  });
184
213
  if (rr.status !== 0) {
@@ -202,9 +231,9 @@ async function runInit(argv) {
202
231
  process.stdout.write(
203
232
  `\nTransport '${name}' ready:\n` +
204
233
  ` storage: ${paths.storageRoot}\n` +
205
- ` transport: ${paths.transportDir}\n` +
234
+ ` transport: ${transportDir}\n` +
206
235
  report.join('\n') + '\n' +
207
- `\nNext: crosstalk server start${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}\n`,
236
+ `\nNext: crosstalk server start${customPath ? ` --path ${transportDir}` : (name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`)}\n`,
208
237
  );
209
238
  return 0;
210
239
  }
@@ -67,18 +67,36 @@ export function startEngine(name, opts = {}) {
67
67
  const poll = String(opts.pollSeconds ?? process.env.DISPATCH_POLL_SECONDS ?? 30);
68
68
  const port = String(apiPortFor(name));
69
69
 
70
+ // --path: the transport lives at an arbitrary directory (e.g. a mounted
71
+ // share). Point the engine there via CROSSTALK_TRANSPORT_PATH, and pin
72
+ // state machine-local (by name) so cursor/heartbeat never land on the
73
+ // share — each machine sharing the dir keeps its own local state.
74
+ const transportDir = opts.transportPath ? resolve(opts.transportPath) : paths.transportDir;
70
75
  const env = {
71
76
  ...process.env,
72
77
  CROSSTALK_API_PORT: port,
73
- TRANSPORT_DIR: paths.transportDir,
78
+ TRANSPORT_DIR: transportDir,
74
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
+ }
90
+ if (opts.transportPath) {
91
+ env.CROSSTALK_TRANSPORT_PATH = transportDir;
92
+ }
75
93
 
76
94
  const logFd = openSync(paths.logFile, 'a');
77
95
  const child = spawn(
78
96
  process.execPath,
79
97
  [selfBin, 'daemon', 'dispatch', '--alias', alias, '--poll', poll, '--json'],
80
98
  {
81
- cwd: paths.transportDir,
99
+ cwd: transportDir,
82
100
  env,
83
101
  stdio: ['ignore', logFd, logFd],
84
102
  detached: true,
package/lib/resolve.js CHANGED
@@ -187,11 +187,13 @@ export function pickFreePort(name, mode = storageMode()) {
187
187
  throw new Error('Could not find a free port between 7001 and 7999.');
188
188
  }
189
189
 
190
- // True if `<base>/<name>/transport/.git` exists. The cheapest signal that
191
- // a transport has been initialized for this name.
190
+ // True if the transport has been scaffolded for this name. Checks the
191
+ // CROSSTALK-VERSION marker that `daemon init` writes for EVERY transport —
192
+ // git and local-fs alike. (Was '.git', which wrongly rejected no-git local
193
+ // transports: `server start` reported "no transport 'mc'".)
192
194
  export function isInitialized(name) {
193
195
  const paths = storagePaths(name);
194
- return existsSync(join(paths.transportDir, '.git'));
196
+ return existsSync(join(paths.transportDir, 'CROSSTALK-VERSION'));
195
197
  }
196
198
 
197
199
  // True if the crosstalk engine is running for this transport. Source of truth:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cordfuse/crosstalk",
3
- "version": "7.0.0-beta.1",
4
- "description": "Crosstalk — agent-agnostic swarm communication protocol built on git. Operator CLI + daemon in one binary.",
3
+ "version": "8.1.0",
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",
7
7
  "homepage": "https://github.com/cordfuse/crosstalk",
@@ -26,7 +26,7 @@
26
26
  "scripts": {
27
27
  "build": "tsc --noEmit",
28
28
  "lint": "tsc --noEmit",
29
- "test": "bun test",
29
+ "test": "node --import tsx --test tests/*.test.ts",
30
30
  "prepack": "cp -r transport template",
31
31
  "postpack": "rm -rf template"
32
32
  },
package/src/api.ts CHANGED
@@ -33,14 +33,16 @@ import { now, messageFilename } from './filenames.js';
33
33
  import { parseFrontmatter, serializeFrontmatter } from './frontmatter.js';
34
34
  import {
35
35
  discoverChannels,
36
- gitCommitAndPush,
37
36
  listChannelMessages,
37
+ type Transport,
38
38
  } from './transport.js';
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';
@@ -66,6 +68,9 @@ export interface ApiContext {
66
68
  version: string;
67
69
  registry: ModelsRegistry;
68
70
  claimed: Map<string, ModelEntry>; // shorthand for registry.claimed
71
+ /** v8: the bus medium (git default, or local-fs). Web-UI writes publish
72
+ * through this so a local-fs deployment never shells out to git. */
73
+ transport: Transport;
69
74
  /** Auth stores; v8-native. Optional — engine still runs without auth
70
75
  * if the operator doesn't want it (just the web UI gating turns off). */
71
76
  userStore?: UserStore;
@@ -205,6 +210,7 @@ function handleStatus(ctx: ApiContext): JsonRes {
205
210
  status: 200,
206
211
  body: {
207
212
  transport_root: transportRoot,
213
+ transport_kind: ctx.transport.kind,
208
214
  alias: ctx.alias,
209
215
  version: ctx.version,
210
216
  claimed_models: [...ctx.claimed.keys()],
@@ -281,7 +287,7 @@ function handleCreateChannel(ctx: ApiContext, body: CreateChannelReq): JsonRes {
281
287
  const dir = join(transportRoot, 'data', 'channels', uuid);
282
288
  mkdirSync(dir, { recursive: true });
283
289
  writeFileSync(join(dir, 'CHANNEL.md'), serializeFrontmatter({ name: body.name }, ''));
284
- const push = gitCommitAndPush(transportRoot, `channel(create): ${body.name} (${uuid.slice(0, 8)})`);
290
+ const push = ctx.transport.publish(`channel(create): ${body.name} (${uuid.slice(0, 8)})`);
285
291
  if (!push.committed && push.error) {
286
292
  // Commit itself failed (e.g. file system / git config issue).
287
293
  throw new HttpError(500, `commit failed: ${push.error.slice(0, 300)}`);
@@ -316,8 +322,7 @@ function handleRenameChannel(ctx: ApiContext, handle: string, body: RenameChanne
316
322
  const fm: Record<string, unknown> = { name: body.name };
317
323
  if (meta.parent) fm['parent'] = meta.parent;
318
324
  writeFileSync(chPath, serializeFrontmatter(fm, ''));
319
- const push = gitCommitAndPush(
320
- transportRoot,
325
+ const push = ctx.transport.publish(
321
326
  `channel(rename): ${meta.name ?? '(unnamed)'} -> ${body.name} (${meta.uuid.slice(0, 8)})`,
322
327
  );
323
328
  if (!push.committed && push.error) {
@@ -348,8 +353,7 @@ function handleDeleteChannel(ctx: ApiContext, handle: string, confirm: string |
348
353
  }
349
354
  const dir = join(transportRoot, 'data', 'channels', meta.uuid);
350
355
  rmSync(dir, { recursive: true, force: true });
351
- const push = gitCommitAndPush(
352
- transportRoot,
356
+ const push = ctx.transport.publish(
353
357
  `channel(delete): ${meta.name ?? '(unnamed)'} (${meta.uuid.slice(0, 8)})`,
354
358
  );
355
359
  if (!push.committed && push.error) {
@@ -458,7 +462,7 @@ function handlePostMessage(ctx: ApiContext, body: PostMessageReq): JsonRes {
458
462
  fanout === 1
459
463
  ? `run: ${from} -> ${toQualified} in ${channelUuid.slice(0, 8)}`
460
464
  : `run: ${from} -> ${toQualified} x${fanout} in ${channelUuid.slice(0, 8)}`;
461
- const push = gitCommitAndPush(transportRoot, commitMsg);
465
+ const push = ctx.transport.publish(commitMsg);
462
466
  sendWakeSignal(transportRoot);
463
467
  if (!push.committed && push.error) {
464
468
  throw new HttpError(500, `commit failed: ${push.error.slice(0, 300)}`);
@@ -494,8 +498,7 @@ function handlePostMessage(ctx: ApiContext, body: PostMessageReq): JsonRes {
494
498
  extraFrontmatter,
495
499
  workflow: true,
496
500
  });
497
- const push = gitCommitAndPush(
498
- transportRoot,
501
+ const push = ctx.transport.publish(
499
502
  `run(workflow): ${from} child ${childUuid.slice(0, 8)}`,
500
503
  );
501
504
  sendWakeSignal(transportRoot);
@@ -663,7 +666,7 @@ async function tryWebAuthFlow(ctx: ApiContext, req: IncomingMessage, res: Server
663
666
  // setup/api-setup path, redirect to /setup so the first browser visit
664
667
  // bootstraps the admin account.
665
668
  const HTML_GATED_PATHS = new Set([
666
- '/', '/agents', '/chat',
669
+ '/', '/welcome', '/agents', '/chat',
667
670
  '/tokens', '/logs', '/settings', '/account', '/users', '/about',
668
671
  ]);
669
672
  if (method === 'GET' && (HTML_GATED_PATHS.has(path) || path.startsWith('/c') || path.startsWith('/w'))) {
@@ -713,6 +716,7 @@ async function tryWebApiRoute(ctx: ApiContext, req: IncomingMessage, res: Server
713
716
  path === '/api/account' || path === '/api/account/passphrase' ||
714
717
  path === '/api/users' || path.startsWith('/api/users/') ||
715
718
  path === '/api/workflows' || path.startsWith('/api/workflows/') ||
719
+ path === '/api/onboarding/agents' || path === '/api/onboarding/connect' ||
716
720
  path === '/api/logs' || path === '/api/logs/stream' ||
717
721
  path === '/api/settings';
718
722
  if (!isApiPath) return false;
@@ -784,6 +788,41 @@ async function tryWebApiRoute(ctx: ApiContext, req: IncomingMessage, res: Server
784
788
  }
785
789
  }
786
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
+
787
826
  // ── Account: change passphrase ───────────────────────────────────
788
827
  if (method === 'POST' && path === '/api/account/passphrase') {
789
828
  const body = await readJson<{ oldPassphrase?: string; newPassphrase?: string }>(req);
@@ -1007,8 +1046,7 @@ async function tryWebApiRoute(ctx: ApiContext, req: IncomingMessage, res: Server
1007
1046
  extraFrontmatter,
1008
1047
  workflow: true,
1009
1048
  });
1010
- const push = gitCommitAndPush(
1011
- ctx.transportRoot,
1049
+ const push = ctx.transport.publish(
1012
1050
  `run(workflow): ${me.username} child ${childUuid.slice(0, 8)} (composed)`,
1013
1051
  );
1014
1052
  sendWakeSignal(ctx.transportRoot);
@@ -1088,6 +1126,7 @@ async function tryHtmlRoute(ctx: ApiContext, req: IncomingMessage, res: ServerRe
1088
1126
  const channels = allChannelMeta(ctx.transportRoot);
1089
1127
  writeHtml(res, 200, dashboardPage({
1090
1128
  host, alias: ctx.alias, version: ctx.version, transportRoot: ctx.transportRoot,
1129
+ transportKind: ctx.transport.kind,
1091
1130
  heartbeat: hb ? { ts: hb.ts, pid: hb.pid, version: hb.version, alias: hb.alias } : null,
1092
1131
  cursor: cursor ?? null,
1093
1132
  claimedModels: [...ctx.claimed.keys()],
@@ -1098,6 +1137,11 @@ async function tryHtmlRoute(ctx: ApiContext, req: IncomingMessage, res: ServerRe
1098
1137
  return true;
1099
1138
  }
1100
1139
 
1140
+ if (path === '/welcome') {
1141
+ writeHtml(res, 200, wizardPage({ host, alias: ctx.alias }), ctx.version);
1142
+ return true;
1143
+ }
1144
+
1101
1145
  if (path === '/c') {
1102
1146
  const channels = allChannelMeta(ctx.transportRoot);
1103
1147
  writeHtml(res, 200, channelListPage({