@bitkyc08/opencodex 2.52.0 → 2.53.0-preview.20260913

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 (236) hide show
  1. package/gui/dist/assets/index-BBOZWGB6.css +1 -0
  2. package/gui/dist/assets/index-D7ynYo2K.js +128 -0
  3. package/gui/dist/index.html +2 -2
  4. package/native/remote-workspace-helper/Cargo.lock +130 -0
  5. package/native/remote-workspace-helper/Cargo.toml +24 -0
  6. package/native/remote-workspace-helper/src/main.rs +49 -0
  7. package/native/remote-workspace-helper/src/protocol.rs +246 -0
  8. package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
  9. package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
  10. package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
  11. package/package.json +6 -1
  12. package/src/adapters/anthropic-image-normalize.ts +30 -2
  13. package/src/adapters/anthropic.ts +1 -1
  14. package/src/adapters/base.ts +8 -2
  15. package/src/adapters/cursor/cursor-errors.ts +12 -0
  16. package/src/adapters/cursor/thread-continuity.ts +93 -0
  17. package/src/adapters/cursor.ts +104 -73
  18. package/src/adapters/devin/cloud-direct/chat.ts +312 -23
  19. package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
  20. package/src/adapters/devin/live-models.ts +70 -3
  21. package/src/adapters/devin.ts +281 -21
  22. package/src/adapters/google-wire-compiler.ts +14 -6
  23. package/src/adapters/google.ts +22 -8
  24. package/src/adapters/kiro/adapter.ts +316 -0
  25. package/src/adapters/kiro/conversation.ts +136 -0
  26. package/src/adapters/kiro/payload.ts +432 -0
  27. package/src/adapters/kiro/reasoning.ts +56 -0
  28. package/src/adapters/kiro/stream.ts +1153 -0
  29. package/src/adapters/kiro/usage.ts +223 -0
  30. package/src/adapters/kiro/wire.ts +76 -0
  31. package/src/adapters/kiro.ts +8 -2319
  32. package/src/adapters/mimo-free.ts +1 -1
  33. package/src/adapters/openai-chat-images.ts +101 -0
  34. package/src/adapters/openai-chat.ts +201 -181
  35. package/src/adapters/openai-responses.ts +92 -224
  36. package/src/adapters/registry.ts +0 -7
  37. package/src/adapters/run-turn-queue.ts +13 -6
  38. package/src/bridge.ts +14 -15
  39. package/src/chat/inbound.ts +29 -4
  40. package/src/chat/outbound.ts +145 -107
  41. package/src/claude/desktop-profile.ts +4 -6
  42. package/src/cli/account-api.ts +14 -0
  43. package/src/cli/account-extended.ts +1 -1
  44. package/src/cli/account-history.ts +60 -0
  45. package/src/cli/account-main.ts +80 -0
  46. package/src/cli/account.ts +11 -3
  47. package/src/cli/capabilities.ts +113 -0
  48. package/src/cli/catalog.ts +109 -0
  49. package/src/cli/dispatch.ts +9 -0
  50. package/src/cli/help.ts +2 -0
  51. package/src/cli/index.ts +2 -2
  52. package/src/cli/observe.ts +28 -1
  53. package/src/cli/opencode.ts +42 -8
  54. package/src/cli/provider-runtime.ts +11 -1
  55. package/src/cli/provider.ts +22 -2
  56. package/src/cli/registry.ts +21 -0
  57. package/src/cli/remote-workspace.ts +154 -0
  58. package/src/cli/status.ts +39 -7
  59. package/src/cli/usage-report.ts +14 -2
  60. package/src/client/hub-client.ts +34 -0
  61. package/src/client/hub-state.ts +9 -1
  62. package/src/codex/account-store.ts +78 -0
  63. package/src/codex/auth-api.ts +81 -54
  64. package/src/codex/auth-context.ts +45 -16
  65. package/src/codex/catalog/effort.ts +1 -1
  66. package/src/codex/catalog/metadata.ts +3 -6
  67. package/src/codex/catalog/native-models.ts +4 -4
  68. package/src/codex/catalog/parsing.ts +2 -20
  69. package/src/codex/catalog/provider-fetch.ts +10 -1
  70. package/src/codex/catalog/remote.ts +233 -0
  71. package/src/codex/catalog/sync.ts +403 -35
  72. package/src/codex/convergence.ts +1 -1
  73. package/src/codex/history-manifest.ts +36 -0
  74. package/src/codex/history-provider.ts +32 -5
  75. package/src/codex/inject.ts +9 -0
  76. package/src/codex/main-account.ts +113 -0
  77. package/src/codex/main-device-reauth-api.ts +89 -0
  78. package/src/codex/main-device-reauth.ts +217 -0
  79. package/src/codex/native-residue.ts +9 -2
  80. package/src/codex/quota-auto-refresh.ts +3 -2
  81. package/src/codex/quota-capacity.ts +98 -0
  82. package/src/codex/quota-history.ts +160 -0
  83. package/src/codex/quota-types.ts +8 -0
  84. package/src/codex/quota.ts +118 -91
  85. package/src/codex/refresh.ts +2 -1
  86. package/src/codex/routing.ts +90 -17
  87. package/src/codex/sync.ts +33 -4
  88. package/src/combos/request.ts +19 -1
  89. package/src/config/multi-agent-surface.ts +61 -0
  90. package/src/config/provider-validation.ts +176 -0
  91. package/src/config.ts +213 -11
  92. package/src/generated/compatibility-version.json +436 -168
  93. package/src/images/loop.ts +119 -36
  94. package/src/lib/admission.ts +12 -6
  95. package/src/lib/redact.ts +7 -0
  96. package/src/lib/translator-budget.ts +4 -3
  97. package/src/lib/windows-atomic-replace.ts +1 -0
  98. package/src/lib/windows-elevation.ts +1 -1
  99. package/src/oauth/chatgpt-device.ts +62 -5
  100. package/src/oauth/devin/cli-import.ts +130 -0
  101. package/src/oauth/devin.ts +63 -8
  102. package/src/oauth/index.ts +29 -14
  103. package/src/oauth/kiro.ts +18 -6
  104. package/src/oauth/login-cli.ts +9 -1
  105. package/src/oauth/meta-muse-device.ts +464 -0
  106. package/src/oauth/meta-muse.ts +123 -32
  107. package/src/oauth/pool-kernel.ts +9 -0
  108. package/src/oauth/pool-settings-capability.ts +2 -2
  109. package/src/oauth/store.ts +57 -0
  110. package/src/oauth/types.ts +31 -0
  111. package/src/providers/derive.ts +13 -3
  112. package/src/providers/devin-cli-authmode-migration.ts +57 -35
  113. package/src/providers/devin-provider-merge-migration.ts +240 -0
  114. package/src/providers/muse-key-quota.ts +117 -0
  115. package/src/providers/muse-subscription-usage.ts +14 -2
  116. package/src/providers/openai-sidecar.ts +25 -3
  117. package/src/providers/opencode-zen-rate-limit.ts +58 -0
  118. package/src/providers/provider-id-rewrite.ts +20 -5
  119. package/src/providers/quota-types.ts +12 -0
  120. package/src/providers/quota.ts +143 -102
  121. package/src/providers/reasoning-metadata.ts +543 -0
  122. package/src/providers/registry.ts +80 -49
  123. package/src/reasoning-effort.ts +26 -2
  124. package/src/remote/hub-usage.ts +32 -0
  125. package/src/remote-control/index.ts +192 -41
  126. package/src/remote-control/workspace-activation.ts +9 -0
  127. package/src/remote-control/workspace-agent-connection.ts +366 -0
  128. package/src/remote-control/workspace-claude-runtime.ts +243 -0
  129. package/src/remote-control/workspace-codex-runtime.ts +531 -0
  130. package/src/remote-control/workspace-codex-sandbox.ts +115 -0
  131. package/src/remote-control/workspace-command-runner.ts +748 -0
  132. package/src/remote-control/workspace-coordinator.ts +231 -0
  133. package/src/remote-control/workspace-device.ts +585 -0
  134. package/src/remote-control/workspace-executable.ts +43 -0
  135. package/src/remote-control/workspace-executor.ts +397 -0
  136. package/src/remote-control/workspace-hub.ts +519 -0
  137. package/src/remote-control/workspace-pi-runtime.ts +382 -0
  138. package/src/remote-control/workspace-process.ts +129 -0
  139. package/src/remote-control/workspace-rpc.ts +304 -0
  140. package/src/remote-control/workspace-runtime.ts +60 -0
  141. package/src/remote-control/workspace-secret-store.ts +39 -0
  142. package/src/remote-control/workspace-sessions.ts +799 -0
  143. package/src/remote-control/workspace-tool-bridge.ts +192 -0
  144. package/src/responses/code-mode-helper-compat.ts +22 -3
  145. package/src/responses/hosted-tool-policy.ts +0 -1
  146. package/src/responses/muse-tool-name-alias.ts +379 -0
  147. package/src/responses/plaintext-v2-agent-messages.ts +902 -0
  148. package/src/router.ts +7 -0
  149. package/src/routing/compatibility/behavior.ts +0 -1
  150. package/src/server/audio-client.ts +64 -0
  151. package/src/server/audio-dictation.ts +91 -0
  152. package/src/server/audio-live.ts +185 -0
  153. package/src/server/audio-transcriptions.ts +183 -0
  154. package/src/server/audio-upstream.ts +153 -0
  155. package/src/server/auth-cors.ts +61 -2
  156. package/src/server/chat-completions.ts +1 -1
  157. package/src/server/chat-native-sse.ts +92 -48
  158. package/src/server/chat-native.ts +37 -15
  159. package/src/server/hub-usage.ts +57 -0
  160. package/src/server/images.ts +4 -0
  161. package/src/server/index.ts +722 -57
  162. package/src/server/lifecycle.ts +5 -6
  163. package/src/server/live-call-bindings.ts +60 -0
  164. package/src/server/live.ts +12 -1
  165. package/src/server/management/agent-settings-routes.ts +25 -4
  166. package/src/server/management/api-access.ts +37 -0
  167. package/src/server/management/api-key-usage.ts +7 -2
  168. package/src/server/management/config-routes.ts +1 -18
  169. package/src/server/management/context.ts +15 -0
  170. package/src/server/management/logs-usage-routes.ts +2 -0
  171. package/src/server/management/oauth-account-routes.ts +39 -12
  172. package/src/server/management/provider-routes.ts +125 -2
  173. package/src/server/management/remote-workspace-routes.ts +140 -0
  174. package/src/server/management/route-registry.ts +15 -0
  175. package/src/server/management/usage-aggregate-cache.ts +14 -15
  176. package/src/server/management/usage-summary-cache.ts +2 -0
  177. package/src/server/management-api.ts +23 -0
  178. package/src/server/ports.ts +17 -0
  179. package/src/server/relay-eager.ts +4 -1
  180. package/src/server/relay.ts +70 -10
  181. package/src/server/request-decompress.ts +6 -3
  182. package/src/server/responses/agent-task-recovery.ts +25 -32
  183. package/src/server/responses/codex-auth-error.ts +11 -0
  184. package/src/server/responses/codex-ws-exchange.ts +52 -3
  185. package/src/server/responses/codex-ws-wire.ts +55 -0
  186. package/src/server/responses/compact.ts +9 -1
  187. package/src/server/responses/core.ts +337 -73
  188. package/src/server/responses/encrypted-payload.ts +45 -2
  189. package/src/server/responses/ws-upstream.ts +4 -1
  190. package/src/server/responses-self-named-namespace-scrub.ts +1 -3
  191. package/src/server/responses-undeclared-tool-guard.ts +1 -1
  192. package/src/server/search.ts +3 -0
  193. package/src/server/sse-payload-rewrite.ts +136 -51
  194. package/src/server/ws-bridge.ts +35 -1
  195. package/src/service/cli.ts +372 -0
  196. package/src/service/diagnostics.ts +340 -0
  197. package/src/service/guards.ts +303 -0
  198. package/src/service/health.ts +222 -0
  199. package/src/service/launchd.ts +853 -0
  200. package/src/service/orchestration.ts +617 -0
  201. package/src/service/repair.ts +334 -0
  202. package/src/service/state.ts +363 -0
  203. package/src/service/systemd.ts +229 -0
  204. package/src/service/windows-ops.ts +690 -0
  205. package/src/service/windows-scheduler.ts +769 -0
  206. package/src/service/windows-taskxml.ts +613 -0
  207. package/src/service.ts +22 -5550
  208. package/src/storage/cleanup/db.ts +258 -0
  209. package/src/storage/cleanup/execute.ts +358 -0
  210. package/src/storage/cleanup/paths.ts +189 -0
  211. package/src/storage/cleanup/pending.ts +140 -0
  212. package/src/storage/cleanup/preview.ts +292 -0
  213. package/src/storage/cleanup/reconcile.ts +347 -0
  214. package/src/storage/cleanup/restore.ts +932 -0
  215. package/src/storage/cleanup/satellite.ts +474 -0
  216. package/src/storage/cleanup/staging.ts +129 -0
  217. package/src/storage/cleanup/types.ts +98 -0
  218. package/src/storage/cleanup.ts +49 -3127
  219. package/src/types/accounts.ts +2 -0
  220. package/src/types/config.ts +13 -12
  221. package/src/types/provider.ts +37 -0
  222. package/src/types/request.ts +2 -0
  223. package/src/types/tools.ts +17 -5
  224. package/src/types.ts +1 -0
  225. package/src/usage/expected-prices.ts +127 -0
  226. package/src/usage/log.ts +58 -1
  227. package/src/vision/eligibility.ts +13 -2
  228. package/src/web-search/loop.ts +56 -3
  229. package/gui/dist/assets/index-CWXut3rG.js +0 -115
  230. package/gui/dist/assets/index-EdoPnm9_.css +0 -1
  231. package/src/adapters/devin-cli/acp.ts +0 -204
  232. package/src/adapters/devin-cli/adapter.ts +0 -345
  233. package/src/adapters/devin-cli/binary.ts +0 -69
  234. package/src/adapters/devin-cli/models.ts +0 -57
  235. package/src/oauth/devin-cli.ts +0 -149
  236. package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
@@ -0,0 +1,799 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { atomicWriteFile } from "../config/atomic-write";
5
+ import { getConfigDir } from "../config/paths";
6
+ import { workspaceSecretFileExists, workspaceSecretPermissions, type WorkspaceSecretPermissions } from "./workspace-secret-store";
7
+ import { RemoteWorkspaceCoordinator, type RemoteWorkspaceTransport } from "./workspace-coordinator";
8
+ import type { RemoteWorkspaceHub } from "./workspace-hub";
9
+ import { isRemoteWorkspaceAgentProfile, type RemoteWorkspaceAgentProfile } from "./workspace-agent-protocol";
10
+ import type { RemoteWorkspaceExecutionRequest } from "./workspace-executor";
11
+ import { runRemoteWorkspaceCleanupSteps } from "./workspace-process";
12
+ import { truncateRemoteWorkspaceUtf8 } from "./workspace-utf8";
13
+ import { REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE } from "./protocol";
14
+ import {
15
+ parseRemoteWorkspaceCapabilities,
16
+ remoteWorkspaceToolsForCapabilities,
17
+ type RemoteWorkspaceCapability,
18
+ type RemoteWorkspaceToolName,
19
+ type RemoteWorkspaceToolResult,
20
+ } from "./workspace-tools";
21
+
22
+ export const REMOTE_WORKSPACE_SESSION_STATE_VERSION = 1 as const;
23
+
24
+ export type RemoteWorkspaceSessionStatus =
25
+ | "starting"
26
+ | "ready"
27
+ | "running"
28
+ | "waiting_for_executor"
29
+ | "failed"
30
+ | "stopped";
31
+
32
+ export type RemoteWorkspaceAccessMode = "read-only" | "workspace";
33
+
34
+ export interface RemoteWorkspaceSessionEvent {
35
+ sequence: number;
36
+ at: string;
37
+ type: "status" | "assistant" | "tool" | "error";
38
+ text: string;
39
+ }
40
+
41
+ export interface RemoteWorkspaceSessionSummary {
42
+ id: string;
43
+ profile: RemoteWorkspaceAgentProfile;
44
+ accessMode: RemoteWorkspaceAccessMode;
45
+ deviceId: string;
46
+ deviceName: string;
47
+ rootId: string;
48
+ rootLabel: string;
49
+ capabilities: RemoteWorkspaceCapability[];
50
+ tools: RemoteWorkspaceToolName[];
51
+ threadId: string | null;
52
+ /** True only after the runtime has created durable history that can be resumed. */
53
+ resumable: boolean;
54
+ status: RemoteWorkspaceSessionStatus;
55
+ createdAt: string;
56
+ updatedAt: string;
57
+ events: RemoteWorkspaceSessionEvent[];
58
+ }
59
+
60
+ export interface RemoteWorkspaceRuntimeHandle {
61
+ threadId: string;
62
+ canResume?(): boolean;
63
+ prompt(text: string): Promise<void>;
64
+ stop(): Promise<void>;
65
+ }
66
+
67
+ export interface RemoteWorkspaceRuntimeFactory {
68
+ profile: RemoteWorkspaceAgentProfile;
69
+ available(): Promise<{ available: boolean; version?: string; reason?: string }>;
70
+ start(options: {
71
+ sessionId: string;
72
+ deviceId: string;
73
+ deviceName: string;
74
+ rootId: string;
75
+ rootLabel: string;
76
+ capabilities: RemoteWorkspaceCapability[];
77
+ tools: RemoteWorkspaceToolName[];
78
+ resumeThreadId?: string;
79
+ coordinator: RemoteWorkspaceCoordinator;
80
+ emit(type: RemoteWorkspaceSessionEvent["type"], text: string): void;
81
+ }): Promise<RemoteWorkspaceRuntimeHandle>;
82
+ }
83
+
84
+ export interface RemoteWorkspaceSessionState {
85
+ version: typeof REMOTE_WORKSPACE_SESSION_STATE_VERSION;
86
+ sessions: RemoteWorkspaceSessionSummary[];
87
+ }
88
+
89
+ export interface RemoteWorkspaceSessionStateStore {
90
+ load(): RemoteWorkspaceSessionState | null;
91
+ save(state: RemoteWorkspaceSessionState): void;
92
+ }
93
+
94
+ interface LiveSession extends RemoteWorkspaceSessionSummary {
95
+ handle: RemoteWorkspaceRuntimeHandle | null;
96
+ unregister: (() => void) | null;
97
+ closeTransport: (() => Promise<void>) | null;
98
+ operation: Promise<void>;
99
+ stopOperation: Promise<boolean> | null;
100
+ remoteTransport: SwitchableRemoteWorkspaceTransport | null;
101
+ turnActive: boolean;
102
+ }
103
+
104
+ const MAX_EVENTS_PER_SESSION = 100;
105
+ const MAX_EVENT_TEXT_BYTES = 8 * 1024;
106
+ const MAX_PROMPT_BYTES = 256 * 1024;
107
+ const MAX_LIVE_SESSIONS = 8;
108
+ const MAX_RETAINED_SESSIONS = 64;
109
+ const MAX_LIST_EVENTS_PER_SESSION = 20;
110
+ const MAX_PERSISTED_EVENTS_PER_SESSION = 40;
111
+ const MAX_PERSISTED_EVENT_TEXT_BYTES = 4 * 1024;
112
+ const MAX_SESSION_STATE_BYTES = 16 * 1024 * 1024;
113
+ const AVAILABILITY_CACHE_MS = 30_000;
114
+ type RuntimeAvailability = Record<RemoteWorkspaceAgentProfile, { available: boolean; version?: string; reason?: string }>;
115
+
116
+ class SwitchableRemoteWorkspaceTransport implements RemoteWorkspaceTransport {
117
+ constructor(private current: RemoteWorkspaceTransport) {}
118
+
119
+ replace(next: RemoteWorkspaceTransport): void {
120
+ this.current = next;
121
+ }
122
+
123
+ isOnline(deviceId: string): boolean {
124
+ return this.current.isOnline(deviceId);
125
+ }
126
+
127
+ invoke(request: RemoteWorkspaceExecutionRequest): Promise<RemoteWorkspaceToolResult> {
128
+ return this.current.invoke(request);
129
+ }
130
+ }
131
+
132
+ function boundedPrompt(value: unknown): string {
133
+ if (typeof value !== "string" || value.trim().length < 1 || Buffer.byteLength(value, "utf8") > MAX_PROMPT_BYTES) {
134
+ throw new Error("remote workspace prompt must contain 1 to 262144 UTF-8 bytes");
135
+ }
136
+ return value;
137
+ }
138
+
139
+ function boundedEventText(value: string): string {
140
+ if (Buffer.byteLength(value, "utf8") <= MAX_EVENT_TEXT_BYTES) return value;
141
+ const marker = "\n[truncated]";
142
+ return `${truncateRemoteWorkspaceUtf8(value, MAX_EVENT_TEXT_BYTES - Buffer.byteLength(marker, "utf8"))}${marker}`;
143
+ }
144
+
145
+ function boundedPersistedEventText(value: string): string {
146
+ if (Buffer.byteLength(value, "utf8") <= MAX_PERSISTED_EVENT_TEXT_BYTES) return value;
147
+ const marker = "\n[truncated for restart snapshot]";
148
+ const maximum = MAX_PERSISTED_EVENT_TEXT_BYTES - Buffer.byteLength(marker, "utf8");
149
+ return `${truncateRemoteWorkspaceUtf8(value, maximum)}${marker}`;
150
+ }
151
+
152
+ function boundedString(value: unknown, label: string, maximum = 256): string {
153
+ if (typeof value !== "string" || value.length < 1 || value.length > maximum || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value)) {
154
+ throw new Error(`invalid remote workspace ${label}`);
155
+ }
156
+ return value;
157
+ }
158
+
159
+ function timestamp(value: unknown): string {
160
+ const result = boundedString(value, "timestamp", 64);
161
+ if (!Number.isFinite(Date.parse(result))) throw new Error("invalid remote workspace timestamp");
162
+ return result;
163
+ }
164
+
165
+ function parseStatus(value: unknown): RemoteWorkspaceSessionStatus {
166
+ if (value === "starting" || value === "ready" || value === "running"
167
+ || value === "waiting_for_executor" || value === "failed" || value === "stopped") return value;
168
+ throw new Error("invalid remote workspace session status");
169
+ }
170
+
171
+ function parseAccessMode(value: unknown): RemoteWorkspaceAccessMode {
172
+ if (value === undefined || value === "workspace") return "workspace";
173
+ if (value === "read-only") return value;
174
+ throw new Error("invalid remote workspace access mode");
175
+ }
176
+
177
+ function parseEvent(value: unknown): RemoteWorkspaceSessionEvent {
178
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace session event");
179
+ const raw = value as Record<string, unknown>;
180
+ if (typeof raw.sequence !== "number" || !Number.isSafeInteger(raw.sequence) || raw.sequence < 1) {
181
+ throw new Error("invalid remote workspace event sequence");
182
+ }
183
+ if (raw.type !== "status" && raw.type !== "assistant" && raw.type !== "tool" && raw.type !== "error") {
184
+ throw new Error("invalid remote workspace event type");
185
+ }
186
+ return {
187
+ sequence: raw.sequence,
188
+ at: timestamp(raw.at),
189
+ type: raw.type,
190
+ text: boundedString(raw.text, "event text", MAX_EVENT_TEXT_BYTES),
191
+ };
192
+ }
193
+
194
+ function parseSession(value: unknown): RemoteWorkspaceSessionSummary {
195
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace session");
196
+ const raw = value as Record<string, unknown>;
197
+ if (!isRemoteWorkspaceAgentProfile(raw.profile)) throw new Error("invalid remote workspace session profile");
198
+ const accessMode = parseAccessMode(raw.accessMode);
199
+ const capabilities = parseRemoteWorkspaceCapabilities(raw.capabilities);
200
+ if (accessMode === "read-only"
201
+ && (capabilities.length !== 1 || capabilities[0] !== "workspace.read")) {
202
+ throw new Error("read-only remote workspace state contains write capabilities");
203
+ }
204
+ const tools = remoteWorkspaceToolsForCapabilities(capabilities);
205
+ if (!Array.isArray(raw.events) || raw.events.length > MAX_EVENTS_PER_SESSION) {
206
+ throw new Error("invalid remote workspace session events");
207
+ }
208
+ if (raw.threadId !== null && typeof raw.threadId !== "string") throw new Error("invalid remote workspace thread ID");
209
+ const resumable = raw.resumable === undefined
210
+ ? raw.threadId !== null
211
+ : raw.resumable === true;
212
+ if (raw.resumable !== undefined && typeof raw.resumable !== "boolean") {
213
+ throw new Error("invalid remote workspace resumable state");
214
+ }
215
+ if (resumable && raw.threadId === null) throw new Error("resumable remote workspace session has no thread ID");
216
+ return {
217
+ id: boundedString(raw.id, "session ID"),
218
+ profile: raw.profile,
219
+ accessMode,
220
+ deviceId: boundedString(raw.deviceId, "device ID"),
221
+ deviceName: boundedString(raw.deviceName, "device name", 80),
222
+ rootId: boundedString(raw.rootId, "root ID"),
223
+ rootLabel: boundedString(raw.rootLabel, "root label", 80),
224
+ capabilities,
225
+ tools,
226
+ threadId: raw.threadId === null ? null : boundedString(raw.threadId, "thread ID"),
227
+ resumable,
228
+ status: parseStatus(raw.status),
229
+ createdAt: timestamp(raw.createdAt),
230
+ updatedAt: timestamp(raw.updatedAt),
231
+ events: raw.events.map(parseEvent),
232
+ };
233
+ }
234
+
235
+ export function parseRemoteWorkspaceSessionState(value: unknown): RemoteWorkspaceSessionState {
236
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace session state");
237
+ const raw = value as Record<string, unknown>;
238
+ if (raw.version !== REMOTE_WORKSPACE_SESSION_STATE_VERSION || !Array.isArray(raw.sessions)) {
239
+ throw new Error("unsupported remote workspace session state");
240
+ }
241
+ if (raw.sessions.length > MAX_RETAINED_SESSIONS) throw new Error("remote workspace retained session limit exceeded");
242
+ const ids = new Set<string>();
243
+ const sessions = raw.sessions.map(item => {
244
+ const session = parseSession(item);
245
+ if (ids.has(session.id)) throw new Error("duplicate remote workspace session ID");
246
+ ids.add(session.id);
247
+ return session;
248
+ });
249
+ return { version: REMOTE_WORKSPACE_SESSION_STATE_VERSION, sessions };
250
+ }
251
+
252
+ export class RemoteWorkspaceSessionFileStore implements RemoteWorkspaceSessionStateStore {
253
+ constructor(
254
+ private readonly path = join(getConfigDir(), "remote-workspace-sessions.json"),
255
+ private readonly permissions: WorkspaceSecretPermissions = workspaceSecretPermissions,
256
+ ) {}
257
+
258
+ load(): RemoteWorkspaceSessionState | null {
259
+ if (!workspaceSecretFileExists(this.path)) return null;
260
+ this.permissions.prepareDirectory(dirname(this.path));
261
+ this.permissions.hardenFile(this.path);
262
+ const metadata = statSync(this.path);
263
+ if (!metadata.isFile() || metadata.size > MAX_SESSION_STATE_BYTES) {
264
+ throw new Error("remote workspace session state is too large");
265
+ }
266
+ return parseRemoteWorkspaceSessionState(JSON.parse(readFileSync(this.path, "utf8")));
267
+ }
268
+
269
+ save(state: RemoteWorkspaceSessionState): void {
270
+ const parsed = parseRemoteWorkspaceSessionState(state);
271
+ const body = `${JSON.stringify(parsed, null, 2)}\n`;
272
+ if (Buffer.byteLength(body, "utf8") > MAX_SESSION_STATE_BYTES) {
273
+ throw new Error("remote workspace session state is too large");
274
+ }
275
+ this.permissions.prepareDirectory(dirname(this.path));
276
+ if (workspaceSecretFileExists(this.path)) this.permissions.hardenFile(this.path);
277
+ atomicWriteFile(this.path, body);
278
+ }
279
+ }
280
+
281
+ export class RemoteWorkspaceSessionService {
282
+ private shuttingDown = false;
283
+ private readonly pendingCreates = new Set<Promise<RemoteWorkspaceSessionSummary>>();
284
+ private startupCleanupFailure: Error | null = null;
285
+ private readonly sessions = new Map<string, LiveSession>();
286
+ private readonly runtimeReservations = new Map<string, string>();
287
+ private readonly runtimes = new Map<RemoteWorkspaceAgentProfile, RemoteWorkspaceRuntimeFactory>();
288
+ private sequence = 0;
289
+ private availabilityCache: { at: number; value: RuntimeAvailability } | null = null;
290
+ private availabilityFlight: Promise<RuntimeAvailability> | null = null;
291
+
292
+ constructor(
293
+ private readonly hub: RemoteWorkspaceHub,
294
+ factories: readonly RemoteWorkspaceRuntimeFactory[],
295
+ private readonly now: () => number = Date.now,
296
+ private readonly store?: RemoteWorkspaceSessionStateStore,
297
+ ) {
298
+ for (const factory of factories) {
299
+ if (this.runtimes.has(factory.profile)) throw new Error("duplicate remote workspace runtime profile");
300
+ this.runtimes.set(factory.profile, factory);
301
+ }
302
+ for (const summary of this.store?.load()?.sessions ?? []) {
303
+ const restoredStatus = summary.status === "stopped"
304
+ ? "stopped"
305
+ : summary.threadId && summary.resumable
306
+ ? "waiting_for_executor"
307
+ : "failed";
308
+ this.sessions.set(summary.id, {
309
+ ...summary,
310
+ status: restoredStatus,
311
+ handle: null,
312
+ unregister: null,
313
+ closeTransport: null,
314
+ operation: Promise.resolve(),
315
+ stopOperation: null,
316
+ remoteTransport: null,
317
+ turnActive: false,
318
+ });
319
+ for (const event of summary.events) this.sequence = Math.max(this.sequence, event.sequence);
320
+ }
321
+ }
322
+
323
+ async availability(): Promise<RuntimeAvailability> {
324
+ if (this.availabilityCache && this.now() - this.availabilityCache.at < AVAILABILITY_CACHE_MS) {
325
+ return structuredClone(this.availabilityCache.value);
326
+ }
327
+ if (this.availabilityFlight) return structuredClone(await this.availabilityFlight);
328
+ this.availabilityFlight = (async () => {
329
+ const probe = async (profile: RemoteWorkspaceAgentProfile) => {
330
+ const factory = this.runtimes.get(profile);
331
+ if (!factory) return { available: false, reason: "runtime adapter is not installed" };
332
+ try { return await factory.available(); }
333
+ catch { return { available: false, reason: "runtime availability probe failed" }; }
334
+ };
335
+ const [codex, claude, pi] = await Promise.all([
336
+ probe("codex"),
337
+ probe("claude"),
338
+ probe("pi"),
339
+ ]);
340
+ const value: RuntimeAvailability = { codex, claude, pi };
341
+ this.availabilityCache = { at: this.now(), value };
342
+ return value;
343
+ })();
344
+ try { return structuredClone(await this.availabilityFlight); }
345
+ finally { this.availabilityFlight = null; }
346
+ }
347
+
348
+ list(): RemoteWorkspaceSessionSummary[] {
349
+ this.refreshOfflineStates();
350
+ return [...this.sessions.values()].map(session => this.publicSession(session, MAX_LIST_EVENTS_PER_SESSION));
351
+ }
352
+
353
+ get(sessionId: string): RemoteWorkspaceSessionSummary | null {
354
+ this.refreshOfflineStates();
355
+ const session = this.sessions.get(sessionId);
356
+ return session ? this.publicSession(session) : null;
357
+ }
358
+
359
+ private reserveRuntime(deviceId: string): () => void {
360
+ const live = [...this.sessions.values()].filter(session => session.handle !== null);
361
+ const pending = [...this.runtimeReservations.values()];
362
+ if (live.length + pending.length >= MAX_LIVE_SESSIONS) {
363
+ throw new Error("remote workspace active session limit reached");
364
+ }
365
+ if (live.filter(session => session.deviceId === deviceId).length
366
+ + pending.filter(device => device === deviceId).length >= REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE) {
367
+ throw new Error("remote workspace executor session limit reached");
368
+ }
369
+ const reservation = randomUUID();
370
+ this.runtimeReservations.set(reservation, deviceId);
371
+ return () => { this.runtimeReservations.delete(reservation); };
372
+ }
373
+
374
+ create(input: {
375
+ profile: RemoteWorkspaceAgentProfile;
376
+ deviceId: string;
377
+ rootId: string;
378
+ accessMode?: RemoteWorkspaceAccessMode;
379
+ }): Promise<RemoteWorkspaceSessionSummary> {
380
+ const operation = this.createSession(input);
381
+ this.pendingCreates.add(operation);
382
+ void operation.then(() => this.pendingCreates.delete(operation), () => this.pendingCreates.delete(operation));
383
+ return operation;
384
+ }
385
+
386
+ private async createSession(input: {
387
+ profile: RemoteWorkspaceAgentProfile;
388
+ deviceId: string;
389
+ rootId: string;
390
+ accessMode?: RemoteWorkspaceAccessMode;
391
+ }): Promise<RemoteWorkspaceSessionSummary> {
392
+ if (this.shuttingDown) throw new Error("remote workspace hub is stopping");
393
+ this.pruneRetainedSessions();
394
+ const releaseRuntime = this.reserveRuntime(input.deviceId);
395
+ try {
396
+ const factory = this.runtimes.get(input.profile);
397
+ if (!factory) throw new Error(`remote workspace ${input.profile} runtime is not installed on the hub`);
398
+ const available = await factory.available();
399
+ if (this.shuttingDown) throw new Error("remote workspace hub is stopping");
400
+ if (!available.available) throw new Error(available.reason ?? `remote workspace ${input.profile} runtime is unavailable`);
401
+ const device = this.hub.listDevices().find(candidate => candidate.id === input.deviceId);
402
+ if (!device) throw new Error("remote workspace device not found");
403
+ const root = device.roots.find(candidate => candidate.id === input.rootId);
404
+ if (!root) throw new Error("remote workspace root not found on the selected device");
405
+ const connection = this.hub.connection(device.id);
406
+ if (!connection) throw new Error("remote workspace executor is offline");
407
+ const id = randomUUID();
408
+ const accessMode = parseAccessMode(input.accessMode ?? "read-only");
409
+ const deviceCapabilities = parseRemoteWorkspaceCapabilities(device.capabilities);
410
+ const capabilities = accessMode === "read-only"
411
+ ? parseRemoteWorkspaceCapabilities(["workspace.read"])
412
+ : deviceCapabilities;
413
+ const tools = remoteWorkspaceToolsForCapabilities(capabilities);
414
+ const connectionCapabilities = connection.capabilities();
415
+ if (capabilities.some(capability => !connectionCapabilities.includes(capability))) {
416
+ throw new Error("remote workspace executor capability advertisement is stale; refresh and try again");
417
+ }
418
+ const timestamp = new Date(this.now()).toISOString();
419
+ const session: LiveSession = {
420
+ id,
421
+ profile: input.profile,
422
+ accessMode,
423
+ deviceId: device.id,
424
+ deviceName: device.name,
425
+ rootId: root.id,
426
+ rootLabel: root.label,
427
+ capabilities,
428
+ tools,
429
+ threadId: null,
430
+ resumable: false,
431
+ status: "starting",
432
+ createdAt: timestamp,
433
+ updatedAt: timestamp,
434
+ events: [],
435
+ handle: null,
436
+ unregister: null,
437
+ closeTransport: null,
438
+ operation: Promise.resolve(),
439
+ stopOperation: null,
440
+ remoteTransport: null,
441
+ turnActive: false,
442
+ };
443
+ this.sessions.set(id, session);
444
+ this.emit(session, "status", `Starting ${input.profile} on ${device.name}/${root.label}`);
445
+ try {
446
+ this.persist();
447
+ } catch (error) {
448
+ this.sessions.delete(id);
449
+ throw error;
450
+ }
451
+ try {
452
+ session.closeTransport = () => connection.closeSession(id);
453
+ const transport = await connection.openSession({ sessionId: id, rootId: root.id, profile: input.profile, capabilities });
454
+ if (session.stopOperation) {
455
+ await session.closeTransport().catch(() => {});
456
+ session.closeTransport = null;
457
+ throw new Error("remote workspace session was stopped while starting");
458
+ }
459
+ const remoteTransport = new SwitchableRemoteWorkspaceTransport(transport);
460
+ session.remoteTransport = remoteTransport;
461
+ const coordinator = new RemoteWorkspaceCoordinator(remoteTransport);
462
+ const handle = await factory.start({
463
+ sessionId: id,
464
+ deviceId: device.id,
465
+ deviceName: device.name,
466
+ rootId: root.id,
467
+ rootLabel: root.label,
468
+ capabilities,
469
+ tools,
470
+ coordinator,
471
+ emit: (type, text) => this.emit(session, type, text),
472
+ });
473
+ if (session.stopOperation || this.shuttingDown) {
474
+ await this.stopLateRuntime(handle);
475
+ throw new Error("remote workspace session was stopped while starting");
476
+ }
477
+ session.threadId = handle.threadId;
478
+ session.resumable = handle.canResume?.() ?? true;
479
+ session.handle = handle;
480
+ session.unregister = coordinator.register({
481
+ sessionId: id,
482
+ threadId: handle.threadId,
483
+ executorDeviceId: device.id,
484
+ executorName: device.name,
485
+ rootId: root.id,
486
+ capabilities,
487
+ tools,
488
+ });
489
+ this.status(session, "ready", `${input.profile} is ready on ${device.name}/${root.label}`);
490
+ return this.publicSession(session);
491
+ } catch (error) {
492
+ let reported = error;
493
+ if (session.status !== "stopped") {
494
+ try {
495
+ this.status(session, "failed", error instanceof Error ? error.message : "remote workspace session failed to start");
496
+ } catch (persistenceError) {
497
+ reported = persistenceError;
498
+ }
499
+ }
500
+ session.unregister?.();
501
+ session.unregister = null;
502
+ await session.handle?.stop().catch(() => {});
503
+ session.handle = null;
504
+ await session.closeTransport?.().catch(() => {});
505
+ session.closeTransport = null;
506
+ session.remoteTransport = null;
507
+ throw reported;
508
+ }
509
+ } finally {
510
+ releaseRuntime();
511
+ }
512
+ }
513
+
514
+ async prompt(sessionId: string, value: unknown): Promise<RemoteWorkspaceSessionSummary> {
515
+ const session = this.startPrompt(sessionId, value);
516
+ await session.operation;
517
+ return this.publicSession(session);
518
+ }
519
+
520
+ submitPrompt(sessionId: string, value: unknown): RemoteWorkspaceSessionSummary {
521
+ return this.publicSession(this.startPrompt(sessionId, value));
522
+ }
523
+
524
+ private startPrompt(sessionId: string, value: unknown): LiveSession {
525
+ const prompt = boundedPrompt(value);
526
+ const session = this.sessions.get(sessionId);
527
+ if (!session || session.status === "stopped") throw new Error("remote workspace session is not ready");
528
+ if (!session.handle && (!session.threadId || !session.resumable)) {
529
+ throw new Error("remote workspace session cannot be resumed");
530
+ }
531
+ if (session.turnActive) throw new Error("remote workspace session already has an active turn");
532
+ if (session.stopOperation) throw new Error("remote workspace session is stopping");
533
+ session.turnActive = true;
534
+ const run = async () => {
535
+ try {
536
+ this.status(session, "running", "Turn accepted");
537
+ await this.ensureRemoteTransport(session);
538
+ await this.ensureRuntime(session);
539
+ if (session.stopOperation) throw new Error("remote workspace session is stopping");
540
+ this.status(session, "running", "Turn started");
541
+ await session.handle!.prompt(prompt);
542
+ session.resumable = session.handle!.canResume?.() ?? true;
543
+ if (!this.hub.connection(session.deviceId)
544
+ || !session.remoteTransport?.isOnline(session.deviceId)) {
545
+ this.status(session, "waiting_for_executor", "Turn completed; reconnect the remote executor before continuing.");
546
+ } else {
547
+ this.status(session, "ready", "Turn completed");
548
+ }
549
+ } catch (error) {
550
+ const message = error instanceof Error ? error.message : "remote workspace turn failed";
551
+ this.status(session, this.hub.connection(session.deviceId) ? "failed" : "waiting_for_executor", message);
552
+ throw error;
553
+ } finally {
554
+ session.turnActive = false;
555
+ }
556
+ };
557
+ session.operation = run();
558
+ // Polling observes the stored terminal status; awaited callers and cleanup retain
559
+ // the original rejecting promise, with an observer attached before detachment.
560
+ void session.operation.catch(() => {});
561
+ return session;
562
+ }
563
+
564
+ async stop(sessionId: string): Promise<boolean> {
565
+ const session = this.sessions.get(sessionId);
566
+ if (!session) return false;
567
+ if (session.stopOperation) return session.stopOperation;
568
+ session.stopOperation = (async () => {
569
+ const handle = session.handle;
570
+ const activeOperation = session.operation;
571
+ // Cancellation has to run before waiting for the active turn. Waiting first makes
572
+ // Stop unable to interrupt a model request or remote command that never completes.
573
+ try {
574
+ await runRemoteWorkspaceCleanupSteps([
575
+ async () => { if (handle) await handle.stop(); },
576
+ () => activeOperation.catch(() => {}),
577
+ async () => { if (session.handle && session.handle !== handle) await session.handle.stop(); },
578
+ () => { session.unregister?.(); session.unregister = null; },
579
+ async () => { if (session.closeTransport) await session.closeTransport(); },
580
+ () => {
581
+ session.closeTransport = null;
582
+ session.handle = null;
583
+ session.remoteTransport = null;
584
+ },
585
+ ]);
586
+ } catch (error) {
587
+ this.status(session, "failed", "Session cleanup failed; one or more owned resources did not close.");
588
+ throw error;
589
+ }
590
+ this.status(session, "stopped", "Session stopped");
591
+ return true;
592
+ })();
593
+ return session.stopOperation;
594
+ }
595
+
596
+ async stopAll(): Promise<void> {
597
+ const active = [...this.sessions.values()].filter(session => session.status !== "stopped");
598
+ await Promise.all(active.map(session => this.stop(session.id).then(() => undefined)));
599
+ this.persist();
600
+ }
601
+
602
+ async shutdown(): Promise<void> {
603
+ this.shuttingDown = true;
604
+ const active = [...this.sessions.values()].filter(session => session.status !== "stopped");
605
+ const results = await Promise.allSettled(active.map(async session => {
606
+ if (session.stopOperation) {
607
+ await session.stopOperation;
608
+ return;
609
+ }
610
+ session.stopOperation = (async () => {
611
+ const handle = session.handle;
612
+ const activeOperation = session.operation;
613
+ try {
614
+ await runRemoteWorkspaceCleanupSteps([
615
+ async () => { if (handle) await handle.stop(); },
616
+ () => activeOperation.catch(() => {}),
617
+ async () => { if (session.handle && session.handle !== handle) await session.handle.stop(); },
618
+ () => { session.unregister?.(); session.unregister = null; },
619
+ async () => { if (session.closeTransport) await session.closeTransport(); },
620
+ () => {
621
+ session.closeTransport = null;
622
+ session.handle = null;
623
+ session.remoteTransport = null;
624
+ },
625
+ ]);
626
+ } catch (error) {
627
+ this.status(session, "failed", "Hub shutdown could not close every Remote Workspace resource.");
628
+ throw error;
629
+ }
630
+ this.status(
631
+ session,
632
+ session.threadId && session.resumable ? "waiting_for_executor" : "failed",
633
+ session.threadId && session.resumable
634
+ ? "Hub stopped; reconnect the executor to resume this session."
635
+ : "Hub stopped before the model session was created.",
636
+ );
637
+ return true;
638
+ })();
639
+ await session.stopOperation;
640
+ }));
641
+ await Promise.allSettled([...this.pendingCreates]);
642
+ if (this.startupCleanupFailure) throw this.startupCleanupFailure;
643
+ const failed = results.find(result => result.status === "rejected");
644
+ if (failed?.status === "rejected") throw failed.reason;
645
+ this.persist();
646
+ }
647
+
648
+ private async stopLateRuntime(handle: RemoteWorkspaceRuntimeHandle): Promise<void> {
649
+ try { await handle.stop(); }
650
+ catch (error) {
651
+ this.startupCleanupFailure = error instanceof Error ? error : new Error("remote workspace startup cleanup failed");
652
+ throw this.startupCleanupFailure;
653
+ }
654
+ }
655
+
656
+ private status(session: LiveSession, status: RemoteWorkspaceSessionStatus, text: string): void {
657
+ session.status = status;
658
+ this.emit(session, status === "failed" ? "error" : "status", text);
659
+ this.persist();
660
+ }
661
+
662
+ private emit(session: LiveSession, type: RemoteWorkspaceSessionEvent["type"], text: string): void {
663
+ const at = new Date(this.now()).toISOString();
664
+ session.updatedAt = at;
665
+ session.events.push({ sequence: ++this.sequence, at, type, text: boundedEventText(text) });
666
+ if (session.events.length > MAX_EVENTS_PER_SESSION) {
667
+ session.events.splice(0, session.events.length - MAX_EVENTS_PER_SESSION);
668
+ }
669
+ }
670
+
671
+ private publicSession(session: LiveSession, eventLimit = MAX_EVENTS_PER_SESSION): RemoteWorkspaceSessionSummary {
672
+ const {
673
+ handle: _handle,
674
+ unregister: _unregister,
675
+ closeTransport: _close,
676
+ operation: _operation,
677
+ stopOperation: _stopOperation,
678
+ remoteTransport: _remoteTransport,
679
+ turnActive: _turnActive,
680
+ ...publicState
681
+ } = session;
682
+ return structuredClone({ ...publicState, events: publicState.events.slice(-eventLimit) });
683
+ }
684
+
685
+ private async ensureRemoteTransport(session: LiveSession): Promise<void> {
686
+ if (session.remoteTransport?.isOnline(session.deviceId)) return;
687
+ const connection = this.hub.connection(session.deviceId);
688
+ if (!connection) {
689
+ this.status(session, "waiting_for_executor", "Remote executor is offline; local fallback is disabled.");
690
+ throw new Error("remote workspace executor is offline");
691
+ }
692
+ const connectionCapabilities = connection.capabilities();
693
+ if (session.capabilities.some(capability => !connectionCapabilities.includes(capability))) {
694
+ throw new Error("remote workspace executor capabilities changed; start a new session for this computer");
695
+ }
696
+ this.status(session, "starting", `Reconnecting ${session.deviceName}/${session.rootLabel}`);
697
+ const transport = await connection.openSession({
698
+ sessionId: session.id,
699
+ rootId: session.rootId,
700
+ profile: session.profile,
701
+ capabilities: session.capabilities,
702
+ });
703
+ if (session.stopOperation) {
704
+ await connection.closeSession(session.id).catch(() => {});
705
+ throw new Error("remote workspace session is stopping");
706
+ }
707
+ await session.closeTransport?.().catch(() => {});
708
+ if (session.remoteTransport) session.remoteTransport.replace(transport);
709
+ else session.remoteTransport = new SwitchableRemoteWorkspaceTransport(transport);
710
+ session.closeTransport = () => connection.closeSession(session.id);
711
+ this.status(session, session.turnActive ? "running" : "ready", `${session.profile} reconnected to ${session.deviceName}/${session.rootLabel}`);
712
+ }
713
+
714
+ private async ensureRuntime(session: LiveSession): Promise<void> {
715
+ if (session.handle) return;
716
+ if (!session.threadId || !session.resumable || !session.remoteTransport) {
717
+ throw new Error("remote workspace session cannot be resumed");
718
+ }
719
+ const factory = this.runtimes.get(session.profile);
720
+ if (!factory) throw new Error(`remote workspace ${session.profile} runtime is not installed on the hub`);
721
+ const releaseRuntime = this.reserveRuntime(session.deviceId);
722
+ try {
723
+ const available = await factory.available();
724
+ if (this.shuttingDown) throw new Error("remote workspace hub is stopping");
725
+ if (!available.available) throw new Error(available.reason ?? `remote workspace ${session.profile} runtime is unavailable`);
726
+ const coordinator = new RemoteWorkspaceCoordinator(session.remoteTransport);
727
+ const handle = await factory.start({
728
+ sessionId: session.id,
729
+ deviceId: session.deviceId,
730
+ deviceName: session.deviceName,
731
+ rootId: session.rootId,
732
+ rootLabel: session.rootLabel,
733
+ capabilities: [...session.capabilities],
734
+ tools: [...session.tools],
735
+ resumeThreadId: session.threadId,
736
+ coordinator,
737
+ emit: (type, text) => this.emit(session, type, text),
738
+ });
739
+ if (this.shuttingDown || session.stopOperation) {
740
+ // Stop/shutdown awaits this resume and must observe any late-handle cleanup failure.
741
+ session.handle = handle;
742
+ throw new Error("remote workspace session was stopped while resuming");
743
+ }
744
+ try {
745
+ session.unregister = coordinator.register({
746
+ sessionId: session.id,
747
+ threadId: handle.threadId,
748
+ executorDeviceId: session.deviceId,
749
+ executorName: session.deviceName,
750
+ rootId: session.rootId,
751
+ capabilities: [...session.capabilities],
752
+ tools: [...session.tools],
753
+ });
754
+ } catch (error) {
755
+ await handle.stop().catch(() => {});
756
+ throw error;
757
+ }
758
+ session.threadId = handle.threadId;
759
+ session.handle = handle;
760
+ this.status(session, session.turnActive ? "running" : "ready", `${session.profile} resumed on ${session.deviceName}/${session.rootLabel}`);
761
+ } finally {
762
+ releaseRuntime();
763
+ }
764
+ }
765
+
766
+ private refreshOfflineStates(): void {
767
+ for (const session of this.sessions.values()) {
768
+ if (session.status !== "ready" || this.hub.connection(session.deviceId)) continue;
769
+ this.status(session, "waiting_for_executor", "Remote executor is offline; local fallback is disabled.");
770
+ }
771
+ }
772
+
773
+ private pruneRetainedSessions(): void {
774
+ if (this.sessions.size < MAX_RETAINED_SESSIONS) return;
775
+ for (const [id, session] of this.sessions) {
776
+ if (session.status !== "stopped" && !(session.status === "failed" && session.handle === null)) continue;
777
+ this.sessions.delete(id);
778
+ if (this.sessions.size < MAX_RETAINED_SESSIONS) return;
779
+ }
780
+ if (this.sessions.size >= MAX_RETAINED_SESSIONS) {
781
+ throw new Error("remote workspace retained session limit reached; stop an active session first");
782
+ }
783
+ }
784
+
785
+ private persist(): void {
786
+ if (!this.store) return;
787
+ const sessions = [...this.sessions.values()].map(session => {
788
+ const summary = this.publicSession(session);
789
+ return {
790
+ ...summary,
791
+ events: summary.events.slice(-MAX_PERSISTED_EVENTS_PER_SESSION).map(event => ({
792
+ ...event,
793
+ text: boundedPersistedEventText(event.text),
794
+ })),
795
+ };
796
+ });
797
+ this.store.save({ version: REMOTE_WORKSPACE_SESSION_STATE_VERSION, sessions });
798
+ }
799
+ }