@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.
Files changed (94) hide show
  1. package/dist/attachment-service.d.ts +2 -0
  2. package/dist/attachment-service.js +40 -27
  3. package/dist/bin/code-shell-link-cleanup.d.ts +2 -0
  4. package/dist/bin/code-shell-link-cleanup.js +3 -0
  5. package/dist/desktop-web/http-api.d.ts +3 -0
  6. package/dist/desktop-web/http-api.js +16 -1
  7. package/dist/hub/configuration.d.ts +6 -3
  8. package/dist/hub/configuration.js +19 -6
  9. package/dist/hub/mcp-probe.d.ts +4 -24
  10. package/dist/hub/mcp-probe.js +18 -389
  11. package/dist/hub/model-probe.d.ts +2 -20
  12. package/dist/hub/model-probe.js +2 -193
  13. package/dist/hub/run-replay.d.ts +7 -1
  14. package/dist/hub/run-replay.js +10 -2
  15. package/dist/hub/session-management.js +18 -53
  16. package/dist/hub/skills-management.js +6 -8
  17. package/dist/hub/uploads.d.ts +10 -0
  18. package/dist/hub/uploads.js +107 -15
  19. package/dist/index.desktop-web.d.ts +1 -0
  20. package/dist/index.desktop-web.js +1 -0
  21. package/dist/index.links.d.ts +3 -0
  22. package/dist/index.links.js +2 -0
  23. package/dist/index.panels.d.ts +1 -0
  24. package/dist/index.panels.js +1 -0
  25. package/dist/index.serve.d.ts +1 -0
  26. package/dist/index.serve.js +1 -0
  27. package/dist/index.storage.d.ts +1 -0
  28. package/dist/index.storage.js +1 -0
  29. package/dist/links/catalog.js +2 -0
  30. package/dist/links/diagnostics.d.ts +60 -0
  31. package/dist/links/diagnostics.js +184 -0
  32. package/dist/links/http.d.ts +10 -8
  33. package/dist/links/http.js +144 -125
  34. package/dist/links/legacy-cleanup-cli.d.ts +3 -0
  35. package/dist/links/legacy-cleanup-cli.js +64 -0
  36. package/dist/links/providers/defaults.d.ts +5 -0
  37. package/dist/links/providers/defaults.js +782 -0
  38. package/dist/links/providers/index.d.ts +7 -0
  39. package/dist/links/providers/index.js +22 -0
  40. package/dist/links/remote-catalog.d.ts +10 -3
  41. package/dist/links/remote-catalog.js +76 -29
  42. package/dist/links/runtime/operation.d.ts +9 -0
  43. package/dist/links/runtime/operation.js +16 -0
  44. package/dist/links/service.d.ts +18 -2
  45. package/dist/links/service.js +593 -62
  46. package/dist/links/types.d.ts +21 -1
  47. package/dist/mobile-remote/codex-parse.d.ts +3 -1
  48. package/dist/mobile-remote/codex-parse.js +45 -11
  49. package/dist/mobile-remote/codex-room-agent.d.ts +0 -6
  50. package/dist/mobile-remote/codex-room-agent.js +30 -32
  51. package/dist/mobile-remote/mobile-chat-turn.d.ts +1 -0
  52. package/dist/mobile-remote/mobile-chat-turn.js +3 -0
  53. package/dist/mobile-remote/mobile-client-event-validator.js +21 -0
  54. package/dist/mobile-remote/mobile-history.d.ts +10 -1
  55. package/dist/mobile-remote/mobile-history.js +24 -16
  56. package/dist/mobile-remote/remote-host-manager.d.ts +10 -0
  57. package/dist/mobile-remote/remote-host-manager.js +50 -13
  58. package/dist/mobile-remote/resident-agent.js +38 -22
  59. package/dist/mobile-remote/room-manager.d.ts +23 -13
  60. package/dist/mobile-remote/room-manager.js +106 -22
  61. package/dist/mobile-remote/room-model-tracing.d.ts +18 -0
  62. package/dist/mobile-remote/room-model-tracing.js +196 -0
  63. package/dist/mobile-remote/room-process-stop.d.ts +3 -0
  64. package/dist/mobile-remote/room-process-stop.js +28 -0
  65. package/dist/model-trace-history.d.ts +29 -0
  66. package/dist/model-trace-history.js +198 -0
  67. package/dist/model-trace-http.d.ts +14 -0
  68. package/dist/model-trace-http.js +105 -0
  69. package/dist/panels/agent-task-host.js +1 -0
  70. package/dist/panels/agent-task-service.d.ts +2 -1
  71. package/dist/panels/agent-task-service.js +10 -3
  72. package/dist/panels/directory-bookmarks.js +62 -29
  73. package/dist/panels/execution-gate.d.ts +19 -1
  74. package/dist/panels/execution-gate.js +33 -5
  75. package/dist/panels/http.d.ts +1 -0
  76. package/dist/panels/hub-automations.js +1 -0
  77. package/dist/panels/hub-binding.js +5 -5
  78. package/dist/panels/model-trace-channel.d.ts +21 -0
  79. package/dist/panels/model-trace-channel.js +110 -0
  80. package/dist/panels/model-traces.d.ts +39 -0
  81. package/dist/panels/model-traces.js +222 -0
  82. package/dist/panels/process-service.d.ts +12 -2
  83. package/dist/panels/process-service.js +49 -6
  84. package/dist/panels/resources/uploads.js +15 -6
  85. package/dist/panels/runtime.d.ts +1 -0
  86. package/dist/panels/runtime.js +66 -8
  87. package/dist/panels/task-cookies.js +8 -16
  88. package/dist/panels/tool-jobs.d.ts +2 -0
  89. package/dist/panels/tool-jobs.js +28 -3
  90. package/dist/serve/cli.js +8 -1
  91. package/dist/serve/headless-server.js +68 -14
  92. package/dist/sessions-service.js +17 -27
  93. package/dist/worker-bridge-core.js +11 -0
  94. package/package.json +8 -7
@@ -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 start
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 start
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: String(item.result ?? item.output ?? "").slice(0, 400),
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
- 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
- }]
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
- this.turnSealed = false; // fresh turn — not yet sealed
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
- this.turnSealed = true;
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
- this.turnSealed = true; // this error seals the run in the reducer
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("exit", (code, signal) => {
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
- this.turnSealed = true;
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: this.turnSealed,
178
- stopping: this.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 (!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
- }
208
+ if (child)
209
+ stopRoomProcess(child);
212
210
  }
213
211
  }
@@ -13,6 +13,7 @@ export interface DispatchMobileChatTurnInput {
13
13
  text: string;
14
14
  attachments?: MobileImageAttachment[];
15
15
  clientMessageId?: string;
16
+ modelTraceSource?: string;
16
17
  permissionMode?: PermissionMode;
17
18
  runId: string;
18
19
  bridge: MobileRunBridge;
@@ -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
- export declare function buildSessionHistory(sessionId: string, getTranscript: SessionTranscriptReader): Promise<unknown[]>;
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({ type: "user_message", text: item.text });
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 = req.url
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 ? { capabilities: { outputJournal: 1 } } : {}),
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 ? { capabilities: { outputJournal: 1 } } : {}),
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
- client.send(payload);
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.bufferedAmount + Buffer.byteLength(payload) > 4 * 1024 * 1024) {
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
- client.send(payload);
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
- client.send(payload);
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);