nexting-cc-bridge 0.8.3
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 +252 -0
- package/dist/attach-manager.js +259 -0
- package/dist/bridge.js +931 -0
- package/dist/cli-args.js +14 -0
- package/dist/cli.js +742 -0
- package/dist/codex-prompts.js +148 -0
- package/dist/codex-thread-source.js +495 -0
- package/dist/codex-transcript.js +415 -0
- package/dist/dev-server.js +126 -0
- package/dist/discovery.js +111 -0
- package/dist/e2e/codec.js +119 -0
- package/dist/e2e/crypto.js +127 -0
- package/dist/e2e/key-store.js +48 -0
- package/dist/e2e/keychain-identity.js +29 -0
- package/dist/engine/adapter.js +5 -0
- package/dist/engine/claude-adapter.js +77 -0
- package/dist/engine/codex-adapter.js +593 -0
- package/dist/file-preview.js +292 -0
- package/dist/hub-protocol.js +28 -0
- package/dist/hub-server.js +106 -0
- package/dist/hub.js +84 -0
- package/dist/install-util.js +33 -0
- package/dist/local-shell.js +32 -0
- package/dist/mcp-config.js +230 -0
- package/dist/mcp-device-proxy.js +501 -0
- package/dist/media-hydrator.js +222 -0
- package/dist/message-counter.js +79 -0
- package/dist/phone-probe.js +55 -0
- package/dist/prompt-detector.js +213 -0
- package/dist/protocol.js +3 -0
- package/dist/pty-mirror.js +80 -0
- package/dist/pty-spawn.js +53 -0
- package/dist/scanner.js +422 -0
- package/dist/self-update.js +122 -0
- package/dist/session-map.js +15 -0
- package/dist/session-runner.js +131 -0
- package/dist/shell.js +104 -0
- package/dist/skills-scanner.js +167 -0
- package/dist/stdin-encode.js +32 -0
- package/dist/stream-translate.js +122 -0
- package/dist/terminal-render.js +29 -0
- package/dist/transcript-watcher.js +138 -0
- package/dist/transcript.js +346 -0
- package/dist/turn-probe.js +152 -0
- package/dist/types.js +2 -0
- package/dist/watch-manager.js +77 -0
- package/install-cc.sh +90 -0
- package/install-codex.sh +97 -0
- package/package.json +39 -0
- package/shim/claude +55 -0
package/README.md
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# nexting-cc-bridge
|
|
2
|
+
|
|
3
|
+
Runs on a user's Mac so their **phone mirrors and controls their Claude Code
|
|
4
|
+
terminal sessions, two-way synced** — one model, no read-only/bubble split.
|
|
5
|
+
|
|
6
|
+
**How it works (the "manager"):** your `claude` is wrapped by a thin shell that
|
|
7
|
+
runs the real, official `claude` in a pseudo-terminal. Your terminal stays
|
|
8
|
+
native; the shell also mirrors the session to your phone and injects what the
|
|
9
|
+
phone types. A single background **hub** daemon owns the cloud connection and
|
|
10
|
+
multiplexes every wrapped session, so you can run many `claude`s at once and the
|
|
11
|
+
phone switches between them.
|
|
12
|
+
|
|
13
|
+
**The one hard limit (OS):** a `claude` already running bare in a terminal can't
|
|
14
|
+
be reached — macOS won't let an outside process inject into another process's tty.
|
|
15
|
+
So only sessions started _through the manager_ are phone-controllable. The
|
|
16
|
+
installer makes that invisible by putting a `claude` shim first on your PATH, so
|
|
17
|
+
you keep typing `claude` and every new session is automatically controllable.
|
|
18
|
+
|
|
19
|
+
Design: `docs/superpowers/specs/2026-06-07-cc-manager-unified-design.md` (current);
|
|
20
|
+
earlier: `…2026-06-06-cc-shared-session-json-shell-design.md`, `…2026-06-04-…`.
|
|
21
|
+
|
|
22
|
+
## Install (end users)
|
|
23
|
+
|
|
24
|
+
In the Nexting app: **Claude Code → Connect**, which shows a one-line command
|
|
25
|
+
(it embeds an account token, so no second login):
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
curl -fsSL https://nexting.ai/install-cc | NEXTING_CC_TOKEN=<token> bash
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
This binds the Mac to your account, installs the manager, puts the `claude` shim
|
|
32
|
+
on PATH, and starts the hub daemon (launchd). Then just run `claude` as usual.
|
|
33
|
+
|
|
34
|
+
- Bypass once: `NEXTING_CC_DISABLE=1 claude`
|
|
35
|
+
- Remove everything: `nexting-cc-bridge uninstall`
|
|
36
|
+
|
|
37
|
+
(No app token? `curl -fsSL https://nexting.ai/install-cc | bash` falls back to
|
|
38
|
+
device-code browser auth.)
|
|
39
|
+
|
|
40
|
+
### Region-locked egress (e.g. China)
|
|
41
|
+
|
|
42
|
+
A phone send spawns a **fresh** headless `claude`/`codex` under the launchd
|
|
43
|
+
daemon — it does NOT go through your terminal, so it doesn't inherit the
|
|
44
|
+
`https_proxy` your terminal `claude` wrapper uses. If your region needs a proxy
|
|
45
|
+
to reach Anthropic/OpenAI, that child exits direct and fails
|
|
46
|
+
`403 Request not allowed` (while the terminal works). Worse, a different exit IP
|
|
47
|
+
on the same account is an **account-ban** risk.
|
|
48
|
+
|
|
49
|
+
Fix: set `engineEnv` so the bridge child exits from the **same** residential
|
|
50
|
+
IP / timezone / locale as your terminal. If your shell rc defines `CLAUDE_PROXY`
|
|
51
|
+
(or `CLAUDE_PROXY_{USER,PASS,HOST,PORT}`):
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
nexting-cc-bridge sync-proxy # writes engineEnv to ~/.nexting/{cc,codex}-bridge.json
|
|
55
|
+
launchctl kickstart -k gui/$(id -u)/ai.nexting.cc-bridge
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Otherwise add an `engineEnv` object by hand to those config files. It **fails
|
|
59
|
+
closed** (a dead proxy errors the request, never falls back to direct) and the
|
|
60
|
+
daemon log prints `engine egress: PROXIED via … / DIRECT` so you can verify it.
|
|
61
|
+
Re-run `sync-proxy` after rotating the proxy. US users need none of this.
|
|
62
|
+
|
|
63
|
+
## How discovery works
|
|
64
|
+
|
|
65
|
+
- Scans `~/.claude/projects/<encoded-cwd>/*.jsonl` — each JSONL is one session,
|
|
66
|
+
`mtime` = last active. (`encoded-cwd` = every non-alphanumeric char → `-`.)
|
|
67
|
+
- Finds live sessions via `ps`, matching the CLI by argv0 basename `claude` (since
|
|
68
|
+
0.5.1 — covers npm, pnpm, and the native installer's `~/.local/bin/claude`; the
|
|
69
|
+
desktop app is excluded), deduped to **root** processes by PPID. Procs started
|
|
70
|
+
with `--session-id <uuid>` (every wrapper-run session) bind pid↔session
|
|
71
|
+
**exactly**; only bare procs without it fall back to the heuristic: `lsof` the
|
|
72
|
+
proc's cwd, then with N live procs in a project the N newest JSONLs **touched
|
|
73
|
+
within the last 30 minutes** are `running`, the rest `idle` (the freshness
|
|
74
|
+
bound keeps hours-old sessions from showing as running just because unrelated
|
|
75
|
+
procs exist in the same project); extra procs with no JSONL yet are virtual
|
|
76
|
+
`pending-<pid>`.
|
|
77
|
+
- List rows read only head 16KB (title/cwd) + tail 128KB (recent messages). Full
|
|
78
|
+
transcript is read on demand when the phone opens a session.
|
|
79
|
+
- Row title (since 0.4.1) = the session's **live recap**: the freshest
|
|
80
|
+
`{type:"system", subtype:"away_summary"}` entry Claude Code keeps appending to
|
|
81
|
+
the JSONL (tail window beats head). Falls back to `ai-title`, then the first
|
|
82
|
+
user message — slash-command wrappers render as `/name`, local-command caveats
|
|
83
|
+
are skipped. Codex sessions have no recap and keep their meta title.
|
|
84
|
+
- Codex transcripts (since 0.5.1) parse `~/.codex/sessions/` rollout JSONL and
|
|
85
|
+
hide the bookkeeping the Codex TUI never renders — developer/system-role
|
|
86
|
+
instruction messages and user-role `<environment_context>` /
|
|
87
|
+
`<user_instructions>` / `<turn_aborted>` prefix messages — so the phone shows
|
|
88
|
+
exactly what Codex shows.
|
|
89
|
+
- Live streaming (since 0.5.0): while a phone has a session open, the cloud
|
|
90
|
+
arms a watch (`cc_watch` / alias `cc_watch_start`) and the bridge **tails that
|
|
91
|
+
session's JSONL** (`transcript-watcher.ts` + `watch-manager.ts`), pushing new
|
|
92
|
+
entries as `cc_event{kind:"transcript_append", startIndex, …}` (idempotent by
|
|
93
|
+
absolute index) plus `watch_ok{entryCount}` reconciliation heartbeats, so
|
|
94
|
+
terminal-driven turns appear on the phone block-by-block instead of on the
|
|
95
|
+
next poll. Watches stop on `cc_unwatch` / `cc_watch_stop` or expire on their
|
|
96
|
+
own TTL; `watch_failed` tells the phone to fall back to polling.
|
|
97
|
+
|
|
98
|
+
## Commands
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
# normal usage (set up by the installer; you don't run these by hand)
|
|
102
|
+
nexting-cc-bridge install # install the `claude` shim + PATH + start the hub (idempotent)
|
|
103
|
+
nexting-cc-bridge uninstall # remove shim + PATH entry + hub launchd (reversible)
|
|
104
|
+
nexting-cc-bridge hub # the background daemon: holds the cloud link, multiplexes all shells
|
|
105
|
+
nexting-cc-bridge term -- ... # one wrapped `claude` that joins the hub (what the shim calls)
|
|
106
|
+
|
|
107
|
+
# debug / legacy
|
|
108
|
+
nexting-cc-bridge once # print one discovery snapshot as JSON and exit
|
|
109
|
+
nexting-cc-bridge start # legacy read-only discovery daemon
|
|
110
|
+
nexting-cc-bridge mirror # legacy single-session mirror (one bridge per user)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Architecture:** `hub` (one daemon, one cloud WS, listens on
|
|
114
|
+
`~/.nexting/cc-hub.sock`) ← many `term` shells (each = one pty-wrapped `claude`,
|
|
115
|
+
registers over the local socket). The hub multiplexes all shells to the cloud and
|
|
116
|
+
routes phone input back to the right one by `termId`. A shell exiting (Ctrl-C /
|
|
117
|
+
crash) sends `bye`, so the phone never sees a ghost terminal.
|
|
118
|
+
|
|
119
|
+
## Local dev / end-to-end proof (no cloud needed)
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
npm install
|
|
123
|
+
npm test # unit tests (122)
|
|
124
|
+
node --import tsx src/dev-server.ts & # cloud stand-in (ws://localhost:7799)
|
|
125
|
+
NEXTING_CC_URL=ws://localhost:7799/cc-bridge/connect NEXTING_CC_TOKEN=dummy \
|
|
126
|
+
node --import tsx src/cli.ts start & # bridge against real ~/.claude/projects
|
|
127
|
+
node --import tsx src/phone-probe.ts # simulates the phone: lists + opens a transcript
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## End-to-End Encryption
|
|
131
|
+
|
|
132
|
+
The Pinclaw cloud acts as a **relay and store**, not the agent host. By default
|
|
133
|
+
the cloud can see the session content it relays. E2E encryption is an **opt-in**
|
|
134
|
+
feature (`e2eEnabled: true` in `~/.pinclaw/cc-bridge.json`) that makes the cloud
|
|
135
|
+
a pure ciphertext forwarder: it sees routing metadata and opaque blobs but no
|
|
136
|
+
plaintext content.
|
|
137
|
+
|
|
138
|
+
### Trust model
|
|
139
|
+
|
|
140
|
+
- **Trusted endpoints**: the Mac bridge and the user's phone.
|
|
141
|
+
- **Untrusted**: the Pinclaw cloud server (treated as a relay that may be
|
|
142
|
+
compromised).
|
|
143
|
+
- When E2E is on, only the endpoints can decrypt transcript text, titles,
|
|
144
|
+
summaries, and stream deltas.
|
|
145
|
+
|
|
146
|
+
### What gets encrypted
|
|
147
|
+
|
|
148
|
+
| Content | Encrypted when E2E on |
|
|
149
|
+
| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
150
|
+
| Session titles (`cc_snapshot`) | Yes |
|
|
151
|
+
| Session summaries (`cc_snapshot`) | Yes |
|
|
152
|
+
| Recent message text (`cc_snapshot`) | Yes |
|
|
153
|
+
| Transcript entries (`cc_event transcript_append`) | Yes |
|
|
154
|
+
| Streaming text and thinking deltas (`cc_event stream_*_delta`) | Yes |
|
|
155
|
+
| Inbound user sends (`cc_send content`) | Yes |
|
|
156
|
+
| Inbound question answers (`cc_answer updatedInput`) | Yes |
|
|
157
|
+
| Media/image bytes | Yes — `src/media-hydrator.ts` encrypts blob bytes with `sealBytes` (session CEK) before upload and marks them `encrypted: true`; the phone decrypts with `openBytes` |
|
|
158
|
+
| Terminal mirror frames (`cc_term_output`, `cc_term_input`) | No — these are already opaque binary blobs; the content stream above is where the readable text lives |
|
|
159
|
+
| Control / handshake frames (`reg`, `cc_hello`, `cc_term_hello`, etc.) | No |
|
|
160
|
+
|
|
161
|
+
### Cryptographic details (auditable)
|
|
162
|
+
|
|
163
|
+
All algorithms are in `src/e2e/crypto.ts` (Node built-in `node:crypto` only —
|
|
164
|
+
no third-party crypto libraries).
|
|
165
|
+
|
|
166
|
+
**Content encryption** (per session, text fields):
|
|
167
|
+
|
|
168
|
+
- **Algorithm**: AES-256-GCM
|
|
169
|
+
- **Per-session key**: 32-byte Content Encryption Key (CEK), one per session,
|
|
170
|
+
generated fresh by the bridge at session creation.
|
|
171
|
+
- **Wire envelope**: `e2e:v1:<base64(nonce[12] || ciphertext || tag[16])>`
|
|
172
|
+
- **AAD** (authenticated additional data): `"<sessionId>|0|<field>"` — binds
|
|
173
|
+
every ciphertext to its session and field, preventing cross-session replay.
|
|
174
|
+
The `0` is the sequence placeholder (current protocol has no per-frame
|
|
175
|
+
sequence number). Field tags are: `title`, `summary`, `msg`, `text`, `delta`,
|
|
176
|
+
`send`, `answer`.
|
|
177
|
+
|
|
178
|
+
**Media encryption** (binary):
|
|
179
|
+
|
|
180
|
+
- **Algorithm**: AES-256-GCM (same `sealBytes` / `openBytes` helpers).
|
|
181
|
+
- **Wire layout**: raw bytes — `nonce[12] || ciphertext || tag[16]` (no text
|
|
182
|
+
prefix; an `encrypted: true` metadata flag marks the blob).
|
|
183
|
+
- **AAD**: `"<sessionId>|0|media"`
|
|
184
|
+
|
|
185
|
+
**Key wrapping** (CEK delivery to the phone):
|
|
186
|
+
|
|
187
|
+
- **Algorithm**: ephemeral X25519 ECDH → HKDF-SHA256 → AES-256-GCM
|
|
188
|
+
- **Derivation**: `HKDF-SHA256(ikm=X25519_shared, salt=UTF8(sessionId), info=UTF8("pinclaw-e2e-cek-wrap"), L=32)`
|
|
189
|
+
- **Wire format**: `wrap:v1:<base64(ephPubSPKI[44] || nonce[12] || ct || tag[16])>`
|
|
190
|
+
- The bridge generates an ephemeral X25519 keypair per wrap, computes the
|
|
191
|
+
shared secret with the recipient device's long-term public key, derives a
|
|
192
|
+
per-wrap AES-256-GCM key, and seals the CEK. The recipient (phone) reverses
|
|
193
|
+
the process.
|
|
194
|
+
|
|
195
|
+
**Device identity key**:
|
|
196
|
+
|
|
197
|
+
- Each endpoint (Mac bridge, phone) has an X25519 long-term keypair.
|
|
198
|
+
- The bridge private key is stored in the **macOS Keychain** (service
|
|
199
|
+
`pinclaw-bridge-e2e`, written via `security add-generic-password`); it never
|
|
200
|
+
leaves the machine. See `src/e2e/keychain-identity.ts`.
|
|
201
|
+
- Public keys are published to the cloud key directory so endpoints can
|
|
202
|
+
fetch each other's keys for wrapping.
|
|
203
|
+
|
|
204
|
+
**Safety number** (human-verifiable MITM protection):
|
|
205
|
+
|
|
206
|
+
- `SHA-256(sort(pubA, pubB))` — the first 10 bytes encoded in base32
|
|
207
|
+
(`A-Z2-7`), displayed as `XXXXX-XXXXX`.
|
|
208
|
+
- Compare this five-plus-five string between your Mac terminal and the Pinclaw
|
|
209
|
+
app. If they match, no cloud-side key swap has occurred.
|
|
210
|
+
|
|
211
|
+
### What you can verify
|
|
212
|
+
|
|
213
|
+
1. **Algorithms**: read `src/e2e/crypto.ts` — every cipher call is there.
|
|
214
|
+
Node's built-in `node:crypto` module, no opaque libraries.
|
|
215
|
+
2. **Which fields are sealed/unsealed**: read `src/e2e/codec.ts`
|
|
216
|
+
(`EnvelopeCodec.encryptOutbound` / `decryptInbound`). The switch cases
|
|
217
|
+
enumerate every frame type that the codec touches.
|
|
218
|
+
3. **Key storage**: read `src/e2e/keychain-identity.ts` — the only I/O is
|
|
219
|
+
`security find-generic-password` (read) and `security add-generic-password`
|
|
220
|
+
(write) to the macOS login keychain.
|
|
221
|
+
4. **Tests and interop vectors**: `test/e2e-crypto.test.ts`,
|
|
222
|
+
`test/e2e-codec.test.ts`, `test/e2e-wire.test.ts` — the vectors in
|
|
223
|
+
`e2e-crypto.test.ts` are the shared ground truth used to verify byte-level
|
|
224
|
+
compatibility between the bridge, cloud, iOS, and Android.
|
|
225
|
+
|
|
226
|
+
To run just the E2E tests:
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
npm test -- --testPathPattern="e2e-"
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## Wire protocols
|
|
235
|
+
|
|
236
|
+
**Terminal mirror — hub ⇄ cloud** (the current model):
|
|
237
|
+
Hub → cloud: `cc_term_hello {termId,cwd,title,cols,rows}`, `cc_term_output
|
|
238
|
+
{termId,data(b64)}`, `cc_term_bye {termId}`. Cloud → hub: `cc_term_input
|
|
239
|
+
{termId,data}`, `cc_term_resize {termId,cols,rows}`.
|
|
240
|
+
|
|
241
|
+
**Shell ⇄ hub (local socket, newline-JSON, `hub-protocol.ts`):**
|
|
242
|
+
shell → hub `reg` / `out` / `bye`; hub → shell `in` / `resize`.
|
|
243
|
+
|
|
244
|
+
**Phone ⇄ cloud (`routes/cc.ts`):** `GET /cc/term/list`, `GET /cc/term/stream`
|
|
245
|
+
(SSE bytes, replays the rolling buffer then live), `POST /cc/term/input`, `POST
|
|
246
|
+
/cc/term/resize`, and `POST /cc/pair` (logged-in phone mints an account-bound
|
|
247
|
+
install token). Cloud relay: `services/cc-term-service.ts` (per-term rolling
|
|
248
|
+
256KB buffer, replay-then-live, drops on bye).
|
|
249
|
+
|
|
250
|
+
Legacy (bubble/discovery, still present): `cc_hello`/`cc_snapshot`/`cc_event` +
|
|
251
|
+
`/cc/sessions|attach|send|stream`; migrations `049_claude_code_mode.sql` +
|
|
252
|
+
`050_cc_sessions_controllable.sql`. The unified iOS UI uses the terminal path only.
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// Owns the set of attached sessions. Reacts to phone→bridge frames
|
|
2
|
+
// (cc_attach / cc_send / cc_answer / cc_detach), manages one SessionRunner per
|
|
3
|
+
// session, and forwards each child frame upward as a cc_* envelope (+ sessionId).
|
|
4
|
+
import { createSessionRunner, } from "./session-runner.js";
|
|
5
|
+
export function createAttachManager(deps) {
|
|
6
|
+
const runners = new Map();
|
|
7
|
+
const owned = new Set(); // session ids owned by the local terminal shell
|
|
8
|
+
// Runners we are killing ON PURPOSE (detach / shutdown / WS-disconnect
|
|
9
|
+
// cleanup): their exit must NOT be reported as a session death.
|
|
10
|
+
const stopping = new WeakSet();
|
|
11
|
+
function stopRunner(r) {
|
|
12
|
+
stopping.add(r);
|
|
13
|
+
r.stop();
|
|
14
|
+
}
|
|
15
|
+
function forward(sessionId, frame) {
|
|
16
|
+
if (frame.type === "event") {
|
|
17
|
+
deps.send({
|
|
18
|
+
type: "cc_event",
|
|
19
|
+
sessionId,
|
|
20
|
+
kind: frame.kind,
|
|
21
|
+
payload: frame.payload,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
else if (frame.type === "control_request") {
|
|
25
|
+
deps.send({
|
|
26
|
+
type: "cc_control_request",
|
|
27
|
+
sessionId,
|
|
28
|
+
controlRequestId: frame.controlRequestId,
|
|
29
|
+
toolName: frame.toolName,
|
|
30
|
+
input: frame.input,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
deps.send({
|
|
35
|
+
type: "cc_control_cancel",
|
|
36
|
+
sessionId,
|
|
37
|
+
controlRequestId: frame.controlRequestId,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// A runner's forwarding id is mutable: a brand-new session is created under
|
|
42
|
+
// "new" and re-keyed to its real id once system_init arrives (see rekey).
|
|
43
|
+
const idBox = new WeakMap();
|
|
44
|
+
function rekey(oldId, newId) {
|
|
45
|
+
if (oldId === newId)
|
|
46
|
+
return;
|
|
47
|
+
const r = runners.get(oldId);
|
|
48
|
+
if (r) {
|
|
49
|
+
runners.delete(oldId);
|
|
50
|
+
runners.set(newId, r);
|
|
51
|
+
const box = idBox.get(r);
|
|
52
|
+
if (box)
|
|
53
|
+
box.current = newId;
|
|
54
|
+
}
|
|
55
|
+
if (owned.delete(oldId))
|
|
56
|
+
owned.add(newId);
|
|
57
|
+
}
|
|
58
|
+
function makeRunner(sessionId, cwd) {
|
|
59
|
+
const isNew = sessionId === "new";
|
|
60
|
+
const box = { current: sessionId };
|
|
61
|
+
const engineName = deps.engine === "codex" ? "Codex" : "Claude Code";
|
|
62
|
+
const canonicalCwd = cwd;
|
|
63
|
+
const runner = createSessionRunner({
|
|
64
|
+
sessionId,
|
|
65
|
+
cwd: canonicalCwd,
|
|
66
|
+
resumeSessionId: isNew ? undefined : sessionId,
|
|
67
|
+
spawn: deps.spawn,
|
|
68
|
+
command: deps.command,
|
|
69
|
+
adapter: deps.adapterFactory?.(),
|
|
70
|
+
// Inject the device-tool MCP proxy when the bridge has cloud wiring. The
|
|
71
|
+
// file/block key is the spawn sessionId (stable across the "new"→real
|
|
72
|
+
// rekey); the route id starts equal and is updated on rekey below.
|
|
73
|
+
mcp: deps.mcp
|
|
74
|
+
? {
|
|
75
|
+
engine: deps.engine === "codex" ? "codex" : "claude",
|
|
76
|
+
sessionId,
|
|
77
|
+
cloudUrl: deps.mcp.cloudUrl,
|
|
78
|
+
busToken: deps.mcp.busToken,
|
|
79
|
+
}
|
|
80
|
+
: undefined,
|
|
81
|
+
onFrame: (frame) => {
|
|
82
|
+
forward(box.current, frame);
|
|
83
|
+
// A phone-created session reports its real id in system_init. The frame
|
|
84
|
+
// itself just went out under "new" — that's how the phone learns the
|
|
85
|
+
// mapping — then the runner re-keys so every later frame (and send)
|
|
86
|
+
// rides the real id. Local-shell does its own rekey too; it's idempotent.
|
|
87
|
+
if (box.current === "new" &&
|
|
88
|
+
frame.type === "event" &&
|
|
89
|
+
frame.kind === "system_init") {
|
|
90
|
+
const real = frame.payload
|
|
91
|
+
?.session_id;
|
|
92
|
+
if (typeof real === "string" && real) {
|
|
93
|
+
rekey("new", real);
|
|
94
|
+
// Re-point the device-tool MCP proxy at the real session id so its
|
|
95
|
+
// calls reach the phone's SSE subscription (which moves to the real
|
|
96
|
+
// id after system_init). Config file path stays stable; only the
|
|
97
|
+
// routed NEXTING_SESSION_ID changes.
|
|
98
|
+
runner.rewriteMcpRoute(real);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
onError: (err) => {
|
|
103
|
+
// Spawn failure (e.g. `claude` not on PATH → ENOENT). Tell the phone and
|
|
104
|
+
// forget the runner; never let it bubble up and crash the bridge.
|
|
105
|
+
deps.send({
|
|
106
|
+
type: "cc_event",
|
|
107
|
+
sessionId: box.current,
|
|
108
|
+
kind: "result_error",
|
|
109
|
+
payload: {
|
|
110
|
+
errors: [`无法启动 ${engineName} 会话进程:${err.message}`],
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
runners.delete(box.current);
|
|
114
|
+
owned.delete(box.current);
|
|
115
|
+
},
|
|
116
|
+
onExit: (code, signal) => {
|
|
117
|
+
// EVERY unexpected exit gets reported — not just non-zero codes. A
|
|
118
|
+
// clean exit (code 0) and a signal kill (code null) used to be silent,
|
|
119
|
+
// leaving the phone spinning "working" on a process that no longer
|
|
120
|
+
// exists. Only intentional stops (detach/shutdown/reconnect cleanup)
|
|
121
|
+
// stay quiet.
|
|
122
|
+
if (!stopping.has(runner)) {
|
|
123
|
+
const msg = code && code !== 0
|
|
124
|
+
? `会话进程意外退出(code ${code})。`
|
|
125
|
+
: signal
|
|
126
|
+
? `会话进程被终止(${signal})。发送消息会自动重启会话。`
|
|
127
|
+
: `会话进程已退出。发送消息会自动重启会话。`;
|
|
128
|
+
deps.send({
|
|
129
|
+
type: "cc_event",
|
|
130
|
+
sessionId: box.current,
|
|
131
|
+
kind: "result_error",
|
|
132
|
+
payload: { errors: [msg] },
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
runners.delete(box.current);
|
|
136
|
+
owned.delete(box.current);
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
runners.set(sessionId, runner);
|
|
140
|
+
idBox.set(runner, box);
|
|
141
|
+
return runner;
|
|
142
|
+
}
|
|
143
|
+
function attach(sessionId, cwd) {
|
|
144
|
+
if (runners.has(sessionId)) {
|
|
145
|
+
// Already attached (incl. local shell) — reuse.
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
makeRunner(sessionId, cwd);
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
handle: (msg) => {
|
|
152
|
+
switch (msg.type) {
|
|
153
|
+
case "cc_attach":
|
|
154
|
+
attach(msg.sessionId, msg.cwd);
|
|
155
|
+
break;
|
|
156
|
+
case "cc_send": {
|
|
157
|
+
const m = msg;
|
|
158
|
+
const r = runners.get(m.sessionId);
|
|
159
|
+
if (!r) {
|
|
160
|
+
// No live runner: report instead of silently dropping, so the phone
|
|
161
|
+
// clears its "working" state and shows a real error.
|
|
162
|
+
deps.send({
|
|
163
|
+
type: "cc_event",
|
|
164
|
+
sessionId: m.sessionId,
|
|
165
|
+
kind: "result_error",
|
|
166
|
+
payload: {
|
|
167
|
+
errors: [
|
|
168
|
+
"该会话在这台 Mac 上没有在运行的会话进程(未附加)。请重新打开该会话后再发。",
|
|
169
|
+
],
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
r.send(m.content);
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
case "cc_answer": {
|
|
178
|
+
const m = msg;
|
|
179
|
+
runners.get(m.sessionId)?.answer(m.controlRequestId, m.updatedInput);
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
case "cc_deny": {
|
|
183
|
+
const m = msg;
|
|
184
|
+
runners.get(m.sessionId)?.deny(m.controlRequestId, m.message);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
case "cc_detach": {
|
|
188
|
+
const m = msg;
|
|
189
|
+
// A locally-owned (terminal shell) runner must survive a remote detach:
|
|
190
|
+
// the phone leaving doesn't end the session the user is driving locally.
|
|
191
|
+
if (owned.has(m.sessionId))
|
|
192
|
+
break;
|
|
193
|
+
const r = runners.get(m.sessionId);
|
|
194
|
+
if (r)
|
|
195
|
+
stopRunner(r);
|
|
196
|
+
runners.delete(m.sessionId);
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
case "codex_queue_edit": {
|
|
200
|
+
const m = msg;
|
|
201
|
+
runners.get(m.sessionId)?.editQueuedTurn(m.itemId, m.content);
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
case "codex_queue_delete": {
|
|
205
|
+
const m = msg;
|
|
206
|
+
runners.get(m.sessionId)?.deleteQueuedTurn(m.itemId);
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
case "codex_queue_close": {
|
|
210
|
+
const m = msg;
|
|
211
|
+
runners.get(m.sessionId)?.closeQueue();
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
case "codex_queue_steer": {
|
|
215
|
+
const m = msg;
|
|
216
|
+
runners.get(m.sessionId)?.steerQueuedTurn(m.itemId);
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
// unknown frames: ignore
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
stopAll: () => {
|
|
223
|
+
for (const r of runners.values())
|
|
224
|
+
stopRunner(r);
|
|
225
|
+
runners.clear();
|
|
226
|
+
owned.clear();
|
|
227
|
+
},
|
|
228
|
+
stopRemote: () => {
|
|
229
|
+
for (const [id, r] of runners) {
|
|
230
|
+
if (owned.has(id))
|
|
231
|
+
continue; // keep the local shell alive across reconnects
|
|
232
|
+
// Intentional kill on a DEAD socket — a frame couldn't reach the cloud
|
|
233
|
+
// anyway; the cloud's bridge_offline broadcast informs the phones.
|
|
234
|
+
stopRunner(r);
|
|
235
|
+
runners.delete(id);
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
attachLocal: ({ sessionId, cwd }) => {
|
|
239
|
+
const r = runners.get(sessionId) ?? makeRunner(sessionId, cwd);
|
|
240
|
+
owned.add(sessionId);
|
|
241
|
+
return r;
|
|
242
|
+
},
|
|
243
|
+
rekey: (oldId, newId) => {
|
|
244
|
+
if (oldId === newId)
|
|
245
|
+
return;
|
|
246
|
+
const r = runners.get(oldId);
|
|
247
|
+
if (r) {
|
|
248
|
+
runners.delete(oldId);
|
|
249
|
+
runners.set(newId, r);
|
|
250
|
+
const box = idBox.get(r);
|
|
251
|
+
if (box)
|
|
252
|
+
box.current = newId;
|
|
253
|
+
}
|
|
254
|
+
if (owned.delete(oldId))
|
|
255
|
+
owned.add(newId);
|
|
256
|
+
},
|
|
257
|
+
controllableIds: () => [...owned],
|
|
258
|
+
};
|
|
259
|
+
}
|