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
|
@@ -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
|
+
}
|
package/dist/protocol.js
ADDED
|
@@ -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
|
+
}
|