@cjhyy/code-shell-server 0.8.0

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 (70) hide show
  1. package/README.md +77 -0
  2. package/dist/attachment-service.d.ts +80 -0
  3. package/dist/attachment-service.js +587 -0
  4. package/dist/bin/code-shell-serve.d.ts +2 -0
  5. package/dist/bin/code-shell-serve.js +7 -0
  6. package/dist/client-message-id.d.ts +1 -0
  7. package/dist/client-message-id.js +8 -0
  8. package/dist/image-byte-probe.d.ts +17 -0
  9. package/dist/image-byte-probe.js +330 -0
  10. package/dist/index.d.ts +10 -0
  11. package/dist/index.js +10 -0
  12. package/dist/index.mobile-remote.d.ts +21 -0
  13. package/dist/index.mobile-remote.js +21 -0
  14. package/dist/index.serve.d.ts +3 -0
  15. package/dist/index.serve.js +3 -0
  16. package/dist/index.storage.d.ts +10 -0
  17. package/dist/index.storage.js +10 -0
  18. package/dist/index.worker.d.ts +2 -0
  19. package/dist/index.worker.js +2 -0
  20. package/dist/mobile-remote/access-passcode.d.ts +80 -0
  21. package/dist/mobile-remote/access-passcode.js +354 -0
  22. package/dist/mobile-remote/cloudflared-binary.d.ts +76 -0
  23. package/dist/mobile-remote/cloudflared-binary.js +242 -0
  24. package/dist/mobile-remote/codex-parse.d.ts +34 -0
  25. package/dist/mobile-remote/codex-parse.js +131 -0
  26. package/dist/mobile-remote/codex-room-agent.d.ts +94 -0
  27. package/dist/mobile-remote/codex-room-agent.js +213 -0
  28. package/dist/mobile-remote/mobile-attachments.d.ts +21 -0
  29. package/dist/mobile-remote/mobile-attachments.js +119 -0
  30. package/dist/mobile-remote/mobile-chat-turn.d.ts +32 -0
  31. package/dist/mobile-remote/mobile-chat-turn.js +67 -0
  32. package/dist/mobile-remote/mobile-history.d.ts +22 -0
  33. package/dist/mobile-remote/mobile-history.js +27 -0
  34. package/dist/mobile-remote/mobile-run-dispatch.d.ts +17 -0
  35. package/dist/mobile-remote/mobile-run-dispatch.js +56 -0
  36. package/dist/mobile-remote/mobile-static.d.ts +43 -0
  37. package/dist/mobile-remote/mobile-static.js +154 -0
  38. package/dist/mobile-remote/mobile-upload-service.d.ts +57 -0
  39. package/dist/mobile-remote/mobile-upload-service.js +370 -0
  40. package/dist/mobile-remote/pairing.d.ts +8 -0
  41. package/dist/mobile-remote/pairing.js +23 -0
  42. package/dist/mobile-remote/path-bins.d.ts +7 -0
  43. package/dist/mobile-remote/path-bins.js +16 -0
  44. package/dist/mobile-remote/pending-approvals.d.ts +14 -0
  45. package/dist/mobile-remote/pending-approvals.js +54 -0
  46. package/dist/mobile-remote/remote-host-manager.d.ts +117 -0
  47. package/dist/mobile-remote/remote-host-manager.js +345 -0
  48. package/dist/mobile-remote/resident-agent.d.ts +98 -0
  49. package/dist/mobile-remote/resident-agent.js +216 -0
  50. package/dist/mobile-remote/room-manager.d.ts +286 -0
  51. package/dist/mobile-remote/room-manager.js +765 -0
  52. package/dist/mobile-remote/trusted-device-store.d.ts +19 -0
  53. package/dist/mobile-remote/trusted-device-store.js +118 -0
  54. package/dist/mobile-remote/tunnel-manager.d.ts +105 -0
  55. package/dist/mobile-remote/tunnel-manager.js +375 -0
  56. package/dist/mobile-remote/types.d.ts +1 -0
  57. package/dist/mobile-remote/types.js +1 -0
  58. package/dist/mobile-remote/viewer-identity.d.ts +5 -0
  59. package/dist/mobile-remote/viewer-identity.js +5 -0
  60. package/dist/serve/cli.d.ts +15 -0
  61. package/dist/serve/cli.js +115 -0
  62. package/dist/serve/headless-server.d.ts +40 -0
  63. package/dist/serve/headless-server.js +276 -0
  64. package/dist/sessions-service.d.ts +73 -0
  65. package/dist/sessions-service.js +303 -0
  66. package/dist/static-files.d.ts +1 -0
  67. package/dist/static-files.js +25 -0
  68. package/dist/worker-bridge-core.d.ts +154 -0
  69. package/dist/worker-bridge-core.js +315 -0
  70. package/package.json +63 -0
@@ -0,0 +1,34 @@
1
+ import type { ResidentAgentEvent } from "./resident-agent.js";
2
+ /**
3
+ * Parse ONE line of `codex exec --json` (JSONL) output into zero-or-more
4
+ * NORMALIZED ResidentAgentEvents — the exact same render-friendly union the
5
+ * claude room emits, so desktop + mobile UIs render a codex room with no
6
+ * UI-side changes. Pure + synchronous → unit-testable against recorded codex
7
+ * output without spawning codex.
8
+ *
9
+ * Codex event shapes (verified against codex-cli 0.142.4):
10
+ * {type:"thread.started", thread_id} → [] (id captured by the process, not here)
11
+ * {type:"turn.started"} → []
12
+ * {type:"item.started"|"item.updated"|"item.completed", item:{id,type,...}}
13
+ * item.type "agent_message" → text (on completed only — dedup)
14
+ * item.type "reasoning" → [] (thinking noise)
15
+ * item.type "command_execution" → tool(Bash) on start, tool_result on complete
16
+ * item.type "mcp_tool_call" → tool(server__tool) on start, tool_result on complete
17
+ * item.type "web_search" → tool(WebSearch) on start
18
+ * item.type "file_change" → tool(Edit) on start
19
+ * {type:"turn.completed", usage} → turn_end
20
+ * {type:"turn.failed", error} → error
21
+ * {type:"error", message} → error
22
+ *
23
+ * NOTE: codex never produces an approval_request — its only guardrail is the
24
+ * sandbox tier chosen at spawn time (read-only / workspace-write / bypass).
25
+ */
26
+ export declare function parseCodexJsonLine(line: string): ResidentAgentEvent[];
27
+ /**
28
+ * Pull the codex thread id out of a `thread.started` line, or undefined for any
29
+ * other line. Kept here (not inline in the room agent) so ALL codex JSONL
30
+ * knowledge lives in one file — parseCodexJsonLine deliberately drops
31
+ * thread.started (it's resume-control info, not a render event), so the room
32
+ * agent uses THIS to capture the id without a second hand-rolled JSON.parse.
33
+ */
34
+ export declare function extractThreadId(line: string): string | undefined;
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Parse ONE line of `codex exec --json` (JSONL) output into zero-or-more
3
+ * NORMALIZED ResidentAgentEvents — the exact same render-friendly union the
4
+ * claude room emits, so desktop + mobile UIs render a codex room with no
5
+ * UI-side changes. Pure + synchronous → unit-testable against recorded codex
6
+ * output without spawning codex.
7
+ *
8
+ * Codex event shapes (verified against codex-cli 0.142.4):
9
+ * {type:"thread.started", thread_id} → [] (id captured by the process, not here)
10
+ * {type:"turn.started"} → []
11
+ * {type:"item.started"|"item.updated"|"item.completed", item:{id,type,...}}
12
+ * item.type "agent_message" → text (on completed only — dedup)
13
+ * item.type "reasoning" → [] (thinking noise)
14
+ * item.type "command_execution" → tool(Bash) on start, tool_result on complete
15
+ * item.type "mcp_tool_call" → tool(server__tool) on start, tool_result on complete
16
+ * item.type "web_search" → tool(WebSearch) on start
17
+ * item.type "file_change" → tool(Edit) on start
18
+ * {type:"turn.completed", usage} → turn_end
19
+ * {type:"turn.failed", error} → error
20
+ * {type:"error", message} → error
21
+ *
22
+ * NOTE: codex never produces an approval_request — its only guardrail is the
23
+ * sandbox tier chosen at spawn time (read-only / workspace-write / bypass).
24
+ */
25
+ export function parseCodexJsonLine(line) {
26
+ const trimmed = line.trim();
27
+ if (!trimmed)
28
+ return [];
29
+ let msg;
30
+ try {
31
+ msg = JSON.parse(trimmed);
32
+ }
33
+ catch {
34
+ return [];
35
+ }
36
+ if (msg.type === "turn.completed")
37
+ return [{ type: "turn_end", reason: "completed" }];
38
+ if (msg.type === "turn.failed") {
39
+ return [{ type: "error", error: errText(msg.error) || "turn failed" }];
40
+ }
41
+ if (msg.type === "error") {
42
+ return [{ type: "error", error: errText(msg.message ?? msg.error) || "error" }];
43
+ }
44
+ const phase = msg.type === "item.started" ? "started" : msg.type === "item.updated" ? "updated" : msg.type === "item.completed" ? "completed" : null;
45
+ if (!phase)
46
+ return []; // thread.started / turn.started / unknown → noise
47
+ const item = msg.item;
48
+ if (!item || typeof item !== "object")
49
+ return [];
50
+ const id = typeof item.id === "string" ? item.id : undefined;
51
+ switch (item.type) {
52
+ case "agent_message":
53
+ // Only the final text (avoid duplicating the streaming updates).
54
+ return phase === "completed" && typeof item.text === "string" ? [{ type: "text", text: item.text }] : [];
55
+ case "reasoning":
56
+ return []; // thinking — not in the render union
57
+ case "command_execution":
58
+ if (phase === "completed") {
59
+ return [{
60
+ type: "tool_result",
61
+ id,
62
+ summary: String(item.aggregated_output ?? "").slice(0, 400),
63
+ isError: typeof item.exit_code === "number" ? item.exit_code !== 0 : false,
64
+ }];
65
+ }
66
+ return phase === "started"
67
+ ? [{ type: "tool", id, tool: "Bash", summary: String(item.command ?? ""), input: { command: String(item.command ?? "") } }]
68
+ : [];
69
+ case "mcp_tool_call":
70
+ if (phase === "completed") {
71
+ return [{
72
+ type: "tool_result",
73
+ id,
74
+ summary: String(item.result ?? item.output ?? "").slice(0, 400),
75
+ isError: item.status === "failed" || Boolean(item.is_error),
76
+ }];
77
+ }
78
+ return phase === "started"
79
+ ? [{
80
+ type: "tool",
81
+ id,
82
+ tool: `${item.server ?? "mcp"}__${item.tool ?? "tool"}`,
83
+ summary: "",
84
+ input: { arguments: item.arguments },
85
+ }]
86
+ : [];
87
+ case "web_search":
88
+ return phase === "started"
89
+ ? [{ type: "tool", id, tool: "WebSearch", summary: String(item.query ?? ""), input: { query: String(item.query ?? "") } }]
90
+ : [];
91
+ case "file_change":
92
+ return phase === "started"
93
+ ? [{
94
+ type: "tool",
95
+ id,
96
+ tool: "Edit",
97
+ summary: String(item.path ?? ""),
98
+ input: item.kind !== undefined ? { path: String(item.path ?? ""), kind: item.kind } : { path: String(item.path ?? "") },
99
+ }]
100
+ : [];
101
+ default:
102
+ return [];
103
+ }
104
+ }
105
+ /**
106
+ * Pull the codex thread id out of a `thread.started` line, or undefined for any
107
+ * other line. Kept here (not inline in the room agent) so ALL codex JSONL
108
+ * knowledge lives in one file — parseCodexJsonLine deliberately drops
109
+ * thread.started (it's resume-control info, not a render event), so the room
110
+ * agent uses THIS to capture the id without a second hand-rolled JSON.parse.
111
+ */
112
+ export function extractThreadId(line) {
113
+ const trimmed = line.trim();
114
+ if (!trimmed.startsWith("{"))
115
+ return undefined;
116
+ let msg;
117
+ try {
118
+ msg = JSON.parse(trimmed);
119
+ }
120
+ catch {
121
+ return undefined;
122
+ }
123
+ return msg.type === "thread.started" && typeof msg.thread_id === "string" ? msg.thread_id : undefined;
124
+ }
125
+ function errText(e) {
126
+ if (typeof e === "string")
127
+ return e;
128
+ if (e && typeof e === "object" && typeof e.message === "string")
129
+ return e.message;
130
+ return "";
131
+ }
@@ -0,0 +1,94 @@
1
+ import type { ResidentAgentEvent } from "./resident-agent.js";
2
+ import type { RoomAgent } from "./room-manager.js";
3
+ type RoomPermissionMode = "default" | "acceptEdits" | "bypassPermissions";
4
+ /** Map a room permission mode to codex's spawn-time sandbox tier. codex has no
5
+ * per-tool approval; the tier IS the guardrail. (Mirrors core's codexAdapter.) */
6
+ export declare function sandboxForMode(mode: RoomPermissionMode): {
7
+ bypass: boolean;
8
+ sandbox?: "read-only" | "workspace-write";
9
+ };
10
+ /** Build `codex exec` argv for one turn. Prompt is fed over stdin (trailing `-`).
11
+ * Pure → unit-testable. */
12
+ export declare function codexArgsForTurn(opts: {
13
+ mode: RoomPermissionMode;
14
+ threadId?: string;
15
+ }): string[];
16
+ /**
17
+ * Decide whether a finished codex turn should surface a stderr-based error.
18
+ * Pure → unit-testable.
19
+ *
20
+ * codex `exec --json` reports most failures as `turn.failed`/`error` JSON on
21
+ * STDOUT (handled by parseCodexJsonLine), but some (e.g. resuming a missing
22
+ * thread id, arg-parse failures) print ONLY to stderr with a non-zero exit and
23
+ * no JSON. So we report stderr as an error *only when the process actually
24
+ * failed* (non-zero exit). On a clean exit, stderr is just warnings/notices and
25
+ * must NOT become a red error in the room. Operating on full lines (not raw
26
+ * chunks) avoids a chunk split mid-line being mis-judged.
27
+ */
28
+ export declare function codexStderrError(stderrLines: string[], exitCode: number | null): string | null;
29
+ /**
30
+ * Decide whether a finished codex turn needs a FALLBACK seal event so the room
31
+ * UI doesn't hang on "working". Pure → unit-testable.
32
+ *
33
+ * The room is sealed (turn_end / error) the moment a `turn.completed` or
34
+ * `turn.failed` JSON line arrives on stdout. But a codex process can die WITHOUT
35
+ * emitting either — OOM-killed, segfaulted, or non-zero exit with output only
36
+ * half-written — and then nothing tells the reducer the turn is over: the
37
+ * progress card spins forever. This function returns the event the exit handler
38
+ * must emit in that gap.
39
+ *
40
+ * Cases:
41
+ * - already sealed (turn JSON seen) → null (don't double-seal)
42
+ * - user stopped the room → null (stop() tears down the UI)
43
+ * - a stderr error will be emitted → null (that error seals the run)
44
+ * - otherwise (crash / silent exit) → a turn_end so the UI settles.
45
+ * reason encodes how it ended for the transcript.
46
+ */
47
+ export declare function sealEventOnExit(opts: {
48
+ code: number | null;
49
+ signal: NodeJS.Signals | null;
50
+ turnSealed: boolean;
51
+ stopping: boolean;
52
+ hasStderrError: boolean;
53
+ }): ResidentAgentEvent | null;
54
+ export interface CodexRoomAgentOptions {
55
+ command: string;
56
+ cwd: string;
57
+ permissionMode: RoomPermissionMode;
58
+ /** Resume an existing codex thread (across app restarts). */
59
+ resumeThreadId?: string;
60
+ onEvent: (event: ResidentAgentEvent) => void;
61
+ /** Surfaced when codex mints/confirms its thread id, so the room can persist
62
+ * it for resume on the next turn / after restart. */
63
+ onThreadId?: (threadId: string) => void;
64
+ }
65
+ /**
66
+ * A codex-backed room agent. Unlike the claude resident process (one long-lived
67
+ * stdin-fed process), codex `exec` runs ONE process per turn and the next turn
68
+ * continues the conversation via `resume <thread_id>`. So `send()` spawns a
69
+ * fresh `codex exec` each time, capturing the thread id from the first turn and
70
+ * resuming it thereafter. Implements the same RoomAgent interface so RoomManager
71
+ * treats it identically; it has no `respondControl` (codex has no per-tool
72
+ * approval — the sandbox tier is the guardrail).
73
+ */
74
+ export declare class CodexRoomAgent implements RoomAgent {
75
+ private readonly opts;
76
+ private child?;
77
+ private threadId?;
78
+ private started;
79
+ /** True once the CURRENT turn emitted a sealing event (turn_end via
80
+ * turn.completed, or an error). Reset at the start of each send(). */
81
+ private turnSealed;
82
+ /** True while stop() is tearing the room down, so the exit handler doesn't
83
+ * mistake an intentional SIGTERM for a crash. */
84
+ private stopping;
85
+ constructor(opts: CodexRoomAgentOptions);
86
+ /** No persistent process to launch; a turn's process is spawned on send(). */
87
+ start(): void;
88
+ send(text: string): boolean;
89
+ /** The room is "running" once started — independent of whether a turn's
90
+ * process is currently alive (turns are ephemeral). */
91
+ isRunning(): boolean;
92
+ stop(): void;
93
+ }
94
+ export {};
@@ -0,0 +1,213 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createInterface } from "node:readline";
3
+ import { pathWithCommonBins } from "./path-bins.js";
4
+ import { parseCodexJsonLine, extractThreadId } from "./codex-parse.js";
5
+ /** Map a room permission mode to codex's spawn-time sandbox tier. codex has no
6
+ * per-tool approval; the tier IS the guardrail. (Mirrors core's codexAdapter.) */
7
+ export function sandboxForMode(mode) {
8
+ if (mode === "bypassPermissions")
9
+ return { bypass: true, sandbox: undefined };
10
+ return { bypass: false, sandbox: mode === "acceptEdits" ? "workspace-write" : "read-only" };
11
+ }
12
+ /** Build `codex exec` argv for one turn. Prompt is fed over stdin (trailing `-`).
13
+ * Pure → unit-testable. */
14
+ export function codexArgsForTurn(opts) {
15
+ const args = ["exec", "--json", "--color", "never", "--skip-git-repo-check"];
16
+ const { bypass, sandbox } = sandboxForMode(opts.mode);
17
+ if (bypass)
18
+ args.push("--dangerously-bypass-approvals-and-sandbox");
19
+ else if (sandbox)
20
+ args.push("--sandbox", sandbox);
21
+ if (opts.threadId)
22
+ args.push("resume", opts.threadId, "-");
23
+ else
24
+ args.push("-");
25
+ return args;
26
+ }
27
+ /**
28
+ * Decide whether a finished codex turn should surface a stderr-based error.
29
+ * Pure → unit-testable.
30
+ *
31
+ * codex `exec --json` reports most failures as `turn.failed`/`error` JSON on
32
+ * STDOUT (handled by parseCodexJsonLine), but some (e.g. resuming a missing
33
+ * thread id, arg-parse failures) print ONLY to stderr with a non-zero exit and
34
+ * no JSON. So we report stderr as an error *only when the process actually
35
+ * failed* (non-zero exit). On a clean exit, stderr is just warnings/notices and
36
+ * must NOT become a red error in the room. Operating on full lines (not raw
37
+ * chunks) avoids a chunk split mid-line being mis-judged.
38
+ */
39
+ export function codexStderrError(stderrLines, exitCode) {
40
+ if (exitCode === 0 || exitCode === null)
41
+ return null;
42
+ const text = stderrLines.join("\n").trim();
43
+ if (!text)
44
+ return null;
45
+ return text.slice(0, 800);
46
+ }
47
+ /**
48
+ * Decide whether a finished codex turn needs a FALLBACK seal event so the room
49
+ * UI doesn't hang on "working". Pure → unit-testable.
50
+ *
51
+ * The room is sealed (turn_end / error) the moment a `turn.completed` or
52
+ * `turn.failed` JSON line arrives on stdout. But a codex process can die WITHOUT
53
+ * emitting either — OOM-killed, segfaulted, or non-zero exit with output only
54
+ * half-written — and then nothing tells the reducer the turn is over: the
55
+ * progress card spins forever. This function returns the event the exit handler
56
+ * must emit in that gap.
57
+ *
58
+ * Cases:
59
+ * - already sealed (turn JSON seen) → null (don't double-seal)
60
+ * - user stopped the room → null (stop() tears down the UI)
61
+ * - a stderr error will be emitted → null (that error seals the run)
62
+ * - otherwise (crash / silent exit) → a turn_end so the UI settles.
63
+ * reason encodes how it ended for the transcript.
64
+ */
65
+ export function sealEventOnExit(opts) {
66
+ if (opts.turnSealed || opts.stopping || opts.hasStderrError)
67
+ return null;
68
+ const reason = opts.signal != null
69
+ ? `killed:${opts.signal}`
70
+ : opts.code && opts.code !== 0
71
+ ? `exited:${opts.code}`
72
+ : "completed";
73
+ return { type: "turn_end", reason };
74
+ }
75
+ /**
76
+ * A codex-backed room agent. Unlike the claude resident process (one long-lived
77
+ * stdin-fed process), codex `exec` runs ONE process per turn and the next turn
78
+ * continues the conversation via `resume <thread_id>`. So `send()` spawns a
79
+ * fresh `codex exec` each time, capturing the thread id from the first turn and
80
+ * resuming it thereafter. Implements the same RoomAgent interface so RoomManager
81
+ * treats it identically; it has no `respondControl` (codex has no per-tool
82
+ * approval — the sandbox tier is the guardrail).
83
+ */
84
+ export class CodexRoomAgent {
85
+ opts;
86
+ child;
87
+ threadId;
88
+ started = false;
89
+ /** True once the CURRENT turn emitted a sealing event (turn_end via
90
+ * turn.completed, or an error). Reset at the start of each send(). */
91
+ turnSealed = false;
92
+ /** True while stop() is tearing the room down, so the exit handler doesn't
93
+ * mistake an intentional SIGTERM for a crash. */
94
+ stopping = false;
95
+ constructor(opts) {
96
+ this.opts = opts;
97
+ this.threadId = opts.resumeThreadId;
98
+ }
99
+ /** No persistent process to launch; a turn's process is spawned on send(). */
100
+ start() {
101
+ this.started = true;
102
+ }
103
+ send(text) {
104
+ if (!this.started)
105
+ return false;
106
+ if (this.child)
107
+ return false; // a turn is already running; one turn at a time
108
+ this.turnSealed = false; // fresh turn — not yet sealed
109
+ const args = codexArgsForTurn({ mode: this.opts.permissionMode, threadId: this.threadId });
110
+ const child = spawn(this.opts.command, args, {
111
+ cwd: this.opts.cwd,
112
+ env: { ...process.env, PATH: pathWithCommonBins() },
113
+ detached: true,
114
+ stdio: ["pipe", "pipe", "pipe"],
115
+ });
116
+ this.child = child;
117
+ // Feed the user's prompt over stdin (codex exec reads it because argv ends `-`).
118
+ child.stdin?.end(text);
119
+ if (child.stdout) {
120
+ const rl = createInterface({ input: child.stdout });
121
+ rl.on("line", (line) => {
122
+ // Capture the thread id (parseCodexJsonLine intentionally drops
123
+ // thread.started — it's resume-control info, not a render event) so we
124
+ // can resume on the next turn and persist it across restarts.
125
+ const tid = extractThreadId(line);
126
+ if (tid) {
127
+ this.threadId = tid;
128
+ this.opts.onThreadId?.(tid);
129
+ }
130
+ for (const ev of parseCodexJsonLine(line)) {
131
+ // turn_end / error are the reducer's sealing events — once one fires
132
+ // for this turn, the exit handler need not synthesize a fallback.
133
+ if (ev.type === "turn_end" || ev.type === "error")
134
+ this.turnSealed = true;
135
+ this.opts.onEvent(ev);
136
+ }
137
+ });
138
+ }
139
+ // Accumulate stderr by LINE; only surface it as an error if the turn exits
140
+ // non-zero (see codexStderrError). Per-chunk `/error/i` matching produced
141
+ // spurious red errors from non-fatal warnings and could misjudge a line
142
+ // split across chunks.
143
+ const stderrLines = [];
144
+ if (child.stderr) {
145
+ const errRl = createInterface({ input: child.stderr });
146
+ errRl.on("line", (line) => stderrLines.push(line));
147
+ }
148
+ child.on("error", (err) => {
149
+ const isMissing = err.code === "ENOENT";
150
+ this.turnSealed = true; // this error seals the run in the reducer
151
+ this.opts.onEvent({
152
+ type: "error",
153
+ error: isMissing
154
+ ? `未找到命令 "${this.opts.command}"。请先安装 Codex CLI 并确保它在 PATH 中。`
155
+ : err.message,
156
+ });
157
+ this.child = undefined;
158
+ });
159
+ child.on("exit", (code, signal) => {
160
+ // The turn's process is done; the room stays "running" (ready for the next
161
+ // turn). We do NOT emit `exit` here — that would tear the room down in the
162
+ // UI after every turn. turn.completed already produced a `turn_end`.
163
+ // A non-zero exit whose error went only to stderr (no turn.failed JSON)
164
+ // is surfaced now so the room shows *why* it failed.
165
+ const stderrErr = codexStderrError(stderrLines, code);
166
+ if (stderrErr) {
167
+ this.turnSealed = true;
168
+ this.opts.onEvent({ type: "error", error: stderrErr });
169
+ }
170
+ // FALLBACK SEAL (#6): if the process died without ever sealing the turn
171
+ // (no turn.completed / turn.failed JSON, no stderr error) and we didn't
172
+ // stop it ourselves, the room UI would hang on "working" forever. Emit a
173
+ // synthetic turn_end so the progress card settles.
174
+ const seal = sealEventOnExit({
175
+ code,
176
+ signal,
177
+ turnSealed: this.turnSealed,
178
+ stopping: this.stopping,
179
+ hasStderrError: stderrErr != null,
180
+ });
181
+ if (seal)
182
+ this.opts.onEvent(seal);
183
+ this.child = undefined;
184
+ });
185
+ return true;
186
+ }
187
+ /** The room is "running" once started — independent of whether a turn's
188
+ * process is currently alive (turns are ephemeral). */
189
+ isRunning() {
190
+ return this.started;
191
+ }
192
+ stop() {
193
+ this.started = false;
194
+ this.stopping = true; // the upcoming SIGTERM exit is intentional, not a crash
195
+ const child = this.child;
196
+ this.child = undefined;
197
+ if (!child?.pid || child.pid <= 1) {
198
+ child?.kill("SIGTERM");
199
+ return;
200
+ }
201
+ try {
202
+ process.kill(-child.pid, "SIGTERM");
203
+ }
204
+ catch {
205
+ try {
206
+ child.kill("SIGTERM");
207
+ }
208
+ catch {
209
+ /* already gone */
210
+ }
211
+ }
212
+ }
213
+ }
@@ -0,0 +1,21 @@
1
+ import { type InputAttachmentMeta } from "../attachment-service.js";
2
+ import type { MobileAttachmentSummary, MobileImageAttachment } from "./types.js";
3
+ import type { ClaimedMobileUpload, MobileUploadService } from "./mobile-upload-service.js";
4
+ export declare const MAX_MOBILE_ATTACHMENTS = 4;
5
+ export declare const MAX_MOBILE_INLINE_IMAGE_BYTES: number;
6
+ export declare const MAX_MOBILE_INLINE_TOTAL_BYTES: number;
7
+ export declare const MAX_MOBILE_ATTACHMENT_TOTAL_BYTES: number;
8
+ interface MaterializeInput {
9
+ deviceId: string;
10
+ cwd: string;
11
+ sessionId: string;
12
+ attachments?: MobileImageAttachment[];
13
+ uploads: Pick<MobileUploadService, "claim" | "release">;
14
+ }
15
+ /** Validate phone descriptors, resolve spools, and stage canonical attachment metadata. */
16
+ export declare function materializeMobileAttachments(input: MaterializeInput): Promise<{
17
+ metas: InputAttachmentMeta[];
18
+ claims: ClaimedMobileUpload[];
19
+ summaries: MobileAttachmentSummary[];
20
+ }>;
21
+ export {};
@@ -0,0 +1,119 @@
1
+ import { stageImageBytes } from "../attachment-service.js";
2
+ export const MAX_MOBILE_ATTACHMENTS = 4;
3
+ export const MAX_MOBILE_INLINE_IMAGE_BYTES = 256 * 1024;
4
+ export const MAX_MOBILE_INLINE_TOTAL_BYTES = 512 * 1024;
5
+ export const MAX_MOBILE_ATTACHMENT_TOTAL_BYTES = 20 * 1024 * 1024;
6
+ const ALLOWED_MIMES = new Set([
7
+ "image/png",
8
+ "image/jpeg",
9
+ "image/webp",
10
+ "image/gif",
11
+ ]);
12
+ function decodeInline(descriptor) {
13
+ const match = /^data:([^;,]+);base64,([\s\S]*)$/.exec(descriptor.dataUrl);
14
+ if (!match || String(match[1]).toLowerCase() !== descriptor.mime) {
15
+ throw new Error("inline image MIME does not match its data URL");
16
+ }
17
+ const base64 = String(match[2] ?? "").replace(/\s+/g, "");
18
+ if (base64.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(base64)) {
19
+ throw new Error("invalid inline image base64");
20
+ }
21
+ const bytes = Buffer.from(base64, "base64");
22
+ if (bytes.byteLength !== descriptor.size) {
23
+ throw new Error("inline image size does not match its decoded bytes");
24
+ }
25
+ if (bytes.byteLength > MAX_MOBILE_INLINE_IMAGE_BYTES) {
26
+ throw new Error("inline image exceeds the inline size limit");
27
+ }
28
+ return bytes;
29
+ }
30
+ function assertBaseFields(descriptor) {
31
+ if (!descriptor.clientId?.trim())
32
+ throw new Error("attachment clientId is required");
33
+ if (!descriptor.name?.trim())
34
+ throw new Error("attachment name is required");
35
+ if (!ALLOWED_MIMES.has(descriptor.mime))
36
+ throw new Error("unsupported image MIME");
37
+ if (!Number.isSafeInteger(descriptor.size) || descriptor.size <= 0) {
38
+ throw new Error("attachment size is invalid");
39
+ }
40
+ }
41
+ /** Validate phone descriptors, resolve spools, and stage canonical attachment metadata. */
42
+ export async function materializeMobileAttachments(input) {
43
+ const descriptors = input.attachments ?? [];
44
+ if (descriptors.length > MAX_MOBILE_ATTACHMENTS) {
45
+ throw new Error(`at most ${MAX_MOBILE_ATTACHMENTS} image attachments are allowed`);
46
+ }
47
+ const seen = new Set();
48
+ const prepared = [];
49
+ const claims = [];
50
+ let inlineTotal = 0;
51
+ let total = 0;
52
+ try {
53
+ for (const descriptor of descriptors) {
54
+ assertBaseFields(descriptor);
55
+ if (seen.has(descriptor.clientId))
56
+ throw new Error("duplicate attachment clientId");
57
+ seen.add(descriptor.clientId);
58
+ if (descriptor.transport === "inline") {
59
+ const bytes = decodeInline(descriptor);
60
+ inlineTotal += bytes.byteLength;
61
+ total += bytes.byteLength;
62
+ prepared.push({ descriptor, bytes });
63
+ }
64
+ else if (descriptor.transport === "upload") {
65
+ if (!descriptor.uploadId?.trim())
66
+ throw new Error("uploadId is required");
67
+ const upload = input.uploads.claim(input.deviceId, descriptor.uploadId);
68
+ claims.push(upload);
69
+ if (upload.clientId !== descriptor.clientId ||
70
+ upload.name !== descriptor.name ||
71
+ upload.mime !== descriptor.mime ||
72
+ upload.size !== descriptor.size) {
73
+ throw new Error("uploaded image metadata does not match its descriptor");
74
+ }
75
+ total += upload.size;
76
+ prepared.push({ descriptor, upload });
77
+ }
78
+ else {
79
+ throw new Error("unsupported attachment transport");
80
+ }
81
+ }
82
+ if (inlineTotal > MAX_MOBILE_INLINE_TOTAL_BYTES) {
83
+ throw new Error("inline image total exceeds the message limit");
84
+ }
85
+ if (total > MAX_MOBILE_ATTACHMENT_TOTAL_BYTES) {
86
+ throw new Error("image attachment total exceeds the message limit");
87
+ }
88
+ const metas = [];
89
+ for (const item of prepared) {
90
+ const common = {
91
+ cwd: input.cwd,
92
+ sessionId: input.sessionId,
93
+ name: item.descriptor.name,
94
+ mime: item.descriptor.mime,
95
+ origin: "mobile",
96
+ };
97
+ if ("bytes" in item) {
98
+ metas.push(await stageImageBytes({ ...common, bytes: item.bytes }));
99
+ }
100
+ else {
101
+ metas.push(await stageImageBytes({ ...common, sourceFile: item.upload.path }));
102
+ }
103
+ }
104
+ return {
105
+ metas,
106
+ claims,
107
+ summaries: descriptors.map(({ clientId, name, mime, size }) => ({
108
+ clientId,
109
+ name,
110
+ mime,
111
+ size,
112
+ })),
113
+ };
114
+ }
115
+ catch (error) {
116
+ await Promise.allSettled(claims.map((claim) => input.uploads.release(input.deviceId, claim.uploadId, claim.claimId)));
117
+ throw error;
118
+ }
119
+ }
@@ -0,0 +1,32 @@
1
+ import { markAttachmentsSent, type InputAttachmentMeta } from "../attachment-service.js";
2
+ import { type MobileRunBridge } from "./mobile-run-dispatch.js";
3
+ import type { MobileUploadService } from "./mobile-upload-service.js";
4
+ import type { MobileAttachmentSummary, MobileImageAttachment, PermissionMode } from "./types.js";
5
+ type ChatTurnUploads = Pick<MobileUploadService, "claim" | "release" | "finalize">;
6
+ export interface DispatchMobileChatTurnInput {
7
+ deviceId: string;
8
+ sessionId: string;
9
+ fallbackCwd: string;
10
+ text: string;
11
+ attachments?: MobileImageAttachment[];
12
+ clientMessageId?: string;
13
+ permissionMode?: PermissionMode;
14
+ runId: string;
15
+ bridge: MobileRunBridge;
16
+ uploads: ChatTurnUploads;
17
+ resolveWorkspace: (sessionId: string, fallbackCwd: string) => Promise<string>;
18
+ markSent?: typeof markAttachmentsSent;
19
+ }
20
+ export type DispatchMobileChatTurnResult = {
21
+ ok: true;
22
+ cwd: string;
23
+ clientMessageId: string;
24
+ metas: InputAttachmentMeta[];
25
+ summaries: MobileAttachmentSummary[];
26
+ } | {
27
+ ok: false;
28
+ message: string;
29
+ };
30
+ /** Transaction boundary for mobile chat: workspace → canonical stage → worker ack → finalize. */
31
+ export declare function dispatchMobileChatTurn(input: DispatchMobileChatTurnInput): Promise<DispatchMobileChatTurnResult>;
32
+ export {};