@rynx-ai/server 0.1.0 → 0.1.10-beta.2

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 (65) hide show
  1. package/dist/browser-surface-control-queue.d.ts +42 -0
  2. package/dist/browser-surface-control-queue.js +187 -0
  3. package/dist/browser-surface-ws.d.ts +16 -0
  4. package/dist/browser-surface-ws.js +356 -0
  5. package/dist/control-api.d.ts +76 -4
  6. package/dist/control-api.js +3014 -133
  7. package/dist/control-web/assets/highlighted-body-OFNGDK62-DHu2g-rk.js +1 -0
  8. package/dist/control-web/assets/index-B6Pb5j9M.js +666 -0
  9. package/dist/control-web/assets/index-BU-_Qud_.css +32 -0
  10. package/dist/control-web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  11. package/dist/control-web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  12. package/dist/control-web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  13. package/dist/control-web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  14. package/dist/control-web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  15. package/dist/control-web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  16. package/dist/control-web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  17. package/dist/control-web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  18. package/dist/control-web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  19. package/dist/control-web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  20. package/dist/control-web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  21. package/dist/control-web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  22. package/dist/control-web/assets/mermaid-GHXKKRXX-CSYzOmBR.js +131 -0
  23. package/dist/control-web/assets/rynx-wordmark-LL1QRYHD.png +0 -0
  24. package/dist/control-web/favicon.png +0 -0
  25. package/dist/control-web/favicon.svg +38 -0
  26. package/dist/control-web/index.html +16 -0
  27. package/dist/control-web-dist.d.ts +1 -1
  28. package/dist/control-web-dist.js +19 -14
  29. package/dist/desktop-browser-host.d.ts +170 -0
  30. package/dist/desktop-browser-host.js +1018 -0
  31. package/dist/direct-runtime-browser-inspect-server.d.ts +72 -0
  32. package/dist/direct-runtime-browser-inspect-server.js +749 -0
  33. package/dist/direct-runtime-browser-surface-server.d.ts +74 -0
  34. package/dist/direct-runtime-browser-surface-server.js +821 -0
  35. package/dist/direct-runtime-server.d.ts +143 -0
  36. package/dist/direct-runtime-server.js +1959 -0
  37. package/dist/emulator-surface-ws.d.ts +8 -0
  38. package/dist/emulator-surface-ws.js +198 -0
  39. package/dist/machine-session-service.d.ts +265 -0
  40. package/dist/machine-session-service.js +822 -0
  41. package/dist/provider-cli-host.d.ts +10 -0
  42. package/dist/provider-cli-host.js +1 -0
  43. package/dist/remote-runtime-dispatcher.d.ts +94 -0
  44. package/dist/remote-runtime-dispatcher.js +403 -0
  45. package/dist/remote-runtime-session-projection.d.ts +19 -0
  46. package/dist/remote-runtime-session-projection.js +566 -0
  47. package/dist/remote-runtime.d.ts +22 -0
  48. package/dist/remote-runtime.js +32 -0
  49. package/dist/runtime-web-auth.d.ts +26 -0
  50. package/dist/runtime-web-auth.js +132 -0
  51. package/dist/server.d.ts +364 -12
  52. package/dist/server.js +341 -100
  53. package/dist/session-browser-service.d.ts +248 -0
  54. package/dist/session-browser-service.js +772 -0
  55. package/dist/session-browser-surface-coordinator.d.ts +24 -0
  56. package/dist/session-browser-surface-coordinator.js +669 -0
  57. package/dist/session-emulator-service.d.ts +167 -0
  58. package/dist/session-emulator-service.js +464 -0
  59. package/dist/session-runtime-index.d.ts +23 -0
  60. package/dist/session-runtime-index.js +96 -0
  61. package/dist/session-terminal-host.d.ts +51 -0
  62. package/dist/session-terminal-host.js +270 -0
  63. package/dist/terminal-ws.d.ts +12 -20
  64. package/dist/terminal-ws.js +421 -97
  65. package/package.json +14 -7
@@ -0,0 +1,167 @@
1
+ import type { RemoteRuntimeEmulatorBackend, RemoteRuntimeEmulatorButtonParams, RemoteRuntimeEmulatorGesturePoint, RemoteRuntimeEmulatorTypeMode, RemoteRuntimeSessionEmulatorBinding, RemoteRuntimeSessionEmulatorDevice } from "@rynx-ai/protocol/remote-runtime-rpc";
2
+ import type { RemoteRuntimeEmulatorHost } from "./remote-runtime-dispatcher.js";
3
+ export interface SessionEmulatorBindingRecord {
4
+ sessionId: string;
5
+ bindingId: string;
6
+ backend: RemoteRuntimeEmulatorBackend;
7
+ deviceId: string;
8
+ deviceName?: string;
9
+ attachedAt: string;
10
+ }
11
+ export interface SessionEmulatorBindingStore {
12
+ get(sessionId: string): SessionEmulatorBindingRecord | undefined | Promise<SessionEmulatorBindingRecord | undefined>;
13
+ findByDevice(backend: RemoteRuntimeEmulatorBackend, deviceId: string): SessionEmulatorBindingRecord | undefined | Promise<SessionEmulatorBindingRecord | undefined>;
14
+ /** Atomically replace this Session's binding while preserving device uniqueness. */
15
+ bind(record: SessionEmulatorBindingRecord): void | Promise<void>;
16
+ release(sessionId: string, bindingId: string): boolean | Promise<boolean>;
17
+ releaseSession(sessionId: string): boolean | Promise<boolean>;
18
+ releaseDevice(backend: RemoteRuntimeEmulatorBackend, deviceId: string): number | Promise<number>;
19
+ }
20
+ export declare class SessionEmulatorBindingConflictError extends Error {
21
+ constructor(message?: string, options?: ErrorOptions);
22
+ }
23
+ export type SessionEmulatorServiceErrorCode = "session_not_found" | "device_not_found" | "device_in_use" | "binding_mismatch" | "emulator_unavailable";
24
+ export declare class SessionEmulatorServiceError extends Error {
25
+ readonly code: SessionEmulatorServiceErrorCode;
26
+ constructor(code: SessionEmulatorServiceErrorCode, message: string, options?: ErrorOptions);
27
+ }
28
+ export interface SessionEmulatorServiceOptions {
29
+ emulator: RemoteRuntimeEmulatorHost & {
30
+ frame?(input: {
31
+ device?: string;
32
+ }): Promise<{
33
+ ok: true;
34
+ backend: RemoteRuntimeEmulatorBackend;
35
+ device: string;
36
+ contentType: "image/png";
37
+ data: Buffer;
38
+ }>;
39
+ imageStream?(input: {
40
+ device?: string;
41
+ signal?: AbortSignal;
42
+ }): Promise<{
43
+ ok: true;
44
+ backend: RemoteRuntimeEmulatorBackend;
45
+ device: string;
46
+ contentType: "image/jpeg";
47
+ nextFrame(): Promise<Buffer>;
48
+ close(): void;
49
+ }>;
50
+ liveTouch?(input: {
51
+ device?: string;
52
+ signal?: AbortSignal;
53
+ }): Promise<{
54
+ ok: true;
55
+ backend: RemoteRuntimeEmulatorBackend;
56
+ device: string;
57
+ send(point: RemoteRuntimeEmulatorGesturePoint): void;
58
+ close(): void;
59
+ }>;
60
+ };
61
+ store: SessionEmulatorBindingStore;
62
+ sessions: {
63
+ exists(sessionId: string): boolean | Promise<boolean>;
64
+ };
65
+ now?: () => Date;
66
+ bindingId?: () => string;
67
+ }
68
+ export interface SessionEmulatorSurface {
69
+ readonly sessionId: string;
70
+ readonly bindingId: string;
71
+ readonly backend: RemoteRuntimeEmulatorBackend;
72
+ readonly deviceId: string;
73
+ readonly format: "png" | "jpeg";
74
+ capture(): Promise<Buffer>;
75
+ touch(point: RemoteRuntimeEmulatorGesturePoint): Promise<void>;
76
+ close(): void;
77
+ }
78
+ /** Runtime-owned authority for the one-Session/one-device Emulator binding. */
79
+ export declare class SessionEmulatorService {
80
+ private readonly options;
81
+ private readonly now;
82
+ private readonly createBindingId;
83
+ private readonly sessionOperations;
84
+ private readonly deviceOperations;
85
+ private readonly deviceReservations;
86
+ constructor(options: SessionEmulatorServiceOptions);
87
+ getState(input: {
88
+ sessionId: string;
89
+ }): Promise<RemoteRuntimeSessionEmulatorBinding | null>;
90
+ listDevices(input: {
91
+ sessionId: string;
92
+ }): Promise<RemoteRuntimeSessionEmulatorDevice[]>;
93
+ private listDevicesUnlocked;
94
+ attach(input: {
95
+ sessionId: string;
96
+ device: string;
97
+ }): Promise<RemoteRuntimeSessionEmulatorBinding>;
98
+ private attachUnlocked;
99
+ detach(input: {
100
+ sessionId: string;
101
+ bindingId: string;
102
+ }): Promise<{
103
+ released: true;
104
+ }>;
105
+ tap(input: {
106
+ sessionId: string;
107
+ bindingId: string;
108
+ x: number;
109
+ y: number;
110
+ }): Promise<unknown>;
111
+ type(input: {
112
+ sessionId: string;
113
+ bindingId: string;
114
+ text: string;
115
+ mode?: RemoteRuntimeEmulatorTypeMode;
116
+ }): Promise<unknown>;
117
+ button(input: {
118
+ sessionId: string;
119
+ bindingId: string;
120
+ name?: RemoteRuntimeEmulatorButtonParams["name"];
121
+ }): Promise<unknown>;
122
+ rotate(input: {
123
+ sessionId: string;
124
+ bindingId: string;
125
+ orientation: string;
126
+ }): Promise<unknown>;
127
+ launch(input: {
128
+ sessionId: string;
129
+ bindingId: string;
130
+ appId: string;
131
+ }): Promise<unknown>;
132
+ gesture(input: {
133
+ sessionId: string;
134
+ bindingId: string;
135
+ points: RemoteRuntimeEmulatorGesturePoint[];
136
+ }): Promise<unknown>;
137
+ frame(input: {
138
+ sessionId: string;
139
+ bindingId: string;
140
+ }): Promise<{
141
+ ok: true;
142
+ backend: RemoteRuntimeEmulatorBackend;
143
+ device: string;
144
+ contentType: "image/png";
145
+ data: Buffer;
146
+ }>;
147
+ openSurface(input: {
148
+ sessionId: string;
149
+ bindingId: string;
150
+ signal?: AbortSignal;
151
+ }): Promise<SessionEmulatorSurface>;
152
+ cleanupSession(sessionId: string): Promise<void>;
153
+ releaseDevice(backend: RemoteRuntimeEmulatorBackend, deviceId: string): Promise<void>;
154
+ /** Serialize destructive device lifecycle commands with Session attachment. */
155
+ stopDevice(input: {
156
+ action: "kill" | "shutdown";
157
+ device?: string;
158
+ }): Promise<unknown>;
159
+ private withBinding;
160
+ private serial;
161
+ private withDeviceReservations;
162
+ private acquireDevice;
163
+ private requireBinding;
164
+ private requireSession;
165
+ private devices;
166
+ private projectBinding;
167
+ }
@@ -0,0 +1,464 @@
1
+ import { randomUUID } from "node:crypto";
2
+ export class SessionEmulatorBindingConflictError extends Error {
3
+ constructor(message = "Emulator device is already bound to another Session", options) {
4
+ super(message, options);
5
+ this.name = "SessionEmulatorBindingConflictError";
6
+ }
7
+ }
8
+ export class SessionEmulatorServiceError extends Error {
9
+ code;
10
+ constructor(code, message, options) {
11
+ super(message, options);
12
+ this.code = code;
13
+ this.name = "SessionEmulatorServiceError";
14
+ }
15
+ }
16
+ /** Runtime-owned authority for the one-Session/one-device Emulator binding. */
17
+ export class SessionEmulatorService {
18
+ options;
19
+ now;
20
+ createBindingId;
21
+ sessionOperations = new Map();
22
+ deviceOperations = new Map();
23
+ deviceReservations = new Map();
24
+ constructor(options) {
25
+ this.options = options;
26
+ this.now = options.now ?? (() => new Date());
27
+ this.createBindingId = options.bindingId ?? randomUUID;
28
+ }
29
+ async getState(input) {
30
+ return this.serial(input.sessionId, async () => {
31
+ await this.requireSession(input.sessionId);
32
+ const record = await this.options.store.get(input.sessionId);
33
+ if (!record)
34
+ return null;
35
+ return this.projectBinding(record, await this.devices());
36
+ });
37
+ }
38
+ async listDevices(input) {
39
+ return this.serial(input.sessionId, () => this.listDevicesUnlocked(input));
40
+ }
41
+ async listDevicesUnlocked(input) {
42
+ await this.requireSession(input.sessionId);
43
+ const current = await this.options.store.get(input.sessionId);
44
+ const devices = await this.devices();
45
+ const visible = [];
46
+ for (const device of devices) {
47
+ const reservation = this.deviceReservations.get(deviceKey(device.backend, device.id));
48
+ if (reservation && reservation !== input.sessionId)
49
+ continue;
50
+ const owner = await this.options.store.findByDevice(device.backend, device.id);
51
+ if (owner && owner.sessionId !== input.sessionId)
52
+ continue;
53
+ const attached = current?.backend === device.backend && current.deviceId === device.id;
54
+ if (!attached && device.isAvailable === false)
55
+ continue;
56
+ visible.push({
57
+ ...device,
58
+ availability: attached ? "attached" : "available",
59
+ });
60
+ }
61
+ return visible;
62
+ }
63
+ async attach(input) {
64
+ return this.serial(input.sessionId, () => this.attachUnlocked(input));
65
+ }
66
+ async attachUnlocked(input) {
67
+ await this.requireSession(input.sessionId);
68
+ const devices = await this.devices();
69
+ const matches = devices.filter((device) => device.id === input.device);
70
+ if (matches.length !== 1) {
71
+ throw new SessionEmulatorServiceError("device_not_found", matches.length === 0
72
+ ? "Emulator device was not found on this Runtime"
73
+ : "Emulator device selector is ambiguous on this Runtime");
74
+ }
75
+ const device = matches[0];
76
+ const current = await this.options.store.get(input.sessionId);
77
+ if (current?.backend === device.backend && current.deviceId === device.id) {
78
+ return this.projectBinding(current, devices);
79
+ }
80
+ const record = {
81
+ sessionId: input.sessionId,
82
+ bindingId: this.createBindingId(),
83
+ backend: device.backend,
84
+ deviceId: device.id,
85
+ deviceName: device.name,
86
+ attachedAt: this.now().toISOString(),
87
+ };
88
+ const reservations = [
89
+ { backend: device.backend, deviceId: device.id },
90
+ ...(current ? [{ backend: current.backend, deviceId: current.deviceId }] : []),
91
+ ];
92
+ return this.withDeviceReservations(input.sessionId, reservations, async () => {
93
+ const owner = await this.options.store.findByDevice(device.backend, device.id);
94
+ if (owner && owner.sessionId !== input.sessionId) {
95
+ throw new SessionEmulatorServiceError("device_in_use", "Emulator device is already bound to another Session");
96
+ }
97
+ let attachment;
98
+ try {
99
+ attachment = await this.options.emulator.attach(device.id);
100
+ const attachedIdentityMatches = attachment.deviceUdid === device.id || (device.backend === "android" &&
101
+ typeof attachment.deviceName === "string" &&
102
+ attachment.deviceName === device.name);
103
+ if (!attachedIdentityMatches) {
104
+ throw new Error("Emulator host attached a different device");
105
+ }
106
+ }
107
+ catch (error) {
108
+ throw new SessionEmulatorServiceError("emulator_unavailable", "Emulator could not be attached", { cause: error });
109
+ }
110
+ try {
111
+ // Publish ownership only after the physical attach succeeds. The
112
+ // in-process device reservations provide exclusivity during the slow
113
+ // boot/helper work, so a failed replacement needs no visible rollback.
114
+ await this.options.store.bind(record);
115
+ }
116
+ catch (error) {
117
+ if (error instanceof SessionEmulatorBindingConflictError) {
118
+ throw new SessionEmulatorServiceError("device_in_use", error.message, { cause: error });
119
+ }
120
+ throw error;
121
+ }
122
+ return {
123
+ ...this.projectBinding(record, devices),
124
+ deviceName: typeof attachment.deviceName === "string"
125
+ ? attachment.deviceName
126
+ : record.deviceName,
127
+ attachedAt: typeof attachment.attachedAt === "string"
128
+ ? attachment.attachedAt
129
+ : record.attachedAt,
130
+ deviceState: "booted",
131
+ available: true,
132
+ };
133
+ });
134
+ }
135
+ async detach(input) {
136
+ return this.serial(input.sessionId, async () => {
137
+ await this.requireSession(input.sessionId);
138
+ const record = await this.requireBinding(input);
139
+ return this.withDeviceReservations(input.sessionId, [{
140
+ backend: record.backend,
141
+ deviceId: record.deviceId,
142
+ }], async () => {
143
+ if (!await this.options.store.release(input.sessionId, input.bindingId)) {
144
+ throw new SessionEmulatorServiceError("binding_mismatch", "Emulator binding no longer matches this Session");
145
+ }
146
+ return { released: true };
147
+ });
148
+ });
149
+ }
150
+ async tap(input) {
151
+ return this.withBinding(input, (record) => this.options.emulator.tap({
152
+ x: input.x,
153
+ y: input.y,
154
+ device: record.deviceId,
155
+ }));
156
+ }
157
+ async type(input) {
158
+ return this.withBinding(input, (record) => this.options.emulator.type({
159
+ text: input.text,
160
+ device: record.deviceId,
161
+ ...(input.mode === undefined ? {} : { mode: input.mode }),
162
+ }));
163
+ }
164
+ async button(input) {
165
+ return this.withBinding(input, (record) => this.options.emulator.button({
166
+ device: record.deviceId,
167
+ ...(input.name === undefined ? {} : { name: input.name }),
168
+ }));
169
+ }
170
+ async rotate(input) {
171
+ return this.withBinding(input, (record) => this.options.emulator.rotate({
172
+ orientation: input.orientation,
173
+ device: record.deviceId,
174
+ }));
175
+ }
176
+ async launch(input) {
177
+ return this.withBinding(input, (record) => this.options.emulator.launch({
178
+ appId: input.appId,
179
+ device: record.deviceId,
180
+ }));
181
+ }
182
+ async gesture(input) {
183
+ return this.withBinding(input, (record) => this.options.emulator.gesture({
184
+ points: input.points,
185
+ device: record.deviceId,
186
+ }));
187
+ }
188
+ async frame(input) {
189
+ return this.withBinding(input, async (record) => {
190
+ if (!this.options.emulator.frame) {
191
+ throw new SessionEmulatorServiceError("emulator_unavailable", "Emulator presentation is unavailable on this Runtime");
192
+ }
193
+ const frame = await this.options.emulator.frame({ device: record.deviceId });
194
+ if (frame.backend !== record.backend || frame.device !== record.deviceId) {
195
+ throw new SessionEmulatorServiceError("emulator_unavailable", "Emulator host returned a frame for a different device");
196
+ }
197
+ return frame;
198
+ });
199
+ }
200
+ async openSurface(input) {
201
+ const opened = await this.withBinding(input, async (record) => {
202
+ let imageStream;
203
+ let liveTouch;
204
+ try {
205
+ input.signal?.throwIfAborted();
206
+ if (record.backend === "ios" && this.options.emulator.imageStream) {
207
+ imageStream = await this.options.emulator.imageStream({
208
+ device: record.deviceId,
209
+ ...(input.signal ? { signal: input.signal } : {}),
210
+ });
211
+ if (imageStream.backend !== record.backend || imageStream.device !== record.deviceId) {
212
+ throw new Error("Emulator image stream belongs to a different device");
213
+ }
214
+ }
215
+ if (this.options.emulator.liveTouch) {
216
+ liveTouch = await this.options.emulator.liveTouch({
217
+ device: record.deviceId,
218
+ ...(input.signal ? { signal: input.signal } : {}),
219
+ });
220
+ if (liveTouch.backend !== record.backend || liveTouch.device !== record.deviceId) {
221
+ throw new Error("Emulator touch stream belongs to a different device");
222
+ }
223
+ }
224
+ input.signal?.throwIfAborted();
225
+ return { record, imageStream, liveTouch };
226
+ }
227
+ catch (error) {
228
+ imageStream?.close();
229
+ liveTouch?.close();
230
+ throw new SessionEmulatorServiceError("emulator_unavailable", "Emulator live Surface could not be opened", { cause: error });
231
+ }
232
+ });
233
+ let closed = false;
234
+ const assertOpen = () => {
235
+ if (closed)
236
+ throw new SessionEmulatorServiceError("emulator_unavailable", "Emulator Surface is closed");
237
+ };
238
+ return {
239
+ sessionId: input.sessionId,
240
+ bindingId: input.bindingId,
241
+ backend: opened.record.backend,
242
+ deviceId: opened.record.deviceId,
243
+ format: opened.imageStream ? "jpeg" : "png",
244
+ capture: async () => {
245
+ assertOpen();
246
+ await this.withBinding(input, async () => undefined);
247
+ const bytes = opened.imageStream
248
+ ? await opened.imageStream.nextFrame()
249
+ : (await this.frame(input)).data;
250
+ assertOpen();
251
+ await this.withBinding(input, async () => undefined);
252
+ return bytes;
253
+ },
254
+ touch: async (point) => {
255
+ assertOpen();
256
+ await this.withBinding(input, async () => {
257
+ if (!opened.liveTouch) {
258
+ throw new SessionEmulatorServiceError("emulator_unavailable", "Emulator live touch is unavailable on this Runtime");
259
+ }
260
+ opened.liveTouch.send(point);
261
+ });
262
+ },
263
+ close: () => {
264
+ if (closed)
265
+ return;
266
+ closed = true;
267
+ opened.imageStream?.close();
268
+ opened.liveTouch?.close();
269
+ },
270
+ };
271
+ }
272
+ async cleanupSession(sessionId) {
273
+ await this.serial(sessionId, async () => {
274
+ const record = await this.options.store.get(sessionId);
275
+ if (!record) {
276
+ await this.options.store.releaseSession(sessionId);
277
+ return;
278
+ }
279
+ await this.withDeviceReservations(sessionId, [{
280
+ backend: record.backend,
281
+ deviceId: record.deviceId,
282
+ }], async () => {
283
+ await this.options.store.releaseSession(sessionId);
284
+ });
285
+ });
286
+ }
287
+ async releaseDevice(backend, deviceId) {
288
+ await this.withDeviceReservations("device-lifecycle", [{ backend, deviceId }], async () => {
289
+ await this.options.store.releaseDevice(backend, deviceId);
290
+ });
291
+ }
292
+ /** Serialize destructive device lifecycle commands with Session attachment. */
293
+ async stopDevice(input) {
294
+ const devices = await this.devices();
295
+ const exactIdCandidates = input.device
296
+ ? devices.filter((device) => device.id === input.device)
297
+ : [];
298
+ const exactNameCandidates = input.device
299
+ ? devices.filter((device) => device.name === input.device)
300
+ : [];
301
+ let candidates = exactIdCandidates.length > 0
302
+ ? exactIdCandidates
303
+ : exactNameCandidates.length === 1
304
+ ? exactNameCandidates
305
+ : [];
306
+ if (candidates.length === 0) {
307
+ const session = input.device && this.options.emulator.deviceSession
308
+ ? await this.options.emulator.deviceSession({ device: input.device })
309
+ : !input.device
310
+ ? await this.options.emulator.active()
311
+ : null;
312
+ const identity = emulatorSessionIdentity(session);
313
+ if (identity) {
314
+ const identityIds = devices.filter((device) => device.backend === identity.backend && device.id === identity.deviceId);
315
+ const identityNames = devices.filter((device) => device.backend === identity.backend &&
316
+ (device.id === identity.deviceName || device.name === identity.deviceName));
317
+ candidates = identityIds.length > 0
318
+ ? identityIds
319
+ : identityNames.length === 1
320
+ ? identityNames
321
+ : [];
322
+ }
323
+ }
324
+ return this.withDeviceReservations("device-lifecycle", candidates.map((device) => ({
325
+ backend: device.backend,
326
+ deviceId: device.id,
327
+ })), async () => {
328
+ const result = input.action === "kill"
329
+ ? await this.options.emulator.kill({ device: input.device })
330
+ : await this.options.emulator.shutdown({ device: input.device });
331
+ if (result && typeof result === "object") {
332
+ const value = result;
333
+ if (value.ok === true &&
334
+ (value.backend === "ios" || value.backend === "android") &&
335
+ typeof value.device === "string") {
336
+ await this.options.store.releaseDevice(value.backend, value.device);
337
+ for (const candidate of candidates) {
338
+ if (candidate.backend === value.backend && candidate.id !== value.device) {
339
+ await this.options.store.releaseDevice(candidate.backend, candidate.id);
340
+ }
341
+ }
342
+ }
343
+ }
344
+ return result;
345
+ });
346
+ }
347
+ async withBinding(input, operation) {
348
+ return this.serial(input.sessionId, async () => {
349
+ const record = await this.requireBinding(input);
350
+ return await this.withDeviceReservations(input.sessionId, [{
351
+ backend: record.backend,
352
+ deviceId: record.deviceId,
353
+ }], () => operation(record));
354
+ });
355
+ }
356
+ async serial(sessionId, operation) {
357
+ const previous = this.sessionOperations.get(sessionId) ?? Promise.resolve();
358
+ let release;
359
+ const current = new Promise((resolve) => { release = resolve; });
360
+ this.sessionOperations.set(sessionId, current);
361
+ await previous.catch(() => undefined);
362
+ try {
363
+ return await operation();
364
+ }
365
+ finally {
366
+ release();
367
+ if (this.sessionOperations.get(sessionId) === current)
368
+ this.sessionOperations.delete(sessionId);
369
+ }
370
+ }
371
+ async withDeviceReservations(sessionId, devices, operation) {
372
+ const keys = [...new Set(devices.map((device) => deviceKey(device.backend, device.deviceId)))].sort();
373
+ const releases = [];
374
+ try {
375
+ for (const key of keys)
376
+ releases.push(await this.acquireDevice(key));
377
+ for (const key of keys)
378
+ this.deviceReservations.set(key, sessionId);
379
+ return await operation();
380
+ }
381
+ finally {
382
+ for (const key of keys) {
383
+ if (this.deviceReservations.get(key) === sessionId)
384
+ this.deviceReservations.delete(key);
385
+ }
386
+ for (const release of releases.reverse())
387
+ release();
388
+ }
389
+ }
390
+ async acquireDevice(key) {
391
+ const previous = this.deviceOperations.get(key) ?? Promise.resolve();
392
+ let unlock;
393
+ const current = new Promise((resolve) => { unlock = resolve; });
394
+ this.deviceOperations.set(key, current);
395
+ await previous.catch(() => undefined);
396
+ let released = false;
397
+ return () => {
398
+ if (released)
399
+ return;
400
+ released = true;
401
+ unlock();
402
+ if (this.deviceOperations.get(key) === current)
403
+ this.deviceOperations.delete(key);
404
+ };
405
+ }
406
+ async requireBinding(input) {
407
+ await this.requireSession(input.sessionId);
408
+ const record = await this.options.store.get(input.sessionId);
409
+ if (!record || record.bindingId !== input.bindingId) {
410
+ throw new SessionEmulatorServiceError("binding_mismatch", "Emulator binding no longer matches this Session");
411
+ }
412
+ return record;
413
+ }
414
+ async requireSession(sessionId) {
415
+ if (!await this.options.sessions.exists(sessionId)) {
416
+ throw new SessionEmulatorServiceError("session_not_found", "Session was not found");
417
+ }
418
+ }
419
+ async devices() {
420
+ const devices = await this.options.emulator.devices();
421
+ return devices.filter(isRemoteRuntimeEmulatorDevice);
422
+ }
423
+ projectBinding(record, devices) {
424
+ const device = devices.find((candidate) => candidate.backend === record.backend && candidate.id === record.deviceId);
425
+ return {
426
+ sessionId: record.sessionId,
427
+ bindingId: record.bindingId,
428
+ backend: record.backend,
429
+ deviceId: record.deviceId,
430
+ ...(record.deviceName ? { deviceName: record.deviceName } : {}),
431
+ deviceState: device?.state ?? "unknown",
432
+ available: Boolean(device && device.isAvailable !== false),
433
+ attachedAt: record.attachedAt,
434
+ };
435
+ }
436
+ }
437
+ function isRemoteRuntimeEmulatorDevice(value) {
438
+ if (!value || typeof value !== "object" || Array.isArray(value))
439
+ return false;
440
+ const device = value;
441
+ return (device.backend === "ios" || device.backend === "android") &&
442
+ typeof device.id === "string" &&
443
+ typeof device.name === "string" &&
444
+ isDeviceState(device.state);
445
+ }
446
+ function isDeviceState(value) {
447
+ return value === "booted" || value === "shutdown" || value === "unknown";
448
+ }
449
+ function deviceKey(backend, deviceId) {
450
+ return JSON.stringify([backend, deviceId]);
451
+ }
452
+ function emulatorSessionIdentity(value) {
453
+ if (!value || typeof value !== "object" || Array.isArray(value))
454
+ return null;
455
+ const session = value;
456
+ if ((session.backend !== "ios" && session.backend !== "android") ||
457
+ typeof session.deviceUdid !== "string")
458
+ return null;
459
+ return {
460
+ backend: session.backend,
461
+ deviceId: session.deviceUdid,
462
+ ...(typeof session.deviceName === "string" ? { deviceName: session.deviceName } : {}),
463
+ };
464
+ }
@@ -0,0 +1,23 @@
1
+ import type { SessionEvent } from "@rynx-ai/protocol";
2
+ import type { ControlSessionRuntimeSnapshot } from "@rynx-ai/protocol/control";
3
+ export type SessionRuntimeSnapshot = ControlSessionRuntimeSnapshot;
4
+ export interface SessionRuntimeActivitySnapshot {
5
+ runningTurns: number;
6
+ pendingInteractions: number;
7
+ }
8
+ /**
9
+ * Process-local index of live Session state. Durable transcript items explain
10
+ * history; this index says which historical requests still have a real runtime
11
+ * waiter after a client reconnect. It intentionally starts empty after daemon
12
+ * restart because an old runner waiter cannot be resumed implicitly.
13
+ */
14
+ export declare class SessionRuntimeIndex {
15
+ private readonly sessions;
16
+ observe(sessionId: string, event: SessionEvent): void;
17
+ snapshot(sessionId: string): SessionRuntimeSnapshot;
18
+ /** Aggregate only process-live work; durable Session history is not activity. */
19
+ activitySnapshot(): SessionRuntimeActivitySnapshot;
20
+ remove(sessionId: string): void;
21
+ private state;
22
+ private removeResponseInteractions;
23
+ }