@cordfuse/crosstalk 7.0.0-alpha.3 → 7.0.0-alpha.5

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/README.md CHANGED
@@ -36,7 +36,7 @@ crosstalk chat --shell
36
36
  exit
37
37
  crosstalk chat --agent claude --login
38
38
  # Auth URL opens in your default browser via xdg-open / open / start.
39
- # Operator never has to copy-paste it. Token saved in the /root
39
+ # Operator never has to copy-paste it. Token saved in the /crosstalk-root
40
40
  # volume so subsequent dispatched workers reuse it.
41
41
 
42
42
  # 4. Send your first message.
@@ -73,7 +73,7 @@ crosstalk replies <relPath printed by step above>
73
73
  ## What lives where
74
74
 
75
75
  - **Host** — `crosstalk` binary on PATH, `~/path/to/mytransport/` (the operator's transport directory, bind-mounted).
76
- - **Container** (`crosstalk-server`, image `ghcr.io/cordfuse/crosstalk-server`) — `crosstalkd` daemon, dispatch state in named volume `crosstalk-state:/var/lib/crosstalkd-state`, operator-installed agent CLIs + auth tokens in named volume `crosstalk-root:/root`.
76
+ - **Container** (`crosstalk-server`, image `ghcr.io/cordfuse/crosstalk-server`) — `crosstalkd` daemon, dispatch state bind-mounted at `<base>/<transport>/crosstalk-state:/var/lib/crosstalk-state`, operator-installed agent CLIs + auth tokens bind-mounted at `<base>/<transport>/crosstalk-root:/crosstalk-root`. `<base>` is per-OS (`$XDG_DATA_HOME/crosstalk` on Linux, `~/Library/Application Support/crosstalk` on macOS, `%LOCALAPPDATA%\crosstalk` on Windows); `CROSSTALK_STORAGE_MODE=system` switches to `/var/lib`-style paths instead.
77
77
  - **Engine HTTP API** — `127.0.0.1:7000` (compose `127.0.0.1:HOST:CONTAINER` port mapping is the actual security boundary; engine binds 0.0.0.0 inside the container).
78
78
  - **Transport bind-mount** — `$PWD:/var/lib/crosstalk-transport`. Operator git commands on the host are seen by the engine immediately.
79
79
 
package/commands/chat.js CHANGED
@@ -28,10 +28,59 @@ import { api, ConnectError } from '../lib/api-client.js';
28
28
  import { flag, has } from '../lib/argv.js';
29
29
 
30
30
  const KNOWN_AGENTS = ['claude', 'codex', 'gemini', 'qwen', 'opencode', 'agy'];
31
- // Conservative URL regex: matches the leading https://...token boundary,
32
- // stops at whitespace/quote/angle. False positives are bounded to --login
33
- // mode where the first URL printed is almost certainly the auth URL.
34
- const URL_RE = /https?:\/\/[^\s<>"']+/;
31
+
32
+ // URL extraction that survives agent TUI rendering. Naive regex against
33
+ // raw stdout fails because agent CLIs (Claude in particular) draw a box
34
+ // around the URL with line wraps every ~N chars; the captured URL is
35
+ // truncated at the first wrap, with ANSI control chars leaking in at
36
+ // the cut point. Real example from alpha.3 macOS test: extracted URL
37
+ // ended with `%18%6b…` (CAN char + box-drawing artifact) instead of
38
+ // the `redirect_uri=…` param, breaking OAuth.
39
+ //
40
+ // Approach:
41
+ // 1. ANSI-strip everything (CSI / OSC / SS3 escapes).
42
+ // 2. Strip control chars (0x00-0x1F except LF, plus DEL).
43
+ // 3. Join line wraps that occur INSIDE a URL-safe character run —
44
+ // `<url-char>\n<url-char>` → `<url-char><url-char>`. Preserves real
45
+ // `<url-char>\n<non-url-char>` boundaries.
46
+ // 4. Match `https?://<url-chars>+` against the joined buffer.
47
+ // 5. Only fire when the character AFTER the matched URL is a true
48
+ // terminator (whitespace, end-of-buffer) — guards against acting
49
+ // on a partial URL still being written.
50
+
51
+ const URL_SAFE_CLASS = "a-zA-Z0-9%/?#&=+\\-._~:@!$()*,;";
52
+ const URL_RE = new RegExp(`https?://[${URL_SAFE_CLASS}]+`);
53
+
54
+ function extractCompleteUrl(rawBuffer) {
55
+ // Strip ANSI CSI escapes (\x1B[ ... terminator).
56
+ let clean = rawBuffer.replace(/\x1B\[[0-9;?]*[a-zA-Z]/g, '');
57
+ // Strip ANSI OSC escapes (\x1B] ... BEL or ST).
58
+ clean = clean.replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)/g, '');
59
+ // Strip SS3 escapes (\x1BO + one char).
60
+ clean = clean.replace(/\x1BO./g, '');
61
+ // Strip remaining single-char escapes that slipped through.
62
+ clean = clean.replace(/\x1B[^a-zA-Z0-9]/g, '');
63
+ // Strip control chars except LF (0x0A); we use LF for line-wrap rejoin.
64
+ clean = clean.replace(/[\x00-\x09\x0B-\x1F\x7F]/g, '');
65
+ // Rejoin URL-character wrap points: `<url-char>\n<url-char>` becomes
66
+ // `<url-char><url-char>`. Doesn't merge `<url-char>\n<space>` etc.
67
+ const safeRe = new RegExp(`[${URL_SAFE_CLASS}]\\n[${URL_SAFE_CLASS}]`, 'g');
68
+ let prev;
69
+ do {
70
+ prev = clean;
71
+ clean = clean.replace(safeRe, (m) => m[0] + m[2]);
72
+ } while (clean !== prev);
73
+
74
+ const match = clean.match(URL_RE);
75
+ if (!match) return null;
76
+ // Require a terminator after the URL — without it, the URL may still
77
+ // be growing in subsequent stdout chunks. Terminator = whitespace,
78
+ // quote/angle, or end-of-buffer-with-trailing-newline.
79
+ const after = clean.slice(match.index + match[0].length);
80
+ if (after.length === 0) return null; // EOF-as-grow case, wait
81
+ if (!/^[\s<>"']/.test(after)) return null; // URL still continuing
82
+ return match[0];
83
+ }
35
84
 
36
85
  function openInBrowser(url) {
37
86
  const cmd =
@@ -107,27 +156,29 @@ function runLogin(container, agent) {
107
156
  //
108
157
  // No agent-specific "login" subcommand — we just run the agent's
109
158
  // default interactive entry point. First-run auth flow happens
110
- // automatically for every agent we support. Operator can pass
111
- // --agent <name> to target a specific one.
159
+ // automatically for every agent we support.
160
+ //
161
+ // No COLUMNS=1000 env: making the agent render the URL "unwrapped" via
162
+ // a wide terminal claim doesn't reliably propagate to the in-container
163
+ // PTY, and even when it does Claude's TUI hard-wraps anyway. The
164
+ // extractCompleteUrl() function joins wrapped URL fragments on the
165
+ // host side, so the agent's rendering width is irrelevant. As bonus,
166
+ // dropping COLUMNS=1000 fixes daily-chat soft-wrap weirdness in
167
+ // long-form prose (agents see the real host terminal width).
112
168
  return new Promise((resolve) => {
113
169
  const child = spawn(
114
170
  'docker',
115
- [
116
- 'exec', '-it',
117
- '--env', 'COLUMNS=1000', // fallback for browser-open failure
118
- '--env', 'LINES=40',
119
- container,
120
- agent,
121
- ],
171
+ ['exec', '-it', container, agent],
122
172
  { stdio: ['inherit', 'pipe', 'inherit'] },
123
173
  );
124
174
  let opened = false;
175
+ let buffer = '';
125
176
  child.stdout.on('data', (chunk) => {
126
177
  process.stdout.write(chunk);
127
178
  if (opened) return;
128
- const match = chunk.toString().match(URL_RE);
129
- if (!match) return;
130
- const url = match[0];
179
+ buffer += chunk.toString();
180
+ const url = extractCompleteUrl(buffer);
181
+ if (!url) return;
131
182
  opened = true;
132
183
  const ok = openInBrowser(url);
133
184
  process.stdout.write(
package/commands/init.js CHANGED
@@ -21,7 +21,7 @@ import { spawnSync } from 'child_process';
21
21
  import { has, positionals } from '../lib/argv.js';
22
22
 
23
23
  const DEFAULT_IMAGE = process.env.CROSSTALK_IMAGE
24
- ?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.3';
24
+ ?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.5';
25
25
 
26
26
  function usage(exit = 0) {
27
27
  const w = exit === 0 ? process.stdout : process.stderr;
@@ -34,9 +34,9 @@ function usage(exit = 0) {
34
34
 
35
35
  The image must be pullable (or already present locally). For local dev
36
36
  before the GHCR publish pipeline ships, build the image first:
37
- docker build -t crosstalk-server:7.0.0-alpha.3 -f server/Dockerfile .
37
+ docker build -t crosstalk-server:7.0.0-alpha.5 -f server/Dockerfile .
38
38
  Then run:
39
- CROSSTALK_IMAGE=crosstalk-server:7.0.0-alpha.3 crosstalk init mytransport
39
+ CROSSTALK_IMAGE=crosstalk-server:7.0.0-alpha.5 crosstalk init mytransport
40
40
  `,
41
41
  );
42
42
  process.exit(exit);
@@ -18,7 +18,22 @@ export async function run(_argv) {
18
18
 
19
19
  process.stdout.write(`Claimed models: ${s.claimed_models.length}\n`);
20
20
  if (s.claimed_models.length === 0) {
21
- process.stdout.write(' (no model CLIs found inside the container install one or edit data/models.yaml)\n');
21
+ // Two failure shapes look the same in claimed_models=0: (a) no CLIs
22
+ // installed at all, (b) CLIs installed but data/models.yaml has no
23
+ // entries referencing them. Ask /agents/installed to distinguish so
24
+ // the operator gets a directly-actionable hint.
25
+ let installed = null;
26
+ try {
27
+ const r = await api.get('/agents/installed');
28
+ installed = r.installed;
29
+ } catch { /* ignore — fall back to generic message */ }
30
+ if (installed && installed.length > 0) {
31
+ process.stdout.write(
32
+ ` (${installed.join(', ')} installed in the container but no data/models.yaml entry references them — add one to claim)\n`,
33
+ );
34
+ } else {
35
+ process.stdout.write(' (no model CLIs found inside the container — install one via `crosstalk chat --shell` and add an entry to data/models.yaml)\n');
36
+ }
22
37
  } else {
23
38
  for (const m of s.claimed_models) process.stdout.write(` - ${m}\n`);
24
39
  }
package/commands/up.js CHANGED
@@ -9,15 +9,17 @@
9
9
  //
10
10
  // Subsequent runs: just shell out to `docker compose up -d`.
11
11
 
12
- import { writeFileSync, existsSync, appendFileSync, readFileSync } from 'fs';
12
+ import { writeFileSync, existsSync, mkdirSync, appendFileSync, readFileSync } from 'fs';
13
13
  import { spawnSync } from 'child_process';
14
14
  import { join } from 'path';
15
+ import { homedir } from 'os';
15
16
  import { requireTransportRoot, transportName, composeFile } from '../lib/transport.js';
16
17
  import { has } from '../lib/argv.js';
17
18
 
18
19
  const DEFAULT_IMAGE = process.env.CROSSTALK_IMAGE
19
- ?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.3';
20
+ ?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.5';
20
21
  const DEFAULT_API_PORT = Number(process.env.CROSSTALK_API_PORT) || 7000;
22
+ const STORAGE_MODE = (process.env.CROSSTALK_STORAGE_MODE ?? 'user').toLowerCase();
21
23
 
22
24
  function usage(exit = 0) {
23
25
  const w = exit === 0 ? process.stdout : process.stderr;
@@ -26,25 +28,94 @@ function usage(exit = 0) {
26
28
 
27
29
  Brings up the engine container for the transport in the current directory
28
30
  (or any ancestor containing CROSSTALK-VERSION). First-time use generates
29
- docker-compose.yml in the transport root. Override the image with
30
- CROSSTALK_IMAGE, the API port with CROSSTALK_API_PORT.
31
+ docker-compose.yml in the transport root.
32
+
33
+ Environment:
34
+ CROSSTALK_IMAGE Image to pin (default: bundled GHCR tag)
35
+ CROSSTALK_API_PORT Host port to map (default: 7000)
36
+ CROSSTALK_ALIAS Dispatcher's machine identity in the bus
37
+ CROSSTALK_STORAGE_MODE user (default) | system
38
+ Where bind-mounted crosstalk-root +
39
+ crosstalk-state live on the host. 'system'
40
+ uses paths under /var/lib (Linux) / /Library
41
+ (macOS) / C:\\ProgramData (Windows) — requires
42
+ sudo/UAC and (on macOS) Docker Desktop file-
43
+ sharing allowlist. 'user' uses paths under
44
+ the operator's home directory with no
45
+ escalation.
31
46
  `,
32
47
  );
33
48
  process.exit(exit);
34
49
  }
35
50
 
36
- function renderCompose({ name, image, apiPort, alias, uid, gid }) {
37
- // Entrypoint runs as root for setup (apt-installed CLIs in /root,
38
- // npm prefix, SSH key import). It then adjusts a 'crosstalkd' user
39
- // to match CROSSTALK_UID/GID (operator's host UID), chowns the
40
- // bind-mounted transport + state dir to that user, and drops via
41
- // setpriv before exec'ing the dispatcher. Net effect: bind-mount
42
- // files written by the engine are operator-owned on the host, so
43
- // \`git remote add\` and friends work without sudo. /root stays
44
- // root-owned for CLI installs.
51
+ // Resolve the host directories that back the container's /crosstalk-root
52
+ // (operator-installed CLIs + auth) and /var/lib/crosstalk-state
53
+ // (dispatcher state) bind mounts.
54
+ //
55
+ // User-mode (default) keeps everything under the operator's home so no
56
+ // sudo / UAC / Docker Desktop file-sharing allowlist gymnastics are
57
+ // needed. System-mode is for headless production-style deployments
58
+ // where multiple operators share a machine and a service user manages
59
+ // the daemon.
60
+ //
61
+ // Host-side dir names use the same `crosstalk-` prefix as the container
62
+ // paths so things stay identifiable wherever they surface (backup tools,
63
+ // file managers, support-email screenshots).
64
+ function resolveStoragePaths(transportName, mode) {
65
+ const platform = process.platform;
66
+ // Per-OS base directories.
67
+ const bases = (platform === 'darwin')
68
+ ? {
69
+ user: join(homedir(), 'Library', 'Application Support', 'crosstalk'),
70
+ system: '/Library/Application Support/crosstalk',
71
+ }
72
+ : (platform === 'win32')
73
+ ? {
74
+ // %LOCALAPPDATA% should always be set on supported Win versions;
75
+ // fall back to ~/AppData/Local if it isn't.
76
+ user: process.env['LOCALAPPDATA']
77
+ ? join(process.env['LOCALAPPDATA'], 'crosstalk')
78
+ : join(homedir(), 'AppData', 'Local', 'crosstalk'),
79
+ system: 'C:\\ProgramData\\crosstalk',
80
+ }
81
+ : /* linux + other unix */ {
82
+ // Honor XDG_DATA_HOME silently (no docs surface; the 99% of
83
+ // operators who haven't set it get ~/.local/share/crosstalk
84
+ // either way).
85
+ user: join(process.env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share'), 'crosstalk'),
86
+ system: '/var/lib/crosstalk',
87
+ };
88
+
89
+ if (mode !== 'user' && mode !== 'system') {
90
+ throw new Error(
91
+ `crosstalk up: CROSSTALK_STORAGE_MODE='${mode}' invalid — must be 'user' or 'system'.`,
92
+ );
93
+ }
94
+ const base = bases[mode];
95
+ const transportRoot = join(base, transportName);
96
+ return {
97
+ crosstalkRoot: join(transportRoot, 'crosstalk-root'),
98
+ crosstalkState: join(transportRoot, 'crosstalk-state'),
99
+ mode,
100
+ };
101
+ }
102
+
103
+ function renderCompose({ name, image, apiPort, alias, uid, gid, crosstalkRootHost, crosstalkStateHost, storageMode }) {
104
+ // Entrypoint runs as root for setup (npm prefix, SSH key import). It
105
+ // then adjusts a 'crosstalkd' user to match CROSSTALK_UID/GID
106
+ // (operator's host UID), chowns the bind-mounted transport, state
107
+ // dir, AND /crosstalk-root to that user, then drops via setpriv before
108
+ // exec'ing the dispatcher. Net effect: bind-mount files written by
109
+ // the engine are operator-owned on the host, so \`git remote add\`
110
+ // and \`rm -rf\` from the host both work without sudo.
45
111
  return `# Generated by \`crosstalk up\`. Safe to edit; subsequent \`up\`s
46
112
  # only regenerate when this file is missing. Compose config is machine-
47
113
  # local — kept out of git via .gitignore by default.
114
+ #
115
+ # Storage mode: ${storageMode}
116
+ # crosstalk-root (container /crosstalk-root) → ${crosstalkRootHost}
117
+ # crosstalk-state (container /var/lib/crosstalk-state) → ${crosstalkStateHost}
118
+ # transport (container /var/lib/crosstalk-transport) → \${PWD} (bind to this directory)
48
119
 
49
120
  services:
50
121
  crosstalkd:
@@ -58,11 +129,12 @@ services:
58
129
  # to the engine on the next tick. No clone-on-startup needed.
59
130
  - .:/var/lib/crosstalk-transport
60
131
  # Operator-installed agent CLIs (claude, codex, agy, ...) + auth
61
- # state. Root-owned, dispatcher reads only.
62
- - crosstalk-root:/root
63
- # Dispatcher's machine-local state: cursor, heartbeat, errors.log.
64
- # Operator-UID-owned (chowned by entrypoint to CROSSTALK_UID/GID).
65
- - crosstalk-state:/var/lib/crosstalkd-state
132
+ # state. Chowned to operator UID at entrypoint so host-side
133
+ # \`rm -rf\` works without sudo.
134
+ - ${crosstalkRootHost}:/crosstalk-root
135
+ # Dispatcher's machine-local state: cursor, heartbeat, errors.log,
136
+ # pidfile, wake.signal. Operator-UID-owned (chowned by entrypoint).
137
+ - ${crosstalkStateHost}:/var/lib/crosstalk-state
66
138
  environment:
67
139
  CROSSTALK_ALIAS: ${alias}
68
140
  CROSSTALK_UID: "${uid}"
@@ -70,10 +142,6 @@ services:
70
142
  CROSSTALKD_API_PORT: "7000"
71
143
  DISPATCH_JSON: "true"
72
144
  DISPATCH_POLL_SECONDS: "30"
73
-
74
- volumes:
75
- crosstalk-root:
76
- crosstalk-state:
77
145
  `;
78
146
  }
79
147
 
@@ -101,6 +169,31 @@ export async function run(argv) {
101
169
  const alias = process.env.CROSSTALK_ALIAS ?? process.env.HOSTNAME ?? transportName(root);
102
170
  const uid = typeof process.getuid === 'function' ? process.getuid() : 1000;
103
171
  const gid = typeof process.getgid === 'function' ? process.getgid() : 1000;
172
+ let storage;
173
+ try {
174
+ storage = resolveStoragePaths(transportName(root), STORAGE_MODE);
175
+ } catch (err) {
176
+ process.stderr.write(`${err.message}\n`);
177
+ return 1;
178
+ }
179
+ // Pre-create the host bind-mount directories. Docker would auto-
180
+ // create them as root-owned otherwise, which would break the
181
+ // operator-UID chown step inside the container.
182
+ try {
183
+ mkdirSync(storage.crosstalkRoot, { recursive: true });
184
+ mkdirSync(storage.crosstalkState, { recursive: true });
185
+ } catch (err) {
186
+ process.stderr.write(
187
+ `crosstalk up: failed to create storage directories — ${err.message}\n`,
188
+ );
189
+ if (storage.mode === 'system') {
190
+ process.stderr.write(
191
+ ` System-mode storage requires write access to ${storage.crosstalkRoot}'s parent.\n` +
192
+ ` Try: sudo mkdir -p ${storage.crosstalkRoot} ${storage.crosstalkState} && sudo chown -R \$USER ${storage.crosstalkRoot} ${storage.crosstalkState}\n`,
193
+ );
194
+ }
195
+ return 1;
196
+ }
104
197
  const content = renderCompose({
105
198
  name: transportName(root),
106
199
  image: DEFAULT_IMAGE,
@@ -108,10 +201,13 @@ export async function run(argv) {
108
201
  alias,
109
202
  uid,
110
203
  gid,
204
+ crosstalkRootHost: storage.crosstalkRoot,
205
+ crosstalkStateHost: storage.crosstalkState,
206
+ storageMode: storage.mode,
111
207
  });
112
208
  writeFileSync(composeYml, content);
113
209
  ensureGitignored(root);
114
- process.stdout.write(`Generated ${composeYml}\n`);
210
+ process.stdout.write(`Generated ${composeYml} (storage: ${storage.mode})\n`);
115
211
  }
116
212
 
117
213
  const r = spawnSync('docker', ['compose', '-f', composeYml, 'up', '-d'], {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cordfuse/crosstalk",
3
- "version": "7.0.0-alpha.3",
3
+ "version": "7.0.0-alpha.5",
4
4
  "description": "Crosstalk client — host-side CLI that talks to the crosstalkd daemon running inside the crosstalk-server container.",
5
5
  "type": "module",
6
6
  "license": "MIT",