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,292 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ const DEFAULT_MAX_BYTES = 512 * 1024;
5
+ const MAX_INLINE_BYTES = 12 * 1024 * 1024;
6
+ const DEFAULT_DIR_LIMIT = 100;
7
+ const IMAGE_MIME = {
8
+ ".gif": "image/gif",
9
+ ".heic": "image/heic",
10
+ ".jpg": "image/jpeg",
11
+ ".jpeg": "image/jpeg",
12
+ ".png": "image/png",
13
+ ".webp": "image/webp",
14
+ };
15
+ const TEXT_MIME = {
16
+ ".c": "text/x-c",
17
+ ".cc": "text/x-c++",
18
+ ".cpp": "text/x-c++",
19
+ ".css": "text/css",
20
+ ".csv": "text/csv",
21
+ ".go": "text/x-go",
22
+ ".h": "text/x-c",
23
+ ".hpp": "text/x-c++",
24
+ ".htm": "text/html",
25
+ ".html": "text/html",
26
+ ".java": "text/x-java-source",
27
+ ".js": "text/javascript",
28
+ ".json": "application/json",
29
+ ".jsx": "text/javascript",
30
+ ".kt": "text/x-kotlin",
31
+ ".log": "text/plain",
32
+ ".m": "text/x-objective-c",
33
+ ".md": "text/markdown",
34
+ ".mdx": "text/markdown",
35
+ ".mm": "text/x-objective-c++",
36
+ ".py": "text/x-python",
37
+ ".rb": "text/x-ruby",
38
+ ".rs": "text/x-rust",
39
+ ".sh": "text/x-shellscript",
40
+ ".sql": "application/sql",
41
+ ".swift": "text/x-swift",
42
+ ".toml": "application/toml",
43
+ ".ts": "text/typescript",
44
+ ".tsx": "text/typescript",
45
+ ".txt": "text/plain",
46
+ ".xml": "application/xml",
47
+ ".yaml": "application/yaml",
48
+ ".yml": "application/yaml",
49
+ };
50
+ export async function readFilePreview(input) {
51
+ const parsed = resolvePreviewTarget(input.path, input.cwd);
52
+ if ("error" in parsed) {
53
+ return errorPreview(input.path, parsed.resolvedPath, parsed.kind, parsed.error);
54
+ }
55
+ let stat;
56
+ try {
57
+ stat = await fs.stat(parsed.resolvedPath);
58
+ }
59
+ catch (err) {
60
+ return errorPreview(input.path, parsed.resolvedPath, "missing", nodeFileError(err), parsed.lineStart);
61
+ }
62
+ const common = {
63
+ path: input.path,
64
+ resolvedPath: parsed.resolvedPath,
65
+ name: path.basename(parsed.resolvedPath),
66
+ sizeBytes: stat.size,
67
+ mtimeMs: stat.mtimeMs,
68
+ lineStart: parsed.lineStart,
69
+ };
70
+ if (stat.isDirectory()) {
71
+ try {
72
+ const names = await fs.readdir(parsed.resolvedPath);
73
+ const sortedNames = names.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }));
74
+ const entries = (await Promise.all(sortedNames
75
+ .slice(0, input.dirLimit ?? DEFAULT_DIR_LIMIT)
76
+ .map(async (name) => {
77
+ const childPath = path.join(parsed.resolvedPath, name);
78
+ try {
79
+ const child = await fs.stat(childPath);
80
+ return {
81
+ name,
82
+ path: childPath,
83
+ kind: child.isDirectory()
84
+ ? "directory"
85
+ : "file",
86
+ };
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ })))
92
+ .filter((entry) => entry != null)
93
+ .sort((a, b) => {
94
+ if (a.kind !== b.kind)
95
+ return a.kind === "directory" ? -1 : 1;
96
+ return a.name.localeCompare(b.name, undefined, {
97
+ numeric: true,
98
+ sensitivity: "base",
99
+ });
100
+ });
101
+ return {
102
+ ...common,
103
+ kind: "directory",
104
+ mimeType: "inode/directory",
105
+ entries,
106
+ truncated: names.length > entries.length,
107
+ };
108
+ }
109
+ catch (err) {
110
+ return {
111
+ ...common,
112
+ kind: "directory",
113
+ mimeType: "inode/directory",
114
+ entries: [],
115
+ error: nodeFileError(err),
116
+ };
117
+ }
118
+ }
119
+ if (!stat.isFile()) {
120
+ return {
121
+ ...common,
122
+ kind: "unsupported",
123
+ mimeType: "application/octet-stream",
124
+ error: "unsupported_file_type",
125
+ };
126
+ }
127
+ const maxBytes = requestedMaxBytes(input.maxBytes);
128
+ const kind = kindForPath(parsed.resolvedPath);
129
+ const mimeType = mimeTypeForPath(parsed.resolvedPath, kind);
130
+ const overLimit = stat.size > maxBytes;
131
+ if (overLimit && kind !== "text") {
132
+ return {
133
+ ...common,
134
+ kind,
135
+ mimeType,
136
+ truncated: true,
137
+ };
138
+ }
139
+ const buffer = await readCapped(parsed.resolvedPath, maxBytes);
140
+ const truncated = stat.size > buffer.length;
141
+ if (kind === "text" || (kind === "binary" && isLikelyText(buffer))) {
142
+ return {
143
+ ...common,
144
+ kind: "text",
145
+ mimeType: kind === "text" ? mimeType : "text/plain",
146
+ text: buffer.toString("utf8"),
147
+ truncated,
148
+ };
149
+ }
150
+ return {
151
+ ...common,
152
+ kind,
153
+ mimeType,
154
+ contentBase64: buffer.toString("base64"),
155
+ truncated,
156
+ };
157
+ }
158
+ function resolvePreviewTarget(raw, cwd) {
159
+ let target = raw.trim();
160
+ if (target.startsWith("<") && target.endsWith(">") && target.length >= 2) {
161
+ target = target.slice(1, -1).trim();
162
+ }
163
+ if (!target) {
164
+ return { error: "missing_path", kind: "unsupported", resolvedPath: "" };
165
+ }
166
+ const fragmentLine = lineFromFragment(target);
167
+ target = target.split("#", 1)[0];
168
+ let decodedTarget = target;
169
+ let lineStart = fragmentLine;
170
+ const suffixed = stripLineSuffix(decodedTarget);
171
+ decodedTarget = suffixed.path;
172
+ lineStart = lineStart ?? suffixed.lineStart;
173
+ const scheme = schemeOf(decodedTarget);
174
+ if (scheme && scheme !== "file") {
175
+ return {
176
+ error: "unsupported_scheme",
177
+ kind: "unsupported",
178
+ resolvedPath: decodedTarget,
179
+ };
180
+ }
181
+ if (scheme === "file") {
182
+ try {
183
+ const url = new URL(decodedTarget);
184
+ decodedTarget = decodeURIComponent(url.pathname);
185
+ }
186
+ catch {
187
+ return {
188
+ error: "invalid_file_url",
189
+ kind: "unsupported",
190
+ resolvedPath: decodedTarget,
191
+ };
192
+ }
193
+ }
194
+ else {
195
+ decodedTarget = decodedTarget.replace(/^~(?=$|\/)/, os.homedir());
196
+ decodedTarget = decodedTarget.replace(/%([0-9a-fA-F]{2})/g, (_m, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
197
+ }
198
+ const base = cwd && path.isAbsolute(cwd) ? cwd : os.homedir();
199
+ const resolvedPath = path.isAbsolute(decodedTarget)
200
+ ? path.normalize(decodedTarget)
201
+ : path.resolve(base, decodedTarget);
202
+ return { resolvedPath, lineStart };
203
+ }
204
+ function lineFromFragment(target) {
205
+ const idx = target.indexOf("#");
206
+ if (idx < 0)
207
+ return undefined;
208
+ const fragment = target.slice(idx + 1);
209
+ const match = fragment.match(/(?:^|[^\d])L?(\d+)(?:$|[^\d])/i);
210
+ if (!match)
211
+ return undefined;
212
+ const line = Number.parseInt(match[1], 10);
213
+ return Number.isFinite(line) && line > 0 ? line : undefined;
214
+ }
215
+ function stripLineSuffix(target) {
216
+ const match = target.match(/^(.*):(\d+)(?::\d+)?$/);
217
+ if (!match || !match[1])
218
+ return { path: target };
219
+ const line = Number.parseInt(match[2], 10);
220
+ return {
221
+ path: match[1],
222
+ lineStart: Number.isFinite(line) && line > 0 ? line : undefined,
223
+ };
224
+ }
225
+ function schemeOf(target) {
226
+ const match = target.match(/^([a-z][a-z0-9+.-]*):/i);
227
+ return match ? match[1].toLowerCase() : null;
228
+ }
229
+ function requestedMaxBytes(value) {
230
+ if (!Number.isFinite(value ?? NaN))
231
+ return DEFAULT_MAX_BYTES;
232
+ return Math.max(1, Math.min(Math.floor(value), MAX_INLINE_BYTES));
233
+ }
234
+ async function readCapped(filePath, maxBytes) {
235
+ const handle = await fs.open(filePath, "r");
236
+ try {
237
+ const buffer = Buffer.alloc(maxBytes);
238
+ const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
239
+ return buffer.subarray(0, bytesRead);
240
+ }
241
+ finally {
242
+ await handle.close();
243
+ }
244
+ }
245
+ function kindForPath(filePath) {
246
+ const ext = path.extname(filePath).toLowerCase();
247
+ if (ext === ".pdf")
248
+ return "pdf";
249
+ if (IMAGE_MIME[ext])
250
+ return "image";
251
+ if (TEXT_MIME[ext])
252
+ return "text";
253
+ return "binary";
254
+ }
255
+ function mimeTypeForPath(filePath, kind) {
256
+ const ext = path.extname(filePath).toLowerCase();
257
+ if (kind === "image")
258
+ return IMAGE_MIME[ext] ?? "image/*";
259
+ if (kind === "pdf")
260
+ return "application/pdf";
261
+ if (kind === "text")
262
+ return TEXT_MIME[ext] ?? "text/plain";
263
+ return "application/octet-stream";
264
+ }
265
+ function isLikelyText(buffer) {
266
+ const sample = buffer.subarray(0, Math.min(buffer.length, 8192));
267
+ if (sample.includes(0))
268
+ return false;
269
+ return !sample.toString("utf8").includes("\uFFFD");
270
+ }
271
+ function errorPreview(rawPath, resolvedPath, kind, error, lineStart) {
272
+ return {
273
+ path: rawPath,
274
+ resolvedPath,
275
+ name: resolvedPath ? path.basename(resolvedPath) : rawPath,
276
+ kind,
277
+ mimeType: "application/octet-stream",
278
+ sizeBytes: 0,
279
+ mtimeMs: 0,
280
+ lineStart,
281
+ truncated: false,
282
+ error,
283
+ };
284
+ }
285
+ function nodeFileError(err) {
286
+ const code = err.code;
287
+ if (code === "ENOENT")
288
+ return "not_found";
289
+ if (code === "EACCES" || code === "EPERM")
290
+ return "permission_denied";
291
+ return "read_failed";
292
+ }
@@ -0,0 +1,28 @@
1
+ // Local IPC protocol between a shell (one wrapped `claude` pty) and the hub
2
+ // daemon. Newline-delimited JSON over a unix socket. Pure encode/decode so the
3
+ // framing is unit-testable without any socket.
4
+ /** One frame → a newline-terminated JSON line. */
5
+ export function encodeFrame(f) {
6
+ return JSON.stringify(f) + "\n";
7
+ }
8
+ /** Pull whole frames out of an accumulated buffer; return parsed frames plus the
9
+ * leftover partial line (to prepend to the next chunk). Bad lines are skipped. */
10
+ export function decodeFrames(buffer) {
11
+ const frames = [];
12
+ let rest = buffer;
13
+ let nl;
14
+ while ((nl = rest.indexOf("\n")) !== -1) {
15
+ const line = rest.slice(0, nl);
16
+ rest = rest.slice(nl + 1);
17
+ const trimmed = line.trim();
18
+ if (!trimmed)
19
+ continue;
20
+ try {
21
+ frames.push(JSON.parse(trimmed));
22
+ }
23
+ catch {
24
+ /* skip malformed line */
25
+ }
26
+ }
27
+ return { frames, rest };
28
+ }
@@ -0,0 +1,106 @@
1
+ // Real unix-socket I/O for the hub: server side wraps each connected shell into
2
+ // a ShellConn for the pure hub core; client side gives a shell a HubTransport.
3
+ // Thin glue only — framing/logic live in hub-protocol.ts / hub.ts / shell.ts.
4
+ import net from "node:net";
5
+ import fs from "node:fs";
6
+ import { decodeFrames, encodeFrame } from "./hub-protocol.js";
7
+ /** Listen on a unix socket; each connecting shell becomes a ShellConn fed to the hub. */
8
+ export function startHubServer(hub, socketPath, log) {
9
+ try {
10
+ fs.unlinkSync(socketPath); // clear a stale socket file
11
+ }
12
+ catch {
13
+ /* none */
14
+ }
15
+ const server = net.createServer((sock) => {
16
+ sock.setEncoding("utf8");
17
+ let buf = "";
18
+ let frameCb = () => { };
19
+ let closeCb = () => { };
20
+ const conn = {
21
+ send: (f) => {
22
+ try {
23
+ sock.write(encodeFrame(f));
24
+ }
25
+ catch {
26
+ /* socket gone */
27
+ }
28
+ },
29
+ onFrame: (cb) => {
30
+ frameCb = cb;
31
+ },
32
+ onClose: (cb) => {
33
+ closeCb = cb;
34
+ },
35
+ };
36
+ hub.addShell(conn); // registers frameCb / closeCb synchronously
37
+ sock.on("data", (chunk) => {
38
+ const res = decodeFrames(buf + chunk);
39
+ buf = res.rest;
40
+ for (const f of res.frames)
41
+ frameCb(f);
42
+ });
43
+ sock.on("close", () => closeCb());
44
+ sock.on("error", () => {
45
+ /* a broken shell socket must not crash the hub */
46
+ });
47
+ });
48
+ server.on("error", (e) => log(`hub server error: ${e.message}`));
49
+ server.listen(socketPath, () => log(`hub listening on ${socketPath}`));
50
+ return {
51
+ close: () => {
52
+ try {
53
+ server.close();
54
+ }
55
+ catch {
56
+ /* ignore */
57
+ }
58
+ try {
59
+ fs.unlinkSync(socketPath);
60
+ }
61
+ catch {
62
+ /* ignore */
63
+ }
64
+ },
65
+ };
66
+ }
67
+ /** Connect a shell to the hub's unix socket as a HubTransport. A failed connect
68
+ * (hub not up yet → ECONNREFUSED) or any socket error surfaces through onClose so
69
+ * the shell can retry; 'close' always follows 'error', but we guard against a
70
+ * double fire so the shell only schedules one reconnect. */
71
+ export function realHubConnect(socketPath) {
72
+ const sock = net.connect(socketPath);
73
+ sock.setEncoding("utf8");
74
+ let closed = false;
75
+ let closeCb = () => { };
76
+ const fireClose = () => {
77
+ if (closed)
78
+ return;
79
+ closed = true;
80
+ closeCb();
81
+ };
82
+ sock.on("error", fireClose); // swallow + surface as close (else it throws)
83
+ sock.on("close", fireClose);
84
+ return {
85
+ write: (l) => {
86
+ try {
87
+ sock.write(l);
88
+ }
89
+ catch {
90
+ /* ignore */
91
+ }
92
+ },
93
+ onData: (cb) => sock.on("data", (c) => cb(c)),
94
+ onClose: (cb) => {
95
+ closeCb = cb;
96
+ },
97
+ end: () => {
98
+ try {
99
+ sock.end();
100
+ }
101
+ catch {
102
+ /* ignore */
103
+ }
104
+ },
105
+ };
106
+ }
package/dist/hub.js ADDED
@@ -0,0 +1,84 @@
1
+ export function createHub(deps) {
2
+ const terms = new Map();
3
+ function removeTerm(termId) {
4
+ if (terms.delete(termId)) {
5
+ deps.onCloudSend({ type: "cc_term_bye", termId });
6
+ }
7
+ }
8
+ function addShell(conn) {
9
+ let myTermId = null;
10
+ conn.onFrame((f) => {
11
+ switch (f.t) {
12
+ case "reg":
13
+ myTermId = f.termId;
14
+ terms.set(f.termId, { meta: f, shell: conn });
15
+ deps.onCloudSend({
16
+ type: "cc_term_hello",
17
+ termId: f.termId,
18
+ cwd: f.cwd,
19
+ title: f.title,
20
+ cols: f.cols,
21
+ rows: f.rows,
22
+ sessionId: f.sessionId,
23
+ });
24
+ break;
25
+ case "out":
26
+ deps.onCloudSend({
27
+ type: "cc_term_output",
28
+ termId: f.termId,
29
+ data: f.data,
30
+ });
31
+ break;
32
+ case "bye":
33
+ removeTerm(f.termId);
34
+ break;
35
+ // in / resize are hub→shell only; ignore if a shell sends them
36
+ }
37
+ });
38
+ conn.onClose(() => {
39
+ if (myTermId)
40
+ removeTerm(myTermId);
41
+ });
42
+ }
43
+ function handleCloud(msg) {
44
+ const termId = msg.termId;
45
+ if (!termId)
46
+ return;
47
+ const entry = terms.get(termId);
48
+ if (!entry)
49
+ return; // unknown term — don't crash
50
+ if (msg.type === "cc_term_input") {
51
+ entry.shell.send({ t: "in", termId, data: String(msg.data ?? "") });
52
+ }
53
+ else if (msg.type === "cc_term_resize") {
54
+ entry.shell.send({
55
+ t: "resize",
56
+ termId,
57
+ cols: Number(msg.cols) || 80,
58
+ rows: Number(msg.rows) || 24,
59
+ });
60
+ }
61
+ else if (msg.type === "cc_term_refresh") {
62
+ entry.shell.send({ t: "refresh", termId });
63
+ }
64
+ }
65
+ function resyncToCloud() {
66
+ for (const { meta } of terms.values()) {
67
+ deps.onCloudSend({
68
+ type: "cc_term_hello",
69
+ termId: meta.termId,
70
+ cwd: meta.cwd,
71
+ title: meta.title,
72
+ cols: meta.cols,
73
+ rows: meta.rows,
74
+ sessionId: meta.sessionId,
75
+ });
76
+ }
77
+ }
78
+ return {
79
+ addShell,
80
+ handleCloud,
81
+ activeTermIds: () => [...terms.keys()],
82
+ resyncToCloud,
83
+ };
84
+ }
@@ -0,0 +1,33 @@
1
+ // Pure helpers for editing a shell rc file to put the Nexting `claude` shim on
2
+ // PATH (and removing it). Kept pure + tested because a bad rc edit can break the
3
+ // user's shell. The actual fs/launchd side-effects live in cli.ts.
4
+ export const MARK_START = "# >>> pinclaw cc >>>";
5
+ export const MARK_END = "# <<< pinclaw cc <<<";
6
+ const BLOCK = [
7
+ MARK_START,
8
+ 'export PATH="$HOME/.nexting/bin:$PATH"',
9
+ MARK_END,
10
+ ].join("\n");
11
+ /** Append the PATH block once (idempotent — no-op if already present). */
12
+ export function addPinclawPathBlock(rcText) {
13
+ if (rcText.includes(MARK_START))
14
+ return rcText;
15
+ const sep = rcText.length === 0 || rcText.endsWith("\n") ? "" : "\n";
16
+ return rcText + sep + BLOCK + "\n";
17
+ }
18
+ /** Remove the Nexting block (and a single trailing blank line it may have added). */
19
+ export function stripPinclawBlock(rcText) {
20
+ const start = rcText.indexOf(MARK_START);
21
+ if (start === -1)
22
+ return rcText;
23
+ const endMark = rcText.indexOf(MARK_END, start);
24
+ if (endMark === -1)
25
+ return rcText;
26
+ let end = endMark + MARK_END.length;
27
+ if (rcText[end] === "\n")
28
+ end += 1; // consume the block's own trailing newline
29
+ // `add` always places the block at a line boundary (start follows a newline or
30
+ // is at position 0), so removing [start, end) leaves the surrounding content's
31
+ // own newlines intact — making strip a clean inverse of add.
32
+ return rcText.slice(0, start) + rcText.slice(end);
33
+ }
@@ -0,0 +1,32 @@
1
+ import { renderFrame } from "./terminal-render.js";
2
+ export function startLocalShell(opts) {
3
+ let sid = opts.sessionId;
4
+ const runner = opts.attachMgr.attachLocal({ sessionId: sid, cwd: opts.cwd });
5
+ const sink = (f) => {
6
+ if (sid === "new" && f.type === "event" && f.kind === "system_init") {
7
+ const real = f.payload.session_id;
8
+ if (typeof real === "string" && real) {
9
+ opts.attachMgr.rekey("new", real);
10
+ sid = real;
11
+ opts.write(`\n[会话已就绪: ${real}] 手机现在可以接入这个会话。\n`);
12
+ }
13
+ }
14
+ const text = renderFrame(f);
15
+ if (text)
16
+ opts.write(text);
17
+ };
18
+ runner.addSink(sink);
19
+ return {
20
+ onLine: (line) => {
21
+ const t = line.trim();
22
+ if (!t)
23
+ return;
24
+ runner.send(t);
25
+ },
26
+ currentSessionId: () => sid,
27
+ stop: () => {
28
+ runner.removeSink(sink);
29
+ runner.stop();
30
+ },
31
+ };
32
+ }