@cordfuse/crosstalk 8.2.0 → 8.3.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
@@ -120,7 +120,7 @@ Tabular list of saved profiles with their server URL + username + active marker.
120
120
 
121
121
  ```
122
122
  PROFILE USERNAME SERVER
123
- * default stevekrisjanovs http://127.0.0.1:7000
123
+ * default alice http://127.0.0.1:7000
124
124
  prod ops https://crosstalk.cordfuse.io
125
125
  ```
126
126
 
@@ -183,7 +183,7 @@ Write a primitive sub-message. `<body>` can be an inline string, a file path, or
183
183
  ```sh
184
184
  crosstalk message send --to sonnet "summarise CROSSTALK.md"
185
185
  crosstalk message send --to sonnet --fanout 3 --as junior-developer "draft a release codename"
186
- crosstalk message send --to opus@cachy --channel mctest "review these drafts" < drafts.md
186
+ crosstalk message send --to opus@myhost --channel mctest "review these drafts" < drafts.md
187
187
  ```
188
188
 
189
189
  Flags:
package/GUIDE-PROMPTS.md CHANGED
@@ -8,7 +8,7 @@ The interactive Crosstalk pattern: **`crosstalk chat <agent>`** spawns the named
8
8
 
9
9
  ```sh
10
10
  crosstalk chat claude
11
- > ask sonnet on cachy to summarise report.md
11
+ > ask sonnet on myhost to summarise report.md
12
12
  ```
13
13
 
14
14
  The agent CLI owns your terminal while running; Crosstalk owns the bus on disk; they meet at the transport git repo.
@@ -42,7 +42,7 @@ Say it however feels natural; these are the intents the interactive agent recogn
42
42
  | You say… | What the agent does |
43
43
  |---|---|
44
44
  | "ask sonnet to summarise `report.md`" | `crosstalk message send --to sonnet "..."`, polls with `crosstalk message replies <path>`, surfaces the reply |
45
- | "ask sonnet on cachy to look for typos" | `crosstalk message send --to sonnet@cachy "..."`, waits, reports back |
45
+ | "ask sonnet on myhost to look for typos" | `crosstalk message send --to sonnet@myhost "..."`, waits, reports back |
46
46
  | "fan out three drafts running sonnet" | `crosstalk message send --to sonnet --fanout 3 "..."`, surfaces all three replies when they arrive |
47
47
  | "in the *sprint-42* channel, ask opus to plan the work" | adds `--channel sprint-42` to the `message send` |
48
48
  | "run this workflow" (with file path) | `crosstalk workflow run <file>` — the runtime auto-creates a child channel and runs compile → fanout → synthesize → route deterministically |
@@ -81,7 +81,7 @@ The interactive agent runs `crosstalk workflow compose "<your prose>"`. The engi
81
81
  You see:
82
82
 
83
83
  > *(running workflow…)*
84
- > **workflow@cachy:** *(final synthesis — the chosen unified release-notes draft.)*
84
+ > **workflow@myhost:** *(final synthesis — the chosen unified release-notes draft.)*
85
85
 
86
86
  You never see the orchestration — only the result. To drill into how the work happened, ask "show me the child channel for that workflow."
87
87
 
@@ -99,7 +99,7 @@ You never see the orchestration — only the result. To drill into how the work
99
99
 
100
100
  ## Tips for good results
101
101
 
102
- - **Name the machine when it matters.** "ask sonnet" reaches sonnet on whichever dispatcher claims it first; "ask sonnet on cachy" pins it to one machine. Use the machine when *where* the work runs is part of the ask.
102
+ - **Name the machine when it matters.** "ask sonnet" reaches sonnet on whichever dispatcher claims it first; "ask sonnet on myhost" pins it to one machine. Use the machine when *where* the work runs is part of the ask.
103
103
  - **Name the channel for threaded work.** If you don't, and there's more than one channel, the agent will ask which one rather than guess.
104
104
  - **Be explicit for non-idempotent actions.** "deploy to staging" is a side effect; say so clearly and the model will verify it hasn't already happened.
105
105
  - **You don't need to know UUIDs or paths — ever.** If the agent starts showing you raw file paths or git output, tell it to "talk to me like a person"; hiding that machinery is its job.
package/README.md CHANGED
@@ -34,7 +34,7 @@ The missing piece: a way to make **any** agent CLI talk to **any** other agent C
34
34
 
35
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
- - **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.
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, GitHub Copilot CLI — or any future CLI that follows the same shape — in one transport.
38
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
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.
@@ -162,6 +162,11 @@ providers:
162
162
  env_file: auth/openrouter.env # OPENAI_API_KEY=sk-or-... + OPENAI_BASE_URL=...
163
163
  models:
164
164
  qwen3-coder: qwen --auth-type openai --yolo --model qwen/qwen3-coder
165
+
166
+ github-copilot:
167
+ env_file: auth/github-copilot.env # COPILOT_GITHUB_TOKEN=github_pat_...
168
+ models:
169
+ copilot: copilot --yolo --silent -p
165
170
  ```
166
171
 
167
172
  Per-agent install commands + the env vars each agent reads are documented in [transport/auth/README.md](transport/auth/README.md). For the auth schema design rationale, see [V8-AUTH-DESIGN.md](V8-AUTH-DESIGN.md).
@@ -186,13 +191,13 @@ Example `workflows/review-and-synthesize.md`:
186
191
  ```markdown
187
192
  ---
188
193
  type: workflow
189
- to: opus@cachy
194
+ to: opus@desktop
190
195
  as: orchestrator
191
196
  ---
192
197
 
193
198
  1. Fan out 3 junior developers running sonnet@laptop. Each drafts the
194
199
  proposal in their own voice.
195
- 2. Send the 3 drafts to a reviewer running opus@cachy to synthesize
200
+ 2. Send the 3 drafts to a reviewer running opus@desktop to synthesize
196
201
  into a single coherent proposal.
197
202
  3. Return the synthesis to the original requester.
198
203
  ```
@@ -375,7 +380,6 @@ The engine serves a complete web UI on the same loopback port. After `crosstalk
375
380
  | Route | Purpose |
376
381
  |---|---|
377
382
  | `/` | Dashboard — engine heartbeat, claimed models, channels, pending work, SSE live updates |
378
- | `/chat` | Interactive chat panel — pick an agent CLI + cwd, attach via WebSocket (`/ws/chat`) to a fresh pty. `/exit` or the kill button SIGTERMs/SIGKILLs the agent. Async-by-design: no persistent sessions |
379
383
  | `/c`, `/c/<handle>` | Channel list + threaded message view; send / rename / delete inline |
380
384
  | `/w` | Workflow list + **compose form** (natural-language → `POST /api/workflows/compose` → editable YAML preview → `POST /api/workflows/submit`) |
381
385
  | `/w/<childUuid>` | Live workflow detail with phase badge (compile / fanout / synthesize / complete / failed), per-dispatch + per-reply rows, completion banner, opt-in browser notification |
@@ -392,7 +396,6 @@ Off-host access (e.g., from a phone): the engine binds 127.0.0.1 only — front
392
396
 
393
397
  - [`tsx`](https://www.npmjs.com/package/tsx) — run TypeScript directly without a build step (engine source lives under `src/`).
394
398
  - [`yaml`](https://www.npmjs.com/package/yaml) — frontmatter and `data/crosstalk.yaml` parsing.
395
- - [`ws`](https://www.npmjs.com/package/ws) + [`node-pty`](https://www.npmjs.com/package/node-pty) — WebSocket bridge for the `/chat` web panel (browser xterm.js ↔ host pty). Native build via `node-pty`'s prebuilds; falls back to source build on uncommon platforms.
396
399
 
397
400
  ---
398
401
 
package/commands/chat.js CHANGED
@@ -26,7 +26,7 @@ import { spawn, spawnSync } from 'child_process';
26
26
  import { requireInitialized } from '../lib/resolve.js';
27
27
  import { flag, has, positionals } from '../lib/argv.js';
28
28
 
29
- const KNOWN_AGENTS = ['claude', 'codex', 'gemini', 'qwen', 'opencode', 'agy'];
29
+ const KNOWN_AGENTS = ['claude', 'codex', 'gemini', 'qwen', 'opencode', 'agy', 'copilot'];
30
30
 
31
31
  function usage(exit = 0) {
32
32
  const w = exit === 0 ? process.stdout : process.stderr;
@@ -15,7 +15,7 @@
15
15
  // "profiles": {
16
16
  // "default": {
17
17
  // "server": "http://127.0.0.1:7000",
18
- // "username": "stevekrisjanovs",
18
+ // "username": "alice",
19
19
  // "token": "sas_<id>.<secret>",
20
20
  // "tokenId": "<id>",
21
21
  // "createdAt": "2026-06-22T16:54:00Z"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cordfuse/crosstalk",
3
- "version": "8.2.0",
3
+ "version": "8.3.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",
@@ -31,10 +31,7 @@
31
31
  "postpack": "rm -rf template"
32
32
  },
33
33
  "dependencies": {
34
- "@types/ws": "^8.18.1",
35
- "node-pty": "^1.1.0",
36
34
  "tsx": "^4.20.0",
37
- "ws": "^8.21.0",
38
35
  "yaml": "^2.8.0"
39
36
  },
40
37
  "devDependencies": {
package/src/api.ts CHANGED
@@ -27,7 +27,7 @@
27
27
  import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'http';
28
28
  import { randomUUID } from 'crypto';
29
29
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
30
- import { hostname, homedir } from 'os';
30
+ import { hostname } from 'os';
31
31
  import { join } from 'path';
32
32
  import { now, messageFilename } from './filenames.js';
33
33
  import { parseFrontmatter, serializeFrontmatter } from './frontmatter.js';
@@ -46,8 +46,6 @@ import { wizardPage } from './web/wizard.js';
46
46
  import { channelListPage, channelViewPage, agentsPage, type BusMessage } from './web/channels.js';
47
47
  import { workflowsListPage, workflowDetailPage, type WorkflowSummary } from './web/workflows.js';
48
48
  import { setupPage as authSetupPage, loginPage as authLoginPage } from './web/auth-pages.js';
49
- import { chatPage } from './web/chat-page.js';
50
- import { mountChatPty } from './web/chat-pty.js';
51
49
  import { accountPage, usersPage, tokensPage, logsPage, settingsPage, aboutPage } from './web/stubs.js';
52
50
  import { logBuffer, type LogEntry } from './log-buffer.js';
53
51
  import { findOpenWorkflows, validatePlan, COMPILE_PROMPT, extractPlanFromOutput, type WorkflowPlan } from './workflow.js';
@@ -239,7 +237,7 @@ function handleListChannels(ctx: ApiContext): JsonRes {
239
237
  // data/crosstalk.yaml entries whose CLI is on PATH (e.g. "sonnet", "haiku"
240
238
  // — model names). /agents/installed lists raw CLI binaries (e.g.
241
239
  // "claude" — the interactive entry point operators talk to).
242
- const KNOWN_AGENT_BINARIES = ['claude', 'codex', 'gemini', 'qwen', 'opencode', 'agy'];
240
+ const KNOWN_AGENT_BINARIES = ['claude', 'codex', 'gemini', 'qwen', 'opencode', 'agy', 'copilot'];
243
241
 
244
242
  function handleAgentsInstalled(ctx: ApiContext): JsonRes {
245
243
  const installed = KNOWN_AGENT_BINARIES.filter((bin) => isOnPath(bin));
@@ -666,7 +664,7 @@ async function tryWebAuthFlow(ctx: ApiContext, req: IncomingMessage, res: Server
666
664
  // setup/api-setup path, redirect to /setup so the first browser visit
667
665
  // bootstraps the admin account.
668
666
  const HTML_GATED_PATHS = new Set([
669
- '/', '/welcome', '/agents', '/chat',
667
+ '/', '/welcome', '/agents',
670
668
  '/tokens', '/logs', '/settings', '/account', '/users', '/about',
671
669
  ]);
672
670
  if (method === 'GET' && (HTML_GATED_PATHS.has(path) || path.startsWith('/c') || path.startsWith('/w'))) {
@@ -1265,15 +1263,6 @@ async function tryHtmlRoute(ctx: ApiContext, req: IncomingMessage, res: ServerRe
1265
1263
  return true;
1266
1264
  }
1267
1265
 
1268
- if (path === '/chat') {
1269
- const installed = KNOWN_AGENT_BINARIES.filter((bin) => isOnPath(bin));
1270
- writeHtml(res, 200, chatPage({
1271
- host, alias: ctx.alias, installedAgents: installed, version: ctx.version,
1272
- home: homedir(),
1273
- }), ctx.version);
1274
- return true;
1275
- }
1276
-
1277
1266
  // Auth-aware stub pages. authenticateRequest is a no-op (returns
1278
1267
  // notOk) when the stores aren't configured — the gate above already
1279
1268
  // short-circuited unauthed access, so by the time we get here we
@@ -1736,23 +1725,6 @@ export function startApi(ctx: ApiContext, opts: StartApiOptions): Server {
1736
1725
  }
1737
1726
  }
1738
1727
  });
1739
- // Mount the chat pty WS bridge. Same auth model as the HTML routes —
1740
- // if user/token stores are configured, the WS upgrade is gated by a
1741
- // valid crosstalk_session cookie (or bearer token). Without auth
1742
- // stores (legacy/loopback-only mode), the WS is open since the bind
1743
- // address is the security boundary.
1744
- mountChatPty(server, {
1745
- authorize: ctx.userStore && ctx.tokenStore
1746
- ? async (req) => {
1747
- const auth = await authenticateRequest(req, {
1748
- tokenStore: ctx.tokenStore!,
1749
- userStore: ctx.userStore!,
1750
- });
1751
- return auth.ok;
1752
- }
1753
- : undefined,
1754
- });
1755
-
1756
1728
  server.listen(port, bind, () => {
1757
1729
  opts.log('api_listening', { host: bind, port });
1758
1730
  });
package/src/dispatch.ts CHANGED
@@ -294,7 +294,7 @@ async function runActivationPass(
294
294
  // Normalize asker identity the same way self-suppression does: strip
295
295
  // the @machine suffix before comparing against actorName. Without
296
296
  // this, an actor that sent a sub-message never wakes for its reply,
297
- // because asker='alice@cachy' never matches actorName='alice'. The
297
+ // because asker='alice@myhost' never matches actorName='alice'. The
298
298
  // bug was masked by the workflow smoke test, which used direct
299
299
  // stdout replies — fan-out + fan-in is the case that surfaces it.
300
300
  const senderOf = (relPath: string) => {
package/src/invoke.ts CHANGED
@@ -174,7 +174,7 @@ export function formatBatchedUserMessage(msgs: ChannelMessage[]): string {
174
174
  export interface ReplyOpts {
175
175
  transportRoot: string;
176
176
  channelUuid: string;
177
- fromModel: string; // e.g. "sonnet@cachy"
177
+ fromModel: string; // e.g. "sonnet@myhost"
178
178
  to: string; // the requester
179
179
  re: string | string[];
180
180
  body: string;
package/src/onboarding.ts CHANGED
@@ -61,6 +61,21 @@ export const AGENT_CATALOG: Record<string, AgentCatalogEntry> = {
61
61
  keyEnv: null,
62
62
  keyHint: 'OpenCode uses its own provider config — no key needed here.',
63
63
  },
64
+ // Binary is `copilot` (npm: @github/copilot) — NOT `gh copilot`, a
65
+ // separate thin launcher bundled with the gh CLI that downloads and
66
+ // runs a different copy of the same binary from a different install
67
+ // path; KNOWN_AGENT_BINARIES in api.ts checks for the real one. -p
68
+ // must stay the LAST token in `command` — it takes an explicit value
69
+ // (like claude/gemini's -p/--print here), not a bare trailing
70
+ // positional, so the dispatcher's appended message body becomes its
71
+ // argument. --yolo skips the tool/path/URL confirmation prompts;
72
+ // --silent keeps stdout to just the reply text.
73
+ copilot: {
74
+ cli: 'copilot', label: 'GitHub Copilot CLI', provider: 'github-copilot', model: 'copilot',
75
+ command: 'copilot --yolo --silent -p',
76
+ keyEnv: 'COPILOT_GITHUB_TOKEN',
77
+ keyHint: 'Leave blank if `gh` is already logged in on this host; otherwise paste a fine-grained PAT with the "Copilot Requests" permission.',
78
+ },
64
79
  };
65
80
 
66
81
  export interface ConnectResult {
package/src/resolve.ts CHANGED
@@ -75,7 +75,7 @@ export function formatResolutionError(r: Exclude<ResolutionResult, { kind: 'ok'
75
75
 
76
76
  // Resolve a `to:` field value that may carry an `@host` suffix. The host
77
77
  // suffix is split off, the actor part resolved, then re-attached. This
78
- // lets workflow scoping (`model@cachy`) work uniformly with bare or
78
+ // lets workflow scoping (`model@myhost`) work uniformly with bare or
79
79
  // qualified model references.
80
80
  export interface ToResolution {
81
81
  kind: 'ok' | 'unknown' | 'ambiguous' | 'reserved';
@@ -47,7 +47,7 @@ export function dashboardPage(d: DashboardData): string {
47
47
  } else if (d.channels.length === 0) {
48
48
  stColor = '#7ee787';
49
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>.';
50
+ stHint = 'Create a channel to start a conversation.';
51
51
  } else {
52
52
  stColor = '#7ee787';
53
53
  stLabel = `Live · ${claimed} agent${claimed === 1 ? '' : 's'}, ${d.channels.length} channel${d.channels.length === 1 ? '' : 's'}`;
package/src/web/layout.ts CHANGED
@@ -4,8 +4,9 @@
4
4
  // sky-blue accents, monospace) so a single Cordfuse operator sees a
5
5
  // consistent visual family across tools. Layout is async-by-design:
6
6
  // dashboards, channel browser, threaded message view, dispatcher
7
- // status. The chat panel (interactive agent attach) is Phase 2 pty
8
- // over WebSocket + xterm.js, fresh agent per attach, killed on close.
7
+ // status. (An interactive WS+pty chat panel existed briefly and was
8
+ // removed redundant with @cordfuse/llmux, which does this with a
9
+ // real tracked/tmux-backed session model; crosstalk stays async-only.)
9
10
 
10
11
  const BRAND_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="90" ry="90" fill="#0b0c10"/><rect x="1.5" y="1.5" width="509" height="509" rx="89" ry="89" fill="none" stroke="#7cc4ff" stroke-width="1.5" stroke-opacity="0.22"/><text x="256" y="236" text-anchor="middle" dominant-baseline="central" font-family="'Noto Sans Mono', 'Courier New', monospace" font-size="155" font-weight="700" fill="#7cc4ff" letter-spacing="-3">{Ct}</text></svg>`;
11
12
  const FAVICON_DATA_URL = `data:image/svg+xml,${encodeURIComponent(BRAND_SVG)}`;
@@ -37,7 +38,6 @@ interface NavItem {
37
38
 
38
39
  const NAV: NavItem[] = [
39
40
  { id: 'dashboard', label: 'Dashboard', icon: '▦', href: '/' },
40
- { id: 'chat', label: 'Chat', icon: '◉', href: '/chat' },
41
41
  { id: 'channels', label: 'Channels', icon: '⇄', href: '/c' },
42
42
  { id: 'workflows', label: 'Workflows', icon: '⎇', href: '/w' },
43
43
  { id: 'tokens', label: 'Tokens', icon: '⚿', href: '/tokens' },
package/src/web/stubs.ts CHANGED
@@ -267,7 +267,7 @@ export function tokensPage(opts: CommonOpts & { tokens: IdentityToken[]; viewerU
267
267
  <form id="mint-form" autocomplete="off">
268
268
  <div class="field">
269
269
  <label for="tk-name">token name</label>
270
- <input type="text" id="tk-name" required placeholder="e.g. cachy-cli, mac-ci">
270
+ <input type="text" id="tk-name" required placeholder="e.g. laptop-cli, workstation-ci">
271
271
  </div>
272
272
  <div class="actions">
273
273
  <button type="submit" class="primary">Mint</button>
@@ -72,7 +72,7 @@ Every message is a markdown file with YAML frontmatter:
72
72
  ```
73
73
  ---
74
74
  from: alice
75
- to: sonnet@cachy
75
+ to: sonnet@myhost
76
76
  timestamp: 2026-06-11T19:00:00.000Z
77
77
  ---
78
78
 
@@ -223,7 +223,7 @@ The model never sees state machinery: each invocation is a single-turn content t
223
223
  The `to:` field accepts:
224
224
 
225
225
  - `to: sonnet` — bare model name. Any dispatcher claiming `sonnet` may pick it up; first-grab wins (via git push race on the git transport, via collision-free filenames on the filesystem transport).
226
- - `to: sonnet@cachy` — narrowed to the dispatcher whose `--alias` is `cachy`.
226
+ - `to: sonnet@myhost` — narrowed to the dispatcher whose `--alias` is `myhost`.
227
227
 
228
228
  The model name is everything before the `@`; the machine alias is everything after. The `re:` activation rule ignores `@machine` suffixes — only addressing honors them.
229
229
 
@@ -144,6 +144,23 @@ Then `codex login --with-api-key` in that HOME gives you a second account, isola
144
144
 
145
145
  ---
146
146
 
147
+ ## copilot (GitHub Copilot CLI)
148
+
149
+ Binary is `copilot` (`npm install -g @github/copilot`) — **not** `gh copilot`, a separate thin launcher bundled with the `gh` CLI that downloads and runs a different copy of the same binary from a different install path. Confirmed live: `gh copilot`'s own install-detection path (`~/.local/share/gh/copilot`) and the real npm install (`copilot` on `$PATH`) are unrelated locations.
150
+
151
+ **Headless auth env** (checked in this order): `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN`. Use a fine-grained personal access token with the "Copilot Requests" permission (github.com/settings/personal-access-tokens/new — classic `ghp_` tokens are not supported), or an OAuth token already stored by `gh auth login` / `copilot login`.
152
+
153
+ ```
154
+ # auth/github-copilot.env
155
+ COPILOT_GITHUB_TOKEN=github_pat_...
156
+ ```
157
+
158
+ **Required CLI flags:** `copilot --yolo --silent -p` — `--yolo` skips the tool/path/URL confirmation prompts (documented equivalent of `--allow-all-tools --allow-all-paths --allow-all-urls`); `--silent` suppresses the trailing usage-stats line so stdout is exactly the reply text; `-p` **must be the last token** in the command string so the runtime's appended message body becomes its value — unlike the other agents' `-p`/`--print`-style flags, this one takes an explicit value rather than reading a bare trailing positional (see Command contract above).
159
+
160
+ **No folder-trust prompt in this mode.** The interactive TUI (what llmux drives) gates every spawn in a new directory behind a folder-trust confirmation with no flag to skip it. Verified live that headless `-p` mode doesn't have this gate at all, in both a plain directory and a git repo — no wrapper or workaround needed here, unlike the interactive case.
161
+
162
+ ---
163
+
147
164
  ## qwen (Qwen Code CLI)
148
165
 
149
166
  Multi-provider via `--auth-type`. The OpenAI-compatible path is the cleanest headless route:
@@ -153,6 +153,23 @@ providers:
153
153
  # models:
154
154
  # codex: codex --skip-git-repo-check
155
155
 
156
+ # ── GitHub Copilot CLI ─────────────────────────────────────────────
157
+ # Binary is `copilot` (npm: @github/copilot) — NOT `gh copilot`, a
158
+ # separate thin launcher bundled with the gh CLI that downloads and
159
+ # runs a different copy of the same binary from a different path.
160
+ # Headless auth env (checked in order): COPILOT_GITHUB_TOKEN, GH_TOKEN,
161
+ # GITHUB_TOKEN — a fine-grained PAT with the "Copilot Requests"
162
+ # permission, or an OAuth token from `gh auth login` / `copilot login`.
163
+ # -p must be the LAST token here (unlike a bare trailing positional,
164
+ # it takes an explicit value — see COMMAND CONTRACT above). No
165
+ # folder-trust prompt in this mode, verified in both a plain
166
+ # directory and a git repo — see transport/auth/README.md for detail.
167
+ #
168
+ # github-copilot:
169
+ # env_file: auth/github-copilot.env
170
+ # models:
171
+ # copilot: copilot --yolo --silent -p
172
+
156
173
  # ── OpenRouter (gateway → many models) ─────────────────────────────
157
174
  # OpenAI-compatible gateway. Use with codex, qwen, opencode (native),
158
175
  # or any other OpenAI-SDK-shaped client.
@@ -1,636 +0,0 @@
1
- // chat-page.ts — interactive chat panel.
2
- //
3
- // Layout mirrors llmux's session screen (CSS + structure copied
4
- // verbatim where it touches the bar layout — Steve's phone proved that
5
- // version works in practice, so don't redesign it):
6
- //
7
- // ┌──────────────────────────────────────┐
8
- // │ ⌂ × · ● agent · CROSSTALK v… │ ← #chat-topbar (fixed)
9
- // ├──────────────────────────────────────┤
10
- // │ │
11
- // │ xterm.js terminal │ ← #term-wrap (fills middle)
12
- // │ │
13
- // ├──────────────────────────────────────┤
14
- // │ Home ▲ ▼ ◀ ▶ End │ ← #chat-bar (mobile-only)
15
- // │ Esc Tab Ctrl Alt │
16
- // └──────────────────────────────────────┘
17
- //
18
- // Two-button kill model:
19
- // • ⌂ (back) / `/exit` typed in terminal → SIGTERM, SIGKILL 1s later
20
- // • × (kill) → immediate SIGKILL (confirm prompt)
21
- //
22
- // Soft-keyboard handling: viewport meta carries
23
- // `interactive-widget=resizes-content` so Android Chrome resizes the
24
- // visual viewport when the keyboard pops up — the fixed-bottom bar
25
- // then rides above the keyboard instead of being covered.
26
-
27
- import { page, escapeHtml, TOAST_HELPER } from './layout.js';
28
-
29
- export interface ChatPageData {
30
- host: string;
31
- alias: string;
32
- /** Installed agent CLIs on the host (from /agents installed list). */
33
- installedAgents: string[];
34
- /** Engine version — rendered in the topbar brand strip. */
35
- version?: string;
36
- /** Engine-side $HOME — used as the default CWD in the picker. */
37
- home: string;
38
- }
39
-
40
- export function chatPage(d: ChatPageData): string {
41
- const agentsJson = JSON.stringify(d.installedAgents);
42
- const versionStr = d.version ?? '';
43
- const homeJson = JSON.stringify(d.home);
44
- return page({
45
- title: `crosstalk on ${d.host} · Chat`,
46
- host: d.host,
47
- alias: d.alias,
48
- activeNav: 'chat',
49
- pageTitle: 'Chat',
50
- // Critical: the chat session uses fixed-position layout +
51
- // visualViewport math. Without `interactive-widget=resizes-content`
52
- // the Android soft keyboard overlays the bar instead of pushing it
53
- // up. Caught 2026-06-22 — the picker was unaffected because it
54
- // doesn't have fixed-bottom UI.
55
- viewport: 'width=device-width,initial-scale=1,viewport-fit=cover,interactive-widget=resizes-content',
56
- extraCss: `
57
- /* Picker (default state) */
58
- .picker-card .pick-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
59
- .picker-card .pick-row label{font-size:11px;color:#9aa0a6;text-transform:uppercase;letter-spacing:.05em;min-width:48px}
60
- .picker-card select,
61
- .picker-card input[type="text"]{background:#0b0c10;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;padding:7px 10px;font:13px ui-monospace,monospace;outline:none;flex:1 1 200px;min-width:160px}
62
- .picker-card select:focus,
63
- .picker-card input[type="text"]:focus{border-color:#2d4a66}
64
- .picker-card .cwd-row{position:relative}
65
- .picker-card #cwd-recent{background:#1c2128;color:#9aa0a6;border:1px solid #262c34;border-radius:6px;padding:7px 8px;font:11px ui-monospace,monospace;cursor:pointer;min-width:36px}
66
- .picker-card #cwd-recent:hover{background:#252b34;color:#e6e8eb}
67
- .picker-card #cwd-recent-menu{position:absolute;top:38px;right:0;background:#0e1116;border:1px solid #1f2329;border-radius:6px;padding:4px;display:none;z-index:5;max-width:90vw;min-width:240px}
68
- .picker-card #cwd-recent-menu.open{display:block}
69
- .picker-card #cwd-recent-menu button{display:block;width:100%;text-align:left;background:transparent;border:none;color:#c9d1d9;padding:7px 10px;font:12px ui-monospace,monospace;cursor:pointer;border-radius:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
70
- .picker-card #cwd-recent-menu button:hover{background:#11141a;color:#7cc4ff}
71
- .picker-card #cwd-recent-menu .empty{padding:8px 10px;color:#7a7f87;font-style:italic;font-size:11px}
72
- .picker-card .actions-row{display:flex;justify-content:flex-end;margin-top:6px}
73
-
74
- /* ── Session mode (toggled via <body class="chat-session">) ──── */
75
- :root{--topbar-h:38px;--bar-h:92px}
76
- body.chat-session{margin:0;padding:0;max-width:none;height:100dvh;min-height:100dvh;overscroll-behavior:none}
77
- body.chat-session > header,
78
- body.chat-session #nav-drawer,
79
- body.chat-session #nav-backdrop,
80
- body.chat-session .picker-card{display:none !important}
81
- body.chat-session main{margin:0;padding:0}
82
-
83
- /* Topbar */
84
- #chat-topbar{position:fixed;top:0;left:0;right:0;height:var(--topbar-h);background:#11141a;border-bottom:1px solid #1f2329;display:none;align-items:center;gap:8px;padding:0 10px;z-index:21;box-sizing:border-box}
85
- body.chat-session #chat-topbar{display:flex}
86
- #chat-topbar button.iconbtn{flex:0 0 auto;background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;height:26px;width:36px;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;font:14px ui-monospace,monospace;-webkit-tap-highlight-color:transparent;outline:none;padding:0}
87
- #chat-topbar button.iconbtn:active{background:#252b34;border-color:#3a414b}
88
- #chat-topbar button#chat-kill{color:#f85149;border-color:#4a2329}
89
- #chat-topbar button#chat-kill:active{background:#4a2329}
90
- #chat-topbar .title{flex:1 1 auto;display:flex;align-items:center;gap:8px;min-width:0;color:#c9d1d9;font-size:12px}
91
- #chat-topbar .title-dot{flex:0 0 auto;width:9px;height:9px;border-radius:50%;background:#9aa0a6;transition:background .2s,box-shadow .2s}
92
- #chat-topbar .title-dot[data-state="connecting"]{background:#d29922}
93
- #chat-topbar .title-dot[data-state="connected"]{background:#7ee787;box-shadow:0 0 6px #7ee78766}
94
- #chat-topbar .title-dot[data-state="closed"],
95
- #chat-topbar .title-dot[data-state="error"]{background:#f85149}
96
- #chat-topbar .title-name{flex:0 1 auto;font-weight:600;color:#e6e8eb;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
97
- #chat-topbar .brand{flex:0 0 auto;color:#7cc4ff;font-size:11px;font-weight:600;letter-spacing:.08em;padding-left:8px;margin-left:auto}
98
- #chat-topbar .ver{flex:0 0 auto;color:#7a7f87;font-size:10px;padding-left:6px}
99
-
100
- /* Terminal — exact position spec copied from llmux. The touch-action
101
- claim is what lets pinch-to-zoom work on mobile. */
102
- #term-wrap{display:none;background:#000;position:fixed;top:var(--topbar-h);left:0;right:0;bottom:var(--bar-h);touch-action:none}
103
- body.chat-session #term-wrap{display:block}
104
- .xterm{height:100%}
105
- .xterm-viewport{background:#000 !important}
106
-
107
- /* Soft-keyboard helper bar — CSS copied verbatim from
108
- @cordfuse/llmux/src/daemon/web/server.ts (session page) so the
109
- visual feel matches exactly. */
110
- :root{--allkeys-h:0px}
111
- #chat-bar{display:none;position:fixed;bottom:0;left:0;right:0;height:var(--bar-h);background:#11141a;border-top:1px solid #1f2329;flex-direction:column;gap:8px;padding:6px 0 14px;z-index:20;box-sizing:border-box}
112
- body.chat-session #chat-bar{display:flex}
113
- #chat-bar .row{display:flex;align-items:center;gap:6px;padding:0 6px;flex:0 0 auto;height:32px}
114
- #chat-bar .row.arrows{justify-content:center}
115
- #chat-bar .row.keys{justify-content:flex-start}
116
- #chat-bar #more{flex:0 0 auto;margin-left:auto}
117
- #chat-bar button{flex:0 0 auto;min-width:40px;height:30px;padding:0 10px;background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;font:13px ui-monospace,monospace;cursor:pointer;user-select:none;-webkit-user-select:none;-webkit-tap-highlight-color:transparent;touch-action:manipulation;outline:none;transition:background .15s,border-color .15s}
118
- #chat-bar button:active{background:#252b34;border-color:#3a414b}
119
- #chat-bar button[aria-pressed="true"]{background:#1e3a52;border-color:#2d5a85;color:#7cc4ff}
120
- #chat-bar button[aria-pressed="locked"]{background:#2d5a85;border-color:#4a7fae;color:#fff}
121
-
122
- /* Expanded all-keys panel — opens above #chat-bar when ⋯ is tapped.
123
- Contains shell chars, numbers, brackets, operators, navigation,
124
- and function keys. Same structure as llmux's #all-keys. */
125
- #all-keys{position:fixed;bottom:var(--bar-h);left:0;right:0;background:#0e1116;border-top:1px solid #1f2329;display:none;padding:8px;z-index:19;max-height:40vh;overflow-y:auto;box-sizing:border-box}
126
- body.chat-session #all-keys.open{display:block}
127
- #all-keys h4{margin:14px 4px 6px;font:500 10px/1 ui-monospace,monospace;color:#7a7f87;text-transform:uppercase;letter-spacing:.06em}
128
- #all-keys h4:first-child{margin-top:4px}
129
- #all-keys .row{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px}
130
- #all-keys button{flex:0 0 auto;min-width:36px;height:30px;padding:0 8px;background:#1c2128;color:#e6e8eb;border:1px solid #262c34;border-radius:6px;font:12px ui-monospace,monospace;cursor:pointer;-webkit-tap-highlight-color:transparent;touch-action:manipulation;outline:none}
131
- #all-keys button:active{background:#252b34;border-color:#3a414b}
132
-
133
- /* When the all-keys panel is open, the terminal shrinks to make
134
- room. --allkeys-h is set in JS based on the panel's actual height. */
135
- body.chat-session.allkeys-open #term-wrap{bottom:calc(var(--bar-h) + var(--allkeys-h))}
136
-
137
- /* Desktop / mouse-primary devices have a real keyboard — hide the
138
- bar entirely so the terminal extends to the viewport bottom. */
139
- @media (pointer: fine) and (hover: hover){
140
- body.chat-session{--bar-h:0px}
141
- #chat-bar, #all-keys{display:none !important}
142
- }
143
- /* Landscape phones — compact rows */
144
- @media (orientation: landscape) and (max-height: 500px){
145
- body.chat-session{--topbar-h:28px;--bar-h:64px}
146
- #chat-bar button{height:22px;min-width:36px;padding:0 8px;font-size:11px}
147
- #chat-bar{padding:4px 0 10px;gap:4px}
148
- #chat-bar .row{gap:4px;height:24px}
149
- #all-keys{max-height:60vh}
150
- #all-keys button{height:24px;min-width:30px;padding:0 7px;font-size:11px}
151
- }
152
- `,
153
- body: `
154
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css" />
155
-
156
- <section class="card picker-card">
157
- <h3>Interactive chat</h3>
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
-
160
- <div class="pick-row">
161
- <label for="chat-agent">agent</label>
162
- <select id="chat-agent"></select>
163
- </div>
164
- <div class="pick-row cwd-row">
165
- <label for="chat-cwd">cwd</label>
166
- <input type="text" id="chat-cwd" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="/path/to/dir">
167
- <button type="button" id="cwd-recent" title="Recent directories">recent ⌄</button>
168
- <div id="cwd-recent-menu" role="menu" aria-hidden="true"></div>
169
- </div>
170
- <div class="actions-row">
171
- <button type="button" class="primary" id="chat-start">start session</button>
172
- </div>
173
- </section>
174
-
175
- <div id="chat-topbar">
176
- <button class="iconbtn" id="chat-back" title="Back to picker (graceful exit)" aria-label="back">⌂</button>
177
- <button class="iconbtn" id="chat-kill" title="SIGKILL the agent immediately" aria-label="kill">×</button>
178
- <span class="title">
179
- <span class="title-dot" id="chat-dot" data-state="connecting" title="connecting"></span>
180
- <span class="title-name" id="chat-title">—</span>
181
- </span>
182
- <span class="brand">CROSSTALK</span>
183
- <span class="ver">${versionStr ? 'v' + escapeHtml(versionStr) : ''}</span>
184
- </div>
185
-
186
- <div id="term-wrap"></div>
187
-
188
- <div id="chat-bar" aria-label="keyboard helper">
189
- <div class="row arrows">
190
- <button data-mod="shift" title="Shift (tap then key; double-tap to lock)">Shift</button>
191
- <button data-key="home" title="Home">Home</button>
192
- <button data-key="up" title="Up">▲</button>
193
- <button data-key="down" title="Down">▼</button>
194
- <button data-key="left" title="Left">◀</button>
195
- <button data-key="right" title="Right">▶</button>
196
- <button data-key="end" title="End">End</button>
197
- </div>
198
- <div class="row keys">
199
- <button data-key="esc" title="Escape">Esc</button>
200
- <button data-key="tab" title="Tab">Tab</button>
201
- <button data-mod="ctrl" title="Ctrl (tap then key; double-tap to lock)">Ctrl</button>
202
- <button data-mod="alt" title="Alt (tap then key; double-tap to lock)">Alt</button>
203
- <button id="more" title="More keys (functions, brackets, etc.)">⋯</button>
204
- </div>
205
- </div>
206
-
207
- <div id="all-keys" aria-hidden="true">
208
- <h4>shell</h4>
209
- <div class="row">
210
- <button data-char="~">~</button>
211
- <button data-char="\`">\`</button>
212
- <button data-char="/">/</button>
213
- <button data-char="\\\\">\\</button>
214
- <button data-char="|">|</button>
215
- <button data-char="-">-</button>
216
- <button data-char="_">_</button>
217
- </div>
218
- <h4>numbers</h4>
219
- <div class="row">
220
- <button data-char="0">0</button><button data-char="1">1</button><button data-char="2">2</button>
221
- <button data-char="3">3</button><button data-char="4">4</button><button data-char="5">5</button>
222
- <button data-char="6">6</button><button data-char="7">7</button><button data-char="8">8</button>
223
- <button data-char="9">9</button>
224
- </div>
225
- <h4>brackets &amp; quotes</h4>
226
- <div class="row">
227
- <button data-char="(">(</button><button data-char=")">)</button>
228
- <button data-char="[">[</button><button data-char="]">]</button>
229
- <button data-char="{">{</button><button data-char="}">}</button>
230
- <button data-char="&lt;">&lt;</button><button data-char="&gt;">&gt;</button>
231
- <button data-char="'">'</button><button data-char='"'>"</button>
232
- </div>
233
- <h4>operators</h4>
234
- <div class="row">
235
- <button data-char="=">=</button><button data-char="+">+</button>
236
- <button data-char="*">*</button><button data-char="&amp;">&amp;</button>
237
- <button data-char="^">^</button><button data-char="%">%</button>
238
- <button data-char="$">$</button><button data-char="#">#</button>
239
- <button data-char="@">@</button><button data-char="!">!</button>
240
- <button data-char="?">?</button>
241
- </div>
242
- <h4>punctuation</h4>
243
- <div class="row">
244
- <button data-char=":">:</button><button data-char=";">;</button>
245
- <button data-char=",">,</button><button data-char=".">.</button>
246
- </div>
247
- <h4>navigation &amp; edit</h4>
248
- <div class="row">
249
- <button data-key="pgup">PgUp</button><button data-key="pgdn">PgDn</button>
250
- <button data-key="del">Del</button><button data-key="ins">Ins</button>
251
- <button data-key="bsp">⌫ Bsp</button><button data-key="enter">↵ Enter</button>
252
- </div>
253
- <h4>function keys</h4>
254
- <div class="row">
255
- <button data-key="f1">F1</button><button data-key="f2">F2</button>
256
- <button data-key="f3">F3</button><button data-key="f4">F4</button>
257
- <button data-key="f5">F5</button><button data-key="f6">F6</button>
258
- <button data-key="f7">F7</button><button data-key="f8">F8</button>
259
- <button data-key="f9">F9</button><button data-key="f10">F10</button>
260
- <button data-key="f11">F11</button><button data-key="f12">F12</button>
261
- </div>
262
- <h4>actions</h4>
263
- <div class="row">
264
- <button id="reset-term" title="Clear xterm buffer and send Ctrl-L to redraw">Reset terminal</button>
265
- </div>
266
- </div>
267
- `,
268
- inlineScript: `${TOAST_HELPER}
269
- window.__CHAT_AGENTS__ = ${agentsJson};
270
- window.__CHAT_HOME__ = ${homeJson};
271
- `,
272
- }).replace('</body>', `
273
- <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
274
- <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
275
- <script>
276
- (() => {
277
- const agents = window.__CHAT_AGENTS__ || [];
278
- const home = window.__CHAT_HOME__ || '/';
279
- const sel = document.getElementById('chat-agent');
280
- const cwdInput = document.getElementById('chat-cwd');
281
- const cwdRecentBtn = document.getElementById('cwd-recent');
282
- const cwdRecentMenu = document.getElementById('cwd-recent-menu');
283
- const startBtn = document.getElementById('chat-start');
284
- const backBtn = document.getElementById('chat-back');
285
- const killBtn = document.getElementById('chat-kill');
286
- const dot = document.getElementById('chat-dot');
287
- const titleEl = document.getElementById('chat-title');
288
- const wrap = document.getElementById('term-wrap');
289
-
290
- if (agents.length === 0) {
291
- sel.innerHTML = '<option value="">(no agents installed)</option>';
292
- sel.disabled = true;
293
- startBtn.disabled = true;
294
- } else {
295
- sel.innerHTML = agents.map(a => '<option value="' + a + '">' + a + '</option>').join('') +
296
- '<option value="bash">bash (shell)</option>';
297
- }
298
-
299
- // ── CWD picker (Option A) ─────────────────────────────────────
300
- // Pre-fill the input from the last-used cwd, falling back to $HOME.
301
- // The recent-cwds dropdown surfaces the last 5 used dirs from
302
- // localStorage. Server-side validates the path on spawn — invalid
303
- // paths fall back to $HOME with a warning line in the terminal.
304
- const LS_KEY = 'crosstalk_recent_cwds';
305
- function readRecent(){
306
- try {
307
- const raw = localStorage.getItem(LS_KEY);
308
- const arr = raw ? JSON.parse(raw) : [];
309
- return Array.isArray(arr) ? arr.filter(s => typeof s === 'string') : [];
310
- } catch { return []; }
311
- }
312
- function writeRecent(arr){
313
- try { localStorage.setItem(LS_KEY, JSON.stringify(arr.slice(0, 5))); } catch {}
314
- }
315
- function pushRecent(dir){
316
- if (!dir) return;
317
- const cur = readRecent().filter(d => d !== dir);
318
- cur.unshift(dir);
319
- writeRecent(cur);
320
- }
321
- function renderRecentMenu(){
322
- const recent = readRecent();
323
- if (recent.length === 0){
324
- cwdRecentMenu.innerHTML = '<div class="empty">no recent directories yet</div>';
325
- return;
326
- }
327
- cwdRecentMenu.innerHTML = recent
328
- .map(d => '<button type="button" data-cwd="' + d.replace(/"/g, '&quot;') + '" title="' + d.replace(/"/g, '&quot;') + '">' + d + '</button>')
329
- .join('');
330
- }
331
- function setCwdMenuOpen(open){
332
- cwdRecentMenu.classList.toggle('open', open);
333
- cwdRecentMenu.setAttribute('aria-hidden', String(!open));
334
- }
335
- // Initial value: last-used cwd or $HOME.
336
- const recentInit = readRecent();
337
- cwdInput.value = recentInit[0] || home;
338
- cwdInput.placeholder = home;
339
-
340
- cwdRecentBtn.addEventListener('click', () => {
341
- const isOpen = cwdRecentMenu.classList.contains('open');
342
- if (!isOpen) renderRecentMenu();
343
- setCwdMenuOpen(!isOpen);
344
- });
345
- cwdRecentMenu.addEventListener('click', (ev) => {
346
- const t = ev.target;
347
- if (!(t instanceof HTMLElement) || !t.dataset.cwd) return;
348
- cwdInput.value = t.dataset.cwd;
349
- setCwdMenuOpen(false);
350
- });
351
- document.addEventListener('click', (ev) => {
352
- if (!cwdRecentMenu.classList.contains('open')) return;
353
- const t = ev.target;
354
- if (t === cwdRecentBtn || cwdRecentMenu.contains(t)) return;
355
- setCwdMenuOpen(false);
356
- });
357
-
358
- let term = null;
359
- let fitAddon = null;
360
- let ws = null;
361
-
362
- // Modifier state — 'off' | 'pending' (one-shot) | 'locked' (sticky).
363
- // Tap once → pending; tap again → locked; tap third time → off.
364
- const mods = { ctrl: 'off', alt: 'off', shift: 'off' };
365
- function setMod(mod, val){
366
- mods[mod] = val;
367
- const btn = document.querySelector('[data-mod="' + mod + '"]');
368
- if (btn){
369
- if (val === 'off') btn.removeAttribute('aria-pressed');
370
- else btn.setAttribute('aria-pressed', val === 'locked' ? 'locked' : 'true');
371
- }
372
- }
373
- function consumeMods(d){
374
- let out = d;
375
- if (mods.shift !== 'off' && d.length === 1){
376
- out = d.toUpperCase();
377
- if (mods.shift === 'pending') setMod('shift', 'off');
378
- }
379
- if (mods.ctrl !== 'off' && out.length === 1){
380
- const c = out.charCodeAt(0);
381
- if (c >= 0x40 && c <= 0x7f) out = String.fromCharCode(c & 0x1f);
382
- else if (c === 0x20) out = String.fromCharCode(0);
383
- if (mods.ctrl === 'pending') setMod('ctrl', 'off');
384
- }
385
- if (mods.alt !== 'off'){
386
- out = String.fromCharCode(0x1b) + out;
387
- if (mods.alt === 'pending') setMod('alt', 'off');
388
- }
389
- return out;
390
- }
391
-
392
- function setStatus(state, label){
393
- dot.dataset.state = state;
394
- dot.title = label;
395
- }
396
-
397
- function enterSession(agent){
398
- document.body.classList.add('chat-session');
399
- titleEl.textContent = agent;
400
- setStatus('connecting', 'connecting…');
401
- }
402
- function leaveSession(){
403
- document.body.classList.remove('chat-session');
404
- document.body.classList.remove('allkeys-open');
405
- document.getElementById('all-keys').classList.remove('open');
406
- if (term){ try { term.dispose(); } catch(_){} }
407
- term = null; fitAddon = null;
408
- wrap.innerHTML = '';
409
- setMod('ctrl', 'off');
410
- setMod('alt', 'off');
411
- setMod('shift', 'off');
412
- }
413
-
414
- function startSession(){
415
- const agent = sel.value;
416
- if (!agent){ window.showToast('pick an agent first', 'err'); return; }
417
- const cwd = (cwdInput.value || '').trim() || home;
418
- pushRecent(cwd);
419
- enterSession(agent);
420
-
421
- term = new window.Terminal({
422
- cursorBlink: true,
423
- fontFamily: 'ui-monospace, monospace',
424
- fontSize: 13,
425
- theme: { background: '#000000', foreground: '#e6e8eb', cursor: '#7cc4ff' },
426
- scrollback: 5000,
427
- });
428
- fitAddon = new window.FitAddon.FitAddon();
429
- term.loadAddon(fitAddon);
430
- term.open(wrap);
431
- requestAnimationFrame(() => {
432
- try { fitAddon.fit(); } catch(_){}
433
- });
434
-
435
- const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
436
- const wsUrl = proto + '//' + location.host + '/ws/chat?agent=' + encodeURIComponent(agent) + '&cols=' + (term.cols || 80) + '&rows=' + (term.rows || 24) + '&cwd=' + encodeURIComponent(cwd);
437
- ws = new WebSocket(wsUrl);
438
- ws.binaryType = 'arraybuffer';
439
-
440
- ws.addEventListener('open', () => {
441
- setStatus('connected', 'connected');
442
- try {
443
- if (fitAddon) fitAddon.fit();
444
- ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
445
- } catch(_){}
446
- term.focus();
447
- });
448
- ws.addEventListener('message', (ev) => {
449
- const data = typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(new Uint8Array(ev.data));
450
- term.write(data);
451
- });
452
- ws.addEventListener('close', (ev) => {
453
- setStatus('closed', 'disconnected (code ' + ev.code + ')');
454
- ws = null;
455
- // Server-initiated close (agent exited naturally — 'exit' in
456
- // bash, '/exit' inside an agent CLI, Ctrl-D, etc.) — auto-return
457
- // to the picker after a short delay so the operator can read the
458
- // exit line. Mirrors the back-button behavior; means the operator
459
- // never has to manually dismiss the terminal screen.
460
- //
461
- // Skip auto-leave for transport-level disconnects (code 1006
462
- // abnormal closure) so the operator can still see what happened
463
- // and choose to retry rather than getting bounced silently.
464
- const NATURAL_EXIT_CODES = new Set([1000, 1001, 1003, 1011]);
465
- if (NATURAL_EXIT_CODES.has(ev.code)){
466
- setTimeout(leaveSession, 1500);
467
- }
468
- });
469
- ws.addEventListener('error', () => {
470
- setStatus('error', 'connection error');
471
- });
472
-
473
- term.onData((data) => {
474
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
475
- // Apply Ctrl/Alt modifiers before forwarding.
476
- const mapped = consumeMods(data);
477
- // /exit interception (graceful exit).
478
- if (data === String.fromCharCode(13) || data === String.fromCharCode(10)){
479
- const trailingLine = (term.__buf || '').trim();
480
- term.__buf = '';
481
- if (trailingLine === '/exit'){
482
- ws.send(JSON.stringify({ type: 'exit' }));
483
- return;
484
- }
485
- }
486
- term.__buf = (term.__buf || '') + data;
487
- if (term.__buf.length > 64) term.__buf = term.__buf.slice(-64);
488
- ws.send(mapped);
489
- });
490
-
491
- // Resize handler. visualViewport.resize is the event that fires
492
- // when the soft keyboard opens/closes — re-fit the terminal so
493
- // its cell grid matches the available height.
494
- let resizeT = null;
495
- const onResize = () => {
496
- if (resizeT) clearTimeout(resizeT);
497
- resizeT = setTimeout(() => {
498
- try { fitAddon.fit(); } catch(_){}
499
- if (ws && ws.readyState === WebSocket.OPEN){
500
- ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
501
- }
502
- }, 150);
503
- };
504
- window.addEventListener('resize', onResize);
505
- if (window.visualViewport) window.visualViewport.addEventListener('resize', onResize);
506
- }
507
-
508
- function gracefulExit(){
509
- if (ws && ws.readyState === WebSocket.OPEN){
510
- ws.send(JSON.stringify({ type: 'exit' }));
511
- setTimeout(leaveSession, 250);
512
- } else {
513
- leaveSession();
514
- }
515
- }
516
-
517
- function forceKill(){
518
- if (ws && ws.readyState === WebSocket.OPEN){
519
- ws.send(JSON.stringify({ type: 'kill' }));
520
- setTimeout(leaveSession, 250);
521
- } else {
522
- leaveSession();
523
- }
524
- }
525
-
526
- startBtn.addEventListener('click', startSession);
527
- backBtn.addEventListener('click', gracefulExit);
528
- killBtn.addEventListener('click', () => {
529
- if (confirm('SIGKILL this agent immediately? Unsaved work in the agent will be lost.')) forceKill();
530
- });
531
-
532
- // Keyboard helper bar + all-keys panel.
533
- //
534
- // KEYS map carries the byte sequence each named key emits. Values
535
- // are written with \\u escapes so they survive the engine's HTML
536
- // template-literal embedding, then unescaped at runtime.
537
- const KEYS = {
538
- esc: '\\u001b', tab: '\\t', enter: '\\r', bsp: '\\u007f',
539
- up: '\\u001b[A', down: '\\u001b[B', right: '\\u001b[C', left: '\\u001b[D',
540
- home: '\\u001b[H', end: '\\u001b[F',
541
- pgup: '\\u001b[5~', pgdn: '\\u001b[6~',
542
- del: '\\u001b[3~', ins: '\\u001b[2~',
543
- f1: '\\u001bOP', f2: '\\u001bOQ', f3: '\\u001bOR', f4: '\\u001bOS',
544
- f5: '\\u001b[15~', f6: '\\u001b[17~', f7: '\\u001b[18~', f8: '\\u001b[19~',
545
- f9: '\\u001b[20~', f10: '\\u001b[21~', f11: '\\u001b[23~', f12: '\\u001b[24~',
546
- };
547
-
548
- const allKeysEl = document.getElementById('all-keys');
549
- const moreBtn = document.getElementById('more');
550
-
551
- function updateAllKeysH(){
552
- if (!allKeysEl.classList.contains('open')){
553
- document.documentElement.style.setProperty('--allkeys-h', '0px');
554
- return;
555
- }
556
- // Cap the panel at 40% of the visible viewport so it doesn't crowd
557
- // the terminal entirely on short screens (landscape phones, etc.).
558
- const visibleH = window.visualViewport ? window.visualViewport.height : window.innerHeight;
559
- const h = Math.min(allKeysEl.scrollHeight, Math.floor(visibleH * 0.4));
560
- document.documentElement.style.setProperty('--allkeys-h', h + 'px');
561
- }
562
- function toggleAllKeys(){
563
- const open = !allKeysEl.classList.contains('open');
564
- allKeysEl.classList.toggle('open', open);
565
- document.body.classList.toggle('allkeys-open', open);
566
- allKeysEl.setAttribute('aria-hidden', String(!open));
567
- moreBtn.setAttribute('aria-pressed', open ? 'true' : 'false');
568
- updateAllKeysH();
569
- // Re-fit the terminal so the cell grid adapts to the new height.
570
- requestAnimationFrame(() => {
571
- try { fitAddon && fitAddon.fit(); } catch(_){}
572
- if (ws && ws.readyState === WebSocket.OPEN && term){
573
- ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
574
- }
575
- });
576
- }
577
- moreBtn.addEventListener('click', toggleAllKeys);
578
-
579
- function sendKey(key){
580
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
581
- const seq = KEYS[key];
582
- if (!seq) return;
583
- ws.send(consumeMods(seq));
584
- if (term) term.focus();
585
- }
586
-
587
- function sendChar(ch){
588
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
589
- ws.send(consumeMods(ch));
590
- if (term) term.focus();
591
- }
592
-
593
- // Bar (modifiers + Esc/Tab/arrows/Home/End)
594
- document.getElementById('chat-bar').addEventListener('click', (ev) => {
595
- const t = ev.target;
596
- if (!(t instanceof HTMLElement)) return;
597
- if (t.id === 'more') return; // moreBtn has its own listener
598
- if (t.dataset.mod){
599
- const mod = t.dataset.mod;
600
- const cur = mods[mod];
601
- const next = cur === 'off' ? 'pending' : (cur === 'pending' ? 'locked' : 'off');
602
- setMod(mod, next);
603
- return;
604
- }
605
- if (t.dataset.key) sendKey(t.dataset.key);
606
- });
607
-
608
- // All-keys panel (chars + extended keys + reset action)
609
- allKeysEl.addEventListener('click', (ev) => {
610
- const t = ev.target;
611
- if (!(t instanceof HTMLElement)) return;
612
- if (t.id === 'reset-term'){
613
- // Reset = clear xterm scrollback + send Ctrl-L to redraw the
614
- // remote prompt. Useful when an agent CLI gets confused about
615
- // terminal state (cursor in weird position, garbled output).
616
- if (term){ try { term.clear(); term.reset(); } catch(_){} }
617
- if (ws && ws.readyState === WebSocket.OPEN) ws.send('\\u000c'.replace(/\\\\u([0-9a-f]{4})/gi, (_,h) => String.fromCharCode(parseInt(h,16))));
618
- return;
619
- }
620
- if (t.dataset.char){
621
- sendChar(t.dataset.char);
622
- return;
623
- }
624
- if (t.dataset.key) sendKey(t.dataset.key);
625
- });
626
-
627
- // The KEYS literal strings carry \\u escapes that survive engine
628
- // HTML embedding. Unescape them once at boot so sendKey works as
629
- // expected — converts '\\u001b[A' into the 3-byte sequence ESC [ A.
630
- for (const k of Object.keys(KEYS)){
631
- KEYS[k] = KEYS[k].replace(/\\\\u([0-9a-f]{4})/gi, (_, h) => String.fromCharCode(parseInt(h, 16)));
632
- }
633
- })();
634
- </script>
635
- </body>`);
636
- }
@@ -1,254 +0,0 @@
1
- // chat-pty.ts — WebSocket bridge from xterm.js in the browser to a pty
2
- // running an agent CLI (or $SHELL) on the host.
3
- //
4
- // Lifecycle contract (Steve's directive: 'when the user exits, kill
5
- // the interactive agent'):
6
- //
7
- // client connects WS /ws/chat?agent=<name>
8
- // ↓
9
- // server spawns a pty running the named agent (or 'bash' for --shell)
10
- // ↓
11
- // pty stdout → WS text frames (browser xterm.js writes them in)
12
- // ↑ ↓
13
- // pty stdin ← WS text frames operator types
14
- // ↓ ↓
15
- // when client disconnects (tab close, /exit typed by operator):
16
- // pty.kill(SIGTERM) → SIGKILL 1s later if still alive
17
- //
18
- // Async-by-design contract: NO persistent session. Each WS attach
19
- // spawns a fresh agent. There's no detach + reattach. If you want
20
- // durable state, send messages on a channel (the dispatcher replies).
21
-
22
- import type { Server as HttpServer } from 'http';
23
- import type { Duplex } from 'stream';
24
- import { WebSocketServer, type WebSocket } from 'ws';
25
- import { spawn as ptySpawn, type IPty } from 'node-pty';
26
- import { spawnSync } from 'child_process';
27
- import { hostname } from 'os';
28
- import { statSync } from 'fs';
29
-
30
- // node-pty's native bindings load under bun, but tmux/SIGHUP semantics
31
- // break at runtime (parallels the llmuxd 'must launch under node' issue).
32
- // Production is safe — commands/daemon.js spawns the dispatch loop with
33
- // process.execPath = node. This guard fires loudly if someone tries to
34
- // load the chat-pty module under bun (e.g. via `bun test` importing
35
- // src/api.ts, or `bun bin/crosstalk.js daemon dispatch` directly), so
36
- // the failure mode is a clear message instead of a mysterious tmux hang.
37
- declare const Bun: unknown;
38
- if (typeof Bun !== 'undefined') {
39
- throw new Error(
40
- 'src/web/chat-pty.ts cannot load under bun — node-pty pty operations ' +
41
- 'fail at runtime (tmux/SIGHUP). Run under node instead. ' +
42
- '(Production daemon already does — this guard catches test/dev mis-invocations.)',
43
- );
44
- }
45
-
46
- /**
47
- * Set of agent binary names crosstalk knows how to PTY-wrap. Matches
48
- * the static list in chat.js — keep them in sync if a new agent is
49
- * added to the catalog.
50
- */
51
- const KNOWN_AGENT_BINARIES = ['claude', 'codex', 'gemini', 'qwen', 'opencode', 'agy'];
52
-
53
- interface AttachOptions {
54
- agent: string; // 'bash' (for --shell) or an entry in KNOWN_AGENT_BINARIES
55
- cwd?: string;
56
- cols?: number;
57
- rows?: number;
58
- }
59
-
60
- function isOnPath(bin: string): boolean {
61
- const r = spawnSync('which', [bin], { encoding: 'utf-8' });
62
- return r.status === 0;
63
- }
64
-
65
- function resolveBinary(agent: string): string | null {
66
- if (agent === 'bash') {
67
- // Use $SHELL when present, fall back to bash. Matches the CLI
68
- // chat --shell behavior we added in v8-native.
69
- const shell = process.env['SHELL'];
70
- return shell || 'bash';
71
- }
72
- if (!KNOWN_AGENT_BINARIES.includes(agent)) return null;
73
- return isOnPath(agent) ? agent : null;
74
- }
75
-
76
- function safeKill(pty: IPty): void {
77
- try { pty.kill('SIGTERM'); } catch { /* already gone */ }
78
- setTimeout(() => {
79
- try { pty.kill('SIGKILL'); } catch { /* expected if SIGTERM took */ }
80
- }, 1000);
81
- }
82
-
83
- /**
84
- * Mount the chat WebSocket onto an existing http server. Wires
85
- * /ws/chat?agent=<name>&cols=<n>&rows=<n> to a pty spawn.
86
- *
87
- * Trust model: same as the rest of the API — engine binds 127.0.0.1,
88
- * so anyone reaching the WS is on the host (or coming via the
89
- * operator's tailscale-serve front). v8 auth: the upgrade handshake
90
- * checks the crosstalk_session cookie via the auth middleware so a
91
- * browser without a valid session can't open a chat panel even via
92
- * the loopback bind.
93
- */
94
- export interface MountChatPtyOptions {
95
- /** Validates the upgrade request's auth (cookie). Returns true to
96
- * allow, false to reject (results in 401 close). When undefined,
97
- * the WS is open — caller must ensure the bind address is the
98
- * security boundary. */
99
- authorize?: (req: import('http').IncomingMessage) => Promise<boolean>;
100
- }
101
-
102
- export function mountChatPty(server: HttpServer, opts: MountChatPtyOptions = {}): WebSocketServer {
103
- // `noServer: true` so we drive the upgrade handshake ourselves and
104
- // can write a proper auth rejection. With `server: server`, ws would
105
- // accept the upgrade unconditionally.
106
- const wss = new WebSocketServer({ noServer: true });
107
-
108
- server.on('upgrade', async (req, socket, head) => {
109
- try {
110
- const url = new URL(req.url ?? '/', 'http://localhost');
111
- if (url.pathname !== '/ws/chat') {
112
- // Not our route — let the default 404 fall through.
113
- rejectUpgrade(socket, 404, 'not found');
114
- return;
115
- }
116
- if (opts.authorize) {
117
- const ok = await opts.authorize(req);
118
- if (!ok) {
119
- rejectUpgrade(socket, 401, 'unauthorized');
120
- return;
121
- }
122
- }
123
- wss.handleUpgrade(req, socket, head, (ws) => {
124
- const agent = url.searchParams.get('agent') ?? 'bash';
125
- const cols = clamp(Number(url.searchParams.get('cols') ?? 80), 20, 500);
126
- const rows = clamp(Number(url.searchParams.get('rows') ?? 24), 5, 200);
127
- const cwd = url.searchParams.get('cwd') ?? undefined;
128
- bridgePty(ws, { agent, cols, rows, cwd: cwd ?? undefined });
129
- });
130
- } catch (err) {
131
- rejectUpgrade(socket, 500, 'upgrade failure');
132
- void err;
133
- }
134
- });
135
-
136
- return wss;
137
- }
138
-
139
- function rejectUpgrade(socket: Duplex, status: number, reason: string): void {
140
- try {
141
- socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\n\r\n`);
142
- socket.destroy();
143
- } catch { /* socket already gone */ }
144
- }
145
-
146
- function clamp(n: number, lo: number, hi: number): number {
147
- if (!Number.isFinite(n)) return lo;
148
- return Math.max(lo, Math.min(hi, Math.floor(n)));
149
- }
150
-
151
- function bridgePty(ws: WebSocket, opts: AttachOptions): void {
152
- const bin = resolveBinary(opts.agent);
153
- if (!bin) {
154
- ws.send(`\r\n\x1b[31mcrosstalk: agent '${opts.agent}' not on PATH or not supported.\x1b[0m\r\n`);
155
- ws.close(1003, 'agent unavailable');
156
- return;
157
- }
158
-
159
- const env: Record<string, string> = {};
160
- for (const [k, v] of Object.entries(process.env)) if (v !== undefined) env[k] = v;
161
- env.TERM = 'xterm-256color';
162
-
163
- // Resolve cwd. Operator can pass any path via the cwd= query param,
164
- // but the engine refuses to spawn into a non-existent or non-dir
165
- // path — falls back to $HOME with a warning line so the session
166
- // still starts. The engine already runs as the operator's user, so
167
- // there's no privilege boundary to enforce beyond fs existence.
168
- const home = env['HOME'] ?? '/';
169
- let cwd = opts.cwd && opts.cwd.length > 0 ? opts.cwd : home;
170
- let cwdWarning: string | null = null;
171
- if (cwd !== home) {
172
- try {
173
- const st = statSync(cwd);
174
- if (!st.isDirectory()) {
175
- cwdWarning = `crosstalk: '${cwd}' is not a directory, falling back to $HOME (${home})`;
176
- cwd = home;
177
- }
178
- } catch {
179
- cwdWarning = `crosstalk: '${cwd}' not accessible, falling back to $HOME (${home})`;
180
- cwd = home;
181
- }
182
- }
183
-
184
- let pty: IPty;
185
- try {
186
- pty = ptySpawn(bin, [], {
187
- name: 'xterm-256color',
188
- cols: opts.cols ?? 80,
189
- rows: opts.rows ?? 24,
190
- cwd,
191
- env,
192
- });
193
- } catch (err) {
194
- ws.send(`\r\n\x1b[31mcrosstalk: spawn failed: ${(err as Error).message}\x1b[0m\r\n`);
195
- ws.close(1011, 'spawn failed');
196
- return;
197
- }
198
-
199
- if (cwdWarning) {
200
- ws.send(`\r\n\x1b[33m${cwdWarning}\x1b[0m\r\n`);
201
- }
202
-
203
- // Greeting so the operator sees something even before the agent's
204
- // own TUI renders. Sky-blue branded line + cwd + small hint.
205
- const host = (() => { try { return hostname(); } catch { return 'host'; } })();
206
- ws.send(`\r\n\x1b[36m── crosstalk chat: ${opts.agent} @ ${host} ────────────────\x1b[0m\r\n`);
207
- ws.send(`\x1b[90m cwd: ${cwd}\r\n Type /exit to disconnect (or close the tab).\r\n When you exit, this agent process is terminated.\x1b[0m\r\n\r\n`);
208
-
209
- // pty → ws (output stream)
210
- pty.onData((data) => {
211
- try { ws.send(data); } catch { /* socket closing */ }
212
- });
213
-
214
- // pty exit → close ws
215
- pty.onExit(({ exitCode, signal }) => {
216
- try {
217
- ws.send(`\r\n\x1b[36m── agent exited (code=${exitCode}${signal ? `, signal=${signal}` : ''}) ──\x1b[0m\r\n`);
218
- ws.close(1000, 'agent exited');
219
- } catch { /* already closed */ }
220
- });
221
-
222
- // ws → pty (input)
223
- // Two message kinds:
224
- // strings — interpreted as terminal input (typed characters)
225
- // JSON { type: 'resize', cols, rows } — pty resize from the browser
226
- // JSON { type: 'exit' } — explicit operator '/exit' command
227
- ws.on('message', (raw) => {
228
- const text = typeof raw === 'string' ? raw : raw.toString('utf-8');
229
- if (text.startsWith('{')) {
230
- try {
231
- const msg = JSON.parse(text) as { type?: string; cols?: number; rows?: number };
232
- if (msg.type === 'resize' && typeof msg.cols === 'number' && typeof msg.rows === 'number') {
233
- try { pty.resize(clamp(msg.cols, 20, 500), clamp(msg.rows, 5, 200)); } catch {}
234
- return;
235
- }
236
- // Two terminations:
237
- // exit — graceful: SIGTERM, then SIGKILL after 1s if the
238
- // process didn't honor the TERM (matches /exit typed
239
- // in the terminal).
240
- // kill — immediate SIGKILL. No grace window. For when an
241
- // agent CLI is wedged and you want it gone NOW.
242
- if (msg.type === 'exit') { safeKill(pty); return; }
243
- if (msg.type === 'kill') {
244
- try { pty.kill('SIGKILL'); } catch { /* already gone */ }
245
- return;
246
- }
247
- } catch { /* fall through to treat as raw input */ }
248
- }
249
- try { pty.write(text); } catch { /* pty closed */ }
250
- });
251
-
252
- ws.on('close', () => safeKill(pty));
253
- ws.on('error', () => safeKill(pty));
254
- }