@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.
- package/README.md +77 -0
- package/dist/attachment-service.d.ts +80 -0
- package/dist/attachment-service.js +587 -0
- package/dist/bin/code-shell-serve.d.ts +2 -0
- package/dist/bin/code-shell-serve.js +7 -0
- package/dist/client-message-id.d.ts +1 -0
- package/dist/client-message-id.js +8 -0
- package/dist/image-byte-probe.d.ts +17 -0
- package/dist/image-byte-probe.js +330 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/index.mobile-remote.d.ts +21 -0
- package/dist/index.mobile-remote.js +21 -0
- package/dist/index.serve.d.ts +3 -0
- package/dist/index.serve.js +3 -0
- package/dist/index.storage.d.ts +10 -0
- package/dist/index.storage.js +10 -0
- package/dist/index.worker.d.ts +2 -0
- package/dist/index.worker.js +2 -0
- package/dist/mobile-remote/access-passcode.d.ts +80 -0
- package/dist/mobile-remote/access-passcode.js +354 -0
- package/dist/mobile-remote/cloudflared-binary.d.ts +76 -0
- package/dist/mobile-remote/cloudflared-binary.js +242 -0
- package/dist/mobile-remote/codex-parse.d.ts +34 -0
- package/dist/mobile-remote/codex-parse.js +131 -0
- package/dist/mobile-remote/codex-room-agent.d.ts +94 -0
- package/dist/mobile-remote/codex-room-agent.js +213 -0
- package/dist/mobile-remote/mobile-attachments.d.ts +21 -0
- package/dist/mobile-remote/mobile-attachments.js +119 -0
- package/dist/mobile-remote/mobile-chat-turn.d.ts +32 -0
- package/dist/mobile-remote/mobile-chat-turn.js +67 -0
- package/dist/mobile-remote/mobile-history.d.ts +22 -0
- package/dist/mobile-remote/mobile-history.js +27 -0
- package/dist/mobile-remote/mobile-run-dispatch.d.ts +17 -0
- package/dist/mobile-remote/mobile-run-dispatch.js +56 -0
- package/dist/mobile-remote/mobile-static.d.ts +43 -0
- package/dist/mobile-remote/mobile-static.js +154 -0
- package/dist/mobile-remote/mobile-upload-service.d.ts +57 -0
- package/dist/mobile-remote/mobile-upload-service.js +370 -0
- package/dist/mobile-remote/pairing.d.ts +8 -0
- package/dist/mobile-remote/pairing.js +23 -0
- package/dist/mobile-remote/path-bins.d.ts +7 -0
- package/dist/mobile-remote/path-bins.js +16 -0
- package/dist/mobile-remote/pending-approvals.d.ts +14 -0
- package/dist/mobile-remote/pending-approvals.js +54 -0
- package/dist/mobile-remote/remote-host-manager.d.ts +117 -0
- package/dist/mobile-remote/remote-host-manager.js +345 -0
- package/dist/mobile-remote/resident-agent.d.ts +98 -0
- package/dist/mobile-remote/resident-agent.js +216 -0
- package/dist/mobile-remote/room-manager.d.ts +286 -0
- package/dist/mobile-remote/room-manager.js +765 -0
- package/dist/mobile-remote/trusted-device-store.d.ts +19 -0
- package/dist/mobile-remote/trusted-device-store.js +118 -0
- package/dist/mobile-remote/tunnel-manager.d.ts +105 -0
- package/dist/mobile-remote/tunnel-manager.js +375 -0
- package/dist/mobile-remote/types.d.ts +1 -0
- package/dist/mobile-remote/types.js +1 -0
- package/dist/mobile-remote/viewer-identity.d.ts +5 -0
- package/dist/mobile-remote/viewer-identity.js +5 -0
- package/dist/serve/cli.d.ts +15 -0
- package/dist/serve/cli.js +115 -0
- package/dist/serve/headless-server.d.ts +40 -0
- package/dist/serve/headless-server.js +276 -0
- package/dist/sessions-service.d.ts +73 -0
- package/dist/sessions-service.js +303 -0
- package/dist/static-files.d.ts +1 -0
- package/dist/static-files.js +25 -0
- package/dist/worker-bridge-core.d.ts +154 -0
- package/dist/worker-bridge-core.js +315 -0
- 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 {};
|