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,593 @@
1
+ // Verified against codex 0.138.0 app-server protocol (generate-json-schema).
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import fs from "node:fs";
5
+ import { expandCodexPrompt } from "../codex-prompts.js";
6
+ /** Classify a parsed JSON-RPC "lite" object (no jsonrpc field). */
7
+ export function classifyRpc(o) {
8
+ if (!o || typeof o !== "object")
9
+ return "unknown";
10
+ const r = o;
11
+ if ("id" in r && ("result" in r || "error" in r))
12
+ return "response";
13
+ if ("id" in r && "method" in r)
14
+ return "server_request";
15
+ if ("method" in r && !("id" in r))
16
+ return "notification";
17
+ return "unknown";
18
+ }
19
+ // ---------------------------------------------------------------------------
20
+ // Binary resolver
21
+ // ---------------------------------------------------------------------------
22
+ /** Resolve the `codex` binary by absolute path (launchd PATH is minimal). */
23
+ export function resolveCodexBin() {
24
+ const home = os.homedir();
25
+ const candidates = [
26
+ process.env.NEXTING_CODEX_BIN,
27
+ path.join(home, ".npm-global/bin/codex"),
28
+ path.join(home, ".local/bin/codex"),
29
+ "/opt/homebrew/bin/codex",
30
+ "/usr/local/bin/codex",
31
+ ].filter((p) => !!p);
32
+ for (const c of candidates) {
33
+ try {
34
+ if (fs.existsSync(c))
35
+ return c;
36
+ }
37
+ catch {
38
+ /* keep looking */
39
+ }
40
+ }
41
+ return "codex";
42
+ }
43
+ // ---------------------------------------------------------------------------
44
+ // Adapter factory
45
+ // ---------------------------------------------------------------------------
46
+ /** TUI substrings the Codex CLI prints while a turn is running (best-effort —
47
+ * casing has varied across codex versions; the probe matches case-insensitively,
48
+ * so these collapse, but both observed spellings are kept for documentation). */
49
+ export const CODEX_RUNNING_MARKERS = ["esc to interrupt", "Esc to interrupt"];
50
+ /** Per-session stateful adapter for `codex app-server` JSON-RPC over stdio. */
51
+ export function createCodexAdapter() {
52
+ let threadId = null;
53
+ let resumeId;
54
+ let nextId = 0;
55
+ let nextQueueId = 0;
56
+ let eventSink = null;
57
+ // Fix 1: guard against re-firing handshake on any future response with userAgent
58
+ let handshakeDone = false;
59
+ // Fix 2: buffer user turns sent before threadId is known
60
+ let io = null;
61
+ const pendingTurns = [];
62
+ // True once a turn/start has been sent and until Codex completes that turn.
63
+ // This includes the short window before the app-server emits turn/started.
64
+ let turnBusy = false;
65
+ let activeTurnId = null;
66
+ const queuedFollowUps = [];
67
+ // Codex-owned "current activity" latch that drives the phone's state bar
68
+ // (`activity_status` → iOS `CodexSessionModel.activityState`). We emit a frame
69
+ // ONLY when the phase transitions, so a burst of same-phase events (e.g. many
70
+ // text deltas) never spams identical frames. iOS falls back to transcript-tail
71
+ // inference only when no activity_status frame has arrived.
72
+ let activityPhase = null;
73
+ function id() {
74
+ return nextId++;
75
+ }
76
+ function line(obj) {
77
+ return JSON.stringify(obj) + "\n";
78
+ }
79
+ function queueItem(text) {
80
+ nextQueueId += 1;
81
+ return {
82
+ id: `q${nextQueueId}`,
83
+ text,
84
+ createdAt: new Date().toISOString(),
85
+ };
86
+ }
87
+ function emitQueueStatus() {
88
+ eventSink?.({
89
+ type: "event",
90
+ kind: "queue_status",
91
+ payload: {
92
+ items: queuedFollowUps.map((item, index) => ({
93
+ id: item.id,
94
+ text: item.text,
95
+ createdAt: item.createdAt,
96
+ position: index,
97
+ })),
98
+ activeTurnId,
99
+ canSteer: activeTurnId != null,
100
+ },
101
+ });
102
+ }
103
+ // Fix 2: shared helper so encodeUserTurn and the flush path produce identical lines
104
+ function buildTurnLine(content) {
105
+ return line({
106
+ id: id(),
107
+ method: "turn/start",
108
+ params: {
109
+ threadId,
110
+ input: [{ type: "text", text: content }],
111
+ },
112
+ });
113
+ }
114
+ function buildSteerLine(content) {
115
+ return line({
116
+ id: id(),
117
+ method: "turn/steer",
118
+ params: {
119
+ threadId,
120
+ expectedTurnId: activeTurnId,
121
+ input: [{ type: "text", text: content }],
122
+ },
123
+ });
124
+ }
125
+ function buildBusyTurnLine(content) {
126
+ turnBusy = true;
127
+ return buildTurnLine(content);
128
+ }
129
+ function writeBusyTurn(content) {
130
+ if (!io)
131
+ return;
132
+ io.write(buildBusyTurnLine(content));
133
+ }
134
+ function drainOneQueuedFollowUp() {
135
+ if (!io || threadId === null || queuedFollowUps.length === 0)
136
+ return false;
137
+ const next = queuedFollowUps.shift();
138
+ if (next == null)
139
+ return false;
140
+ emitQueueStatus();
141
+ writeBusyTurn(next.text);
142
+ return true;
143
+ }
144
+ function itemText(item) {
145
+ const content = item.content;
146
+ if (typeof content === "string")
147
+ return content;
148
+ if (!Array.isArray(content))
149
+ return "";
150
+ return content
151
+ .map((block) => {
152
+ if (typeof block === "string")
153
+ return block;
154
+ if (!block || typeof block !== "object")
155
+ return "";
156
+ const b = block;
157
+ return typeof b.text === "string" ? b.text : "";
158
+ })
159
+ .join("");
160
+ }
161
+ function isoFromMs(value) {
162
+ return typeof value === "number" && Number.isFinite(value)
163
+ ? new Date(value).toISOString()
164
+ : undefined;
165
+ }
166
+ function fileChangePayload(item) {
167
+ return {
168
+ id: item.id,
169
+ changes: Array.isArray(item.changes) ? item.changes : [],
170
+ status: item.status,
171
+ };
172
+ }
173
+ /** Emit an `activity_status` frame iff the phase changed since the last one.
174
+ * `extra` carries phase-specific context (search `summary`, edit `count`). */
175
+ function activityFrame(phase, extra = {}) {
176
+ if (activityPhase === phase)
177
+ return [];
178
+ activityPhase = phase;
179
+ return [
180
+ {
181
+ type: "event",
182
+ kind: "activity_status",
183
+ payload: { phase, active: phase !== "idle", ...extra },
184
+ },
185
+ ];
186
+ }
187
+ /** Map a commandExecution's actions to an activity phase + summary. Codex's
188
+ * exploring commands (search/read/listFiles) drive the "searching" phase; a
189
+ * search action also surfaces its query as the bar summary. */
190
+ function commandActivity(item) {
191
+ const actions = Array.isArray(item.commandActions)
192
+ ? item.commandActions
193
+ : [];
194
+ const search = actions.find((a) => a && a.type === "search");
195
+ const exploring = search != null ||
196
+ actions.some((a) => a && (a.type === "read" || a.type === "listFiles"));
197
+ if (!exploring)
198
+ return activityFrame("working");
199
+ const summary = typeof search?.query === "string" ? search.query : undefined;
200
+ return activityFrame("searching", summary ? { summary } : {});
201
+ }
202
+ return {
203
+ runningMarkers: CODEX_RUNNING_MARKERS,
204
+ setEventSink(sink) {
205
+ eventSink = sink;
206
+ },
207
+ editQueuedTurn(itemId, text) {
208
+ const trimmed = text.trim();
209
+ if (!trimmed)
210
+ return false;
211
+ const item = queuedFollowUps.find((q) => q.id === itemId);
212
+ if (!item)
213
+ return false;
214
+ item.text = trimmed;
215
+ emitQueueStatus();
216
+ return true;
217
+ },
218
+ deleteQueuedTurn(itemId) {
219
+ const index = queuedFollowUps.findIndex((q) => q.id === itemId);
220
+ if (index < 0)
221
+ return false;
222
+ queuedFollowUps.splice(index, 1);
223
+ emitQueueStatus();
224
+ return true;
225
+ },
226
+ closeQueue() {
227
+ if (queuedFollowUps.length === 0)
228
+ return false;
229
+ queuedFollowUps.length = 0;
230
+ emitQueueStatus();
231
+ return true;
232
+ },
233
+ steerQueuedTurn(itemId) {
234
+ if (threadId === null || activeTurnId === null) {
235
+ emitQueueStatus();
236
+ return "";
237
+ }
238
+ const index = queuedFollowUps.findIndex((q) => q.id === itemId);
239
+ if (index < 0) {
240
+ emitQueueStatus();
241
+ return "";
242
+ }
243
+ const [item] = queuedFollowUps.splice(index, 1);
244
+ emitQueueStatus();
245
+ return buildSteerLine(item.text);
246
+ },
247
+ command(_resumeSessionId, _ctx) {
248
+ // codex app-server is always the same command; resuming is done via
249
+ // thread/resume inside the JSON-RPC handshake, not via CLI flags. The
250
+ // device-tool MCP server is configured out-of-band in ~/.codex/config.toml
251
+ // (written by mcp-config.ts before spawn), so mcpConfigPath is unused here.
252
+ return { bin: resolveCodexBin(), args: ["app-server"] };
253
+ },
254
+ start(ioArg, resumeSessionId) {
255
+ io = ioArg;
256
+ resumeId = resumeSessionId;
257
+ // If resuming, set threadId immediately so encodeUserTurn works even
258
+ // before thread/started arrives.
259
+ if (resumeSessionId) {
260
+ threadId = resumeSessionId;
261
+ }
262
+ // Send the initialize request — the handshake continues from translate()
263
+ // once the server responds, keeping ordering guarantees.
264
+ io.write(line({
265
+ id: id(),
266
+ method: "initialize",
267
+ params: {
268
+ clientInfo: {
269
+ name: "pinclaw-codex-bridge",
270
+ title: "Nexting",
271
+ version: "0.1.0",
272
+ },
273
+ },
274
+ }));
275
+ },
276
+ translate(parsed, ioArg) {
277
+ const kind = classifyRpc(parsed);
278
+ const o = parsed;
279
+ // ---- terminal engine errors (surface, never drop) ----
280
+ // Codex reports request failures in two shapes that would otherwise be
281
+ // swallowed: an error RESPONSE ({id, error}) AND — the one that bit us — a
282
+ // TOP-LEVEL id-less {error} frame (e.g. "no rollout found for thread id …"
283
+ // when resuming a thread whose rollout was never written). classifyRpc tags
284
+ // the latter "unknown", so without this it is dropped at the function tail
285
+ // → the phone spins "working" forever with no feedback. Surface both as a
286
+ // result_error so the UI clears and shows a real message. (Retryable stream
287
+ // hiccups use the {method:"error",params.willRetry} NOTIFICATION path below;
288
+ // those carry no top-level `error`, so kind==="notification" is excluded.)
289
+ if (kind !== "notification") {
290
+ const errObj = o.error;
291
+ if (errObj && typeof errObj === "object") {
292
+ const msg = typeof errObj.message === "string"
293
+ ? errObj.message
294
+ : "Codex 引擎返回错误";
295
+ const detail = typeof errObj.additionalDetails === "string"
296
+ ? errObj.additionalDetails
297
+ : undefined;
298
+ return [
299
+ {
300
+ type: "event",
301
+ kind: "result_error",
302
+ payload: { errors: [detail ? `${msg}: ${detail}` : msg] },
303
+ },
304
+ ];
305
+ }
306
+ }
307
+ // ---- initialize RESPONSE ----
308
+ if (kind === "response") {
309
+ const result = o.result;
310
+ // Fix 1: only fire the handshake continuation once, even if a future
311
+ // response happens to carry a userAgent field.
312
+ if (result && typeof result.userAgent === "string" && !handshakeDone) {
313
+ handshakeDone = true;
314
+ // Handshake step 3: send "initialized" notification
315
+ ioArg.write(line({ method: "initialized" }));
316
+ // Handshake step 4: start or resume the thread
317
+ if (resumeId) {
318
+ ioArg.write(line({
319
+ id: id(),
320
+ method: "thread/resume",
321
+ params: {
322
+ threadId: resumeId,
323
+ approvalPolicy: "never",
324
+ },
325
+ }));
326
+ }
327
+ else {
328
+ ioArg.write(line({
329
+ id: id(),
330
+ method: "thread/start",
331
+ params: {
332
+ approvalPolicy: "never",
333
+ },
334
+ }));
335
+ }
336
+ // Fix 3: clear resumeId so a defensive second start() can't reuse stale state
337
+ resumeId = undefined;
338
+ }
339
+ // All other responses (e.g. turn/start ACK) are ignored.
340
+ return [];
341
+ }
342
+ // ---- notifications ----
343
+ if (kind === "notification") {
344
+ const method = o.method;
345
+ const params = (o.params ?? {});
346
+ switch (method) {
347
+ case "thread/started": {
348
+ const thread = params.thread;
349
+ threadId = thread.id;
350
+ // Fix 2: flush the first turn that was enqueued before threadId
351
+ // arrived. Any extra phone sends become Codex follow-ups, not
352
+ // concurrent turn/start requests.
353
+ const firstPending = pendingTurns.shift();
354
+ if (firstPending != null)
355
+ writeBusyTurn(firstPending);
356
+ queuedFollowUps.push(...pendingTurns.map(queueItem));
357
+ pendingTurns.length = 0;
358
+ if (queuedFollowUps.length > 0)
359
+ emitQueueStatus();
360
+ return [
361
+ {
362
+ type: "event",
363
+ kind: "system_init",
364
+ payload: {
365
+ session_id: thread.id,
366
+ cwd: thread.cwd,
367
+ },
368
+ },
369
+ ];
370
+ }
371
+ case "turn/started": {
372
+ turnBusy = true;
373
+ const turn = params.turn;
374
+ activeTurnId = typeof turn?.id === "string" ? turn.id : null;
375
+ emitQueueStatus();
376
+ return [
377
+ ...activityFrame("working"),
378
+ { type: "event", kind: "status", payload: { state: "running" } },
379
+ ];
380
+ }
381
+ case "turn/completed": {
382
+ activeTurnId = null;
383
+ const drained = drainOneQueuedFollowUp();
384
+ if (!drained)
385
+ turnBusy = false;
386
+ emitQueueStatus();
387
+ // status idle first (legacy), then the activity bar clears.
388
+ return [
389
+ { type: "event", kind: "status", payload: { state: "idle" } },
390
+ ...activityFrame("idle"),
391
+ ];
392
+ }
393
+ case "item/agentMessage/delta": {
394
+ const delta = params.delta;
395
+ return [
396
+ ...activityFrame("writing"),
397
+ {
398
+ type: "event",
399
+ kind: "stream_text_delta",
400
+ payload: { index: 0, text: delta },
401
+ },
402
+ ];
403
+ }
404
+ case "item/reasoning/textDelta":
405
+ case "item/reasoning/summaryTextDelta": {
406
+ const delta = params.delta;
407
+ return [
408
+ ...activityFrame("thinking"),
409
+ {
410
+ type: "event",
411
+ kind: "stream_thinking_delta",
412
+ payload: { index: 0, text: delta },
413
+ },
414
+ ];
415
+ }
416
+ case "item/completed": {
417
+ const item = params.item;
418
+ if (item.type === "userMessage") {
419
+ const text = itemText(item);
420
+ if (!text)
421
+ return [];
422
+ return [
423
+ {
424
+ type: "event",
425
+ kind: "user",
426
+ payload: {
427
+ uuid: item.id,
428
+ text,
429
+ timestamp: isoFromMs(params.completedAtMs),
430
+ },
431
+ },
432
+ ];
433
+ }
434
+ if (item.type === "agentMessage") {
435
+ return [
436
+ {
437
+ type: "event",
438
+ kind: "assistant",
439
+ payload: {
440
+ uuid: item.id,
441
+ index: 0,
442
+ text: item.text,
443
+ },
444
+ },
445
+ ];
446
+ }
447
+ if (item.type === "commandExecution") {
448
+ const exitCode = item.exitCode;
449
+ return [
450
+ {
451
+ type: "event",
452
+ kind: "tool_result",
453
+ payload: {
454
+ tool_use_id: item.id,
455
+ content: item.aggregatedOutput,
456
+ is_error: exitCode !== 0 && exitCode != null,
457
+ },
458
+ },
459
+ ];
460
+ }
461
+ if (item.type === "fileChange") {
462
+ return [
463
+ {
464
+ type: "event",
465
+ kind: "file_change",
466
+ payload: fileChangePayload(item),
467
+ },
468
+ ];
469
+ }
470
+ return [];
471
+ }
472
+ case "item/started": {
473
+ const item = params.item;
474
+ if (item.type === "commandExecution") {
475
+ const commandActions = Array.isArray(item.commandActions)
476
+ ? item.commandActions
477
+ : [];
478
+ return [
479
+ ...commandActivity(item),
480
+ {
481
+ type: "event",
482
+ kind: "tool_use",
483
+ payload: {
484
+ id: item.id,
485
+ name: "shell",
486
+ input: { command: item.command },
487
+ commandActions,
488
+ commandSource: item.source,
489
+ },
490
+ },
491
+ ];
492
+ }
493
+ if (item.type === "fileChange") {
494
+ const changes = Array.isArray(item.changes) ? item.changes : [];
495
+ return [
496
+ ...activityFrame("editing", { count: changes.length }),
497
+ {
498
+ type: "event",
499
+ kind: "file_change",
500
+ payload: fileChangePayload(item),
501
+ },
502
+ ];
503
+ }
504
+ return [];
505
+ }
506
+ case "item/fileChange/patchUpdated": {
507
+ const changes = Array.isArray(params.changes) ? params.changes : [];
508
+ return [
509
+ ...activityFrame("editing", { count: changes.length }),
510
+ {
511
+ type: "event",
512
+ kind: "file_change",
513
+ payload: {
514
+ id: params.itemId,
515
+ changes,
516
+ status: "inProgress",
517
+ },
518
+ },
519
+ ];
520
+ }
521
+ case "error": {
522
+ const willRetry = params.willRetry;
523
+ if (willRetry)
524
+ return []; // engine will retry; don't disrupt the phone
525
+ const err = params.error;
526
+ const msg = err.message;
527
+ const detail = err.additionalDetails;
528
+ const full = detail ? `${msg}: ${detail}` : msg;
529
+ return [
530
+ {
531
+ type: "event",
532
+ kind: "result_error",
533
+ payload: { errors: [full] },
534
+ },
535
+ ];
536
+ }
537
+ // MCP server startup status: not surfaced to the phone, but logged to
538
+ // stderr so a failed pinclaw-device proxy launch is debuggable in the
539
+ // codex-bridge.err.log (stdout is the JSON-RPC channel — never log there).
540
+ case "mcpServer/startupStatus/updated": {
541
+ try {
542
+ process.stderr.write(`[codex-adapter] mcpServer/startupStatus: ${JSON.stringify(params)}\n`);
543
+ }
544
+ catch {
545
+ /* logging must never break translate */
546
+ }
547
+ return [];
548
+ }
549
+ // Ignored notifications
550
+ case "remoteControl/status/changed":
551
+ case "thread/status/changed":
552
+ case "warning":
553
+ default:
554
+ return [];
555
+ }
556
+ }
557
+ // server_request and unknown: ignore
558
+ return [];
559
+ },
560
+ encodeUserTurn(content) {
561
+ // Custom prompts ("/name args") are a TUI feature the app-server does
562
+ // NOT expand — substitute the prompt body here so phone sends behave
563
+ // like the Codex terminal. Non-matching text passes through untouched.
564
+ content = expandCodexPrompt(content);
565
+ // Fix 2: if threadId is not yet known, buffer the turn and return an
566
+ // empty string (a harmless noop write to stdin); it will be flushed
567
+ // in the thread/started handler.
568
+ if (threadId === null) {
569
+ pendingTurns.push(content);
570
+ return "";
571
+ }
572
+ // Codex App does not accept a second same-thread turn/start while a turn
573
+ // is active. Match the desktop app's end-of-turn follow-up queue instead
574
+ // of leaking Claude Code's immediate-send semantics into Codex.
575
+ if (turnBusy) {
576
+ queuedFollowUps.push(queueItem(content));
577
+ emitQueueStatus();
578
+ return "";
579
+ }
580
+ return buildBusyTurnLine(content);
581
+ },
582
+ encodeAnswer(_controlRequestId, _updatedInput) {
583
+ // v1 runs app-server with approvalPolicy:"never", so approval server_requests never arrive.
584
+ // If approvalPolicy ever changes, implement real tool-approval encoding here instead of this noop.
585
+ return line({ method: "noop" });
586
+ },
587
+ encodeDeny(_controlRequestId, _message) {
588
+ // v1 runs app-server with approvalPolicy:"never", so approval server_requests never arrive.
589
+ // If approvalPolicy ever changes, implement real tool-approval encoding here instead of this noop.
590
+ return line({ method: "noop" });
591
+ },
592
+ };
593
+ }