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,501 @@
1
+ // Nexting device-tool MCP proxy — a standalone stdio MCP server spawned by
2
+ // claude/codex (configured by the cc-bridge). It exposes the phone's device
3
+ // tools (calendar / reminders / contacts / location / health / homekit) to the
4
+ // agent and forwards each call to the cloud, which fans it out to the phone's
5
+ // SSE stream for execution.
6
+ //
7
+ // IMPORTANT: stdout is the MCP JSON-RPC channel. Anything written to stdout that
8
+ // is not a framed protocol message corrupts the session. ALL logging here goes
9
+ // to stderr (see `log`).
10
+ //
11
+ // Env inputs (set by the bridge when writing the per-session MCP config):
12
+ // NEXTING_CLOUD_URL — cloud API base, e.g. https://api.nexting.ai
13
+ // NEXTING_BUS_TOKEN — device-tool-scoped bearer token
14
+ // NEXTING_ENGINE — "cc" | "codex" (selects the /api/v1/{engine}/* route)
15
+ // NEXTING_SESSION_ID — the phone session this proxy is bound to
16
+ //
17
+ // Run: node dist/mcp-device-proxy.js
18
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
19
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
20
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
21
+ /** Build an inputSchema from a flat param list, mirroring the Swift defaults
22
+ * (`required` defaults to true). */
23
+ function schema(params) {
24
+ const properties = {};
25
+ const required = [];
26
+ for (const p of params) {
27
+ properties[p.name] = { type: p.type, description: p.description };
28
+ if (p.required !== false)
29
+ required.push(p.name);
30
+ }
31
+ return { type: "object", properties, required };
32
+ }
33
+ /** All device tools, 1:1 with iOS DeviceSkillManager.toolDefs(for:). The cloud
34
+ * only stores which SKILLS are enabled; the schemas are fixed and live here. */
35
+ export const DEVICE_TOOL_CATALOG = [
36
+ // --- calendar ---
37
+ {
38
+ name: "calendar_today",
39
+ description: "Get today's calendar events",
40
+ inputSchema: schema([]),
41
+ },
42
+ {
43
+ name: "calendar_upcoming",
44
+ description: "Get upcoming events for the next N days",
45
+ inputSchema: schema([
46
+ {
47
+ name: "days",
48
+ type: "number",
49
+ required: false,
50
+ description: "Number of days to look ahead (default 7)",
51
+ },
52
+ ]),
53
+ },
54
+ {
55
+ name: "calendar_add_event",
56
+ description: "Create a new calendar event",
57
+ inputSchema: schema([
58
+ { name: "title", type: "string", description: "Event title" },
59
+ {
60
+ name: "startDate",
61
+ type: "string",
62
+ description: "Start date in ISO 8601 format",
63
+ },
64
+ {
65
+ name: "endDate",
66
+ type: "string",
67
+ required: false,
68
+ description: "End date in ISO 8601 format (default: 1 hour after start)",
69
+ },
70
+ ]),
71
+ },
72
+ // --- reminders ---
73
+ {
74
+ name: "reminders_list",
75
+ description: "List incomplete reminders",
76
+ inputSchema: schema([]),
77
+ },
78
+ {
79
+ name: "reminders_add",
80
+ description: "Add a new reminder",
81
+ inputSchema: schema([
82
+ { name: "title", type: "string", description: "Reminder title" },
83
+ {
84
+ name: "dueDate",
85
+ type: "string",
86
+ required: false,
87
+ description: "Due date in ISO 8601 format",
88
+ },
89
+ ]),
90
+ },
91
+ {
92
+ name: "reminders_complete",
93
+ description: "Mark a reminder as completed",
94
+ inputSchema: schema([
95
+ {
96
+ name: "title",
97
+ type: "string",
98
+ description: "Title of the reminder to complete",
99
+ },
100
+ ]),
101
+ },
102
+ // --- contacts ---
103
+ {
104
+ name: "contacts_search",
105
+ description: "Search contacts by name",
106
+ inputSchema: schema([
107
+ { name: "query", type: "string", description: "Name to search for" },
108
+ ]),
109
+ },
110
+ {
111
+ name: "contacts_get_phone",
112
+ description: "Get a contact's phone number",
113
+ inputSchema: schema([
114
+ { name: "name", type: "string", description: "Contact name" },
115
+ ]),
116
+ },
117
+ // --- location ---
118
+ {
119
+ name: "location_current",
120
+ description: "Get the user's current GPS coordinates and reverse-geocoded address",
121
+ inputSchema: schema([]),
122
+ },
123
+ // --- health ---
124
+ {
125
+ name: "health_today_summary",
126
+ description: "Get today's health summary: steps, active calories, heart rate, walking distance",
127
+ inputSchema: schema([]),
128
+ },
129
+ {
130
+ name: "health_steps_history",
131
+ description: "Get daily step counts for the last N days",
132
+ inputSchema: schema([
133
+ {
134
+ name: "days",
135
+ type: "number",
136
+ required: false,
137
+ description: "Number of days (default 7, max 30)",
138
+ },
139
+ ]),
140
+ },
141
+ {
142
+ name: "health_sleep",
143
+ description: "Get last night's sleep data including duration and sleep stages",
144
+ inputSchema: schema([]),
145
+ },
146
+ // --- homekit ---
147
+ {
148
+ name: "homekit_list",
149
+ description: "List all HomeKit homes, rooms, accessories and their current state",
150
+ inputSchema: schema([]),
151
+ },
152
+ {
153
+ name: "homekit_control",
154
+ description: "Control a HomeKit accessory (turn on/off, set brightness, set temperature)",
155
+ inputSchema: schema([
156
+ {
157
+ name: "accessory",
158
+ type: "string",
159
+ description: "Name of the accessory to control",
160
+ },
161
+ {
162
+ name: "action",
163
+ type: "string",
164
+ description: "Action: on, off, brightness:N (0-100), temperature:N",
165
+ },
166
+ ]),
167
+ },
168
+ {
169
+ name: "homekit_scene",
170
+ description: "List available scenes, or trigger a scene by name",
171
+ inputSchema: schema([
172
+ {
173
+ name: "scene",
174
+ type: "string",
175
+ required: false,
176
+ description: "Scene name to trigger (omit to list all scenes)",
177
+ },
178
+ ]),
179
+ },
180
+ ];
181
+ /** Read + validate the proxy config from the environment. Throws on missing
182
+ * required vars (the proxy can't function without them). */
183
+ export function readProxyConfig(env) {
184
+ const rawUrl = (env.NEXTING_CLOUD_URL ?? "").trim();
185
+ const busToken = (env.NEXTING_BUS_TOKEN ?? "").trim();
186
+ const rawEngine = (env.NEXTING_ENGINE ?? "").trim();
187
+ const sessionId = (env.NEXTING_SESSION_ID ?? "").trim();
188
+ const missing = [];
189
+ if (!rawUrl)
190
+ missing.push("NEXTING_CLOUD_URL");
191
+ if (!busToken)
192
+ missing.push("NEXTING_BUS_TOKEN");
193
+ if (!sessionId)
194
+ missing.push("NEXTING_SESSION_ID");
195
+ if (rawEngine !== "cc" && rawEngine !== "codex") {
196
+ missing.push("NEXTING_ENGINE(cc|codex)");
197
+ }
198
+ if (missing.length > 0) {
199
+ throw new Error(`missing/invalid env: ${missing.join(", ")}`);
200
+ }
201
+ return {
202
+ cloudUrl: rawUrl.replace(/\/+$/, ""),
203
+ busToken,
204
+ engine: rawEngine === "codex" ? "codex" : "cc",
205
+ sessionId,
206
+ };
207
+ }
208
+ export const ASK_USER_TOOL_NAME = "ask_user";
209
+ export const ASK_USER_TOOL = {
210
+ name: ASK_USER_TOOL_NAME,
211
+ description: "Ask the user a multiple-choice question and BLOCK until they answer on " +
212
+ "their phone. Use this whenever you would otherwise stop to ask the user " +
213
+ "to choose between options or confirm a direction — the user has no " +
214
+ "terminal, only the Nexting phone app, so this is the ONLY way to get an " +
215
+ "interactive answer. Provide 1-4 questions; each needs a short `header` " +
216
+ "(<=12 chars), the `question` text, and 2-4 `options` each with a `label` " +
217
+ "and a `description`. Set `multiSelect: true` to let the user pick several. " +
218
+ "Returns the chosen option label(s) per question. If the user does not " +
219
+ "answer in time you are told so — then proceed with your best judgment.",
220
+ inputSchema: {
221
+ type: "object",
222
+ properties: {
223
+ questions: {
224
+ type: "array",
225
+ description: "1-4 questions to ask the user.",
226
+ items: {
227
+ type: "object",
228
+ properties: {
229
+ question: {
230
+ type: "string",
231
+ description: "The full question text.",
232
+ },
233
+ header: {
234
+ type: "string",
235
+ description: "A short label for the question (<=12 chars).",
236
+ },
237
+ multiSelect: {
238
+ type: "boolean",
239
+ description: "Allow picking more than one option (default false).",
240
+ },
241
+ options: {
242
+ type: "array",
243
+ description: "2-4 options the user can choose from.",
244
+ items: {
245
+ type: "object",
246
+ properties: {
247
+ label: {
248
+ type: "string",
249
+ description: "The option text the user picks.",
250
+ },
251
+ description: {
252
+ type: "string",
253
+ description: "A short explanation of this option.",
254
+ },
255
+ },
256
+ required: ["label"],
257
+ },
258
+ },
259
+ },
260
+ required: ["question", "header", "options"],
261
+ },
262
+ },
263
+ },
264
+ required: ["questions"],
265
+ },
266
+ };
267
+ // --- HTTP wiring -------------------------------------------------------------
268
+ const CALL_TIMEOUT_MS = 30_000;
269
+ // 11 min — just OVER the cloud's 10 min ask wait, so the cloud's own timeout
270
+ // resolves the round-trip first (the proxy receives {ok:false,error:"timeout"})
271
+ // instead of the proxy aborting the request mid-flight.
272
+ const ASK_USER_TIMEOUT_MS = 11 * 60_000;
273
+ /** POST {cloud}/api/v1/{engine}/ask-user — ask the phone user a structured
274
+ * multiple-choice question and BLOCK for their answer. The agent's turn waits
275
+ * on this tool result, which is exactly the "stop and ask the user" semantics
276
+ * the native AskUserQuestion can't deliver headless. Returns the chosen labels
277
+ * as JSON text; a no-answer resolves to a NON-error nudge so the agent proceeds
278
+ * instead of retrying. */
279
+ export async function callAskUser(cfg, questions, fetchImpl = fetch, timeoutMs = ASK_USER_TIMEOUT_MS) {
280
+ const url = `${cfg.cloudUrl}/api/v1/${cfg.engine}/ask-user`;
281
+ const controller = new AbortController();
282
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
283
+ try {
284
+ const res = await fetchImpl(url, {
285
+ method: "POST",
286
+ headers: {
287
+ Authorization: `Bearer ${cfg.busToken}`,
288
+ "Content-Type": "application/json",
289
+ },
290
+ body: JSON.stringify({ sessionId: cfg.sessionId, questions }),
291
+ signal: controller.signal,
292
+ });
293
+ if (!res.ok) {
294
+ const body = await res.text().catch(() => "");
295
+ return {
296
+ isError: true,
297
+ text: `ask_user failed (HTTP ${res.status})${body ? `: ${body}` : ""}`,
298
+ };
299
+ }
300
+ const json = (await res.json().catch(() => null));
301
+ if (json && json.ok === true && json.answers != null) {
302
+ return { isError: false, text: JSON.stringify(json.answers) };
303
+ }
304
+ // Not answered (timeout / dismissed / phone offline). NON-error so the agent
305
+ // continues with its own judgment instead of treating it as a tool failure.
306
+ const reason = json && typeof json.error === "string" ? json.error : "no_answer";
307
+ return {
308
+ isError: false,
309
+ text: `The user did not answer (${reason}). Proceed with your best judgment.`,
310
+ };
311
+ }
312
+ catch (err) {
313
+ if (controller.signal.aborted) {
314
+ return {
315
+ isError: false,
316
+ text: "The user did not answer (timeout). Proceed with your best judgment.",
317
+ };
318
+ }
319
+ return { isError: true, text: `ask_user error: ${errText(err)}` };
320
+ }
321
+ finally {
322
+ clearTimeout(timer);
323
+ }
324
+ }
325
+ /** GET {cloud}/api/v1/{engine}/device-skills → list of enabled tool names.
326
+ * On any failure returns [] (a failed fetch must never block the session). */
327
+ export async function fetchEnabledToolNames(cfg, fetchImpl = fetch) {
328
+ const url = `${cfg.cloudUrl}/api/v1/${cfg.engine}/device-skills`;
329
+ try {
330
+ const res = await fetchImpl(url, {
331
+ method: "GET",
332
+ headers: { Authorization: `Bearer ${cfg.busToken}` },
333
+ });
334
+ if (!res.ok) {
335
+ log(`device-skills GET failed (${res.status})`);
336
+ return [];
337
+ }
338
+ const json = await res.json();
339
+ return parseEnabledToolNames(json);
340
+ }
341
+ catch (err) {
342
+ log(`device-skills GET error: ${errText(err)}`);
343
+ return [];
344
+ }
345
+ }
346
+ /** Pull the enabled device-tool names out of the cloud response. We accept a
347
+ * couple of shapes defensively (the cloud may key by tool or by skill). */
348
+ export function parseEnabledToolNames(json) {
349
+ if (!json || typeof json !== "object")
350
+ return [];
351
+ const obj = json;
352
+ // Preferred: { tools: ["calendar_today", ...] }
353
+ if (Array.isArray(obj.tools)) {
354
+ return obj.tools.filter((t) => typeof t === "string");
355
+ }
356
+ // Alternate: { enabled: [...] }
357
+ if (Array.isArray(obj.enabled)) {
358
+ return obj.enabled.filter((t) => typeof t === "string");
359
+ }
360
+ return [];
361
+ }
362
+ /** POST {cloud}/api/v1/{engine}/device-tool — fan the call out to the phone.
363
+ * 30s timeout; success → result text, timeout/HTTP error → isError text. */
364
+ export async function callDeviceTool(cfg, toolName, params, fetchImpl = fetch, timeoutMs = CALL_TIMEOUT_MS) {
365
+ const url = `${cfg.cloudUrl}/api/v1/${cfg.engine}/device-tool`;
366
+ const controller = new AbortController();
367
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
368
+ try {
369
+ const res = await fetchImpl(url, {
370
+ method: "POST",
371
+ headers: {
372
+ Authorization: `Bearer ${cfg.busToken}`,
373
+ "Content-Type": "application/json",
374
+ },
375
+ body: JSON.stringify({
376
+ sessionId: cfg.sessionId,
377
+ toolName,
378
+ params,
379
+ }),
380
+ signal: controller.signal,
381
+ });
382
+ if (!res.ok) {
383
+ const body = await res.text().catch(() => "");
384
+ return {
385
+ isError: true,
386
+ text: `device tool '${toolName}' failed (HTTP ${res.status})${body ? `: ${body}` : ""}`,
387
+ };
388
+ }
389
+ const json = await res.json().catch(() => null);
390
+ return interpretCallResponse(toolName, json);
391
+ }
392
+ catch (err) {
393
+ if (controller.signal.aborted) {
394
+ return {
395
+ isError: true,
396
+ text: `device tool '${toolName}' timed out after ${timeoutMs / 1000}s (phone offline or not subscribed)`,
397
+ };
398
+ }
399
+ return {
400
+ isError: true,
401
+ text: `device tool '${toolName}' error: ${errText(err)}`,
402
+ };
403
+ }
404
+ finally {
405
+ clearTimeout(timer);
406
+ }
407
+ }
408
+ /** Map the cloud's { ok, result } / { ok:false, error } envelope to MCP text. */
409
+ export function interpretCallResponse(toolName, json) {
410
+ if (!json || typeof json !== "object") {
411
+ return {
412
+ isError: true,
413
+ text: `device tool '${toolName}' returned no result`,
414
+ };
415
+ }
416
+ const obj = json;
417
+ const ok = obj.ok === true || obj.success === true;
418
+ if (!ok) {
419
+ const error = typeof obj.error === "string" && obj.error
420
+ ? obj.error
421
+ : "device tool execution failed";
422
+ return { isError: true, text: error };
423
+ }
424
+ const result = obj.result;
425
+ if (typeof result === "string") {
426
+ return { isError: false, text: result };
427
+ }
428
+ if (result == null) {
429
+ return { isError: false, text: "(no output)" };
430
+ }
431
+ return { isError: false, text: JSON.stringify(result) };
432
+ }
433
+ // --- logging (stderr only) ---------------------------------------------------
434
+ function log(msg) {
435
+ process.stderr.write(`[pinclaw-device-proxy] ${msg}\n`);
436
+ }
437
+ function errText(err) {
438
+ if (err instanceof Error)
439
+ return err.message;
440
+ return String(err);
441
+ }
442
+ // --- MCP server --------------------------------------------------------------
443
+ /** Build the MCP server with ListTools / CallTool wired to the cloud. Exported
444
+ * for tests (the entrypoint below just builds + connects stdio). */
445
+ export function buildServer(cfg, fetchImpl = fetch) {
446
+ const server = new Server({ name: "pinclaw-device", version: "0.1.0" }, { capabilities: { tools: {} } });
447
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
448
+ const enabled = await fetchEnabledToolNames(cfg, fetchImpl);
449
+ const enabledSet = new Set(enabled);
450
+ const tools = DEVICE_TOOL_CATALOG.filter((t) => enabledSet.has(t.name)).map((t) => ({
451
+ name: t.name,
452
+ description: t.description,
453
+ inputSchema: t.inputSchema,
454
+ }));
455
+ // ask_user — always available, every session, both engines.
456
+ tools.push(ASK_USER_TOOL);
457
+ log(`ListTools → ${tools.length} tools`);
458
+ return { tools };
459
+ });
460
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
461
+ const toolName = request.params.name;
462
+ const args = (request.params.arguments ?? {});
463
+ log(`CallTool ${toolName}`);
464
+ // ask_user blocks for a phone answer; everything else is a device tool
465
+ // fanned out to the phone.
466
+ const result = toolName === ASK_USER_TOOL_NAME
467
+ ? await callAskUser(cfg, args.questions, fetchImpl)
468
+ : await callDeviceTool(cfg, toolName, args, fetchImpl);
469
+ return {
470
+ content: [{ type: "text", text: result.text }],
471
+ isError: result.isError,
472
+ };
473
+ });
474
+ return server;
475
+ }
476
+ async function main() {
477
+ let cfg;
478
+ try {
479
+ cfg = readProxyConfig(process.env);
480
+ }
481
+ catch (err) {
482
+ log(`startup failed: ${errText(err)}`);
483
+ process.exitCode = 1;
484
+ return;
485
+ }
486
+ log(`starting (engine=${cfg.engine} session=${cfg.sessionId})`);
487
+ const server = buildServer(cfg);
488
+ const transport = new StdioServerTransport();
489
+ await server.connect(transport);
490
+ log("connected on stdio");
491
+ }
492
+ // Entrypoint guard: run only when invoked directly (not when imported in tests).
493
+ const isDirectRun = process.argv[1] !== undefined &&
494
+ (process.argv[1].endsWith("mcp-device-proxy.js") ||
495
+ process.argv[1].endsWith("mcp-device-proxy.ts"));
496
+ if (isDirectRun) {
497
+ main().catch((err) => {
498
+ log(`fatal: ${errText(err)}`);
499
+ process.exitCode = 1;
500
+ });
501
+ }