@cjhyy/code-shell-server 0.9.44 → 0.9.47
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/dist/attachment-service.d.ts +2 -0
- package/dist/attachment-service.js +40 -27
- package/dist/bin/code-shell-link-cleanup.d.ts +2 -0
- package/dist/bin/code-shell-link-cleanup.js +3 -0
- package/dist/desktop-web/http-api.d.ts +3 -0
- package/dist/desktop-web/http-api.js +16 -1
- package/dist/hub/configuration.d.ts +6 -3
- package/dist/hub/configuration.js +19 -6
- package/dist/hub/mcp-probe.d.ts +4 -24
- package/dist/hub/mcp-probe.js +18 -389
- package/dist/hub/model-probe.d.ts +2 -20
- package/dist/hub/model-probe.js +2 -193
- package/dist/hub/run-replay.d.ts +7 -1
- package/dist/hub/run-replay.js +10 -2
- package/dist/hub/session-management.js +18 -53
- package/dist/hub/skills-management.js +6 -8
- package/dist/hub/uploads.d.ts +10 -0
- package/dist/hub/uploads.js +107 -15
- package/dist/index.desktop-web.d.ts +1 -0
- package/dist/index.desktop-web.js +1 -0
- package/dist/index.links.d.ts +3 -0
- package/dist/index.links.js +2 -0
- package/dist/index.panels.d.ts +1 -0
- package/dist/index.panels.js +1 -0
- package/dist/index.serve.d.ts +1 -0
- package/dist/index.serve.js +1 -0
- package/dist/index.storage.d.ts +1 -0
- package/dist/index.storage.js +1 -0
- package/dist/links/catalog.js +2 -0
- package/dist/links/diagnostics.d.ts +60 -0
- package/dist/links/diagnostics.js +184 -0
- package/dist/links/http.d.ts +10 -8
- package/dist/links/http.js +144 -125
- package/dist/links/legacy-cleanup-cli.d.ts +3 -0
- package/dist/links/legacy-cleanup-cli.js +64 -0
- package/dist/links/providers/defaults.d.ts +5 -0
- package/dist/links/providers/defaults.js +782 -0
- package/dist/links/providers/index.d.ts +7 -0
- package/dist/links/providers/index.js +22 -0
- package/dist/links/remote-catalog.d.ts +10 -3
- package/dist/links/remote-catalog.js +76 -29
- package/dist/links/runtime/operation.d.ts +9 -0
- package/dist/links/runtime/operation.js +16 -0
- package/dist/links/service.d.ts +18 -2
- package/dist/links/service.js +593 -62
- package/dist/links/types.d.ts +21 -1
- package/dist/mobile-remote/codex-parse.d.ts +3 -1
- package/dist/mobile-remote/codex-parse.js +45 -11
- package/dist/mobile-remote/codex-room-agent.d.ts +0 -6
- package/dist/mobile-remote/codex-room-agent.js +30 -32
- package/dist/mobile-remote/mobile-chat-turn.d.ts +1 -0
- package/dist/mobile-remote/mobile-chat-turn.js +3 -0
- package/dist/mobile-remote/mobile-client-event-validator.js +21 -0
- package/dist/mobile-remote/mobile-history.d.ts +10 -1
- package/dist/mobile-remote/mobile-history.js +24 -16
- package/dist/mobile-remote/remote-host-manager.d.ts +10 -0
- package/dist/mobile-remote/remote-host-manager.js +50 -13
- package/dist/mobile-remote/resident-agent.js +38 -22
- package/dist/mobile-remote/room-manager.d.ts +23 -13
- package/dist/mobile-remote/room-manager.js +106 -22
- package/dist/mobile-remote/room-model-tracing.d.ts +18 -0
- package/dist/mobile-remote/room-model-tracing.js +196 -0
- package/dist/mobile-remote/room-process-stop.d.ts +3 -0
- package/dist/mobile-remote/room-process-stop.js +28 -0
- package/dist/model-trace-history.d.ts +29 -0
- package/dist/model-trace-history.js +198 -0
- package/dist/model-trace-http.d.ts +14 -0
- package/dist/model-trace-http.js +105 -0
- package/dist/panels/agent-task-host.js +1 -0
- package/dist/panels/agent-task-service.d.ts +2 -1
- package/dist/panels/agent-task-service.js +10 -3
- package/dist/panels/directory-bookmarks.js +62 -29
- package/dist/panels/execution-gate.d.ts +19 -1
- package/dist/panels/execution-gate.js +33 -5
- package/dist/panels/http.d.ts +1 -0
- package/dist/panels/hub-automations.js +1 -0
- package/dist/panels/hub-binding.js +5 -5
- package/dist/panels/model-trace-channel.d.ts +21 -0
- package/dist/panels/model-trace-channel.js +110 -0
- package/dist/panels/model-traces.d.ts +39 -0
- package/dist/panels/model-traces.js +222 -0
- package/dist/panels/process-service.d.ts +12 -2
- package/dist/panels/process-service.js +49 -6
- package/dist/panels/resources/uploads.js +15 -6
- package/dist/panels/runtime.d.ts +1 -0
- package/dist/panels/runtime.js +66 -8
- package/dist/panels/task-cookies.js +8 -16
- package/dist/panels/tool-jobs.d.ts +2 -0
- package/dist/panels/tool-jobs.js +28 -3
- package/dist/serve/cli.js +8 -1
- package/dist/serve/headless-server.js +68 -14
- package/dist/sessions-service.js +17 -27
- package/dist/worker-bridge-core.js +11 -0
- package/package.json +8 -7
package/dist/links/types.d.ts
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
|
-
export type { LinkAuthorization, LinkAuthMode, LinkAuthorizationInput, LinkAuthorizationResponse, LinkAuthorizationStep, LinkConnectionInput, LinkErrorCode, LinkSnapshot, MaskedLinkConnection, TokenConnectionInput, } from "@cjhyy/code-shell-link";
|
|
1
|
+
export type { LinkAuthorization, LinkAuthorizationFailure, LinkAuthMode, LinkAuthorizationInput, LinkAuthorizationResponse, LinkAuthorizationStep, LinkConnectionInput, LinkErrorCode, LinkSnapshot, MaskedLinkConnection, TokenConnectionInput, } from "@cjhyy/code-shell-link";
|
|
2
2
|
export interface LinkOperationContext {
|
|
3
3
|
ownerId: string;
|
|
4
4
|
/** Rechecked after every external await and immediately before persistence. */
|
|
5
5
|
authorize: () => boolean | Promise<boolean>;
|
|
6
|
+
/** Native Host request lifetime; never accepted from the remote request body. */
|
|
7
|
+
signal?: AbortSignal;
|
|
8
|
+
}
|
|
9
|
+
/** Trusted Host integration. Secrets and registration settings never enter browser inputs. */
|
|
10
|
+
export interface HostLinkOAuthAdapter {
|
|
11
|
+
providerId: string;
|
|
12
|
+
methodId: string;
|
|
13
|
+
authModeId: string;
|
|
14
|
+
label: string;
|
|
15
|
+
authorize(input: {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
previous: import("@cjhyy/code-shell-core").Credential | null;
|
|
19
|
+
signal: AbortSignal;
|
|
20
|
+
onAuthorizationUrl(url: string, expiresAt: string): Promise<void>;
|
|
21
|
+
}): Promise<import("@cjhyy/code-shell-core").Credential>;
|
|
22
|
+
/** The adapter owns its callback listener; the native browser must not bind it again. */
|
|
23
|
+
openAuthorizationUrl(url: string): Promise<void>;
|
|
24
|
+
/** Called after local authority is removed; must not delete a subsequently reconnected record. */
|
|
25
|
+
disconnect?(credential: import("@cjhyy/code-shell-core").Credential): Promise<void>;
|
|
6
26
|
}
|
|
@@ -14,8 +14,10 @@ import type { ResidentAgentEvent } from "./resident-agent.js";
|
|
|
14
14
|
* item.type "reasoning" → [] (thinking noise)
|
|
15
15
|
* item.type "command_execution" → tool(Bash) on start, tool_result on complete
|
|
16
16
|
* item.type "mcp_tool_call" → tool(server__tool) on start, tool_result on complete
|
|
17
|
+
* (result.content text blocks, or error.message on failure)
|
|
17
18
|
* item.type "web_search" → tool(WebSearch) on start
|
|
18
|
-
* item.type "file_change" → tool(Edit) on
|
|
19
|
+
* item.type "file_change" → tool(Edit) + tool_result on complete (only the
|
|
20
|
+
* completion carries `changes[]` {path, kind})
|
|
19
21
|
* {type:"turn.completed", usage} → turn_end
|
|
20
22
|
* {type:"turn.failed", error} → error
|
|
21
23
|
* {type:"error", message} → error
|
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
* item.type "reasoning" → [] (thinking noise)
|
|
14
14
|
* item.type "command_execution" → tool(Bash) on start, tool_result on complete
|
|
15
15
|
* item.type "mcp_tool_call" → tool(server__tool) on start, tool_result on complete
|
|
16
|
+
* (result.content text blocks, or error.message on failure)
|
|
16
17
|
* item.type "web_search" → tool(WebSearch) on start
|
|
17
|
-
* item.type "file_change" → tool(Edit) on
|
|
18
|
+
* item.type "file_change" → tool(Edit) + tool_result on complete (only the
|
|
19
|
+
* completion carries `changes[]` {path, kind})
|
|
18
20
|
* {type:"turn.completed", usage} → turn_end
|
|
19
21
|
* {type:"turn.failed", error} → error
|
|
20
22
|
* {type:"error", message} → error
|
|
@@ -33,6 +35,9 @@ export function parseCodexJsonLine(line) {
|
|
|
33
35
|
catch {
|
|
34
36
|
return [];
|
|
35
37
|
}
|
|
38
|
+
// A valid JSON scalar (e.g. `null`) is not an event; ignore it like noise.
|
|
39
|
+
if (!msg || typeof msg !== "object")
|
|
40
|
+
return [];
|
|
36
41
|
if (msg.type === "turn.completed")
|
|
37
42
|
return [{ type: "turn_end", reason: "completed" }];
|
|
38
43
|
if (msg.type === "turn.failed") {
|
|
@@ -71,7 +76,7 @@ export function parseCodexJsonLine(line) {
|
|
|
71
76
|
return [{
|
|
72
77
|
type: "tool_result",
|
|
73
78
|
id,
|
|
74
|
-
summary:
|
|
79
|
+
summary: mcpResultText(item).slice(0, 400),
|
|
75
80
|
isError: item.status === "failed" || Boolean(item.is_error),
|
|
76
81
|
}];
|
|
77
82
|
}
|
|
@@ -88,16 +93,24 @@ export function parseCodexJsonLine(line) {
|
|
|
88
93
|
return phase === "started"
|
|
89
94
|
? [{ type: "tool", id, tool: "WebSearch", summary: String(item.query ?? ""), input: { query: String(item.query ?? "") } }]
|
|
90
95
|
: [];
|
|
91
|
-
case "file_change":
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
input: item.kind !== undefined ? { path: String(item.path ?? ""), kind: item.kind } : { path: String(item.path ?? "") },
|
|
99
|
-
}]
|
|
96
|
+
case "file_change": {
|
|
97
|
+
if (phase !== "completed")
|
|
98
|
+
return [];
|
|
99
|
+
const changes = Array.isArray(item.changes)
|
|
100
|
+
? item.changes
|
|
101
|
+
.filter((c) => c && typeof c === "object" && typeof c.path === "string")
|
|
102
|
+
.map((c) => ({ path: c.path, kind: c.kind }))
|
|
100
103
|
: [];
|
|
104
|
+
return [
|
|
105
|
+
{ type: "tool", id, tool: "Edit", summary: changes.map((c) => c.path).join(", "), input: { changes } },
|
|
106
|
+
{
|
|
107
|
+
type: "tool_result",
|
|
108
|
+
id,
|
|
109
|
+
summary: changes.map((c) => `${typeof c.kind === "string" ? c.kind : "update"} ${c.path}`).join("\n").slice(0, 400),
|
|
110
|
+
isError: item.status === "failed",
|
|
111
|
+
},
|
|
112
|
+
];
|
|
113
|
+
}
|
|
101
114
|
default:
|
|
102
115
|
return [];
|
|
103
116
|
}
|
|
@@ -122,6 +135,27 @@ export function extractThreadId(line) {
|
|
|
122
135
|
}
|
|
123
136
|
return msg.type === "thread.started" && typeof msg.thread_id === "string" ? msg.thread_id : undefined;
|
|
124
137
|
}
|
|
138
|
+
/** Readable MCP outcome: the failure message, else the result's text content
|
|
139
|
+
* blocks, else its structured content (an older plain-string result passes). */
|
|
140
|
+
function mcpResultText(item) {
|
|
141
|
+
const failure = errText(item.error);
|
|
142
|
+
if (failure)
|
|
143
|
+
return failure;
|
|
144
|
+
const result = item.result ?? item.output;
|
|
145
|
+
if (result === undefined || result === null)
|
|
146
|
+
return "";
|
|
147
|
+
if (typeof result !== "object")
|
|
148
|
+
return String(result);
|
|
149
|
+
if (Array.isArray(result.content)) {
|
|
150
|
+
const text = result.content
|
|
151
|
+
.map((block) => (block?.type === "text" && typeof block.text === "string" ? block.text : ""))
|
|
152
|
+
.filter(Boolean)
|
|
153
|
+
.join("\n");
|
|
154
|
+
if (text)
|
|
155
|
+
return text;
|
|
156
|
+
}
|
|
157
|
+
return JSON.stringify(result.structured_content ?? result) ?? "";
|
|
158
|
+
}
|
|
125
159
|
function errText(e) {
|
|
126
160
|
if (typeof e === "string")
|
|
127
161
|
return e;
|
|
@@ -76,12 +76,6 @@ export declare class CodexRoomAgent implements RoomAgent {
|
|
|
76
76
|
private child?;
|
|
77
77
|
private threadId?;
|
|
78
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
79
|
constructor(opts: CodexRoomAgentOptions);
|
|
86
80
|
/** No persistent process to launch; a turn's process is spawned on send(). */
|
|
87
81
|
start(): void;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { stopRoomProcess } from "./room-process-stop.js";
|
|
2
3
|
import { createInterface } from "node:readline";
|
|
3
4
|
import { pathWithCommonBins } from "./path-bins.js";
|
|
4
5
|
import { parseCodexJsonLine, extractThreadId } from "./codex-parse.js";
|
|
@@ -86,12 +87,6 @@ export class CodexRoomAgent {
|
|
|
86
87
|
child;
|
|
87
88
|
threadId;
|
|
88
89
|
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
90
|
constructor(opts) {
|
|
96
91
|
this.opts = opts;
|
|
97
92
|
this.threadId = opts.resumeThreadId;
|
|
@@ -105,7 +100,7 @@ export class CodexRoomAgent {
|
|
|
105
100
|
return false;
|
|
106
101
|
if (this.child)
|
|
107
102
|
return false; // a turn is already running; one turn at a time
|
|
108
|
-
|
|
103
|
+
let turnSealed = false; // This child owns only its own turn and callbacks.
|
|
109
104
|
const args = codexArgsForTurn({ mode: this.opts.permissionMode, threadId: this.threadId });
|
|
110
105
|
const child = spawn(this.opts.command, args, {
|
|
111
106
|
cwd: this.opts.cwd,
|
|
@@ -114,11 +109,11 @@ export class CodexRoomAgent {
|
|
|
114
109
|
stdio: ["pipe", "pipe", "pipe"],
|
|
115
110
|
});
|
|
116
111
|
this.child = child;
|
|
117
|
-
// Feed the user's prompt over stdin (codex exec reads it because argv ends `-`).
|
|
118
|
-
child.stdin?.end(text);
|
|
119
112
|
if (child.stdout) {
|
|
120
113
|
const rl = createInterface({ input: child.stdout });
|
|
121
114
|
rl.on("line", (line) => {
|
|
115
|
+
if (this.child !== child)
|
|
116
|
+
return;
|
|
122
117
|
// Capture the thread id (parseCodexJsonLine intentionally drops
|
|
123
118
|
// thread.started — it's resume-control info, not a render event) so we
|
|
124
119
|
// can resume on the next turn and persist it across restarts.
|
|
@@ -131,7 +126,7 @@ export class CodexRoomAgent {
|
|
|
131
126
|
// turn_end / error are the reducer's sealing events — once one fires
|
|
132
127
|
// for this turn, the exit handler need not synthesize a fallback.
|
|
133
128
|
if (ev.type === "turn_end" || ev.type === "error")
|
|
134
|
-
|
|
129
|
+
turnSealed = true;
|
|
135
130
|
this.opts.onEvent(ev);
|
|
136
131
|
}
|
|
137
132
|
});
|
|
@@ -146,17 +141,32 @@ export class CodexRoomAgent {
|
|
|
146
141
|
errRl.on("line", (line) => stderrLines.push(line));
|
|
147
142
|
}
|
|
148
143
|
child.on("error", (err) => {
|
|
144
|
+
if (this.child !== child)
|
|
145
|
+
return;
|
|
146
|
+
this.child = undefined;
|
|
149
147
|
const isMissing = err.code === "ENOENT";
|
|
150
|
-
|
|
148
|
+
turnSealed = true; // this error seals the run in the reducer
|
|
151
149
|
this.opts.onEvent({
|
|
152
150
|
type: "error",
|
|
153
151
|
error: isMissing
|
|
154
152
|
? `未找到命令 "${this.opts.command}"。请先安装 Codex CLI 并确保它在 PATH 中。`
|
|
155
153
|
: err.message,
|
|
156
154
|
});
|
|
155
|
+
});
|
|
156
|
+
child.stdin?.on("error", (error) => {
|
|
157
|
+
if (this.child !== child)
|
|
158
|
+
return;
|
|
157
159
|
this.child = undefined;
|
|
160
|
+
stopRoomProcess(child);
|
|
161
|
+
this.opts.onEvent({
|
|
162
|
+
type: "error",
|
|
163
|
+
error: `Codex input pipe closed${error.code ? ` (${error.code})` : ""}`,
|
|
164
|
+
});
|
|
158
165
|
});
|
|
159
|
-
child.on("
|
|
166
|
+
child.on("close", (code, signal) => {
|
|
167
|
+
if (this.child !== child)
|
|
168
|
+
return;
|
|
169
|
+
this.child = undefined;
|
|
160
170
|
// The turn's process is done; the room stays "running" (ready for the next
|
|
161
171
|
// turn). We do NOT emit `exit` here — that would tear the room down in the
|
|
162
172
|
// UI after every turn. turn.completed already produced a `turn_end`.
|
|
@@ -164,7 +174,7 @@ export class CodexRoomAgent {
|
|
|
164
174
|
// is surfaced now so the room shows *why* it failed.
|
|
165
175
|
const stderrErr = codexStderrError(stderrLines, code);
|
|
166
176
|
if (stderrErr) {
|
|
167
|
-
|
|
177
|
+
turnSealed = true;
|
|
168
178
|
this.opts.onEvent({ type: "error", error: stderrErr });
|
|
169
179
|
}
|
|
170
180
|
// FALLBACK SEAL (#6): if the process died without ever sealing the turn
|
|
@@ -174,14 +184,16 @@ export class CodexRoomAgent {
|
|
|
174
184
|
const seal = sealEventOnExit({
|
|
175
185
|
code,
|
|
176
186
|
signal,
|
|
177
|
-
turnSealed
|
|
178
|
-
stopping:
|
|
187
|
+
turnSealed,
|
|
188
|
+
stopping: false,
|
|
179
189
|
hasStderrError: stderrErr != null,
|
|
180
190
|
});
|
|
181
191
|
if (seal)
|
|
182
192
|
this.opts.onEvent(seal);
|
|
183
|
-
this.child = undefined;
|
|
184
193
|
});
|
|
194
|
+
// Register stdin's error owner before writing: early CLI exits fail writes
|
|
195
|
+
// asynchronously even when end() itself returns successfully.
|
|
196
|
+
child.stdin?.end(text);
|
|
185
197
|
return true;
|
|
186
198
|
}
|
|
187
199
|
/** The room is "running" once started — independent of whether a turn's
|
|
@@ -191,23 +203,9 @@ export class CodexRoomAgent {
|
|
|
191
203
|
}
|
|
192
204
|
stop() {
|
|
193
205
|
this.started = false;
|
|
194
|
-
this.stopping = true; // the upcoming SIGTERM exit is intentional, not a crash
|
|
195
206
|
const child = this.child;
|
|
196
207
|
this.child = undefined;
|
|
197
|
-
if (
|
|
198
|
-
child
|
|
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
|
-
}
|
|
208
|
+
if (child)
|
|
209
|
+
stopRoomProcess(child);
|
|
212
210
|
}
|
|
213
211
|
}
|
|
@@ -44,6 +44,9 @@ export async function dispatchMobileChatTurn(input) {
|
|
|
44
44
|
id: input.runId,
|
|
45
45
|
params: {
|
|
46
46
|
task: text,
|
|
47
|
+
...(input.modelTraceSource && /^panel:[a-z][a-z0-9-]{0,63}$/.test(input.modelTraceSource)
|
|
48
|
+
? { modelTraceSource: input.modelTraceSource }
|
|
49
|
+
: {}),
|
|
47
50
|
cwd,
|
|
48
51
|
sessionId: input.sessionId,
|
|
49
52
|
...(input.projectId ? { projectId: input.projectId } : {}),
|
|
@@ -9,6 +9,7 @@ const MAX_NAME_LENGTH = 512;
|
|
|
9
9
|
const MAX_PATH_LENGTH = 32_768;
|
|
10
10
|
const MAX_MESSAGE_LENGTH = 512 * 1024;
|
|
11
11
|
const MAX_HISTORY_LIMIT = 500;
|
|
12
|
+
const MAX_ASK_USER_ANSWERS = 32;
|
|
12
13
|
/** Runtime validation at the WebSocket boundary; TypeScript types do not validate JSON. */
|
|
13
14
|
export function parseMobileClientEvent(value) {
|
|
14
15
|
if (!isRecord(value) || typeof value.type !== "string")
|
|
@@ -29,6 +30,9 @@ export function parseMobileClientEvent(value) {
|
|
|
29
30
|
valid =
|
|
30
31
|
boundedOptionalEmptyString(event.text, MAX_MESSAGE_LENGTH) &&
|
|
31
32
|
optionalBoundedString(event.sessionId, MAX_ID_LENGTH) &&
|
|
33
|
+
(event.modelTraceSource === undefined ||
|
|
34
|
+
(typeof event.modelTraceSource === "string" &&
|
|
35
|
+
/^panel:[a-z][a-z0-9-]{0,63}$/.test(event.modelTraceSource))) &&
|
|
32
36
|
optionalBoundedString(event.clientMessageId, MAX_ID_LENGTH) &&
|
|
33
37
|
optionalAttachments(event.attachments);
|
|
34
38
|
break;
|
|
@@ -48,6 +52,14 @@ export function parseMobileClientEvent(value) {
|
|
|
48
52
|
case "session.recovery.cancel":
|
|
49
53
|
valid = true;
|
|
50
54
|
break;
|
|
55
|
+
case "session.legacyBase":
|
|
56
|
+
valid =
|
|
57
|
+
boundedString(event.sessionId, 128) &&
|
|
58
|
+
boundedString(event.recoveryId, 128) &&
|
|
59
|
+
boundedString(event.requestId, 128) &&
|
|
60
|
+
((boundedString(event.throughEventId, 512) && event.cursor === undefined) ||
|
|
61
|
+
(boundedString(event.cursor, 2048) && event.throughEventId === undefined));
|
|
62
|
+
break;
|
|
51
63
|
case "session.outputJournal":
|
|
52
64
|
valid =
|
|
53
65
|
boundedString(event.sessionId, 128) &&
|
|
@@ -246,12 +258,21 @@ function isAttachment(value) {
|
|
|
246
258
|
? boundedString(value.dataUrl, MAX_MESSAGE_LENGTH)
|
|
247
259
|
: value.transport === "upload" && boundedString(value.uploadId, MAX_ID_LENGTH);
|
|
248
260
|
}
|
|
261
|
+
function isAnswerRecord(value) {
|
|
262
|
+
if (!isRecord(value))
|
|
263
|
+
return false;
|
|
264
|
+
const entries = Object.entries(value);
|
|
265
|
+
return (entries.length <= MAX_ASK_USER_ANSWERS &&
|
|
266
|
+
entries.every(([question, answer]) => question.length <= MAX_MESSAGE_LENGTH &&
|
|
267
|
+
boundedOptionalEmptyString(answer, MAX_MESSAGE_LENGTH)));
|
|
268
|
+
}
|
|
249
269
|
function isApprovalDecision(value) {
|
|
250
270
|
if (!isRecord(value))
|
|
251
271
|
return false;
|
|
252
272
|
if (value.behavior === "allow") {
|
|
253
273
|
return ((value.answer === undefined ||
|
|
254
274
|
boundedOptionalEmptyString(value.answer, MAX_MESSAGE_LENGTH)) &&
|
|
275
|
+
(value.answers === undefined || isAnswerRecord(value.answers)) &&
|
|
255
276
|
(value.updatedInput === undefined || isRecord(value.updatedInput)));
|
|
256
277
|
}
|
|
257
278
|
return value.behavior === "deny" && boundedOptionalEmptyString(value.message, MAX_MESSAGE_LENGTH);
|
|
@@ -16,7 +16,16 @@
|
|
|
16
16
|
export interface TranscriptHistoryItem {
|
|
17
17
|
kind: string;
|
|
18
18
|
text?: unknown;
|
|
19
|
+
/** Image blocks of a raw model-facing user input (it has no displayText). */
|
|
20
|
+
images?: unknown;
|
|
19
21
|
event?: unknown;
|
|
20
22
|
}
|
|
21
23
|
export type SessionTranscriptReader = (sessionId: string) => Promise<readonly TranscriptHistoryItem[]>;
|
|
22
|
-
|
|
24
|
+
/** The client's user-input display projection (Web transcriptUserDisplay), injected. */
|
|
25
|
+
export type UserInputDisplay = (data: {
|
|
26
|
+
content: unknown;
|
|
27
|
+
}) => {
|
|
28
|
+
text: string;
|
|
29
|
+
attachments: readonly unknown[];
|
|
30
|
+
};
|
|
31
|
+
export declare function buildSessionHistory(sessionId: string, getTranscript: SessionTranscriptReader, displayUserInput?: UserInputDisplay): Promise<unknown[]>;
|
|
@@ -1,23 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
* Build a session's history as a flat list of events the phone's streamReducer
|
|
3
|
-
* understands. We REUSE the desktop's transcript reader (transcriptToFoldItems
|
|
4
|
-
* via its getSessionTranscript) so there is one transcript parser, then project
|
|
5
|
-
* its FoldItems into reducer events:
|
|
6
|
-
* - { kind: "stream", event } → the raw StreamEvent (reducer eats it directly)
|
|
7
|
-
* - { kind: "user", text } → a synthetic { type: "user_message", text }
|
|
8
|
-
*
|
|
9
|
-
* The phone feeds these through the SAME reducer used for the live stream, so
|
|
10
|
-
* replay and live are identical (design §4).
|
|
11
|
-
*
|
|
12
|
-
* The transcript reader itself stays in the desktop package (it depends on
|
|
13
|
-
* renderer FoldItem types), so it is injected here instead of imported.
|
|
14
|
-
*/
|
|
15
|
-
export async function buildSessionHistory(sessionId, getTranscript) {
|
|
1
|
+
export async function buildSessionHistory(sessionId, getTranscript, displayUserInput) {
|
|
16
2
|
const items = await getTranscript(sessionId);
|
|
17
3
|
const events = [];
|
|
18
4
|
for (const item of items) {
|
|
19
5
|
if (item.kind === "user") {
|
|
20
|
-
events.push(
|
|
6
|
+
events.push(userHistoryEvent(item, displayUserInput));
|
|
21
7
|
}
|
|
22
8
|
else if (item.kind === "stream") {
|
|
23
9
|
events.push(item.event);
|
|
@@ -25,3 +11,25 @@ export async function buildSessionHistory(sessionId, getTranscript) {
|
|
|
25
11
|
}
|
|
26
12
|
return events;
|
|
27
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* A user input with image blocks but no displayText (e.g. an old paired
|
|
16
|
+
* chat.send) persisted the engine's model-facing text, including the
|
|
17
|
+
* attached-image path envelope and tool hint. Project it through the client's
|
|
18
|
+
* durable-replay display rules instead of showing that as the user's text.
|
|
19
|
+
*/
|
|
20
|
+
function userHistoryEvent(item, displayUserInput) {
|
|
21
|
+
const images = typeof item.images === "number" && item.images > 0 ? item.images : 0;
|
|
22
|
+
if (!images || typeof item.text !== "string" || !displayUserInput)
|
|
23
|
+
return { type: "user_message", text: item.text };
|
|
24
|
+
const display = displayUserInput({
|
|
25
|
+
content: [
|
|
26
|
+
{ type: "text", text: item.text },
|
|
27
|
+
...Array.from({ length: images }, () => ({ type: "image", source: {} })),
|
|
28
|
+
],
|
|
29
|
+
});
|
|
30
|
+
return {
|
|
31
|
+
type: "user_message",
|
|
32
|
+
text: display.text,
|
|
33
|
+
...(display.attachments.length ? { attachments: display.attachments } : {}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -37,6 +37,8 @@ export interface RemoteHostStarted {
|
|
|
37
37
|
export interface RemoteHostManagerOptions {
|
|
38
38
|
devices: TrustedDeviceStore;
|
|
39
39
|
outputJournal?: boolean;
|
|
40
|
+
/** Enable only when the attached selected-session host implements legacy base pages. */
|
|
41
|
+
legacyBasePages?: boolean;
|
|
40
42
|
onClientEvent: (event: unknown, ws: WebSocket) => void | Promise<void>;
|
|
41
43
|
/**
|
|
42
44
|
* Absolute path to the built mobile app (out/mobile). Defaults to the
|
|
@@ -126,6 +128,14 @@ export declare class RemoteHostManager extends EventEmitter {
|
|
|
126
128
|
* every authenticated mobile socket. Unauthenticated sockets are skipped so
|
|
127
129
|
* a half-paired client never sees session output. */
|
|
128
130
|
broadcastRaw(payload: string): void;
|
|
131
|
+
/**
|
|
132
|
+
* Queue one frame unless the peer already stopped reading. A paused tab or
|
|
133
|
+
* stalled network keeps its socket OPEN while ws buffers every live frame in
|
|
134
|
+
* Host memory; retire that socket instead so the client reconnects and
|
|
135
|
+
* recovers through its normal resume path. One large frame on a drained
|
|
136
|
+
* socket is still delivered. Returns false when retired.
|
|
137
|
+
*/
|
|
138
|
+
private sendBounded;
|
|
129
139
|
private releaseSocket;
|
|
130
140
|
private bindSocketDevice;
|
|
131
141
|
/** Call after revoking/removing a trusted device to release both transports immediately. */
|
|
@@ -8,6 +8,8 @@ import { serveMobile } from "./mobile-static.js";
|
|
|
8
8
|
import { PairingTokenManager } from "./pairing.js";
|
|
9
9
|
import { parseMobileClientEvent } from "./mobile-client-event-validator.js";
|
|
10
10
|
import { isRelayOrigin } from "../remote-relay/protocol.js";
|
|
11
|
+
/** Outbound bytes one paired socket may leave unread before it is retired. */
|
|
12
|
+
const MAX_SOCKET_BACKLOG_BYTES = 4 * 1024 * 1024;
|
|
11
13
|
/**
|
|
12
14
|
* Pick the Mac's real LAN IPv4 so a phone on the same Wi-Fi can reach the
|
|
13
15
|
* remote host. We bind to a concrete LAN address (NOT 0.0.0.0) per the design's
|
|
@@ -124,6 +126,16 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
124
126
|
const gate = this.passcode;
|
|
125
127
|
this.opts.webApi?.start();
|
|
126
128
|
const server = createServer((req, res) => {
|
|
129
|
+
let pathname;
|
|
130
|
+
try {
|
|
131
|
+
pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
|
|
135
|
+
res.end("invalid request target");
|
|
136
|
+
req.resume();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
127
139
|
// Tunnel mode: a passcode gate sits in front of EVERY route. The gate
|
|
128
140
|
// either allows (returns true) or writes its own 401/403 challenge.
|
|
129
141
|
if (gate && !gate.gate(req, res))
|
|
@@ -156,9 +168,7 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
156
168
|
});
|
|
157
169
|
return;
|
|
158
170
|
}
|
|
159
|
-
const uploadPath =
|
|
160
|
-
? new URL(req.url, "http://localhost").pathname.match(/^\/api\/mobile\/uploads\/([A-Za-z0-9_-]+)$/)
|
|
161
|
-
: null;
|
|
171
|
+
const uploadPath = pathname.match(/^\/api\/mobile\/uploads\/([A-Za-z0-9_-]+)$/);
|
|
162
172
|
if (uploadPath && this.opts.uploads) {
|
|
163
173
|
void this.opts.uploads.acceptPut(uploadPath[1], req, res);
|
|
164
174
|
return;
|
|
@@ -409,7 +419,14 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
409
419
|
return {
|
|
410
420
|
type: "pair.ok",
|
|
411
421
|
device,
|
|
412
|
-
...(this.opts.outputJournal
|
|
422
|
+
...(this.opts.outputJournal
|
|
423
|
+
? {
|
|
424
|
+
capabilities: {
|
|
425
|
+
outputJournal: 1,
|
|
426
|
+
...(this.opts.legacyBasePages ? { legacyBasePages: 1 } : {}),
|
|
427
|
+
},
|
|
428
|
+
}
|
|
429
|
+
: {}),
|
|
413
430
|
};
|
|
414
431
|
}
|
|
415
432
|
if (event.type === "auth.device") {
|
|
@@ -425,7 +442,14 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
425
442
|
return {
|
|
426
443
|
type: "auth.ok",
|
|
427
444
|
device,
|
|
428
|
-
...(this.opts.outputJournal
|
|
445
|
+
...(this.opts.outputJournal
|
|
446
|
+
? {
|
|
447
|
+
capabilities: {
|
|
448
|
+
outputJournal: 1,
|
|
449
|
+
...(this.opts.legacyBasePages ? { legacyBasePages: 1 } : {}),
|
|
450
|
+
},
|
|
451
|
+
}
|
|
452
|
+
: {}),
|
|
429
453
|
};
|
|
430
454
|
}
|
|
431
455
|
return undefined;
|
|
@@ -442,7 +466,7 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
442
466
|
const payload = JSON.stringify(event);
|
|
443
467
|
for (const client of this.wss?.clients ?? []) {
|
|
444
468
|
if (client.readyState === client.OPEN && this.authed.get(client) === deviceId) {
|
|
445
|
-
|
|
469
|
+
this.sendBounded(client, payload);
|
|
446
470
|
}
|
|
447
471
|
}
|
|
448
472
|
}
|
|
@@ -464,12 +488,8 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
464
488
|
this.viewers.get(client) === viewerId) {
|
|
465
489
|
// A selected viewer cannot accumulate an unbounded outbound page queue
|
|
466
490
|
// by issuing new reads while refusing to consume prior replies.
|
|
467
|
-
if (client
|
|
468
|
-
this.releaseSocket(client);
|
|
469
|
-
client.terminate();
|
|
491
|
+
if (!this.sendBounded(client, payload))
|
|
470
492
|
return;
|
|
471
|
-
}
|
|
472
|
-
client.send(payload);
|
|
473
493
|
}
|
|
474
494
|
}
|
|
475
495
|
}
|
|
@@ -477,7 +497,7 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
477
497
|
sendRawToDevice(deviceId, payload) {
|
|
478
498
|
for (const client of this.wss?.clients ?? []) {
|
|
479
499
|
if (client.readyState === client.OPEN && this.authed.get(client) === deviceId) {
|
|
480
|
-
|
|
500
|
+
this.sendBounded(client, payload);
|
|
481
501
|
}
|
|
482
502
|
}
|
|
483
503
|
}
|
|
@@ -487,10 +507,27 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
487
507
|
broadcastRaw(payload) {
|
|
488
508
|
for (const client of this.wss?.clients ?? []) {
|
|
489
509
|
if (client.readyState === client.OPEN && this.authed.has(client)) {
|
|
490
|
-
|
|
510
|
+
this.sendBounded(client, payload);
|
|
491
511
|
}
|
|
492
512
|
}
|
|
493
513
|
}
|
|
514
|
+
/**
|
|
515
|
+
* Queue one frame unless the peer already stopped reading. A paused tab or
|
|
516
|
+
* stalled network keeps its socket OPEN while ws buffers every live frame in
|
|
517
|
+
* Host memory; retire that socket instead so the client reconnects and
|
|
518
|
+
* recovers through its normal resume path. One large frame on a drained
|
|
519
|
+
* socket is still delivered. Returns false when retired.
|
|
520
|
+
*/
|
|
521
|
+
sendBounded(client, payload) {
|
|
522
|
+
if (client.bufferedAmount > 0 &&
|
|
523
|
+
client.bufferedAmount + Buffer.byteLength(payload) > MAX_SOCKET_BACKLOG_BYTES) {
|
|
524
|
+
this.releaseSocket(client);
|
|
525
|
+
client.terminate();
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
client.send(payload);
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
494
531
|
releaseSocket(ws) {
|
|
495
532
|
const deviceId = this.authed.get(ws);
|
|
496
533
|
const viewerId = this.viewers.get(ws);
|