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.
Files changed (50) hide show
  1. package/README.md +252 -0
  2. package/dist/attach-manager.js +259 -0
  3. package/dist/bridge.js +931 -0
  4. package/dist/cli-args.js +14 -0
  5. package/dist/cli.js +742 -0
  6. package/dist/codex-prompts.js +148 -0
  7. package/dist/codex-thread-source.js +495 -0
  8. package/dist/codex-transcript.js +415 -0
  9. package/dist/dev-server.js +126 -0
  10. package/dist/discovery.js +111 -0
  11. package/dist/e2e/codec.js +119 -0
  12. package/dist/e2e/crypto.js +127 -0
  13. package/dist/e2e/key-store.js +48 -0
  14. package/dist/e2e/keychain-identity.js +29 -0
  15. package/dist/engine/adapter.js +5 -0
  16. package/dist/engine/claude-adapter.js +77 -0
  17. package/dist/engine/codex-adapter.js +593 -0
  18. package/dist/file-preview.js +292 -0
  19. package/dist/hub-protocol.js +28 -0
  20. package/dist/hub-server.js +106 -0
  21. package/dist/hub.js +84 -0
  22. package/dist/install-util.js +33 -0
  23. package/dist/local-shell.js +32 -0
  24. package/dist/mcp-config.js +230 -0
  25. package/dist/mcp-device-proxy.js +501 -0
  26. package/dist/media-hydrator.js +222 -0
  27. package/dist/message-counter.js +79 -0
  28. package/dist/phone-probe.js +55 -0
  29. package/dist/prompt-detector.js +213 -0
  30. package/dist/protocol.js +3 -0
  31. package/dist/pty-mirror.js +80 -0
  32. package/dist/pty-spawn.js +53 -0
  33. package/dist/scanner.js +422 -0
  34. package/dist/self-update.js +122 -0
  35. package/dist/session-map.js +15 -0
  36. package/dist/session-runner.js +131 -0
  37. package/dist/shell.js +104 -0
  38. package/dist/skills-scanner.js +167 -0
  39. package/dist/stdin-encode.js +32 -0
  40. package/dist/stream-translate.js +122 -0
  41. package/dist/terminal-render.js +29 -0
  42. package/dist/transcript-watcher.js +138 -0
  43. package/dist/transcript.js +346 -0
  44. package/dist/turn-probe.js +152 -0
  45. package/dist/types.js +2 -0
  46. package/dist/watch-manager.js +77 -0
  47. package/install-cc.sh +90 -0
  48. package/install-codex.sh +97 -0
  49. package/package.json +39 -0
  50. 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
+ }