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,119 @@
|
|
|
1
|
+
import { sealEnvelope, openEnvelope, isEnvelope } from "./crypto.js";
|
|
2
|
+
/**
|
|
3
|
+
* EnvelopeCodec — the single seam between bridge transport and crypto.
|
|
4
|
+
*
|
|
5
|
+
* Business logic stays plaintext; only this module calls sealEnvelope / openEnvelope.
|
|
6
|
+
*
|
|
7
|
+
* AAD contract (must match phone side exactly):
|
|
8
|
+
* Outbound (bridge → phone):
|
|
9
|
+
* cc_snapshot title → `${sid}|0|title`
|
|
10
|
+
* summary → `${sid}|0|summary`
|
|
11
|
+
* msg.text → `${sid}|0|msg`
|
|
12
|
+
* cc_event transcript_append entry.text → `${sid}|0|text`
|
|
13
|
+
* stream_*_delta payload.delta → `${sid}|0|delta`
|
|
14
|
+
*
|
|
15
|
+
* Inbound (phone → bridge):
|
|
16
|
+
* cc_send content → `${sid}|0|send`
|
|
17
|
+
* cc_answer updatedInput → `${sid}|0|answer`
|
|
18
|
+
*
|
|
19
|
+
* Seq is always `0` (no per-frame sequence number in the current wire protocol).
|
|
20
|
+
*/
|
|
21
|
+
export class EnvelopeCodec {
|
|
22
|
+
keys;
|
|
23
|
+
enabled;
|
|
24
|
+
constructor(keys, enabled) {
|
|
25
|
+
this.keys = keys;
|
|
26
|
+
this.enabled = enabled;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Encrypt content fields of an outbound frame.
|
|
30
|
+
* Returns a new object — never mutates the input.
|
|
31
|
+
* Passthrough (returns the SAME reference) when disabled.
|
|
32
|
+
*/
|
|
33
|
+
encryptOutbound(frame) {
|
|
34
|
+
if (!this.enabled())
|
|
35
|
+
return frame;
|
|
36
|
+
switch (frame?.type) {
|
|
37
|
+
case "cc_snapshot": {
|
|
38
|
+
const sessions = (frame.sessions ?? []).map((s) => {
|
|
39
|
+
const sid = s.sessionId;
|
|
40
|
+
const aad = (field) => `${sid}|0|${field}`;
|
|
41
|
+
const out = { ...s };
|
|
42
|
+
if (typeof s.title === "string")
|
|
43
|
+
out.title = sealEnvelope(s.title, this.keys.cekFor(sid), aad("title"));
|
|
44
|
+
if (typeof s.summary === "string")
|
|
45
|
+
out.summary = sealEnvelope(s.summary, this.keys.cekFor(sid), aad("summary"));
|
|
46
|
+
if (Array.isArray(s.recentMessages))
|
|
47
|
+
out.recentMessages = s.recentMessages.map((m) => typeof m?.text === "string"
|
|
48
|
+
? {
|
|
49
|
+
...m,
|
|
50
|
+
text: sealEnvelope(m.text, this.keys.cekFor(sid), aad("msg")),
|
|
51
|
+
}
|
|
52
|
+
: m);
|
|
53
|
+
return out;
|
|
54
|
+
});
|
|
55
|
+
return { ...frame, sessions };
|
|
56
|
+
}
|
|
57
|
+
case "cc_event": {
|
|
58
|
+
const sid = frame.sessionId;
|
|
59
|
+
const cek = this.keys.cekFor(sid);
|
|
60
|
+
const p = frame.payload ?? {};
|
|
61
|
+
if (frame.kind === "transcript_append" && Array.isArray(p.entries)) {
|
|
62
|
+
return {
|
|
63
|
+
...frame,
|
|
64
|
+
payload: {
|
|
65
|
+
...p,
|
|
66
|
+
entries: p.entries.map((e) => typeof e?.text === "string"
|
|
67
|
+
? { ...e, text: sealEnvelope(e.text, cek, `${sid}|0|text`) }
|
|
68
|
+
: e),
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if ((frame.kind === "stream_text_delta" ||
|
|
73
|
+
frame.kind === "stream_thinking_delta") &&
|
|
74
|
+
typeof p.delta === "string") {
|
|
75
|
+
return {
|
|
76
|
+
...frame,
|
|
77
|
+
payload: {
|
|
78
|
+
...p,
|
|
79
|
+
delta: sealEnvelope(p.delta, cek, `${sid}|0|delta`),
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return frame;
|
|
84
|
+
}
|
|
85
|
+
default:
|
|
86
|
+
return frame;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Decrypt content fields of an inbound frame.
|
|
91
|
+
* Always attempts decryption when a field IS an envelope (even when disabled),
|
|
92
|
+
* so a temporarily-disabled bridge can still receive encrypted frames from the phone.
|
|
93
|
+
* Passthrough when field is plaintext — back-compat with unencrypted phone clients.
|
|
94
|
+
*/
|
|
95
|
+
decryptInbound(frame) {
|
|
96
|
+
switch (frame?.type) {
|
|
97
|
+
case "cc_send": {
|
|
98
|
+
const sid = frame.sessionId;
|
|
99
|
+
if (isEnvelope(frame.content) && this.keys.hasCek(sid))
|
|
100
|
+
return {
|
|
101
|
+
...frame,
|
|
102
|
+
content: openEnvelope(frame.content, this.keys.cekFor(sid), `${sid}|0|send`),
|
|
103
|
+
};
|
|
104
|
+
return frame;
|
|
105
|
+
}
|
|
106
|
+
case "cc_answer": {
|
|
107
|
+
const sid = frame.sessionId;
|
|
108
|
+
if (isEnvelope(frame.updatedInput) && this.keys.hasCek(sid))
|
|
109
|
+
return {
|
|
110
|
+
...frame,
|
|
111
|
+
updatedInput: openEnvelope(frame.updatedInput, this.keys.cekFor(sid), `${sid}|0|answer`),
|
|
112
|
+
};
|
|
113
|
+
return frame;
|
|
114
|
+
}
|
|
115
|
+
default:
|
|
116
|
+
return frame;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { randomBytes, createCipheriv, createDecipheriv, createHash, generateKeyPairSync, createPublicKey, createPrivateKey, diffieHellman, hkdfSync, } from "node:crypto";
|
|
2
|
+
const ALG = "aes-256-gcm";
|
|
3
|
+
const ENV_PREFIX = "e2e:v1:";
|
|
4
|
+
const WRAP_PREFIX = "wrap:v1:";
|
|
5
|
+
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
6
|
+
export function generateCEK() {
|
|
7
|
+
// 32-byte key (Uint8Array-compatible); Swift/Kotlin treat as raw bytes.
|
|
8
|
+
return randomBytes(32);
|
|
9
|
+
}
|
|
10
|
+
export function sealEnvelope(plaintext, cek, aad) {
|
|
11
|
+
const nonce = randomBytes(12);
|
|
12
|
+
const cipher = createCipheriv(ALG, cek, nonce);
|
|
13
|
+
cipher.setAAD(Buffer.from(aad, "utf8"));
|
|
14
|
+
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
15
|
+
const tag = cipher.getAuthTag();
|
|
16
|
+
return ENV_PREFIX + Buffer.concat([nonce, ct, tag]).toString("base64");
|
|
17
|
+
}
|
|
18
|
+
export function openEnvelope(sealed, cek, aad) {
|
|
19
|
+
if (!sealed.startsWith(ENV_PREFIX))
|
|
20
|
+
throw new Error("not an e2e envelope");
|
|
21
|
+
const buf = Buffer.from(sealed.slice(ENV_PREFIX.length), "base64");
|
|
22
|
+
if (buf.length < 12 + 16)
|
|
23
|
+
throw new Error("envelope too short");
|
|
24
|
+
const nonce = buf.subarray(0, 12);
|
|
25
|
+
const tag = buf.subarray(buf.length - 16);
|
|
26
|
+
const ct = buf.subarray(12, buf.length - 16);
|
|
27
|
+
const decipher = createDecipheriv(ALG, cek, nonce);
|
|
28
|
+
decipher.setAAD(Buffer.from(aad, "utf8"));
|
|
29
|
+
decipher.setAuthTag(tag);
|
|
30
|
+
return decipher.update(ct, undefined, "utf8") + decipher.final("utf8");
|
|
31
|
+
}
|
|
32
|
+
export function isEnvelope(v) {
|
|
33
|
+
return typeof v === "string" && v.startsWith(ENV_PREFIX);
|
|
34
|
+
}
|
|
35
|
+
export function generateDeviceKeyPair() {
|
|
36
|
+
const { publicKey, privateKey } = generateKeyPairSync("x25519");
|
|
37
|
+
return {
|
|
38
|
+
publicKeyB64: publicKey
|
|
39
|
+
.export({ type: "spki", format: "der" })
|
|
40
|
+
.toString("base64"),
|
|
41
|
+
privateKeyB64: privateKey
|
|
42
|
+
.export({ type: "pkcs8", format: "der" })
|
|
43
|
+
.toString("base64"),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// HKDF-SHA256: salt = UTF-8(sessionId), info = UTF-8("pinclaw-e2e-cek-wrap"), L=32.
|
|
47
|
+
// Must match cloud/iOS/Android byte-for-byte or unwrap fails silently.
|
|
48
|
+
function deriveWrapKey(shared, salt) {
|
|
49
|
+
return Buffer.from(hkdfSync("sha256", shared, Buffer.from(salt, "utf8"), "pinclaw-e2e-cek-wrap", 32));
|
|
50
|
+
}
|
|
51
|
+
export function wrapCEK(cek, recipientPubB64, sessionId) {
|
|
52
|
+
const eph = generateKeyPairSync("x25519");
|
|
53
|
+
const recipientPub = createPublicKey({
|
|
54
|
+
key: Buffer.from(recipientPubB64, "base64"),
|
|
55
|
+
type: "spki",
|
|
56
|
+
format: "der",
|
|
57
|
+
});
|
|
58
|
+
const shared = Buffer.from(diffieHellman({ privateKey: eph.privateKey, publicKey: recipientPub }));
|
|
59
|
+
const wrapKey = deriveWrapKey(shared, sessionId);
|
|
60
|
+
const nonce = randomBytes(12);
|
|
61
|
+
const cipher = createCipheriv(ALG, wrapKey, nonce);
|
|
62
|
+
const ct = Buffer.concat([cipher.update(cek), cipher.final()]);
|
|
63
|
+
const tag = cipher.getAuthTag();
|
|
64
|
+
const ephPub = eph.publicKey.export({ type: "spki", format: "der" });
|
|
65
|
+
return (WRAP_PREFIX + Buffer.concat([ephPub, nonce, ct, tag]).toString("base64"));
|
|
66
|
+
}
|
|
67
|
+
export function unwrapCEK(wrapped, recipientPrivB64, sessionId) {
|
|
68
|
+
if (!wrapped.startsWith(WRAP_PREFIX))
|
|
69
|
+
throw new Error("not a wrapped key");
|
|
70
|
+
const buf = Buffer.from(wrapped.slice(WRAP_PREFIX.length), "base64");
|
|
71
|
+
if (buf.length < 44 + 12 + 16)
|
|
72
|
+
throw new Error("wrapped key too short");
|
|
73
|
+
const ephPubDer = buf.subarray(0, 44); // X25519 SPKI DER is 44 bytes
|
|
74
|
+
const nonce = buf.subarray(44, 56);
|
|
75
|
+
const tag = buf.subarray(buf.length - 16);
|
|
76
|
+
const ct = buf.subarray(56, buf.length - 16);
|
|
77
|
+
const ephPub = createPublicKey({
|
|
78
|
+
key: ephPubDer,
|
|
79
|
+
type: "spki",
|
|
80
|
+
format: "der",
|
|
81
|
+
});
|
|
82
|
+
const priv = createPrivateKey({
|
|
83
|
+
key: Buffer.from(recipientPrivB64, "base64"),
|
|
84
|
+
type: "pkcs8",
|
|
85
|
+
format: "der",
|
|
86
|
+
});
|
|
87
|
+
const shared = Buffer.from(diffieHellman({ privateKey: priv, publicKey: ephPub }));
|
|
88
|
+
const wrapKey = deriveWrapKey(shared, sessionId);
|
|
89
|
+
const decipher = createDecipheriv(ALG, wrapKey, nonce);
|
|
90
|
+
decipher.setAuthTag(tag);
|
|
91
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Binary-oriented AES-256-GCM seal/open.
|
|
95
|
+
*
|
|
96
|
+
* Layout on wire: nonce[12] || ciphertext || tag[16]
|
|
97
|
+
* No text prefix — the metadata `encrypted:true` flag marks blobs encrypted.
|
|
98
|
+
* AAD for media: `${sessionId}|0|media`
|
|
99
|
+
*/
|
|
100
|
+
export function sealBytes(data, cek, aad) {
|
|
101
|
+
const nonce = randomBytes(12);
|
|
102
|
+
const cipher = createCipheriv(ALG, cek, nonce);
|
|
103
|
+
cipher.setAAD(Buffer.from(aad, "utf8"));
|
|
104
|
+
const ct = Buffer.concat([cipher.update(data), cipher.final()]);
|
|
105
|
+
const tag = cipher.getAuthTag();
|
|
106
|
+
return Buffer.concat([nonce, ct, tag]);
|
|
107
|
+
}
|
|
108
|
+
export function openBytes(buf, cek, aad) {
|
|
109
|
+
if (buf.length < 12 + 16)
|
|
110
|
+
throw new Error("encrypted buffer too short");
|
|
111
|
+
const nonce = buf.subarray(0, 12);
|
|
112
|
+
const tag = buf.subarray(buf.length - 16);
|
|
113
|
+
const ct = buf.subarray(12, buf.length - 16);
|
|
114
|
+
const decipher = createDecipheriv(ALG, cek, nonce);
|
|
115
|
+
decipher.setAAD(Buffer.from(aad, "utf8"));
|
|
116
|
+
decipher.setAuthTag(tag);
|
|
117
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
118
|
+
}
|
|
119
|
+
// Human-verification fingerprint (~50 bits base32), NOT a secret. Order-independent.
|
|
120
|
+
export function safetyNumber(pubA, pubB) {
|
|
121
|
+
const [x, y] = [pubA, pubB].sort();
|
|
122
|
+
const h = createHash("sha256").update(x).update(y).digest();
|
|
123
|
+
let s = "";
|
|
124
|
+
for (let i = 0; i < 10; i++)
|
|
125
|
+
s += B32[h[i] & 31];
|
|
126
|
+
return s.slice(0, 5) + "-" + s.slice(5);
|
|
127
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { generateCEK, generateDeviceKeyPair, wrapCEK, unwrapCEK, } from "./crypto.js";
|
|
2
|
+
export class SessionKeyStore {
|
|
3
|
+
idStore;
|
|
4
|
+
identity;
|
|
5
|
+
ceks = new Map(); // sessionId -> CEK
|
|
6
|
+
constructor(idStore) {
|
|
7
|
+
this.idStore = idStore;
|
|
8
|
+
this.identity = idStore.load() ?? this.#initIdentity();
|
|
9
|
+
}
|
|
10
|
+
#initIdentity() {
|
|
11
|
+
const kp = generateDeviceKeyPair();
|
|
12
|
+
this.idStore.save(kp);
|
|
13
|
+
return kp;
|
|
14
|
+
}
|
|
15
|
+
publicKeyB64() {
|
|
16
|
+
return this.identity.publicKeyB64;
|
|
17
|
+
}
|
|
18
|
+
// Get-or-create the CEK for a session (bridge originates sessions).
|
|
19
|
+
cekFor(sessionId) {
|
|
20
|
+
let c = this.ceks.get(sessionId);
|
|
21
|
+
if (!c) {
|
|
22
|
+
c = generateCEK();
|
|
23
|
+
this.ceks.set(sessionId, c);
|
|
24
|
+
}
|
|
25
|
+
return c;
|
|
26
|
+
}
|
|
27
|
+
hasCek(sessionId) {
|
|
28
|
+
return this.ceks.has(sessionId);
|
|
29
|
+
}
|
|
30
|
+
// Adopt a CEK we unwrapped (e.g. a session another device originated).
|
|
31
|
+
setCek(sessionId, cek) {
|
|
32
|
+
this.ceks.set(sessionId, cek);
|
|
33
|
+
}
|
|
34
|
+
// Wrap this session's CEK for each recipient device pubkey (for upload to cloud session_keys).
|
|
35
|
+
wrapForDevices(sessionId, recipientPubsB64) {
|
|
36
|
+
const cek = this.cekFor(sessionId);
|
|
37
|
+
return recipientPubsB64.map((pub) => ({
|
|
38
|
+
deviceKey: pub,
|
|
39
|
+
wrappedCek: wrapCEK(cek, pub, sessionId),
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
// Unwrap a CEK addressed to THIS device and cache it.
|
|
43
|
+
acceptWrapped(sessionId, wrapped) {
|
|
44
|
+
const cek = unwrapCEK(wrapped, this.identity.privateKeyB64, sessionId);
|
|
45
|
+
this.ceks.set(sessionId, cek);
|
|
46
|
+
return cek;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
// Stores the device identity keypair (JSON) in the macOS login keychain under a
|
|
3
|
+
// service name. Private key never leaves the machine. Falls back to throwing on
|
|
4
|
+
// non-darwin so callers can choose a file fallback if needed.
|
|
5
|
+
export function keychainIdentityStore(service = "pinclaw-bridge-e2e") {
|
|
6
|
+
return {
|
|
7
|
+
load() {
|
|
8
|
+
try {
|
|
9
|
+
const out = execFileSync("security", ["find-generic-password", "-s", service, "-w"], { encoding: "utf8" }).trim();
|
|
10
|
+
return out ? JSON.parse(out) : null;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
save(kp) {
|
|
17
|
+
const val = JSON.stringify(kp);
|
|
18
|
+
try {
|
|
19
|
+
execFileSync("security", ["delete-generic-password", "-s", service], {
|
|
20
|
+
stdio: "ignore",
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
/* none existed */
|
|
25
|
+
}
|
|
26
|
+
execFileSync("security", ["add-generic-password", "-s", service, "-a", service, "-w", val], { stdio: "ignore" });
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Per-session engine adapter. One instance per SessionRunner. Captures everything
|
|
2
|
+
// engine-specific: how to spawn the child, an optional async handshake before the
|
|
3
|
+
// first turn, how to encode stdin actions, and how to translate one parsed stdout
|
|
4
|
+
// JSON object into zero-or-more TranslatedFrame the cloud/phone understand.
|
|
5
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Claude Code engine adapter. Wraps the existing (unchanged) claude stdin-encode +
|
|
2
|
+
// stream-translate + stream-json spawn flags. Behavior identical to the pre-refactor
|
|
3
|
+
// hardcoded path — covered by the existing session-runner / translate / encode tests.
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import { buildUserMessageLine, buildControlAllowLine, buildControlDenyLine, } from "../stdin-encode.js";
|
|
8
|
+
import { translateChildEvent } from "../stream-translate.js";
|
|
9
|
+
/** Resolve the `claude` binary by absolute path (launchd PATH is minimal). */
|
|
10
|
+
export function resolveClaudeBin() {
|
|
11
|
+
const home = os.homedir();
|
|
12
|
+
const candidates = [
|
|
13
|
+
process.env.NEXTING_CC_CLAUDE_BIN,
|
|
14
|
+
path.join(home, "Library/pnpm/claude"),
|
|
15
|
+
path.join(home, ".local/bin/claude"),
|
|
16
|
+
path.join(home, ".npm-global/bin/claude"),
|
|
17
|
+
"/opt/homebrew/bin/claude",
|
|
18
|
+
"/usr/local/bin/claude",
|
|
19
|
+
].filter((p) => !!p);
|
|
20
|
+
for (const c of candidates) {
|
|
21
|
+
try {
|
|
22
|
+
if (fs.existsSync(c))
|
|
23
|
+
return c;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
/* keep looking */
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return "claude";
|
|
30
|
+
}
|
|
31
|
+
/** TUI substrings Claude Code prints while a turn is running (status line
|
|
32
|
+
* "… (esc to interrupt)"). Matched case-insensitively by the pty turn probe. */
|
|
33
|
+
export const CLAUDE_RUNNING_MARKERS = ["esc to interrupt"];
|
|
34
|
+
export function createClaudeAdapter() {
|
|
35
|
+
return {
|
|
36
|
+
runningMarkers: CLAUDE_RUNNING_MARKERS,
|
|
37
|
+
command(resumeSessionId, ctx) {
|
|
38
|
+
const args = [
|
|
39
|
+
"--print",
|
|
40
|
+
"--input-format",
|
|
41
|
+
"stream-json",
|
|
42
|
+
"--output-format",
|
|
43
|
+
"stream-json",
|
|
44
|
+
"--include-partial-messages",
|
|
45
|
+
"--replay-user-messages",
|
|
46
|
+
"--verbose",
|
|
47
|
+
"--dangerously-skip-permissions",
|
|
48
|
+
// Disable the native AskUserQuestion tool: in --print mode it auto-
|
|
49
|
+
// dismisses (no interactive consumer attached), so the phone could only
|
|
50
|
+
// ever SHOW the question read-only. With it disabled the model routes to
|
|
51
|
+
// our `ask_user` MCP tool (mcp-device-proxy), which blocks for a phone
|
|
52
|
+
// answer. Applies to every session so argv stays identical. Variadic
|
|
53
|
+
// flag — the next arg starts with `--`, so it consumes only
|
|
54
|
+
// "AskUserQuestion".
|
|
55
|
+
"--disallowed-tools",
|
|
56
|
+
"AskUserQuestion",
|
|
57
|
+
];
|
|
58
|
+
// Device-tool MCP proxy: `--mcp-config <path>` is a global flag that
|
|
59
|
+
// works in --print mode (verified CLI 2.1.170), so we load the per-session
|
|
60
|
+
// config file (written by mcp-config.ts) instead of dropping a `.mcp.json`
|
|
61
|
+
// into the user's repo. Pushed before --resume; order is irrelevant to
|
|
62
|
+
// claude but keeps resume last for readability.
|
|
63
|
+
if (ctx?.mcpConfigPath)
|
|
64
|
+
args.push("--mcp-config", ctx.mcpConfigPath);
|
|
65
|
+
if (resumeSessionId)
|
|
66
|
+
args.push("--resume", resumeSessionId);
|
|
67
|
+
return { bin: resolveClaudeBin(), args };
|
|
68
|
+
},
|
|
69
|
+
start: (_io, _resumeSessionId) => {
|
|
70
|
+
/* Claude needs no handshake. */
|
|
71
|
+
},
|
|
72
|
+
encodeUserTurn: (content) => buildUserMessageLine(content),
|
|
73
|
+
encodeAnswer: (id, updatedInput) => buildControlAllowLine(id, updatedInput),
|
|
74
|
+
encodeDeny: (id, message) => buildControlDenyLine(id, message),
|
|
75
|
+
translate: (parsed) => translateChildEvent(parsed),
|
|
76
|
+
};
|
|
77
|
+
}
|