@cjhyy/code-shell-server 0.9.52 → 0.10.1

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 (85) hide show
  1. package/dist/account/cli.d.ts +23 -0
  2. package/dist/account/cli.js +249 -0
  3. package/dist/account/cloud-contract.d.ts +39 -0
  4. package/dist/account/cloud-contract.js +134 -0
  5. package/dist/account/cloud-types.d.ts +47 -0
  6. package/dist/account/cloud-types.js +1 -0
  7. package/dist/account/feature-service.d.ts +47 -0
  8. package/dist/account/feature-service.js +184 -0
  9. package/dist/account/http.d.ts +17 -0
  10. package/dist/account/http.js +158 -0
  11. package/dist/account/links.d.ts +13 -0
  12. package/dist/account/links.js +137 -0
  13. package/dist/account/manager.d.ts +96 -0
  14. package/dist/account/manager.js +976 -0
  15. package/dist/account/native-lock.d.ts +6 -0
  16. package/dist/account/native-lock.js +26 -0
  17. package/dist/account/native-store.d.ts +32 -0
  18. package/dist/account/native-store.js +162 -0
  19. package/dist/account/profile.d.ts +4 -0
  20. package/dist/account/profile.js +46 -0
  21. package/dist/account/store.d.ts +34 -0
  22. package/dist/account/store.js +118 -0
  23. package/dist/account/types.d.ts +138 -0
  24. package/dist/account/types.js +2 -0
  25. package/dist/desktop-web/http-api.d.ts +4 -0
  26. package/dist/desktop-web/http-api.js +12 -4
  27. package/dist/environment-identity.d.ts +1 -1
  28. package/dist/environment-identity.js +2 -1
  29. package/dist/headless-worker-env.d.ts +2 -0
  30. package/dist/headless-worker-env.js +15 -0
  31. package/dist/hub/approval-lease.js +6 -1
  32. package/dist/hub/run-replay.js +1 -1
  33. package/dist/hub/session-management.js +1 -1
  34. package/dist/index.account.d.ts +12 -0
  35. package/dist/index.account.js +12 -0
  36. package/dist/index.mobile-remote.d.ts +2 -0
  37. package/dist/index.mobile-remote.js +1 -0
  38. package/dist/index.worker.d.ts +1 -0
  39. package/dist/index.worker.js +1 -0
  40. package/dist/mobile-remote/account-relay-access.d.ts +59 -0
  41. package/dist/mobile-remote/account-relay-access.js +174 -0
  42. package/dist/mobile-remote/approval-clock.d.ts +3 -0
  43. package/dist/mobile-remote/approval-clock.js +15 -0
  44. package/dist/mobile-remote/codex-room-agent.d.ts +6 -0
  45. package/dist/mobile-remote/codex-room-agent.js +17 -1
  46. package/dist/mobile-remote/mobile-client-event-validator.js +40 -1
  47. package/dist/mobile-remote/mobile-upload-service.d.ts +9 -0
  48. package/dist/mobile-remote/mobile-upload-service.js +25 -0
  49. package/dist/mobile-remote/remote-host-manager.d.ts +19 -4
  50. package/dist/mobile-remote/remote-host-manager.js +124 -17
  51. package/dist/mobile-remote/resident-agent.d.ts +14 -0
  52. package/dist/mobile-remote/resident-agent.js +38 -11
  53. package/dist/mobile-remote/room-history.d.ts +7 -0
  54. package/dist/mobile-remote/room-history.js +56 -1
  55. package/dist/mobile-remote/room-manager.d.ts +216 -9
  56. package/dist/mobile-remote/room-manager.js +700 -65
  57. package/dist/mobile-remote/room-process-stop.d.ts +15 -0
  58. package/dist/mobile-remote/room-process-stop.js +41 -0
  59. package/dist/mobile-remote/trusted-device-store.d.ts +12 -0
  60. package/dist/mobile-remote/trusted-device-store.js +54 -1
  61. package/dist/mobile-remote/turn-queue.d.ts +183 -0
  62. package/dist/mobile-remote/turn-queue.js +422 -0
  63. package/dist/mobile-remote/types.d.ts +1 -1
  64. package/dist/panels/agent-task-host.js +6 -12
  65. package/dist/panels/agent-task-service.d.ts +2 -0
  66. package/dist/panels/agent-task-service.js +7 -0
  67. package/dist/panels/browser-capture.d.ts +1 -15
  68. package/dist/panels/browser-capture.js +1 -35
  69. package/dist/panels/http.d.ts +15 -15
  70. package/dist/panels/management-http.d.ts +9 -9
  71. package/dist/panels/process-service.d.ts +5 -4
  72. package/dist/panels/process-service.js +54 -16
  73. package/dist/panels/runtime.js +25 -14
  74. package/dist/panels/tool-jobs-storage.d.ts +4 -0
  75. package/dist/panels/tool-jobs-storage.js +111 -34
  76. package/dist/panels/types.d.ts +1 -97
  77. package/dist/remote-relay/connector.d.ts +8 -0
  78. package/dist/remote-relay/connector.js +29 -2
  79. package/dist/remote-relay/protocol.d.ts +21 -0
  80. package/dist/remote-relay/protocol.js +31 -0
  81. package/dist/serve/cli.js +6 -0
  82. package/dist/serve/headless-server.js +24 -19
  83. package/dist/sessions-service.d.ts +4 -0
  84. package/dist/sessions-service.js +14 -1
  85. package/package.json +10 -6
@@ -0,0 +1,15 @@
1
+ /** Desktop host clients that have no IPC peer in a headless worker. */
2
+ const HEADLESS_WORKER_HOST_ENV_KEYS = [
3
+ "CODE_SHELL_REMOTE_LINK_CLIENT_SECRET",
4
+ "CODE_SHELL_OPERATION_BATCH_ACCESS",
5
+ "CODE_SHELL_MODEL_REQUEST_SIGNING",
6
+ "CODE_SHELL_WORKSPACE_ACCESS",
7
+ "CODE_SHELL_HOST_MCP_SERVERS",
8
+ ];
9
+ /** Preserve the caller's environment while disabling Desktop-only host access. */
10
+ export function headlessWorkerEnv(base, extra = {}) {
11
+ const env = { ...base };
12
+ for (const key of HEADLESS_WORKER_HOST_ENV_KEYS)
13
+ env[key] = undefined;
14
+ return { ...env, CODE_SHELL_CREDENTIAL_ACCESS: "local", ...extra };
15
+ }
@@ -9,8 +9,13 @@ export class ApprovalLeases {
9
9
  this.now = now;
10
10
  }
11
11
  add(approval) {
12
- if (this.entries.has(approval.requestId))
12
+ const existing = this.entries.get(approval.requestId);
13
+ if (existing) {
14
+ // A republished request (the user kept a question open) replaces its
15
+ // payload for later snapshots; the holder lease is unchanged.
16
+ existing.approval = approval;
13
17
  return;
18
+ }
14
19
  if (this.entries.size >= 256)
15
20
  throw new Error("too many pending approvals");
16
21
  this.entries.set(approval.requestId, {
@@ -1,4 +1,4 @@
1
- import { OutputCoverage } from "@cjhyy/code-shell-web";
1
+ import { OutputCoverage } from "@cjhyy/code-shell-web/output-coverage";
2
2
  import { randomUUID } from "node:crypto";
3
3
  /**
4
4
  * A refresh can happen before the worker persists its current assistant text.
@@ -2,7 +2,7 @@ import { SessionFileError, readSessionFileSync, sessionListStorage, } from "@cjh
2
2
  import { listSessionEntries } from "@cjhyy/code-shell-core/internal";
3
3
  import { join, resolve } from "node:path";
4
4
  import { SessionManager } from "@cjhyy/code-shell-core";
5
- import { replayTranscript, transcriptUserDisplay } from "@cjhyy/code-shell-web";
5
+ import { replayTranscript, transcriptUserDisplay } from "@cjhyy/code-shell-web/transcript-replay";
6
6
  import { createSessionTitlesStore, SessionTitleConflictError } from "../session-titles-store.js";
7
7
  import { hubJson } from "./uploads.js";
8
8
  const ROOT = "/api/v1/sessions";
@@ -0,0 +1,12 @@
1
+ /** Optional native account authority. No Electron or renderer dependencies. */
2
+ export * from "./account/types.js";
3
+ export * from "./account/manager.js";
4
+ export * from "./account/http.js";
5
+ export * from "./account/store.js";
6
+ export * from "./account/links.js";
7
+ export * from "./account/profile.js";
8
+ export * from "./account/cloud-contract.js";
9
+ export * from "./account/cloud-types.js";
10
+ export * from "./account/feature-service.js";
11
+ export * from "./account/native-store.js";
12
+ export * from "./account/cli.js";
@@ -0,0 +1,12 @@
1
+ /** Optional native account authority. No Electron or renderer dependencies. */
2
+ export * from "./account/types.js";
3
+ export * from "./account/manager.js";
4
+ export * from "./account/http.js";
5
+ export * from "./account/store.js";
6
+ export * from "./account/links.js";
7
+ export * from "./account/profile.js";
8
+ export * from "./account/cloud-contract.js";
9
+ export * from "./account/cloud-types.js";
10
+ export * from "./account/feature-service.js";
11
+ export * from "./account/native-store.js";
12
+ export * from "./account/cli.js";
@@ -1,5 +1,6 @@
1
1
  /** Mobile HTTP/WebSocket transport, rooms, uploads, pairing, and tunnels. */
2
2
  export * from "./mobile-remote/access-passcode.js";
3
+ export type { AccountRelayAccessProvider, AccountRelayBinding, AccountRelayProject, } from "./mobile-remote/account-relay-access.js";
3
4
  export * from "./mobile-remote/cloudflared-binary.js";
4
5
  export * from "./mobile-remote/codex-parse.js";
5
6
  export * from "./mobile-remote/codex-room-agent.js";
@@ -17,5 +18,6 @@ export * from "./mobile-remote/resident-agent.js";
17
18
  export * from "./mobile-remote/room-manager.js";
18
19
  export * from "./mobile-remote/trusted-device-store.js";
19
20
  export * from "./mobile-remote/tunnel-manager.js";
21
+ export * from "./mobile-remote/turn-queue.js";
20
22
  export * from "./mobile-remote/types.js";
21
23
  export * from "./mobile-remote/viewer-identity.js";
@@ -17,5 +17,6 @@ export * from "./mobile-remote/resident-agent.js";
17
17
  export * from "./mobile-remote/room-manager.js";
18
18
  export * from "./mobile-remote/trusted-device-store.js";
19
19
  export * from "./mobile-remote/tunnel-manager.js";
20
+ export * from "./mobile-remote/turn-queue.js";
20
21
  export * from "./mobile-remote/types.js";
21
22
  export * from "./mobile-remote/viewer-identity.js";
@@ -1,2 +1,3 @@
1
1
  /** Transport-agnostic stdio worker bridge for Node hosts. */
2
2
  export * from "./worker-bridge-core.js";
3
+ export { headlessWorkerEnv } from "./headless-worker-env.js";
@@ -1,2 +1,3 @@
1
1
  /** Transport-agnostic stdio worker bridge for Node hosts. */
2
2
  export * from "./worker-bridge-core.js";
3
+ export { headlessWorkerEnv } from "./headless-worker-env.js";
@@ -0,0 +1,59 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ import type { TrustedDeviceStore } from "./trusted-device-store.js";
3
+ export interface AccountRelayBinding {
4
+ authorizationId: string;
5
+ accountId: string;
6
+ rootSessionId: string;
7
+ hostId: string;
8
+ environmentId: string;
9
+ leaseId: string;
10
+ expiresAt: number;
11
+ }
12
+ export interface AccountRelayProject {
13
+ id: string;
14
+ name: string;
15
+ path: string;
16
+ }
17
+ /** Main owns device credentials and redeems only against its configured directory. */
18
+ export interface AccountRelayAccessProvider {
19
+ hostId: string;
20
+ environmentId: string;
21
+ accountId?: string;
22
+ redeem: (handoff: string) => Promise<AccountRelayBinding>;
23
+ listProjects: () => Promise<AccountRelayProject[]>;
24
+ }
25
+ export interface CapturedAccountRelayAccess {
26
+ deviceId: string;
27
+ isAuthorized: () => boolean;
28
+ }
29
+ /** Independent entrance gate; an account credential is never a Host credential. */
30
+ export declare class AccountRelayAccessGate {
31
+ private readonly devices;
32
+ private readonly onRevoke;
33
+ private provider?;
34
+ private readonly grants;
35
+ private readonly consumed;
36
+ private readonly pending;
37
+ private generation;
38
+ constructor(devices: TrustedDeviceStore, onRevoke: (deviceId: string) => void);
39
+ setProvider(provider?: AccountRelayAccessProvider): void;
40
+ get available(): boolean;
41
+ clear(): void;
42
+ revokeDevice(deviceId: string): boolean;
43
+ private retire;
44
+ exchange(handoff: string): Promise<{
45
+ hostAccessToken: string;
46
+ deviceId: string;
47
+ secretHash: string;
48
+ expiresAt: number;
49
+ hostId: string;
50
+ environmentId: string;
51
+ }>;
52
+ capture(request: IncomingMessage): CapturedAccountRelayAccess | undefined;
53
+ projects(access: CapturedAccountRelayAccess): Promise<{
54
+ hostId: string;
55
+ environmentId: string;
56
+ projects: AccountRelayProject[];
57
+ }>;
58
+ }
59
+ export declare function readRelayHandoff(request: IncomingMessage): Promise<string>;
@@ -0,0 +1,174 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ /** Independent entrance gate; an account credential is never a Host credential. */
3
+ export class AccountRelayAccessGate {
4
+ devices;
5
+ onRevoke;
6
+ provider;
7
+ grants = new Map();
8
+ consumed = new Map();
9
+ pending = new Set();
10
+ generation = 0;
11
+ constructor(devices, onRevoke) {
12
+ this.devices = devices;
13
+ this.onRevoke = onRevoke;
14
+ }
15
+ setProvider(provider) {
16
+ this.clear();
17
+ this.provider = provider;
18
+ }
19
+ get available() {
20
+ return !!this.provider;
21
+ }
22
+ clear() {
23
+ this.generation++;
24
+ for (const token of this.grants.keys())
25
+ this.retire(token);
26
+ this.consumed.clear();
27
+ this.pending.clear();
28
+ }
29
+ revokeDevice(deviceId) {
30
+ let revoked = false;
31
+ for (const [token, grant] of this.grants)
32
+ if (grant.deviceId === deviceId) {
33
+ this.retire(token);
34
+ revoked = true;
35
+ }
36
+ return revoked;
37
+ }
38
+ retire(token) {
39
+ const grant = this.grants.get(token);
40
+ if (!grant)
41
+ return;
42
+ this.grants.delete(token);
43
+ clearTimeout(grant.timer);
44
+ this.devices.removeEphemeralDevice(grant.deviceId);
45
+ this.onRevoke(grant.deviceId);
46
+ }
47
+ async exchange(handoff) {
48
+ const provider = this.provider;
49
+ const generation = this.generation;
50
+ const digest = createHash("sha256").update(handoff).digest("hex");
51
+ for (const [key, expiresAt] of this.consumed)
52
+ if (expiresAt <= Date.now())
53
+ this.consumed.delete(key);
54
+ if (!provider ||
55
+ this.grants.size + this.pending.size >= 128 ||
56
+ this.pending.has(digest) ||
57
+ this.consumed.has(digest))
58
+ throw new Error("Account relay handoff is unavailable");
59
+ this.pending.add(digest);
60
+ try {
61
+ const binding = await provider.redeem(handoff);
62
+ const now = Date.now();
63
+ if (this.provider !== provider ||
64
+ this.generation !== generation ||
65
+ !binding ||
66
+ typeof binding !== "object" ||
67
+ ![
68
+ binding.authorizationId,
69
+ binding.accountId,
70
+ binding.rootSessionId,
71
+ binding.hostId,
72
+ binding.environmentId,
73
+ binding.leaseId,
74
+ ].every((value) => typeof value === "string" &&
75
+ value.length > 0 &&
76
+ value.length <= 512 &&
77
+ !value.includes("\0")) ||
78
+ binding.hostId !== provider.hostId ||
79
+ binding.environmentId !== provider.environmentId ||
80
+ (provider.accountId !== undefined && binding.accountId !== provider.accountId) ||
81
+ !Number.isSafeInteger(binding.expiresAt) ||
82
+ binding.expiresAt <= now ||
83
+ binding.expiresAt > now + 10 * 60_000)
84
+ throw new Error("Account relay handoff target is invalid");
85
+ this.consumed.set(digest, binding.expiresAt);
86
+ const hostAccessToken = randomBytes(32).toString("base64url");
87
+ const secretHash = randomBytes(32).toString("base64url");
88
+ const device = this.devices.addEphemeralDevice({
89
+ name: "Account device session",
90
+ secretHash,
91
+ expiresAt: binding.expiresAt,
92
+ });
93
+ const timer = setTimeout(() => this.retire(hostAccessToken), binding.expiresAt - now);
94
+ timer.unref();
95
+ this.grants.set(hostAccessToken, {
96
+ provider,
97
+ deviceId: device.id,
98
+ expiresAt: binding.expiresAt,
99
+ timer,
100
+ });
101
+ return {
102
+ hostAccessToken,
103
+ deviceId: device.id,
104
+ secretHash,
105
+ expiresAt: binding.expiresAt,
106
+ hostId: binding.hostId,
107
+ environmentId: binding.environmentId,
108
+ };
109
+ }
110
+ finally {
111
+ // A retired generation cannot release a newer redemption of the same handoff.
112
+ if (this.generation === generation)
113
+ this.pending.delete(digest);
114
+ }
115
+ }
116
+ capture(request) {
117
+ const token = request.headers["x-codeshell-host-access"];
118
+ if (typeof token !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(token))
119
+ return;
120
+ const grant = this.grants.get(token);
121
+ if (!grant)
122
+ return;
123
+ const isAuthorized = () => this.provider === grant.provider &&
124
+ this.grants.get(token) === grant &&
125
+ this.devices.isEphemeralDevice(grant.deviceId) &&
126
+ grant.expiresAt > Date.now();
127
+ if (!isAuthorized()) {
128
+ this.retire(token);
129
+ return;
130
+ }
131
+ return { deviceId: grant.deviceId, isAuthorized };
132
+ }
133
+ async projects(access) {
134
+ const provider = this.provider;
135
+ if (!provider || !access.isAuthorized())
136
+ throw new Error("Account relay access expired");
137
+ const projects = await provider.listProjects();
138
+ if (this.provider !== provider || !access.isAuthorized())
139
+ throw new Error("Account relay access expired");
140
+ return { hostId: provider.hostId, environmentId: provider.environmentId, projects };
141
+ }
142
+ }
143
+ export async function readRelayHandoff(request) {
144
+ if (request.headers["content-type"]?.split(";", 1)[0]?.trim() !== "application/json")
145
+ throw new Error("Invalid relay handoff");
146
+ const declared = Number(request.headers["content-length"] ?? 0);
147
+ if (!Number.isFinite(declared) || declared > 8192)
148
+ throw new Error("Invalid relay handoff");
149
+ const chunks = [];
150
+ let bytes = 0;
151
+ const timeout = setTimeout(() => request.destroy(), 10_000);
152
+ timeout.unref();
153
+ try {
154
+ for await (const chunk of request) {
155
+ bytes += Buffer.byteLength(chunk);
156
+ if (bytes > 8192)
157
+ throw new Error("Invalid relay handoff");
158
+ chunks.push(Buffer.from(chunk));
159
+ }
160
+ const input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
161
+ if (!input ||
162
+ typeof input !== "object" ||
163
+ Array.isArray(input) ||
164
+ Object.keys(input).length !== 1 ||
165
+ !("handoff" in input) ||
166
+ typeof input.handoff !== "string" ||
167
+ !/^[A-Za-z0-9_-]{32,4096}$/.test(input.handoff))
168
+ throw new Error("Invalid relay handoff");
169
+ return input.handoff;
170
+ }
171
+ finally {
172
+ clearTimeout(timeout);
173
+ }
174
+ }
@@ -0,0 +1,3 @@
1
+ /** Timestamp at delivery, including reconnect replay, so a phone never compares
2
+ * a worker deadline directly with its own potentially skewed wall clock. */
3
+ export declare function stampApprovalClock(line: string, now?: number): string;
@@ -0,0 +1,15 @@
1
+ /** Timestamp at delivery, including reconnect replay, so a phone never compares
2
+ * a worker deadline directly with its own potentially skewed wall clock. */
3
+ export function stampApprovalClock(line, now = Date.now()) {
4
+ if (!line.includes("agent/approvalRequest"))
5
+ return line;
6
+ try {
7
+ const message = JSON.parse(line);
8
+ if (message?.method !== "agent/approvalRequest" || !Number.isFinite(message.params?.expiresAt))
9
+ return line;
10
+ return JSON.stringify({ ...message, serverTime: now });
11
+ }
12
+ catch {
13
+ return line;
14
+ }
15
+ }
@@ -74,6 +74,8 @@ export interface CodexRoomAgentOptions {
74
74
  export declare class CodexRoomAgent implements RoomAgent {
75
75
  private readonly opts;
76
76
  private child?;
77
+ /** Last turn process, kept so stopAndWait() can prove it exited. */
78
+ private lastChild?;
77
79
  private threadId?;
78
80
  private started;
79
81
  constructor(opts: CodexRoomAgentOptions);
@@ -84,5 +86,9 @@ export declare class CodexRoomAgent implements RoomAgent {
84
86
  * process is currently alive (turns are ephemeral). */
85
87
  isRunning(): boolean;
86
88
  stop(): void;
89
+ /** A turn's `codex exec` child may still be alive after turn_end was parsed. */
90
+ isBusy(): boolean;
91
+ /** Stop, then resolve true only once the last turn process has exited. */
92
+ stopAndWait(timeoutMs: number): Promise<boolean>;
87
93
  }
88
94
  export {};
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { releaseStdioAfterExit, stopRoomProcess } from "./room-process-stop.js";
2
+ import { releaseStdioAfterExit, stopRoomProcess, stopRoomProcessAndWait, } from "./room-process-stop.js";
3
3
  import { createInterface } from "node:readline";
4
4
  import { pathWithCommonBins } from "./path-bins.js";
5
5
  import { parseCodexJsonLine, extractThreadId } from "./codex-parse.js";
@@ -85,6 +85,8 @@ export function sealEventOnExit(opts) {
85
85
  export class CodexRoomAgent {
86
86
  opts;
87
87
  child;
88
+ /** Last turn process, kept so stopAndWait() can prove it exited. */
89
+ lastChild;
88
90
  threadId;
89
91
  started = false;
90
92
  constructor(opts) {
@@ -109,6 +111,7 @@ export class CodexRoomAgent {
109
111
  stdio: ["pipe", "pipe", "pipe"],
110
112
  });
111
113
  this.child = child;
114
+ this.lastChild = child;
112
115
  // Settle on `close` so trailing output is read, without waiting on grandchildren.
113
116
  releaseStdioAfterExit(child);
114
117
  if (child.stdout) {
@@ -210,4 +213,17 @@ export class CodexRoomAgent {
210
213
  if (child)
211
214
  stopRoomProcess(child);
212
215
  }
216
+ /** A turn's `codex exec` child may still be alive after turn_end was parsed. */
217
+ isBusy() {
218
+ return Boolean(this.child);
219
+ }
220
+ /** Stop, then resolve true only once the last turn process has exited. */
221
+ stopAndWait(timeoutMs) {
222
+ this.started = false;
223
+ const child = this.child ?? this.lastChild;
224
+ this.child = undefined;
225
+ if (!child)
226
+ return Promise.resolve(true);
227
+ return stopRoomProcessAndWait(child, timeoutMs);
228
+ }
213
229
  }
@@ -44,6 +44,7 @@ export function parseMobileClientEvent(value) {
44
44
  (typeof event.modelTraceSource === "string" &&
45
45
  /^panel:[a-z][a-z0-9-]{0,63}$/.test(event.modelTraceSource))) &&
46
46
  optionalBoundedString(event.clientMessageId, MAX_ID_LENGTH) &&
47
+ optionalQueueFlag(event.queue) &&
47
48
  optionalAttachments(event.attachments);
48
49
  break;
49
50
  case "attachment.upload.begin":
@@ -137,6 +138,14 @@ export function parseMobileClientEvent(value) {
137
138
  optionalMember(event.scope, SCOPES) &&
138
139
  optionalMember(event.pathScope, PATH_SCOPES);
139
140
  break;
141
+ case "approval.keepWaiting":
142
+ valid =
143
+ boundedString(event.approvalId, MAX_ID_LENGTH) &&
144
+ optionalBoundedString(event.sessionId, MAX_ID_LENGTH) &&
145
+ optionalBoundedString(event.connectionId, MAX_ID_LENGTH) &&
146
+ (event.generation === undefined ||
147
+ (Number.isSafeInteger(event.generation) && event.generation >= 0));
148
+ break;
140
149
  case "session.list":
141
150
  valid =
142
151
  optionalBoundedString(event.requestId, 128) &&
@@ -196,8 +205,23 @@ export function parseMobileClientEvent(value) {
196
205
  boundedString(event.roomId, MAX_ID_LENGTH) &&
197
206
  boundedOptionalEmptyString(event.text, MAX_MESSAGE_LENGTH) &&
198
207
  optionalBoundedString(event.clientMessageId, MAX_ID_LENGTH) &&
208
+ optionalQueueFlag(event.queue) &&
199
209
  optionalAttachments(event.attachments);
200
210
  break;
211
+ case "queue.sync":
212
+ case "queue.clear":
213
+ case "queue.resume":
214
+ valid =
215
+ queueTarget(event.target) &&
216
+ Object.keys(event).every((key) => key === "type" || key === "target");
217
+ break;
218
+ case "queue.cancel":
219
+ case "queue.retry":
220
+ valid =
221
+ queueTarget(event.target) &&
222
+ boundedString(event.entryId, MAX_ID_LENGTH) &&
223
+ Object.keys(event).every((key) => ["type", "target", "entryId"].includes(key));
224
+ break;
201
225
  case "room.history":
202
226
  valid =
203
227
  boundedString(event.roomId, MAX_ID_LENGTH) &&
@@ -227,7 +251,8 @@ export function parseMobileClientEvent(value) {
227
251
  boundedString(event.sessionId, MAX_ID_LENGTH) &&
228
252
  boundedPath(event.cwd) &&
229
253
  member(event.mode, PERMISSION_MODES) &&
230
- optionalMember(event.kind, KINDS);
254
+ optionalMember(event.kind, KINDS) &&
255
+ optionalBoundedString(event.requestId, MAX_ID_LENGTH);
231
256
  break;
232
257
  case "ccRoom.subscribeTranscript":
233
258
  valid =
@@ -297,6 +322,20 @@ function optionalPositiveInteger(value) {
297
322
  function isBoundedHistoryLimit(value) {
298
323
  return isPositiveInteger(value) && value <= MAX_HISTORY_LIMIT;
299
324
  }
325
+ function optionalQueueFlag(value) {
326
+ return value === undefined || value === true;
327
+ }
328
+ function queueTarget(value) {
329
+ if (!isRecord(value))
330
+ return false;
331
+ const keys = Object.keys(value);
332
+ if (value.kind === "session")
333
+ return (boundedTrimmedString(value.sessionId, MAX_ID_LENGTH) &&
334
+ keys.every((key) => key === "kind" || key === "sessionId"));
335
+ return (value.kind === "room" &&
336
+ boundedTrimmedString(value.roomId, MAX_ID_LENGTH) &&
337
+ keys.every((key) => key === "kind" || key === "roomId"));
338
+ }
300
339
  function optionalAttachments(value) {
301
340
  return (value === undefined ||
302
341
  (Array.isArray(value) && value.length <= MAX_MOBILE_ATTACHMENTS && value.every(isAttachment)));
@@ -3,6 +3,8 @@ import type { MobileImageBase } from "./types.js";
3
3
  export { MAX_MOBILE_IMAGE_BYTES } from "./mobile-limits.js";
4
4
  export declare const MOBILE_UPLOAD_TTL_MS: number;
5
5
  export declare const MAX_MOBILE_UPLOAD_TICKETS_PER_DEVICE = 16;
6
+ /** Upper bound for an image waiting behind a long turn in the Host FIFO. */
7
+ export declare const MOBILE_UPLOAD_QUEUE_RETENTION_MS: number;
6
8
  export declare const MAX_MOBILE_UPLOAD_CLAIM_ATTEMPTS = 3;
7
9
  export interface MobileUploadServiceOptions {
8
10
  rootDir: string;
@@ -43,6 +45,13 @@ export declare class MobileUploadService {
43
45
  private receivePut;
44
46
  /** Atomically move a ready upload into a request-owned lease. */
45
47
  claim(deviceId: string, uploadId: string): ClaimedMobileUpload;
48
+ /**
49
+ * Keep a ready upload alive for a Host-queued turn that has not run yet. The
50
+ * extension is bounded; the per-device ticket cap still limits retained files.
51
+ */
52
+ retain(deviceId: string, uploadId: string, durationMs?: number): void;
53
+ /** Drop an unclaimed upload of a cancelled queued turn immediately. */
54
+ discard(deviceId: string, uploadId: string): Promise<void>;
46
55
  /** Release a failed staging/dispatch attempt for a bounded retry. */
47
56
  release(deviceId: string, uploadId: string, claimId: string): Promise<void>;
48
57
  /** Finalize a successful turn; only the active claim owner may consume it. */
@@ -8,6 +8,8 @@ import { MAX_MOBILE_IMAGE_BYTES } from "./mobile-limits.js";
8
8
  export { MAX_MOBILE_IMAGE_BYTES } from "./mobile-limits.js";
9
9
  export const MOBILE_UPLOAD_TTL_MS = 5 * 60 * 1000;
10
10
  export const MAX_MOBILE_UPLOAD_TICKETS_PER_DEVICE = 16;
11
+ /** Upper bound for an image waiting behind a long turn in the Host FIFO. */
12
+ export const MOBILE_UPLOAD_QUEUE_RETENTION_MS = 12 * 60 * 60 * 1000;
11
13
  export const MAX_MOBILE_UPLOAD_CLAIM_ATTEMPTS = 3;
12
14
  const IMAGE_MIMES = new Set([
13
15
  "image/png",
@@ -286,6 +288,29 @@ export class MobileUploadService {
286
288
  sha256: record.sha256,
287
289
  };
288
290
  }
291
+ /**
292
+ * Keep a ready upload alive for a Host-queued turn that has not run yet. The
293
+ * extension is bounded; the per-device ticket cap still limits retained files.
294
+ */
295
+ retain(deviceId, uploadId, durationMs = MOBILE_UPLOAD_QUEUE_RETENTION_MS) {
296
+ if (this.revokedDevices.has(deviceId))
297
+ throw new Error("upload device was revoked");
298
+ const record = this.uploads.get(uploadId);
299
+ if (!record || record.expiresAt <= this.now() || record.deviceId !== deviceId)
300
+ throw new Error("upload is not ready or no longer exists");
301
+ if (record.status !== "ready")
302
+ throw new Error("upload is already claimed or not ready");
303
+ const until = this.now() + Math.min(durationMs, MOBILE_UPLOAD_QUEUE_RETENTION_MS);
304
+ if (until > record.expiresAt)
305
+ record.expiresAt = until;
306
+ }
307
+ /** Drop an unclaimed upload of a cancelled queued turn immediately. */
308
+ async discard(deviceId, uploadId) {
309
+ const record = this.uploads.get(uploadId);
310
+ if (!record || record.deviceId !== deviceId || record.status === "claimed")
311
+ return;
312
+ await this.expire(uploadId, record);
313
+ }
289
314
  /** Release a failed staging/dispatch attempt for a bounded retry. */
290
315
  async release(deviceId, uploadId, claimId) {
291
316
  const record = this.assertClaimOwner(deviceId, uploadId, claimId);
@@ -6,6 +6,7 @@ import type { MobileServerEvent } from "./types.js";
6
6
  import type { MobileUploadService } from "./mobile-upload-service.js";
7
7
  import type { DesktopWebHttpApi } from "../desktop-web/http-api.js";
8
8
  import type { RelayLocalTarget } from "../remote-relay/connector.js";
9
+ import { type AccountRelayAccessProvider } from "./account-relay-access.js";
9
10
  /**
10
11
  * Pick the Mac's real LAN IPv4 so a phone on the same Wi-Fi can reach the
11
12
  * remote host. We bind to a concrete LAN address (NOT 0.0.0.0) per the design's
@@ -22,11 +23,11 @@ export interface RemoteHostStartOptions {
22
23
  * "lan" (default) keeps the existing LAN behaviour unchanged. "tunnel" binds
23
24
  * 127.0.0.1 (cloudflared connects to loopback) and inserts a passcode gate in
24
25
  * front of ordinary HTTP routes and the WS upgrade. Host-verified Panel asset
25
- * capabilities use their own short-lived authorization. "relay" also requires an
26
- * initialized passcode and a stable HTTPS origin before pairing/WS upgrades.
26
+ * capabilities use their own short-lived authorization. "relay" requires a
27
+ * passcode or main-owned account handoff provider and a stable HTTPS origin.
27
28
  */
28
29
  mode?: "lan" | "tunnel" | "relay";
29
- /** Required and initialized in relay mode; public gate for tunnel mode too. */
30
+ /** Public passcode gate; account handoffs may independently authorize relay mode. */
30
31
  passcode?: AccessPasscode;
31
32
  }
32
33
  export interface RemoteHostStarted {
@@ -46,6 +47,8 @@ export interface RemoteHostManagerOptions {
46
47
  historyView?: boolean;
47
48
  /** The attached command Host resolves native text connections per authorized Session. */
48
49
  nativeModelSelection?: boolean;
50
+ /** The attached command Host owns a pending-turn FIFO (queue.* events, chat.queued). */
51
+ turnQueue?: boolean;
49
52
  onClientEvent: (event: unknown, ws: WebSocket) => void | Promise<void>;
50
53
  /**
51
54
  * Absolute path to the built mobile app (out/mobile). Defaults to the
@@ -85,11 +88,23 @@ export declare class RemoteHostManager extends EventEmitter {
85
88
  private passcode?;
86
89
  private socketAccessValidity;
87
90
  private accessSweep?;
91
+ private readonly accountAccess;
92
+ private accountSocketDevices;
88
93
  /** Where the built mobile app lives (out/mobile). */
89
94
  private readonly mobileRootDir;
90
95
  /** Dev proxy target for /mobile (HMR); undefined in prod. */
91
96
  private readonly mobileDevUrl?;
97
+ private turnQueueEnabled;
92
98
  constructor(opts: RemoteHostManagerOptions);
99
+ /** Install main-owned directory redemption; replacing it synchronously revokes old sessions. */
100
+ setAccountRelayAccess(provider?: AccountRelayAccessProvider): void;
101
+ /** The attached command Host installed a pending-turn FIFO; advertise it on (re)auth. */
102
+ advertiseTurnQueue(): void;
103
+ /** A paired (or live account) device that has not been revoked or removed. */
104
+ isTrustedDevice(deviceId: string): boolean;
105
+ private get turnQueueAdvertised();
106
+ private relayRequestOrigin;
107
+ private relayJson;
93
108
  /** Device ids with at least one live socket right now. */
94
109
  onlineDeviceIds(): string[];
95
110
  /** Register a live socket for a device; emits `online-change` only when the
@@ -120,7 +135,7 @@ export declare class RemoteHostManager extends EventEmitter {
120
135
  * trusted-device store (which rejects revoked devices), so the remote host
121
136
  * never executes tools itself — it only gates transport.
122
137
  */
123
- handleClientEvent(input: unknown): MobileServerEvent | undefined;
138
+ handleClientEvent(input: unknown, accountDeviceId?: string): MobileServerEvent | undefined;
124
139
  /** Broadcast a server event to every connected, authenticated mobile socket. */
125
140
  broadcast(event: MobileServerEvent): void;
126
141
  /** Send a server event to ONLY the sockets belonging to one device. Used for