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,131 @@
|
|
|
1
|
+
// Owns one persistent engine child for an attached session.
|
|
2
|
+
// Spawns it, parses stdout NDJSON line-by-line through the adapter's translate(),
|
|
3
|
+
// and exposes send / answer / deny / stop. spawn is injected for testability.
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import * as os from "node:os";
|
|
6
|
+
import { StringDecoder } from "node:string_decoder";
|
|
7
|
+
import { createClaudeAdapter } from "./engine/claude-adapter.js";
|
|
8
|
+
import { writeMcpConfig, cleanupMcpConfig, } from "./mcp-config.js";
|
|
9
|
+
/** A claude child spawned with a non-existent cwd fails with a MISLEADING
|
|
10
|
+
* `spawn <command> ENOENT` (Node attributes the missing cwd to the command).
|
|
11
|
+
* A session whose original directory was moved/deleted must still open, so fall
|
|
12
|
+
* back to the home directory when the recorded cwd is gone or isn't a directory. */
|
|
13
|
+
export function resolveSafeCwd(cwd) {
|
|
14
|
+
try {
|
|
15
|
+
if (cwd && fs.statSync(cwd).isDirectory())
|
|
16
|
+
return cwd;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
/* missing / not a directory → fall back */
|
|
20
|
+
}
|
|
21
|
+
return os.homedir();
|
|
22
|
+
}
|
|
23
|
+
export function createSessionRunner(opts) {
|
|
24
|
+
const adapter = opts.adapter ?? createClaudeAdapter();
|
|
25
|
+
// Write the per-session device-tool MCP config BEFORE spawn so the engine sees
|
|
26
|
+
// it on startup. claude loads it via --mcp-config (path returned here); codex
|
|
27
|
+
// writes ~/.codex/config.toml in place and returns null. A failed write
|
|
28
|
+
// (returns null / never throws) simply means the session runs without device
|
|
29
|
+
// tools — it must never block the session.
|
|
30
|
+
let mcpConfigPath = null;
|
|
31
|
+
if (opts.mcp) {
|
|
32
|
+
mcpConfigPath = writeMcpConfig(opts.mcp);
|
|
33
|
+
}
|
|
34
|
+
const { bin, args } = adapter.command(opts.resumeSessionId, {
|
|
35
|
+
cwd: opts.cwd,
|
|
36
|
+
mcpConfigPath: mcpConfigPath ?? undefined,
|
|
37
|
+
});
|
|
38
|
+
const command = opts.command ?? bin;
|
|
39
|
+
const child = opts.spawn(command, args, { cwd: resolveSafeCwd(opts.cwd) });
|
|
40
|
+
// Frames fan out to every sink (cloud forward + optional local terminal).
|
|
41
|
+
const sinks = new Set();
|
|
42
|
+
if (opts.onFrame)
|
|
43
|
+
sinks.add(opts.onFrame);
|
|
44
|
+
const emit = (f) => {
|
|
45
|
+
for (const s of sinks) {
|
|
46
|
+
try {
|
|
47
|
+
s(f);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* one bad sink must not break the rest of the fan-out */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const io = { write: (line) => child.stdin.write(line) };
|
|
55
|
+
adapter.setEventSink?.(emit);
|
|
56
|
+
// Decode stdout through a StringDecoder so a multi-byte UTF-8 character (e.g.
|
|
57
|
+
// any CJK glyph is 3 bytes) split across two `data` events is buffered and
|
|
58
|
+
// reassembled instead of each half being decoded independently into U+FFFD.
|
|
59
|
+
// Plain `chunk.toString()` corrupted streamed CJK deltas mid-turn (garbled
|
|
60
|
+
// text that "healed" once the final transcript was re-read from the jsonl).
|
|
61
|
+
const decoder = new StringDecoder("utf8");
|
|
62
|
+
let buf = "";
|
|
63
|
+
child.stdout.on("data", (chunk) => {
|
|
64
|
+
buf += typeof chunk === "string" ? chunk : decoder.write(chunk);
|
|
65
|
+
let nl;
|
|
66
|
+
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
67
|
+
const line = buf.slice(0, nl).trim();
|
|
68
|
+
buf = buf.slice(nl + 1);
|
|
69
|
+
if (!line)
|
|
70
|
+
continue;
|
|
71
|
+
let parsed;
|
|
72
|
+
try {
|
|
73
|
+
parsed = JSON.parse(line);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
continue; // non-JSON noise
|
|
77
|
+
}
|
|
78
|
+
for (const frame of adapter.translate(parsed, io))
|
|
79
|
+
emit(frame);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
// Engine handshake (claude: noop; codex: initialize + thread setup). Listener is already attached above so handshake responses are not missed.
|
|
83
|
+
adapter.start(io, opts.resumeSessionId);
|
|
84
|
+
// Remove the per-session MCP config once the child is gone (exit or spawn
|
|
85
|
+
// error). Engine-aware: claude unlinks its JSON file, codex strips its fenced
|
|
86
|
+
// block out of the shared config.toml. Never throws.
|
|
87
|
+
function cleanupMcp() {
|
|
88
|
+
if (opts.mcp)
|
|
89
|
+
cleanupMcpConfig(opts.mcp.engine, opts.mcp.sessionId);
|
|
90
|
+
}
|
|
91
|
+
// MUST handle 'error' — an unhandled spawn failure (ENOENT etc.) crashes the
|
|
92
|
+
// whole bridge process. Surface it instead so the phone gets a real error.
|
|
93
|
+
child.on("error", (err) => {
|
|
94
|
+
cleanupMcp();
|
|
95
|
+
opts.onError?.(err);
|
|
96
|
+
});
|
|
97
|
+
child.on("exit", (code, signal) => {
|
|
98
|
+
cleanupMcp();
|
|
99
|
+
opts.onExit?.(code ?? null, signal ?? null);
|
|
100
|
+
});
|
|
101
|
+
return {
|
|
102
|
+
send: (content) => {
|
|
103
|
+
const line = adapter.encodeUserTurn(content);
|
|
104
|
+
if (line)
|
|
105
|
+
child.stdin.write(line);
|
|
106
|
+
},
|
|
107
|
+
answer: (id, updatedInput) => child.stdin.write(adapter.encodeAnswer(id, updatedInput)),
|
|
108
|
+
deny: (id, message) => child.stdin.write(adapter.encodeDeny(id, message)),
|
|
109
|
+
editQueuedTurn: (itemId, content) => adapter.editQueuedTurn?.(itemId, content) ?? false,
|
|
110
|
+
deleteQueuedTurn: (itemId) => adapter.deleteQueuedTurn?.(itemId) ?? false,
|
|
111
|
+
closeQueue: () => adapter.closeQueue?.() ?? false,
|
|
112
|
+
steerQueuedTurn: (itemId) => {
|
|
113
|
+
const line = adapter.steerQueuedTurn?.(itemId) ?? "";
|
|
114
|
+
if (!line)
|
|
115
|
+
return false;
|
|
116
|
+
child.stdin.write(line);
|
|
117
|
+
return true;
|
|
118
|
+
},
|
|
119
|
+
stop: () => child.kill(),
|
|
120
|
+
addSink: (s) => sinks.add(s),
|
|
121
|
+
removeSink: (s) => sinks.delete(s),
|
|
122
|
+
rewriteMcpRoute: (realSessionId) => {
|
|
123
|
+
if (!opts.mcp || opts.mcp.routeSessionId === realSessionId)
|
|
124
|
+
return;
|
|
125
|
+
// Keep the file/block KEY (opts.mcp.sessionId) stable; only change the
|
|
126
|
+
// routed session id so the path baked into argv still resolves.
|
|
127
|
+
opts.mcp = { ...opts.mcp, routeSessionId: realSessionId };
|
|
128
|
+
writeMcpConfig(opts.mcp);
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
package/dist/shell.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// A "shell" = one wrapped `claude` running in a pty, registered with the local
|
|
2
|
+
// hub daemon. It does NOT talk to the cloud directly — it streams its pty output
|
|
3
|
+
// to the hub over a local socket and receives input/resize back. The hub owns the
|
|
4
|
+
// single cloud connection and multiplexes all shells. Transport + pty are injected
|
|
5
|
+
// so the orchestration is unit-testable.
|
|
6
|
+
import { createMirror } from "./pty-mirror.js";
|
|
7
|
+
import { encodeFrame, decodeFrames } from "./hub-protocol.js";
|
|
8
|
+
const BACKOFF_START_MS = 500;
|
|
9
|
+
const BACKOFF_MAX_MS = 5000;
|
|
10
|
+
export function startShell(deps) {
|
|
11
|
+
const title = deps.command.split("/").pop() || "claude";
|
|
12
|
+
// The pty/`claude` outlives any hub link: a hub restart must NOT kill it. We
|
|
13
|
+
// keep ONE mirror for the lifetime of the shell and re-attach a fresh hub
|
|
14
|
+
// transport (with a fresh reg frame) whenever the link drops.
|
|
15
|
+
let hub;
|
|
16
|
+
let stopped = false;
|
|
17
|
+
let reconnectTimer;
|
|
18
|
+
let backoffMs = BACKOFF_START_MS;
|
|
19
|
+
const regFrame = encodeFrame({
|
|
20
|
+
t: "reg",
|
|
21
|
+
termId: deps.termId,
|
|
22
|
+
cwd: deps.cwd,
|
|
23
|
+
title,
|
|
24
|
+
cols: deps.cols,
|
|
25
|
+
rows: deps.rows,
|
|
26
|
+
sessionId: deps.sessionId,
|
|
27
|
+
});
|
|
28
|
+
const scheduleReconnect = () => {
|
|
29
|
+
if (stopped || reconnectTimer)
|
|
30
|
+
return;
|
|
31
|
+
reconnectTimer = setTimeout(() => {
|
|
32
|
+
reconnectTimer = undefined;
|
|
33
|
+
backoffMs = Math.min(backoffMs * 2, BACKOFF_MAX_MS);
|
|
34
|
+
connectHub();
|
|
35
|
+
}, backoffMs);
|
|
36
|
+
};
|
|
37
|
+
const connectHub = () => {
|
|
38
|
+
if (stopped)
|
|
39
|
+
return;
|
|
40
|
+
const link = deps.connect();
|
|
41
|
+
hub = link;
|
|
42
|
+
// Re-announce this terminal so the (restarted) hub re-learns it.
|
|
43
|
+
link.write(regFrame);
|
|
44
|
+
// Hub → shell: input / resize / refresh applied to the live pty.
|
|
45
|
+
let buf = "";
|
|
46
|
+
link.onData((chunk) => {
|
|
47
|
+
const res = decodeFrames(buf + chunk);
|
|
48
|
+
buf = res.rest;
|
|
49
|
+
for (const f of res.frames) {
|
|
50
|
+
if (f.t === "in" && f.termId === deps.termId)
|
|
51
|
+
mirror.writeInput(f.data);
|
|
52
|
+
else if (f.t === "resize" && f.termId === deps.termId)
|
|
53
|
+
mirror.resize(f.cols, f.rows);
|
|
54
|
+
else if (f.t === "refresh" && f.termId === deps.termId)
|
|
55
|
+
mirror.refresh();
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
link.onClose(() => {
|
|
59
|
+
if (hub === link)
|
|
60
|
+
hub = undefined; // current link dropped
|
|
61
|
+
if (!stopped)
|
|
62
|
+
scheduleReconnect();
|
|
63
|
+
});
|
|
64
|
+
// A connect that survives means the hub is up; reset backoff.
|
|
65
|
+
backoffMs = BACKOFF_START_MS;
|
|
66
|
+
};
|
|
67
|
+
const mirror = createMirror({
|
|
68
|
+
spawnPty: deps.spawnPty,
|
|
69
|
+
command: deps.command,
|
|
70
|
+
args: deps.args,
|
|
71
|
+
cwd: deps.cwd,
|
|
72
|
+
cols: deps.cols,
|
|
73
|
+
rows: deps.rows,
|
|
74
|
+
onLocal: deps.onLocal,
|
|
75
|
+
onOutput: (d) =>
|
|
76
|
+
// Drop output while disconnected — never buffer/block the pty.
|
|
77
|
+
hub?.write(encodeFrame({
|
|
78
|
+
t: "out",
|
|
79
|
+
termId: deps.termId,
|
|
80
|
+
data: Buffer.from(d, "utf8").toString("base64"),
|
|
81
|
+
})),
|
|
82
|
+
onExit: () => {
|
|
83
|
+
stopped = true;
|
|
84
|
+
if (reconnectTimer)
|
|
85
|
+
clearTimeout(reconnectTimer);
|
|
86
|
+
hub?.write(encodeFrame({ t: "bye", termId: deps.termId }));
|
|
87
|
+
hub?.end();
|
|
88
|
+
deps.onExit?.();
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
connectHub();
|
|
92
|
+
return {
|
|
93
|
+
writeInput: (d) => mirror.writeInput(d),
|
|
94
|
+
resize: (c, r) => mirror.resize(c, r),
|
|
95
|
+
stop: () => {
|
|
96
|
+
stopped = true;
|
|
97
|
+
if (reconnectTimer)
|
|
98
|
+
clearTimeout(reconnectTimer);
|
|
99
|
+
hub?.write(encodeFrame({ t: "bye", termId: deps.termId }));
|
|
100
|
+
mirror.stop();
|
|
101
|
+
hub?.end();
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import fsp from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/** Pull `name` and `description` from a Markdown YAML frontmatter block.
|
|
5
|
+
* Only the leading `--- ... ---` block is parsed; each value is taken from its
|
|
6
|
+
* single line (frontmatter descriptions in this codebase are one — long — line).
|
|
7
|
+
* Surrounding quotes are stripped. Missing fields come back undefined. */
|
|
8
|
+
export function parseFrontmatter(content) {
|
|
9
|
+
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
10
|
+
if (!m)
|
|
11
|
+
return {};
|
|
12
|
+
const lines = m[1].split(/\r?\n/);
|
|
13
|
+
const grab = (key) => {
|
|
14
|
+
const re = new RegExp(`^${key}\\s*:`);
|
|
15
|
+
const line = lines.find((l) => re.test(l));
|
|
16
|
+
if (!line)
|
|
17
|
+
return undefined;
|
|
18
|
+
const v = line.slice(line.indexOf(":") + 1).trim();
|
|
19
|
+
return v.replace(/^["']|["']$/g, "").trim() || undefined;
|
|
20
|
+
};
|
|
21
|
+
return { name: grab("name"), description: grab("description") };
|
|
22
|
+
}
|
|
23
|
+
/** Claude Code names a command by its path under commands/, ":"-joined, no ".md".
|
|
24
|
+
* e.g. commands/git/commit.md -> "/git:commit", commands/grill.md -> "/grill". */
|
|
25
|
+
export function commandNameFromPath(root, file) {
|
|
26
|
+
const rel = path.relative(root, file).replace(/\.md$/i, "");
|
|
27
|
+
return "/" + rel.split(path.sep).join(":");
|
|
28
|
+
}
|
|
29
|
+
/** Recursively collect files under `root` matching `match`, bounded by `maxDepth`
|
|
30
|
+
* (root is depth 0). Skips node_modules/.git. Silent on unreadable dirs. */
|
|
31
|
+
export async function collectFiles(root, maxDepth, match) {
|
|
32
|
+
const out = [];
|
|
33
|
+
async function walk(dir, depth) {
|
|
34
|
+
let dirents;
|
|
35
|
+
try {
|
|
36
|
+
dirents = await fsp.readdir(dir, { withFileTypes: true });
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
for (const d of dirents) {
|
|
42
|
+
const full = path.join(dir, d.name);
|
|
43
|
+
if (d.isFile()) {
|
|
44
|
+
if (match(d.name))
|
|
45
|
+
out.push(full);
|
|
46
|
+
}
|
|
47
|
+
else if (d.isDirectory() &&
|
|
48
|
+
depth < maxDepth &&
|
|
49
|
+
d.name !== "node_modules" &&
|
|
50
|
+
d.name !== ".git") {
|
|
51
|
+
await walk(full, depth + 1);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
await walk(root, 0);
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
/** Derive the owning plugin's name from a skill file path under the plugins
|
|
59
|
+
* root. Claude Code namespaces plugin skills as `<plugin>:<skill>` — without
|
|
60
|
+
* the prefix, searching the name the user actually knows finds nothing.
|
|
61
|
+
* Known layouts:
|
|
62
|
+
* cache/<marketplace>/<plugin>/<version>/skills/<skill>/SKILL.md
|
|
63
|
+
* marketplaces/<marketplace>/plugins/<plugin>/skills/<skill>/SKILL.md
|
|
64
|
+
* Returns undefined when the path has no `skills` segment to anchor on. */
|
|
65
|
+
export function pluginNameFromPath(pluginsRoot, file) {
|
|
66
|
+
const segs = path.relative(pluginsRoot, file).split(path.sep);
|
|
67
|
+
const skillsIdx = segs.lastIndexOf("skills");
|
|
68
|
+
if (skillsIdx < 1)
|
|
69
|
+
return undefined;
|
|
70
|
+
let candidate = segs[skillsIdx - 1];
|
|
71
|
+
// cache layout keeps a version dir between plugin and skills — step over it
|
|
72
|
+
if (/^\d+(\.\d+)*([-.].*)?$/.test(candidate) && skillsIdx >= 2) {
|
|
73
|
+
candidate = segs[skillsIdx - 2];
|
|
74
|
+
}
|
|
75
|
+
return candidate || undefined;
|
|
76
|
+
}
|
|
77
|
+
const SOURCE_RANK = {
|
|
78
|
+
project: 0,
|
|
79
|
+
user: 1,
|
|
80
|
+
plugin: 2,
|
|
81
|
+
};
|
|
82
|
+
/** Project skills (tied to the active cwd) first, then the user's own, then
|
|
83
|
+
* plugins — alphabetical within each group. */
|
|
84
|
+
export function sortSkills(skills) {
|
|
85
|
+
return [...skills].sort((a, b) => SOURCE_RANK[a.source] - SOURCE_RANK[b.source] ||
|
|
86
|
+
a.name.localeCompare(b.name));
|
|
87
|
+
}
|
|
88
|
+
async function readSkill(file, source, namePrefix) {
|
|
89
|
+
try {
|
|
90
|
+
const fm = parseFrontmatter(await fsp.readFile(file, "utf8"));
|
|
91
|
+
const base = fm.name ?? path.basename(path.dirname(file));
|
|
92
|
+
return {
|
|
93
|
+
name: namePrefix ? `${namePrefix}:${base}` : base,
|
|
94
|
+
description: fm.description ?? "",
|
|
95
|
+
source,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async function readCommand(file, root, scope) {
|
|
103
|
+
try {
|
|
104
|
+
const fm = parseFrontmatter(await fsp.readFile(file, "utf8"));
|
|
105
|
+
return {
|
|
106
|
+
name: commandNameFromPath(root, file),
|
|
107
|
+
description: fm.description ?? "",
|
|
108
|
+
scope,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Scan the Mac for the user's REAL skills + custom commands. `cwd` (the active
|
|
116
|
+
* session's project) adds project-scoped `.claude/skills` + `.claude/commands`.
|
|
117
|
+
* Always includes user (~/.claude) + plugin skills. */
|
|
118
|
+
export async function listSkills(cwd) {
|
|
119
|
+
try {
|
|
120
|
+
const home = os.homedir();
|
|
121
|
+
const isSkill = (n) => n === "SKILL.md";
|
|
122
|
+
const isMd = (n) => n.toLowerCase().endsWith(".md");
|
|
123
|
+
const projectRoot = cwd && cwd.trim() ? path.join(cwd, ".claude") : null;
|
|
124
|
+
// --- skills: project > user > plugin priority, dedup by name ---
|
|
125
|
+
const skillSources = [];
|
|
126
|
+
if (projectRoot)
|
|
127
|
+
skillSources.push([path.join(projectRoot, "skills"), 2, "project"]);
|
|
128
|
+
skillSources.push([path.join(home, ".claude", "skills"), 2, "user"]);
|
|
129
|
+
skillSources.push([path.join(home, ".claude", "plugins"), 7, "plugin"]);
|
|
130
|
+
const skills = [];
|
|
131
|
+
const seen = new Set();
|
|
132
|
+
for (const [root, depth, source] of skillSources) {
|
|
133
|
+
const files = await collectFiles(root, depth, isSkill);
|
|
134
|
+
const entries = await Promise.all(files.map((f) => readSkill(f, source, source === "plugin" ? pluginNameFromPath(root, f) : undefined)));
|
|
135
|
+
for (const e of entries) {
|
|
136
|
+
if (!e || seen.has(e.name))
|
|
137
|
+
continue;
|
|
138
|
+
seen.add(e.name);
|
|
139
|
+
skills.push(e);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const sortedSkills = sortSkills(skills);
|
|
143
|
+
// --- commands: project > user priority, dedup by name ---
|
|
144
|
+
const cmdSources = [];
|
|
145
|
+
if (projectRoot)
|
|
146
|
+
cmdSources.push([path.join(projectRoot, "commands"), "project"]);
|
|
147
|
+
cmdSources.push([path.join(home, ".claude", "commands"), "user"]);
|
|
148
|
+
const commands = [];
|
|
149
|
+
const seenCmd = new Set();
|
|
150
|
+
for (const [root, scope] of cmdSources) {
|
|
151
|
+
// depth 4: covers namespaced commands like commands/ns/sub/cmd.md
|
|
152
|
+
const files = await collectFiles(root, 4, isMd);
|
|
153
|
+
const entries = await Promise.all(files.map((f) => readCommand(f, root, scope)));
|
|
154
|
+
for (const e of entries) {
|
|
155
|
+
if (!e || seenCmd.has(e.name))
|
|
156
|
+
continue;
|
|
157
|
+
seenCmd.add(e.name);
|
|
158
|
+
commands.push(e);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
commands.sort((a, b) => a.name.localeCompare(b.name));
|
|
162
|
+
return { skills: sortedSkills, commands };
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
return { skills: [], commands: [], error: e.message };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Encode bridge actions into NDJSON lines the `claude` stream-json child reads on stdin.
|
|
2
|
+
// Shapes per claude-code-source controlSchemas.ts (StdinMessage / control_response).
|
|
3
|
+
/** A user turn → SDKUserMessage line (cc_send). */
|
|
4
|
+
export function buildUserMessageLine(content) {
|
|
5
|
+
return (JSON.stringify({
|
|
6
|
+
type: "user",
|
|
7
|
+
message: { role: "user", content },
|
|
8
|
+
parent_tool_use_id: null,
|
|
9
|
+
}) + "\n");
|
|
10
|
+
}
|
|
11
|
+
/** Answer an option menu (cc_answer) — allow with the phone-built updatedInput. */
|
|
12
|
+
export function buildControlAllowLine(requestId, updatedInput) {
|
|
13
|
+
return (JSON.stringify({
|
|
14
|
+
type: "control_response",
|
|
15
|
+
response: {
|
|
16
|
+
subtype: "success",
|
|
17
|
+
request_id: requestId,
|
|
18
|
+
response: { behavior: "allow", updatedInput },
|
|
19
|
+
},
|
|
20
|
+
}) + "\n");
|
|
21
|
+
}
|
|
22
|
+
/** Decline an option menu. */
|
|
23
|
+
export function buildControlDenyLine(requestId, message) {
|
|
24
|
+
return (JSON.stringify({
|
|
25
|
+
type: "control_response",
|
|
26
|
+
response: {
|
|
27
|
+
subtype: "success",
|
|
28
|
+
request_id: requestId,
|
|
29
|
+
response: { behavior: "deny", message },
|
|
30
|
+
},
|
|
31
|
+
}) + "\n");
|
|
32
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Pure translation: one parsed NDJSON object from the `claude` stream-json child
|
|
2
|
+
// → zero-or-more bridge frames the cloud/phone understand. No I/O, fully unit-tested.
|
|
3
|
+
//
|
|
4
|
+
// Shapes verified against a real captured turn (test/fixtures/stream-json-turn.ndjson).
|
|
5
|
+
function ev(kind, payload) {
|
|
6
|
+
return { type: "event", kind, payload };
|
|
7
|
+
}
|
|
8
|
+
export function translateChildEvent(raw) {
|
|
9
|
+
const o = raw;
|
|
10
|
+
if (!o || typeof o !== "object")
|
|
11
|
+
return [];
|
|
12
|
+
switch (o.type) {
|
|
13
|
+
case "stream_event":
|
|
14
|
+
return translateStreamEvent(o.event ?? {});
|
|
15
|
+
case "assistant":
|
|
16
|
+
return translateAssistant(o);
|
|
17
|
+
case "user":
|
|
18
|
+
return translateUser(o);
|
|
19
|
+
case "system":
|
|
20
|
+
return translateSystem(o);
|
|
21
|
+
case "result":
|
|
22
|
+
return translateResult(o);
|
|
23
|
+
case "control_request":
|
|
24
|
+
return translateControlRequest(o);
|
|
25
|
+
case "control_cancel_request":
|
|
26
|
+
return o.request_id
|
|
27
|
+
? [{ type: "control_cancel", controlRequestId: o.request_id }]
|
|
28
|
+
: [];
|
|
29
|
+
default:
|
|
30
|
+
return []; // rate_limit_event and anything else: ignore
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function translateStreamEvent(event) {
|
|
34
|
+
// message_delta carries running usage — the phone mirrors the terminal's
|
|
35
|
+
// "(1m 11s · ↓2.6k tokens)" status line from it.
|
|
36
|
+
if (event.type === "message_delta") {
|
|
37
|
+
const tokens = event.usage?.output_tokens;
|
|
38
|
+
return typeof tokens === "number"
|
|
39
|
+
? [ev("usage", { output_tokens: tokens })]
|
|
40
|
+
: [];
|
|
41
|
+
}
|
|
42
|
+
if (event.type !== "content_block_delta")
|
|
43
|
+
return [];
|
|
44
|
+
const d = event.delta ?? {};
|
|
45
|
+
if (d.type === "text_delta") {
|
|
46
|
+
return [ev("stream_text_delta", { index: event.index, text: d.text })];
|
|
47
|
+
}
|
|
48
|
+
if (d.type === "thinking_delta") {
|
|
49
|
+
return [
|
|
50
|
+
ev("stream_thinking_delta", { index: event.index, text: d.thinking }),
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
return []; // signature_delta, input_json_delta: ignore (final assistant msg carries them)
|
|
54
|
+
}
|
|
55
|
+
function translateAssistant(o) {
|
|
56
|
+
const content = o.message?.content;
|
|
57
|
+
if (!Array.isArray(content))
|
|
58
|
+
return [];
|
|
59
|
+
const out = [];
|
|
60
|
+
content.forEach((b, index) => {
|
|
61
|
+
if (b.type === "tool_use") {
|
|
62
|
+
out.push(ev("tool_use", { id: b.id, name: b.name, input: b.input ?? {} }));
|
|
63
|
+
}
|
|
64
|
+
else if (b.type === "text") {
|
|
65
|
+
out.push(ev("assistant", { uuid: o.uuid, index, text: b.text }));
|
|
66
|
+
}
|
|
67
|
+
// thinking blocks: covered live by stream_thinking_delta
|
|
68
|
+
});
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
function translateUser(o) {
|
|
72
|
+
const content = o.message?.content;
|
|
73
|
+
if (!Array.isArray(content))
|
|
74
|
+
return [];
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const b of content) {
|
|
77
|
+
if (b.type === "tool_result") {
|
|
78
|
+
out.push(ev("tool_result", {
|
|
79
|
+
tool_use_id: b.tool_use_id,
|
|
80
|
+
content: b.content,
|
|
81
|
+
is_error: b.is_error === true,
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
function translateSystem(o) {
|
|
88
|
+
if (o.subtype === "init") {
|
|
89
|
+
return [
|
|
90
|
+
ev("system_init", {
|
|
91
|
+
model: o.model,
|
|
92
|
+
cwd: o.cwd,
|
|
93
|
+
session_id: o.session_id,
|
|
94
|
+
tools: o.tools,
|
|
95
|
+
slash_commands: o.slash_commands,
|
|
96
|
+
}),
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
if (o.subtype === "session_state_changed") {
|
|
100
|
+
return [ev("status", { state: o.state })];
|
|
101
|
+
}
|
|
102
|
+
return []; // status(compacting), hook_started, hook_response: ignore
|
|
103
|
+
}
|
|
104
|
+
function translateResult(o) {
|
|
105
|
+
if (o.subtype === "success") {
|
|
106
|
+
return [ev("status", { state: "idle" })];
|
|
107
|
+
}
|
|
108
|
+
return [ev("result_error", { errors: o.errors ?? [] })];
|
|
109
|
+
}
|
|
110
|
+
function translateControlRequest(o) {
|
|
111
|
+
const req = o.request ?? {};
|
|
112
|
+
if (req.subtype !== "can_use_tool")
|
|
113
|
+
return [];
|
|
114
|
+
return [
|
|
115
|
+
{
|
|
116
|
+
type: "control_request",
|
|
117
|
+
controlRequestId: o.request_id,
|
|
118
|
+
toolName: req.tool_name,
|
|
119
|
+
input: req.input,
|
|
120
|
+
},
|
|
121
|
+
];
|
|
122
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function renderFrame(f) {
|
|
2
|
+
if (f.type === "control_request") {
|
|
3
|
+
return `\n[需要确认: ${f.toolName}](请在手机上回答)\n`;
|
|
4
|
+
}
|
|
5
|
+
if (f.type === "control_cancel")
|
|
6
|
+
return "";
|
|
7
|
+
const p = f.payload;
|
|
8
|
+
switch (f.kind) {
|
|
9
|
+
case "stream_text_delta":
|
|
10
|
+
return typeof p.text === "string" ? p.text : "";
|
|
11
|
+
case "stream_thinking_delta":
|
|
12
|
+
return ""; // keep the terminal clean — don't print thinking
|
|
13
|
+
case "tool_use": {
|
|
14
|
+
const name = typeof p.name === "string" ? p.name : "tool";
|
|
15
|
+
const input = (p.input ?? {});
|
|
16
|
+
const arg = input.command ?? input.file_path ?? input.path ?? "";
|
|
17
|
+
const argStr = arg ? " " + String(arg).slice(0, 80) : "";
|
|
18
|
+
return `\n ⚙︎ ${name}${argStr}\n`;
|
|
19
|
+
}
|
|
20
|
+
case "tool_result":
|
|
21
|
+
return ""; // result bodies omitted to avoid flooding the terminal
|
|
22
|
+
case "result_error":
|
|
23
|
+
return `\n ✖ 错误: ${JSON.stringify(p.errors ?? p)}\n`;
|
|
24
|
+
case "status":
|
|
25
|
+
return p.state === "idle" ? "\n" : "";
|
|
26
|
+
default:
|
|
27
|
+
return ""; // system_init, assistant (final), etc.
|
|
28
|
+
}
|
|
29
|
+
}
|