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
@@ -0,0 +1,222 @@
1
+ import fsp from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { sealBytes } from "./e2e/crypto.js";
5
+ const IMAGE_EXT_MIME = {
6
+ ".png": "image/png",
7
+ ".jpg": "image/jpeg",
8
+ ".jpeg": "image/jpeg",
9
+ ".gif": "image/gif",
10
+ ".webp": "image/webp",
11
+ ".heic": "image/heic",
12
+ ".svg": "image/svg+xml",
13
+ };
14
+ export function isImagePath(value) {
15
+ if (!value)
16
+ return false;
17
+ return IMAGE_EXT_MIME[path.extname(value).toLowerCase()] != null;
18
+ }
19
+ export function mimeFromFilename(value) {
20
+ if (!value)
21
+ return "application/octet-stream";
22
+ return (IMAGE_EXT_MIME[path.extname(value).toLowerCase()] ??
23
+ "application/octet-stream");
24
+ }
25
+ export function filenameFromUrlOrPath(value) {
26
+ if (!value)
27
+ return undefined;
28
+ try {
29
+ const url = new URL(value);
30
+ const last = path.basename(url.pathname);
31
+ return last || undefined;
32
+ }
33
+ catch {
34
+ const last = path.basename(value);
35
+ return last || undefined;
36
+ }
37
+ }
38
+ export function mediaFromLocalPath(localPath) {
39
+ if (!isImagePath(localPath))
40
+ return null;
41
+ return {
42
+ source: "local_path",
43
+ localPath,
44
+ filename: path.basename(localPath),
45
+ mimeType: mimeFromFilename(localPath),
46
+ };
47
+ }
48
+ function localPathFromUrlish(value) {
49
+ if (value.startsWith("file://")) {
50
+ return fileURLToPath(value);
51
+ }
52
+ if (path.isAbsolute(value)) {
53
+ return value;
54
+ }
55
+ return null;
56
+ }
57
+ export function mediaFromImageBlock(block) {
58
+ const source = block.source && typeof block.source === "object"
59
+ ? block.source
60
+ : {};
61
+ const directUrl = typeof block.url === "string"
62
+ ? block.url
63
+ : typeof source.url === "string"
64
+ ? source.url
65
+ : typeof block.image_url === "string"
66
+ ? block.image_url
67
+ : typeof block.image_url
68
+ ?.url === "string"
69
+ ? block.image_url.url
70
+ : undefined;
71
+ const directPath = typeof block.path === "string"
72
+ ? block.path
73
+ : typeof block.filePath === "string"
74
+ ? block.filePath
75
+ : typeof source.path === "string"
76
+ ? source.path
77
+ : undefined;
78
+ const mimeType = typeof block.mimeType === "string"
79
+ ? block.mimeType
80
+ : typeof block.mime_type === "string"
81
+ ? block.mime_type
82
+ : typeof source.media_type === "string"
83
+ ? source.media_type
84
+ : typeof source.mimeType === "string"
85
+ ? source.mimeType
86
+ : undefined;
87
+ const dataBase64 = typeof block.data === "string"
88
+ ? block.data
89
+ : typeof source.data === "string"
90
+ ? source.data
91
+ : undefined;
92
+ if (directPath)
93
+ return mediaFromLocalPath(directPath);
94
+ if (directUrl) {
95
+ const localPath = localPathFromUrlish(directUrl);
96
+ if (localPath)
97
+ return mediaFromLocalPath(localPath);
98
+ return {
99
+ source: "remote_url",
100
+ url: directUrl,
101
+ filename: filenameFromUrlOrPath(directUrl),
102
+ mimeType: mimeType ?? mimeFromFilename(directUrl),
103
+ };
104
+ }
105
+ if (dataBase64) {
106
+ const inferred = mimeType ?? "image/png";
107
+ const ext = inferred.split("/")[1]?.replace("jpeg", "jpg") || "png";
108
+ return {
109
+ source: "data_uri",
110
+ dataBase64,
111
+ filename: `image.${ext}`,
112
+ mimeType: inferred,
113
+ };
114
+ }
115
+ return null;
116
+ }
117
+ function publicMedia(media) {
118
+ const { source: _source, localPath: _localPath, dataBase64: _dataBase64, sourceUrl: _sourceUrl, ...pub } = media;
119
+ return pub;
120
+ }
121
+ async function cacheKey(media) {
122
+ if (media.source !== "local_path" || !media.localPath)
123
+ return null;
124
+ const st = await fsp.stat(media.localPath).catch(() => null);
125
+ if (!st)
126
+ return `missing:${media.localPath}`;
127
+ return `local:${media.localPath}:${st.size}:${st.mtimeMs}`;
128
+ }
129
+ export async function hydrateTranscriptMedia(entries, uploader, sessionId) {
130
+ const cache = uploader.cache ?? new Map();
131
+ const hydrateOne = async (media) => {
132
+ if (media.source === "local_path" || media.source === "data_uri") {
133
+ const key = (await cacheKey(media)) ??
134
+ `data:${media.mimeType}:${media.dataBase64 ?? ""}`;
135
+ let pending = cache.get(key);
136
+ if (!pending) {
137
+ pending = uploader.upload(media, sessionId);
138
+ cache.set(key, pending);
139
+ }
140
+ return pending;
141
+ }
142
+ return publicMedia(media);
143
+ };
144
+ const out = [];
145
+ for (const entry of entries) {
146
+ if (entry.kind !== "image" || !entry.media) {
147
+ out.push(entry);
148
+ continue;
149
+ }
150
+ out.push({ ...entry, media: await hydrateOne(entry.media) });
151
+ }
152
+ return out;
153
+ }
154
+ export function mediaUploadUrlForBridge(bridgeUrl, engine) {
155
+ const url = new URL(bridgeUrl);
156
+ url.protocol = url.protocol === "wss:" ? "https:" : "http:";
157
+ url.pathname =
158
+ engine === "codex"
159
+ ? "/api/v1/codex/media/upload"
160
+ : "/api/v1/cc/media/upload";
161
+ url.search = "";
162
+ url.hash = "";
163
+ return url.toString();
164
+ }
165
+ export function createBridgeMediaUploader(opts) {
166
+ const uploadUrl = mediaUploadUrlForBridge(opts.bridgeUrl, opts.engine);
167
+ const fetchImpl = opts.fetchImpl ?? fetch;
168
+ const cache = new Map();
169
+ return {
170
+ cache,
171
+ upload: async (media, sessionId) => {
172
+ const filename = media.filename ?? "image";
173
+ const mimeType = media.mimeType ?? mimeFromFilename(filename);
174
+ let bytes;
175
+ if (media.source === "local_path" && media.localPath) {
176
+ bytes = await fsp.readFile(media.localPath);
177
+ }
178
+ else if (media.source === "data_uri" && media.dataBase64) {
179
+ bytes = Buffer.from(media.dataBase64, "base64");
180
+ }
181
+ else {
182
+ throw new Error("unsupported media source");
183
+ }
184
+ // E2E: when enabled and a CEK exists for the session, seal the raw bytes
185
+ // with AES-256-GCM before upload. The phone decrypts via openBytes using
186
+ // `encrypted:true` as the signal. When E2E is off or no CEK exists yet,
187
+ // the path is byte-identical to the pre-E2E code.
188
+ let uploadBytes = bytes;
189
+ let encrypted = false;
190
+ if (opts.e2eEnabled && opts.cekProvider) {
191
+ const cek = opts.cekProvider(sessionId);
192
+ if (cek) {
193
+ uploadBytes = sealBytes(bytes, cek, `${sessionId}|0|media`);
194
+ encrypted = true;
195
+ }
196
+ }
197
+ const form = new FormData();
198
+ const ab = uploadBytes.buffer.slice(uploadBytes.byteOffset, uploadBytes.byteOffset + uploadBytes.byteLength);
199
+ form.append("file", new Blob([ab], { type: mimeType }), filename);
200
+ const res = await fetchImpl(uploadUrl, {
201
+ method: "POST",
202
+ headers: { Authorization: `Bearer ${opts.token}` },
203
+ body: form,
204
+ });
205
+ if (!res.ok) {
206
+ const body = await res.text().catch(() => "");
207
+ throw new Error(`media upload failed (${res.status}): ${body}`);
208
+ }
209
+ const json = (await res.json());
210
+ return {
211
+ mediaId: json.id,
212
+ url: json.url,
213
+ filename: json.filename,
214
+ mimeType: json.mimeType,
215
+ sizeBytes: json.size,
216
+ expiresAt: json.expiresAt,
217
+ degraded: json.degraded,
218
+ ...(encrypted ? { encrypted: true } : {}),
219
+ };
220
+ },
221
+ };
222
+ }
@@ -0,0 +1,79 @@
1
+ // Exact, incremental user-visible message counts for session jsonl files.
2
+ //
3
+ // Why not the head/tail summary windows: a session's first user line alone can
4
+ // exceed the head window (system-reminder injections make single lines >16KB),
5
+ // and every real user message can hide in the unseen middle — the turn-based
6
+ // count then collapses to 1 for a multi-question session. jsonl files are
7
+ // append-only, so the EXACT count is cheap to maintain: stream-parse the whole
8
+ // file once (cold start, ~seconds across all sessions), then only the appended
9
+ // bytes on every later scan (a no-op when the size didn't change).
10
+ import fsp from "node:fs/promises";
11
+ const CHUNK = 4 * 1024 * 1024;
12
+ export function createMessageCounter(reduce) {
13
+ const cache = new Map();
14
+ const inflight = new Map();
15
+ async function countNow(file, size) {
16
+ let e = cache.get(file);
17
+ // Shrunk file = rewritten/truncated (should not happen for append-only
18
+ // session logs, but never trust it) → full recount from zero.
19
+ if (e && size < e.offset)
20
+ e = undefined;
21
+ if (!e)
22
+ e = { offset: 0, state: { messages: 0, inAssistantTurn: false } };
23
+ cache.set(file, e);
24
+ if (size <= e.offset)
25
+ return e.state.messages;
26
+ const fh = await fsp.open(file, "r");
27
+ try {
28
+ let pos = e.offset;
29
+ // Byte-level remainder between chunks (the bytes after the last '\n').
30
+ // '\n' (0x0A) never occurs inside a UTF-8 multibyte sequence, so byte
31
+ // splitting is encoding-safe and every complete line is whole UTF-8.
32
+ let rem = Buffer.alloc(0);
33
+ const chunk = Buffer.alloc(CHUNK);
34
+ while (pos < size) {
35
+ const len = Math.min(CHUNK, size - pos);
36
+ const { bytesRead } = await fh.read(chunk, 0, len, pos);
37
+ if (bytesRead <= 0)
38
+ break;
39
+ pos += bytesRead;
40
+ const data = rem.length
41
+ ? Buffer.concat([rem, chunk.subarray(0, bytesRead)])
42
+ : chunk.subarray(0, bytesRead);
43
+ const lastNl = data.lastIndexOf(0x0a);
44
+ if (lastNl === -1) {
45
+ // No complete line in this window yet (one huge line) — carry on.
46
+ rem = Buffer.from(data); // copy: `chunk` is reused next iteration
47
+ continue;
48
+ }
49
+ for (const line of data.toString("utf8", 0, lastNl).split("\n")) {
50
+ if (line.trim())
51
+ reduce(line, e.state);
52
+ }
53
+ rem = Buffer.from(data.subarray(lastNl + 1));
54
+ e.offset = pos - rem.length;
55
+ }
56
+ // Anything after the final '\n' is a mid-write partial line — NOT counted
57
+ // and NOT consumed; it completes (and gets counted) on a later scan.
58
+ }
59
+ finally {
60
+ await fh.close();
61
+ }
62
+ return e.state.messages;
63
+ }
64
+ return {
65
+ async count(file, size) {
66
+ const running = inflight.get(file);
67
+ // Coalesce: a concurrent caller gets the in-flight result (possibly for a
68
+ // slightly older size — the next scan catches up).
69
+ if (running)
70
+ return running;
71
+ const p = countNow(file, size).finally(() => inflight.delete(file));
72
+ inflight.set(file, p);
73
+ return p;
74
+ },
75
+ forget(file) {
76
+ cache.delete(file);
77
+ },
78
+ };
79
+ }
@@ -0,0 +1,55 @@
1
+ // Simulates the phone: connects to the dev-server's /phone endpoint, lists
2
+ // sessions, then opens the transcript of a running session. Proves the full
3
+ // Mac-bridge -> cloud -> phone read path end-to-end.
4
+ import WebSocket from "ws";
5
+ const PORT = Number(process.env.CC_DEV_PORT ?? 7799);
6
+ const ws = new WebSocket(`ws://localhost:${PORT}/phone`);
7
+ function send(msg) {
8
+ ws.send(JSON.stringify(msg));
9
+ }
10
+ ws.on("open", () => {
11
+ console.log("[phone] connected, requesting sessions…");
12
+ send({ type: "get_sessions" });
13
+ });
14
+ ws.on("message", (data) => {
15
+ const msg = JSON.parse(data.toString());
16
+ if (msg.type === "sessions_result") {
17
+ const sessions = msg.sessions;
18
+ const running = sessions.filter((s) => s.status === "running");
19
+ console.log(`\n[phone] bridgeOnline=${msg.bridgeOnline} total=${sessions.length} running=${running.length}`);
20
+ console.log("[phone] running sessions:");
21
+ for (const s of running.slice(0, 6)) {
22
+ console.log(` • ${s.key.slice(0, 8)} [${s.status}] ${s.cwd} "${(s.displayName || "").slice(0, 40)}" msgs=${s.totalMessages}`);
23
+ }
24
+ const target = running[0] ?? sessions[0];
25
+ if (!target) {
26
+ console.log("[phone] no sessions to open");
27
+ ws.close();
28
+ return;
29
+ }
30
+ console.log(`\n[phone] opening transcript of ${target.key.slice(0, 8)}…`);
31
+ send({ type: "get_session_transcript", sessionKey: target.key });
32
+ }
33
+ else if (msg.type === "session_transcript") {
34
+ if (msg.error) {
35
+ console.log(`[phone] transcript error: ${msg.error}`);
36
+ }
37
+ else {
38
+ const entries = msg.entries;
39
+ console.log(`[phone] transcript: ${entries.length} entries. First 5 kinds + previews:`);
40
+ for (const e of entries.slice(0, 5)) {
41
+ const preview = (e.text ?? e.name ?? e.subtype ?? "")
42
+ .toString()
43
+ .replace(/\s+/g, " ")
44
+ .slice(0, 60);
45
+ console.log(` [${e.kind}] ${preview}`);
46
+ }
47
+ console.log("\n✅ END-TO-END OK: Mac bridge → cloud → phone read both the session list and a full transcript.");
48
+ }
49
+ ws.close();
50
+ }
51
+ });
52
+ ws.on("error", (e) => {
53
+ console.error("[phone] error:", e.message);
54
+ process.exit(1);
55
+ });
@@ -0,0 +1,213 @@
1
+ import { createHash } from "node:crypto";
2
+ import { stripAnsi } from "./turn-probe.js";
3
+ const KEY_DOWN = "\x1b[B";
4
+ const KEY_UP = "\x1b[A";
5
+ const KEY_ENTER = "\r";
6
+ const BOX_TOP = /[╭┌]/;
7
+ const BOX_BOTTOM = /[╰└]/;
8
+ const BOX_VERTICAL = /[│|]/g;
9
+ const PROCEED_RE = /do you want to proceed\?/i;
10
+ const OPTION_RE = /^\s*(❯|>)?\s*(\d+)\.\s+(\S.*?)\s*$/;
11
+ const BUF_CAP = 16 * 1024;
12
+ function innerContent(line) {
13
+ const noBorders = line.replace(BOX_VERTICAL, " ");
14
+ const stripped = noBorders.replace(/[╭╮╰╯┌┐└┘─━]/g, "");
15
+ return stripped.trim();
16
+ }
17
+ function isBorderRow(line) {
18
+ return (/[╭╮╰╯┌┐└┘]/.test(line) || /^[\s─━]+$/.test(line.replace(BOX_VERTICAL, "")));
19
+ }
20
+ export function parsePrompt(text) {
21
+ const clean = stripAnsi(text).replace(/\r/g, "");
22
+ const lines = clean.split("\n");
23
+ let bottom = -1;
24
+ for (let i = lines.length - 1; i >= 0; i--) {
25
+ if (BOX_BOTTOM.test(lines[i]) && /[╰╯└┘]/.test(lines[i])) {
26
+ bottom = i;
27
+ break;
28
+ }
29
+ }
30
+ if (bottom < 0)
31
+ return null;
32
+ for (let i = bottom + 1; i < lines.length; i++) {
33
+ const rest = lines[i].trim();
34
+ if (rest === "")
35
+ continue;
36
+ if (!/^[\s↑↓←→·•\-—]/.test(rest))
37
+ return null;
38
+ }
39
+ let top = -1;
40
+ for (let i = bottom - 1; i >= 0; i--) {
41
+ if (BOX_TOP.test(lines[i]) && /[╭╮┌┐]/.test(lines[i])) {
42
+ top = i;
43
+ break;
44
+ }
45
+ }
46
+ if (top < 0)
47
+ return null;
48
+ const inner = [];
49
+ for (let i = top + 1; i < bottom; i++) {
50
+ if (isBorderRow(lines[i]))
51
+ continue;
52
+ const content = innerContent(lines[i]);
53
+ if (content)
54
+ inner.push(content);
55
+ }
56
+ if (inner.length === 0)
57
+ return null;
58
+ const headerLines = [];
59
+ const options = [];
60
+ let sawOptions = false;
61
+ for (const content of inner) {
62
+ const match = OPTION_RE.exec(content);
63
+ if (match) {
64
+ sawOptions = true;
65
+ options.push({
66
+ num: Number(match[2]),
67
+ label: match[3].trim(),
68
+ selected: match[1] != null,
69
+ });
70
+ }
71
+ else if (!sawOptions) {
72
+ headerLines.push(content);
73
+ }
74
+ }
75
+ if (options.length < 2)
76
+ return null;
77
+ for (let i = 0; i < options.length; i++) {
78
+ if (options[i].num !== i + 1)
79
+ return null;
80
+ }
81
+ const highlighted = options.filter((o) => o.selected);
82
+ if (highlighted.length !== 1)
83
+ return null;
84
+ if (headerLines.length === 0)
85
+ return null;
86
+ const isPermission = headerLines.some((h) => PROCEED_RE.test(h));
87
+ const kind = isPermission ? "permission" : "select";
88
+ let title;
89
+ let detail;
90
+ if (isPermission) {
91
+ const questionIndex = headerLines.findIndex((h) => PROCEED_RE.test(h));
92
+ title = headerLines[questionIndex].trim();
93
+ const detailLines = headerLines.slice(0, questionIndex).filter(Boolean);
94
+ detail = detailLines.length ? detailLines.join("\n") : undefined;
95
+ }
96
+ else {
97
+ title = headerLines[0].trim();
98
+ const detailLines = headerLines.slice(1).filter(Boolean);
99
+ detail = detailLines.length ? detailLines.join("\n") : undefined;
100
+ }
101
+ const selectedIndex = options.findIndex((o) => o.selected);
102
+ return {
103
+ kind,
104
+ title,
105
+ detail,
106
+ options: options.map((o, index) => ({
107
+ index,
108
+ label: o.label,
109
+ selected: o.selected,
110
+ })),
111
+ selectedIndex,
112
+ };
113
+ }
114
+ export function computePromptId(termId, options) {
115
+ const h = createHash("sha1");
116
+ h.update(termId);
117
+ h.update("\0");
118
+ h.update(options.map((o) => o.label).join("\0"));
119
+ return h.digest("hex").slice(0, 16);
120
+ }
121
+ export function keySequenceForAnswer(from, to) {
122
+ const delta = to - from;
123
+ const seq = [];
124
+ const key = delta > 0 ? KEY_DOWN : KEY_UP;
125
+ for (let i = 0; i < Math.abs(delta); i++)
126
+ seq.push(key);
127
+ seq.push(KEY_ENTER);
128
+ return seq;
129
+ }
130
+ export function createPromptDetector(opts) {
131
+ const terms = new Map();
132
+ function getState(termId) {
133
+ let st = terms.get(termId);
134
+ if (!st) {
135
+ st = { buf: "", active: null, lastEmitSig: null };
136
+ terms.set(termId, st);
137
+ }
138
+ return st;
139
+ }
140
+ function emitGone(termId, st) {
141
+ if (!st.active)
142
+ return;
143
+ opts.emit({
144
+ type: "cc_term_prompt_gone",
145
+ termId,
146
+ promptId: st.active.promptId,
147
+ });
148
+ st.active = null;
149
+ st.lastEmitSig = null;
150
+ }
151
+ return {
152
+ noteOutput(termId, dataBase64) {
153
+ let text;
154
+ try {
155
+ text = Buffer.from(dataBase64, "base64").toString("utf8");
156
+ }
157
+ catch {
158
+ return;
159
+ }
160
+ const st = getState(termId);
161
+ st.buf = (st.buf + stripAnsi(text).replace(/\r/g, "")).slice(-BUF_CAP);
162
+ const prompt = parsePrompt(st.buf);
163
+ if (!prompt) {
164
+ emitGone(termId, st);
165
+ return;
166
+ }
167
+ const promptId = computePromptId(termId, prompt.options);
168
+ if (st.active && st.active.promptId !== promptId) {
169
+ emitGone(termId, st);
170
+ }
171
+ const sig = `${promptId}:${prompt.selectedIndex}`;
172
+ if (sig === st.lastEmitSig) {
173
+ st.active = { promptId, prompt };
174
+ return;
175
+ }
176
+ st.active = { promptId, prompt };
177
+ st.lastEmitSig = sig;
178
+ opts.emit({
179
+ type: "cc_term_prompt",
180
+ termId,
181
+ promptId,
182
+ kind: prompt.kind,
183
+ title: prompt.title,
184
+ ...(prompt.detail != null ? { detail: prompt.detail } : {}),
185
+ options: prompt.options,
186
+ multiSelect: false,
187
+ });
188
+ },
189
+ noteTermBye(termId) {
190
+ const st = terms.get(termId);
191
+ if (st)
192
+ emitGone(termId, st);
193
+ terms.delete(termId);
194
+ },
195
+ answer(termId, promptId, optionIndex) {
196
+ const st = terms.get(termId);
197
+ if (!st?.active)
198
+ return null;
199
+ if (st.active.promptId !== promptId)
200
+ return null;
201
+ const { prompt } = st.active;
202
+ if (!Number.isInteger(optionIndex) ||
203
+ optionIndex < 0 ||
204
+ optionIndex >= prompt.options.length) {
205
+ return null;
206
+ }
207
+ return keySequenceForAnswer(prompt.selectedIndex, optionIndex);
208
+ },
209
+ stop() {
210
+ terms.clear();
211
+ },
212
+ };
213
+ }
@@ -0,0 +1,3 @@
1
+ // Wire protocol between the cc-bridge (user's Mac) and Nexting cloud.
2
+ // This is the contract the cloud `claude-code-handler` implements.
3
+ export {};
@@ -0,0 +1,80 @@
1
+ // Pure orchestrator over a pty handle: fan pty output to the local terminal and
2
+ // the remote (cloud→phone) sender, inject input, and keep a rolling byte buffer
3
+ // so a newly-connecting phone can replay an approximation of the current screen.
4
+ // The pty is injected (spawnPty) so this is fully unit-testable without node-pty.
5
+ export function createMirror(o) {
6
+ const cap = o.maxBuffer ?? 256 * 1024;
7
+ let buf = "";
8
+ let cols = o.cols;
9
+ let rows = o.rows;
10
+ let exited = false;
11
+ const pty = o.spawnPty({
12
+ command: o.command,
13
+ args: o.args,
14
+ cwd: o.cwd,
15
+ cols: o.cols,
16
+ rows: o.rows,
17
+ });
18
+ const finish = () => {
19
+ if (exited)
20
+ return;
21
+ exited = true;
22
+ o.onExit?.();
23
+ };
24
+ const safeResize = (c, r) => {
25
+ if (exited)
26
+ return;
27
+ try {
28
+ pty.resize(c, r);
29
+ }
30
+ catch {
31
+ finish();
32
+ }
33
+ };
34
+ pty.onData((d) => {
35
+ // The local terminal and the phone BOTH get the UNFILTERED byte stream. The
36
+ // shim must be fully transparent: `claude` has to behave exactly like a native
37
+ // run, so its mouse-tracking enable sequences (e.g. ?1003h) must reach the
38
+ // user's terminal. We used to strip those from the local stream, but that made
39
+ // terminals like Ghostty downgrade the scroll wheel to arrow keys (→ claude's
40
+ // input-history navigation) instead of letting claude handle scroll itself.
41
+ // Transparency wins: never alter what the terminal sees vs. a bare `claude`.
42
+ o.onLocal(d);
43
+ o.onOutput(d);
44
+ buf += d;
45
+ if (buf.length > cap)
46
+ buf = buf.slice(buf.length - cap);
47
+ });
48
+ pty.onExit(finish);
49
+ return {
50
+ writeInput: (d) => {
51
+ if (exited)
52
+ return;
53
+ try {
54
+ pty.write(d);
55
+ }
56
+ catch {
57
+ finish();
58
+ }
59
+ },
60
+ resize: (c, r) => {
61
+ cols = c;
62
+ rows = r;
63
+ safeResize(c, r);
64
+ },
65
+ replayBuffer: () => buf,
66
+ refresh: () => {
67
+ const shrunk = Math.max(1, rows - 1);
68
+ safeResize(cols, shrunk);
69
+ setTimeout(() => {
70
+ safeResize(cols, rows);
71
+ }, 60);
72
+ },
73
+ stop: () => {
74
+ if (exited)
75
+ return;
76
+ exited = true;
77
+ pty.kill();
78
+ },
79
+ };
80
+ }