@rynx-ai/server 0.1.9 → 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 (39) hide show
  1. package/dist/browser-surface-ws.d.ts +2 -0
  2. package/dist/browser-surface-ws.js +1 -0
  3. package/dist/control-api.d.ts +17 -0
  4. package/dist/control-api.js +809 -9
  5. package/dist/control-web/assets/{highlighted-body-OFNGDK62-e-w61YLy.js → highlighted-body-OFNGDK62-DHu2g-rk.js} +1 -1
  6. package/dist/control-web/assets/index-B6Pb5j9M.js +666 -0
  7. package/dist/control-web/assets/index-BU-_Qud_.css +32 -0
  8. package/dist/control-web/assets/{mermaid-GHXKKRXX-DZn7Hbr2.js → mermaid-GHXKKRXX-CSYzOmBR.js} +3 -3
  9. package/dist/control-web/index.html +2 -2
  10. package/dist/desktop-browser-host.d.ts +20 -3
  11. package/dist/desktop-browser-host.js +126 -44
  12. package/dist/direct-runtime-browser-inspect-server.d.ts +72 -0
  13. package/dist/direct-runtime-browser-inspect-server.js +749 -0
  14. package/dist/direct-runtime-server.d.ts +6 -0
  15. package/dist/direct-runtime-server.js +29 -1
  16. package/dist/emulator-surface-ws.d.ts +2 -0
  17. package/dist/emulator-surface-ws.js +1 -0
  18. package/dist/machine-session-service.d.ts +88 -5
  19. package/dist/machine-session-service.js +283 -20
  20. package/dist/remote-runtime-dispatcher.d.ts +1 -1
  21. package/dist/remote-runtime-dispatcher.js +25 -2
  22. package/dist/remote-runtime-session-projection.js +10 -0
  23. package/dist/runtime-web-auth.d.ts +5 -0
  24. package/dist/runtime-web-auth.js +43 -3
  25. package/dist/server.d.ts +78 -8
  26. package/dist/server.js +42 -17
  27. package/dist/session-browser-service.d.ts +30 -0
  28. package/dist/session-browser-service.js +89 -3
  29. package/dist/session-browser-surface-coordinator.d.ts +4 -3
  30. package/dist/session-browser-surface-coordinator.js +374 -268
  31. package/dist/session-runtime-index.d.ts +6 -0
  32. package/dist/session-runtime-index.js +10 -0
  33. package/dist/session-terminal-host.d.ts +6 -1
  34. package/dist/session-terminal-host.js +18 -0
  35. package/dist/terminal-ws.d.ts +3 -0
  36. package/dist/terminal-ws.js +1 -0
  37. package/package.json +12 -7
  38. package/dist/control-web/assets/index-BQ7XVBKO.css +0 -32
  39. package/dist/control-web/assets/index-ClpzuBJx.js +0 -606
@@ -46,6 +46,9 @@ export class MachineSessionService {
46
46
  maxSnapshotItemLimit;
47
47
  snapshotPageMaxBytes;
48
48
  directoryScanLimit;
49
+ pendingMessageRetryMs;
50
+ pendingDeliveries = new Map();
51
+ cancelledPendingDeliveries = new Set();
49
52
  constructor(ports, options = {}) {
50
53
  this.ports = ports;
51
54
  this.maxListLimit = boundedInteger(options.maxListLimit ?? MAX_LIST_LIMIT, 1, MAX_LIST_LIMIT, "maxListLimit");
@@ -53,6 +56,8 @@ export class MachineSessionService {
53
56
  this.maxSnapshotItemLimit = boundedInteger(options.maxSnapshotItemLimit ?? MAX_SNAPSHOT_ITEM_LIMIT, 1, MAX_SNAPSHOT_ITEM_LIMIT, "maxSnapshotItemLimit");
54
57
  this.snapshotPageMaxBytes = boundedInteger(options.snapshotPageMaxBytes ?? DEFAULT_SNAPSHOT_PAGE_MAX_BYTES, MIN_PAGE_MAX_BYTES, MAX_PAGE_MAX_BYTES, "snapshotPageMaxBytes");
55
58
  this.directoryScanLimit = boundedInteger(options.directoryScanLimit ?? DEFAULT_DIRECTORY_SCAN_LIMIT, 1, MAX_DIRECTORY_SCAN_LIMIT, "directoryScanLimit");
59
+ this.pendingMessageRetryMs = boundedInteger(options.pendingMessageRetryMs ?? 1_000, 10, 60_000, "pendingMessageRetryMs");
60
+ void this.resumePendingDeliveries();
56
61
  }
57
62
  async list(input = {}) {
58
63
  const limit = requestLimit(input.limit, DEFAULT_LIST_LIMIT, this.maxListLimit, "limit");
@@ -63,14 +68,18 @@ export class MachineSessionService {
63
68
  const metas = [...allMetas]
64
69
  .sort(compareMetaRecency)
65
70
  .slice(0, this.directoryScanLimit);
66
- const logged = await this.ports.log.listSessions({ limit: this.directoryScanLimit + 1 });
71
+ const [logged, pendingMessages] = await Promise.all([
72
+ this.ports.log.listSessions({ limit: this.directoryScanLimit + 1 }),
73
+ this.ports.pendingMessages?.list() ?? [],
74
+ ]);
67
75
  const directoryTruncated = allMetas.length > this.directoryScanLimit || logged.length > this.directoryScanLimit;
68
76
  const boundedLogs = logged.slice(0, this.directoryScanLimit);
69
77
  const metaById = new Map(metas.map((meta) => [meta.id, meta]));
70
78
  const logById = new Map(boundedLogs.map((entry) => [entry.sessionId, entry]));
79
+ const pendingById = new Map(pendingMessages.map((entry) => [entry.sessionId, entry.state]));
71
80
  const ids = new Set([...metaById.keys(), ...logById.keys()]);
72
81
  const summaries = [...ids]
73
- .map((id) => sessionSummary(id, metaById.get(id), logById.get(id), this.ports.runtimeState))
82
+ .map((id) => sessionSummary(id, metaById.get(id), logById.get(id), this.ports.runtimeState, pendingById.get(id)))
74
83
  .sort(compareSummaryRecency);
75
84
  const afterCursor = cursor === undefined
76
85
  ? summaries
@@ -186,11 +195,7 @@ export class MachineSessionService {
186
195
  }
187
196
  else {
188
197
  if (!isSessionProviderId(input.provider)) {
189
- throw new MachineSessionServiceInputError("provider must be codex or claude");
190
- }
191
- const readiness = await this.runtimeReadiness(input.provider);
192
- if (!readiness.ready) {
193
- throw new MachineSessionServiceFailure("failed_precondition", readiness.reason ?? "provider is not ready");
198
+ throw new MachineSessionServiceInputError("provider must be codex, traex, or claude");
194
199
  }
195
200
  target = { provider: input.provider };
196
201
  }
@@ -209,31 +214,188 @@ export class MachineSessionService {
209
214
  /** Inject one turn through the target daemon's native single-writer runner. */
210
215
  async sendMessage(sessionIdInput, messageInput) {
211
216
  const sessionId = validOpaqueToken(sessionIdInput, "sessionId", MAX_SESSION_ID_CHARS);
212
- if (typeof messageInput !== "string" || messageInput.length === 0) {
217
+ const request = typeof messageInput === "string"
218
+ ? { message: messageInput }
219
+ : messageInput;
220
+ const hasMessage = request.message !== undefined;
221
+ const hasContent = request.content !== undefined;
222
+ if (hasMessage === hasContent) {
223
+ throw new MachineSessionServiceInputError("provide exactly one of message or content");
224
+ }
225
+ if (hasMessage && (typeof request.message !== "string" || request.message.length === 0)) {
213
226
  throw new MachineSessionServiceInputError("message must be a non-empty string");
214
227
  }
228
+ if (hasContent && (!Array.isArray(request.content) || request.content.length === 0)) {
229
+ throw new MachineSessionServiceInputError("content must be a non-empty array");
230
+ }
215
231
  const meta = this.ports.registry.get(sessionId);
216
232
  const consoleMeta = meta?.source === "console" ? meta : undefined;
233
+ let prepared;
234
+ const needsPreparedOperation = hasContent || request.clientMessageId !== undefined;
235
+ if (needsPreparedOperation) {
236
+ const resources = this.ports.resources;
237
+ if (!resources) {
238
+ throw new MachineSessionServiceFailure("failed_precondition", "Session resources are unavailable");
239
+ }
240
+ if (hasContent &&
241
+ request.content.some((part) => part.type === "input_image") &&
242
+ !request.clientMessageId) {
243
+ throw new MachineSessionServiceInputError("clientMessageId is required for image input");
244
+ }
245
+ prepared = await resources.prepareInput(sessionId, hasContent
246
+ ? request.content
247
+ : [{ type: "input_text", text: request.message }], request.clientMessageId);
248
+ if (prepared.operationState === "injecting" ||
249
+ prepared.operationState === "outcome_unknown") {
250
+ throw new MachineSessionServiceFailure("outcome_unknown", "message delivery outcome is unknown");
251
+ }
252
+ if (prepared.operationState === "injected" ||
253
+ prepared.operationState === "mirrored") {
254
+ return { ok: true, injected: true };
255
+ }
256
+ }
257
+ else {
258
+ prepared = {
259
+ input: {
260
+ content: [{ type: "text", text: request.message }],
261
+ ...(request.clientMessageId
262
+ ? { clientMessageId: request.clientMessageId }
263
+ : {}),
264
+ },
265
+ title: request.message,
266
+ };
267
+ }
217
268
  if (consoleMeta && !consoleMeta.title) {
218
- this.ports.registry.setTitle(sessionId, synthesizeSessionTitle(messageInput));
269
+ this.ports.registry.setTitle(sessionId, synthesizeSessionTitle(prepared.title));
270
+ }
271
+ const resources = this.ports.resources;
272
+ if (request.clientMessageId) {
273
+ await resources?.markMessageInjecting(sessionId, request.clientMessageId);
274
+ }
275
+ let execution;
276
+ try {
277
+ execution = await this.ensureLiveSession(sessionId, meta);
278
+ }
279
+ catch (error) {
280
+ if (request.clientMessageId) {
281
+ await resources?.markMessageFailedNotStarted(sessionId, request.clientMessageId, error instanceof Error ? error.message : String(error));
282
+ }
283
+ throw error;
284
+ }
285
+ let outcome;
286
+ try {
287
+ outcome = await execution.runner.injectMessage(sessionId, hasMessage && !needsPreparedOperation ? request.message : prepared.input);
288
+ }
289
+ catch (error) {
290
+ if (request.clientMessageId) {
291
+ await Promise.resolve(resources?.markMessageOutcomeUnknown(sessionId, request.clientMessageId, error instanceof Error ? error.message : String(error))).catch(() => undefined);
292
+ }
293
+ throw new MachineSessionServiceFailure("outcome_unknown", "live injection outcome is unknown");
219
294
  }
220
- const execution = await this.ensureLiveSession(sessionId, meta);
221
- const outcome = await execution.runner.injectMessage(sessionId, messageInput);
222
295
  if (outcome === "failed") {
296
+ if (request.clientMessageId) {
297
+ await resources?.markMessageOutcomeUnknown(sessionId, request.clientMessageId, `live injection ${outcome}`);
298
+ }
223
299
  throw new MachineSessionServiceFailure("outcome_unknown", `live injection ${outcome}`);
224
300
  }
225
301
  if (outcome !== "injected") {
302
+ if (request.clientMessageId) {
303
+ await resources?.markMessageFailedNotStarted(sessionId, request.clientMessageId, `live injection ${outcome}`);
304
+ }
226
305
  throw new MachineSessionServiceFailure("failed_precondition", `live injection ${outcome}`);
227
306
  }
307
+ if (request.clientMessageId) {
308
+ try {
309
+ await resources?.markMessageInjected(sessionId, request.clientMessageId);
310
+ }
311
+ catch {
312
+ await Promise.resolve(resources?.markMessageOutcomeUnknown(sessionId, request.clientMessageId, "message state could not be persisted after injection")).catch(() => undefined);
313
+ throw new MachineSessionServiceFailure("outcome_unknown", "message was injected but its delivery state could not be persisted");
314
+ }
315
+ }
228
316
  return { ok: true, injected: true };
229
317
  }
318
+ resourcePolicy(sessionIdInput) {
319
+ const sessionId = this.requireResourceSession(sessionIdInput);
320
+ return this.requireResources().policy(sessionId);
321
+ }
322
+ async beginResourceUpload(input) {
323
+ const sessionId = this.requireResourceSession(input.sessionId);
324
+ return this.requireResources().beginUpload({ ...input, sessionId });
325
+ }
326
+ async writeResourceUploadChunk(input) {
327
+ const sessionId = this.requireResourceSession(input.sessionId);
328
+ return this.requireResources().writeUploadChunk({ ...input, sessionId });
329
+ }
330
+ async commitResourceUpload(input) {
331
+ const sessionId = this.requireResourceSession(input.sessionId);
332
+ return this.requireResources().commitUpload({ ...input, sessionId });
333
+ }
334
+ async getResource(input) {
335
+ const sessionId = this.requireResourceSession(input.sessionId);
336
+ return this.requireResources().getResource({ ...input, sessionId });
337
+ }
338
+ async readResource(input) {
339
+ const sessionId = this.requireResourceSession(input.sessionId);
340
+ return this.requireResources().readResource({ ...input, sessionId });
341
+ }
342
+ async deleteResource(input) {
343
+ const sessionId = this.requireResourceSession(input.sessionId);
344
+ return this.requireResources().deleteResource({ ...input, sessionId });
345
+ }
346
+ /** Persist the first task while login/onboarding owns the Provider TUI. The
347
+ * delivery worker starts the pane now, waits for a real native thread, then
348
+ * injects exactly once. `failed` is fenced as outcome_unknown and never retried. */
349
+ async enqueueMessage(sessionIdInput, messageInput) {
350
+ const sessionId = validOpaqueToken(sessionIdInput, "sessionId", MAX_SESSION_ID_CHARS);
351
+ if (typeof messageInput !== "string" || messageInput.length === 0) {
352
+ throw new MachineSessionServiceInputError("message must be a non-empty string");
353
+ }
354
+ const meta = this.ports.registry.get(sessionId);
355
+ if (!meta)
356
+ throw new MachineSessionServiceFailure("not_found", "session not found");
357
+ const pending = this.ports.pendingMessages;
358
+ if (!pending) {
359
+ throw new MachineSessionServiceFailure("failed_precondition", "Provider setup task queue is unavailable");
360
+ }
361
+ const inserted = await pending.put({
362
+ sessionId,
363
+ message: messageInput,
364
+ createdAt: new Date().toISOString(),
365
+ });
366
+ if (!inserted) {
367
+ throw new MachineSessionServiceFailure("failed_precondition", "This Session already has a task waiting for Provider setup");
368
+ }
369
+ if (meta.source === "console" && !meta.title) {
370
+ this.ports.registry.setTitle(sessionId, synthesizeSessionTitle(messageInput));
371
+ }
372
+ this.schedulePendingDelivery(sessionId);
373
+ // Best effort: delivery also starts/retries the pane, but awaiting this path
374
+ // makes the setup Terminal attachable as soon as the enqueue RPC returns.
375
+ await this.startTerminal(sessionId).catch(() => undefined);
376
+ return { ok: true, queued: true };
377
+ }
230
378
  /** Explicitly restore the target daemon's live runner without starting a turn. */
231
379
  async startTerminal(sessionIdInput) {
232
380
  const sessionId = validOpaqueToken(sessionIdInput, "sessionId", MAX_SESSION_ID_CHARS);
233
- await this.ensureLiveSession(sessionId);
381
+ const request = await this.liveSessionRequest(sessionId);
382
+ const start = request.execution.runner.startLiveSession?.bind(request.execution.runner) ??
383
+ request.execution.runner.ensureLiveSession.bind(request.execution.runner);
384
+ const started = await start(sessionId, request.options);
385
+ if (!started) {
386
+ throw new MachineSessionServiceFailure("failed_precondition", `Provider terminal unavailable (${request.liveRuntime})`, request.execution.runner.lastLiveSessionError?.(sessionId));
387
+ }
234
388
  return { ok: true };
235
389
  }
236
390
  async ensureLiveSession(sessionId, meta = this.ports.registry.get(sessionId)) {
391
+ const request = await this.liveSessionRequest(sessionId, meta);
392
+ const live = await request.execution.runner.ensureLiveSession(sessionId, request.options);
393
+ if (!live) {
394
+ throw new MachineSessionServiceFailure("failed_precondition", `live session unavailable (${request.liveRuntime})`, request.execution.runner.lastLiveSessionError?.(sessionId));
395
+ }
396
+ return request.execution;
397
+ }
398
+ async liveSessionRequest(sessionId, meta = this.ports.registry.get(sessionId)) {
237
399
  const execution = this.requireExecution();
238
400
  const consoleMeta = meta?.source === "console" ? meta : undefined;
239
401
  const bound = consoleMeta ? null : ((await execution.sessionStore?.get(sessionId)) ?? null);
@@ -245,18 +407,19 @@ export class MachineSessionService {
245
407
  agentSpec: consoleMeta?.config,
246
408
  provider: meta?.provider ?? bound?.runtime,
247
409
  });
248
- if (liveRuntime === "codex") {
410
+ if (liveRuntime === "codex" || liveRuntime === "traex") {
249
411
  const codexRecord = await execution.sessionStore?.get(sessionId);
250
412
  if (codexRecord?.codexSessionId) {
251
413
  ensureCodexResumeRollout({
252
414
  sessionId,
415
+ runtime: liveRuntime,
253
416
  threadId: codexRecord.codexSessionId,
254
417
  cwd: codexRecord.cwd ?? process.cwd(),
255
418
  items: await this.ports.log.snapshot(sessionId),
256
419
  });
257
420
  }
258
421
  }
259
- const live = await execution.runner.ensureLiveSession(sessionId, {
422
+ const options = {
260
423
  ...(bound?.cwd
261
424
  ? { cwd: bound.cwd }
262
425
  : consoleMeta?.config?.osEnv?.cwd
@@ -268,11 +431,8 @@ export class MachineSessionService {
268
431
  : {}),
269
432
  ...(consoleMeta?.agent ? { agentName: consoleMeta.agent } : {}),
270
433
  ...(consoleMeta?.config ? { agentSpec: consoleMeta.config } : {}),
271
- });
272
- if (!live) {
273
- throw new MachineSessionServiceFailure("failed_precondition", `live session unavailable (${liveRuntime})`, execution.runner.lastLiveSessionError?.(sessionId));
274
- }
275
- return execution;
434
+ };
435
+ return { execution, liveRuntime, options };
276
436
  }
277
437
  async resolveInteraction(sessionIdInput, interactionIdInput, resolution) {
278
438
  const sessionId = validOpaqueToken(sessionIdInput, "sessionId", MAX_SESSION_ID_CHARS);
@@ -287,8 +447,11 @@ export class MachineSessionService {
287
447
  catch (error) {
288
448
  throw new MachineSessionServiceFailure("failed_precondition", "Session-owned resources could not be fenced before deletion", error instanceof Error ? error.message : String(error));
289
449
  }
450
+ this.cancelledPendingDeliveries.add(sessionId);
290
451
  this.ports.execution?.runner.stopRunner(sessionId);
291
452
  this.ports.events.close?.(sessionId);
453
+ await this.ports.pendingMessages?.delete(sessionId);
454
+ await this.ports.resources?.deleteSession(sessionId);
292
455
  this.ports.registry.remove(sessionId);
293
456
  await this.ports.log.deleteSession(sessionId);
294
457
  this.ports.runtimeState.remove?.(sessionId);
@@ -306,6 +469,93 @@ export class MachineSessionService {
306
469
  }
307
470
  return this.ports.agents;
308
471
  }
472
+ requireResources() {
473
+ if (!this.ports.resources) {
474
+ throw new MachineSessionServiceFailure("failed_precondition", "Session resources are unavailable");
475
+ }
476
+ return this.ports.resources;
477
+ }
478
+ requireResourceSession(sessionIdInput) {
479
+ const sessionId = validOpaqueToken(sessionIdInput, "sessionId", MAX_SESSION_ID_CHARS);
480
+ if (!this.ports.registry.get(sessionId)) {
481
+ throw new MachineSessionServiceFailure("not_found", "session not found");
482
+ }
483
+ return sessionId;
484
+ }
485
+ async resumePendingDeliveries() {
486
+ const pending = this.ports.pendingMessages;
487
+ if (!pending)
488
+ return;
489
+ try {
490
+ for (const message of await pending.list()) {
491
+ if (message.state === "queued")
492
+ this.schedulePendingDelivery(message.sessionId);
493
+ }
494
+ }
495
+ catch {
496
+ // A later enqueue or daemon restart retries recovery; startup remains usable.
497
+ }
498
+ }
499
+ schedulePendingDelivery(sessionId) {
500
+ if (this.pendingDeliveries.has(sessionId))
501
+ return;
502
+ this.cancelledPendingDeliveries.delete(sessionId);
503
+ const delivery = this.deliverPendingMessage(sessionId)
504
+ .catch(() => undefined)
505
+ .finally(() => {
506
+ if (this.pendingDeliveries.get(sessionId) === delivery) {
507
+ this.pendingDeliveries.delete(sessionId);
508
+ }
509
+ });
510
+ this.pendingDeliveries.set(sessionId, delivery);
511
+ }
512
+ async deliverPendingMessage(sessionId) {
513
+ const pendingStore = this.ports.pendingMessages;
514
+ if (!pendingStore)
515
+ return;
516
+ for (;;) {
517
+ if (this.cancelledPendingDeliveries.has(sessionId))
518
+ return;
519
+ const pending = await pendingStore.get(sessionId);
520
+ if (!pending || pending.state !== "queued")
521
+ return;
522
+ let injectionAttempted = false;
523
+ try {
524
+ const execution = await this.ensureLiveSession(sessionId);
525
+ if (this.cancelledPendingDeliveries.has(sessionId))
526
+ return;
527
+ const current = await pendingStore.get(sessionId);
528
+ if (!current ||
529
+ current.state !== "queued" ||
530
+ current.createdAt !== pending.createdAt ||
531
+ current.message !== pending.message)
532
+ return;
533
+ injectionAttempted = true;
534
+ const outcome = await execution.runner.injectMessage(sessionId, pending.message);
535
+ if (outcome === "injected") {
536
+ await Promise.resolve(pendingStore.delete(sessionId)).catch(async () => {
537
+ await Promise.resolve(pendingStore.markOutcomeUnknown(sessionId)).catch(() => undefined);
538
+ });
539
+ return;
540
+ }
541
+ if (outcome === "failed") {
542
+ await pendingStore.markOutcomeUnknown(sessionId);
543
+ return;
544
+ }
545
+ }
546
+ catch (error) {
547
+ if (injectionAttempted) {
548
+ await Promise.resolve(pendingStore.markOutcomeUnknown(sessionId)).catch(() => undefined);
549
+ return;
550
+ }
551
+ if (error instanceof MachineSessionServiceFailure && error.code === "not_found") {
552
+ await pendingStore.delete(sessionId);
553
+ return;
554
+ }
555
+ }
556
+ await retryDelay(this.pendingMessageRetryMs);
557
+ }
558
+ }
309
559
  runtimeReadiness(runtime, cache) {
310
560
  const cached = cache?.get(runtime);
311
561
  if (cached)
@@ -368,6 +618,12 @@ function runtimeSetupReason(runtime, status) {
368
618
  return status.issues.find((issue) => issue.trim().length > 0) ??
369
619
  `${name} needs setup before it can start Sessions.`;
370
620
  }
621
+ function retryDelay(ms) {
622
+ return new Promise((resolve) => {
623
+ const timer = setTimeout(resolve, ms);
624
+ timer.unref?.();
625
+ });
626
+ }
371
627
  function listPage(sessions, hasMore, directoryTruncated) {
372
628
  return {
373
629
  sessions,
@@ -432,7 +688,7 @@ function snapshotPage(sessionId, items, runtime, hasMore) {
432
688
  ...(hasMore && items.length > 0 ? { nextAfterId: items.at(-1).id } : {}),
433
689
  };
434
690
  }
435
- function sessionSummary(id, meta, log, runtimeState) {
691
+ function sessionSummary(id, meta, log, runtimeState, pendingState) {
436
692
  const createdAt = meta?.createdAt ?? timestampFromEpoch(log?.createdAt, "createdAt");
437
693
  const updatedAt = log
438
694
  ? timestampFromEpoch(log.updatedAt, "updatedAt")
@@ -443,6 +699,13 @@ function sessionSummary(id, meta, log, runtimeState) {
443
699
  ...(meta?.agent === undefined ? {} : { agent: meta.agent }),
444
700
  ...(meta?.title === undefined ? {} : { title: normalizeSessionTitle(meta.title) }),
445
701
  status: runtimeState.snapshot(id).status,
702
+ ...(pendingState === undefined
703
+ ? {}
704
+ : {
705
+ setupStatus: pendingState === "queued"
706
+ ? "waiting_for_provider"
707
+ : "delivery_unknown",
708
+ }),
446
709
  createdAt: validTimestamp(createdAt, "createdAt"),
447
710
  source: meta?.source ?? "unknown",
448
711
  updatedAt: validTimestamp(updatedAt, "updatedAt"),
@@ -25,7 +25,7 @@ export interface RemoteRuntimeDispatcherOptions {
25
25
  /** Optional diagnostic sink. Wire errors deliberately omit internal details. */
26
26
  onError?: (error: unknown, request: RemoteRuntimeRpcRequest) => void;
27
27
  }
28
- export type RemoteRuntimeSessionHost = Pick<MachineSessionService, "list" | "snapshot" | "watch" | "interrupt" | "agentOptions" | "launchOptions" | "create" | "sendMessage" | "startTerminal" | "delete" | "resolveInteraction">;
28
+ export type RemoteRuntimeSessionHost = Pick<MachineSessionService, "list" | "snapshot" | "watch" | "interrupt" | "agentOptions" | "launchOptions" | "create" | "sendMessage" | "resourcePolicy" | "beginResourceUpload" | "writeResourceUploadChunk" | "commitResourceUpload" | "getResource" | "readResource" | "deleteResource" | "enqueueMessage" | "startTerminal" | "delete" | "resolveInteraction">;
29
29
  export type RemoteRuntimeBrowserHost = Pick<SessionBrowserService, "getState" | "open" | "close" | "createPage" | "closePage" | "activatePage" | "navigatePage" | "goBack" | "goForward" | "reload">;
30
30
  export type RemoteRuntimeSessionEmulatorHost = Pick<SessionEmulatorService, "getState" | "listDevices" | "attach" | "detach" | "tap" | "type" | "button" | "rotate" | "launch" | "gesture" | "releaseDevice" | "stopDevice">;
31
31
  export interface OpenRemoteRuntimeSessionEvents {
@@ -103,7 +103,28 @@ export class RemoteRuntimeDispatcher {
103
103
  case "session.create":
104
104
  return await this.withSessions(request, (sessions) => sessions.create(request.params));
105
105
  case "session.message.send":
106
- return await this.withSessions(request, (sessions) => sessions.sendMessage(request.params.sessionId, request.params.message));
106
+ return await this.withSessions(request, (sessions) => {
107
+ const { sessionId, ...input } = request.params;
108
+ return sessions.sendMessage(sessionId, input.content === undefined && input.clientMessageId === undefined
109
+ ? input.message
110
+ : input);
111
+ });
112
+ case "session.resource.policy.get":
113
+ return await this.withSessions(request, async (sessions) => sessions.resourcePolicy(request.params.sessionId));
114
+ case "session.resource.upload.begin":
115
+ return await this.withSessions(request, (sessions) => sessions.beginResourceUpload(request.params));
116
+ case "session.resource.upload.chunk":
117
+ return await this.withSessions(request, (sessions) => sessions.writeResourceUploadChunk(request.params));
118
+ case "session.resource.upload.commit":
119
+ return await this.withSessions(request, (sessions) => sessions.commitResourceUpload(request.params));
120
+ case "session.resource.get":
121
+ return await this.withSessions(request, (sessions) => sessions.getResource(request.params));
122
+ case "session.resource.read":
123
+ return await this.withSessions(request, (sessions) => sessions.readResource(request.params));
124
+ case "session.resource.delete":
125
+ return await this.withSessions(request, (sessions) => sessions.deleteResource(request.params));
126
+ case "session.message.enqueue":
127
+ return await this.withSessions(request, (sessions) => sessions.enqueueMessage(request.params.sessionId, request.params.message));
107
128
  case "session.terminal.start":
108
129
  return await this.withSessions(request, (sessions) => sessions.startTerminal(request.params.sessionId));
109
130
  case "session.delete":
@@ -274,7 +295,9 @@ function mapHostError(request, error) {
274
295
  ? "Remote Session resource was not found"
275
296
  : error.code === "failed_precondition"
276
297
  ? "Remote Session operation is unavailable"
277
- : "Remote Session operation may have been applied";
298
+ : error.code === "conflict"
299
+ ? "Remote Session resource conflicts with existing state"
300
+ : "Remote Session operation may have been applied";
278
301
  return { code: error.code, message };
279
302
  }
280
303
  if (request.method === "session.interrupt") {
@@ -94,6 +94,16 @@ function projectRuntimeSnapshot(value) {
94
94
  /** Project one canonical durable item, preserving every identity and array entry. */
95
95
  export function projectRemoteRuntimeSessionItem(item) {
96
96
  const projected = structuredClone(item);
97
+ // Older Traex sessions used an empty model string to mean "use the CLI
98
+ // default". Model is optional in the canonical item, but Direct Runtime's
99
+ // bounded wire string is deliberately non-empty; preserve the meaning by
100
+ // projecting that legacy sentinel as an absent optional field.
101
+ if ((projected.type === "message" ||
102
+ projected.type === "function_call" ||
103
+ projected.type === "reasoning") &&
104
+ projected.data.model === "") {
105
+ delete projected.data.model;
106
+ }
97
107
  boundHistoricalInteractionRouting(projected);
98
108
  const refs = collectItemPayloadRefs(projected);
99
109
  boundPayloads(refs);
@@ -10,6 +10,10 @@ export interface RuntimeWebAuthorizationFailure {
10
10
  status: 401 | 403;
11
11
  error: "loopback_only" | "invalid_host" | "cross_origin" | "invalid_runtime_capability";
12
12
  }
13
+ export interface RuntimeWebAccessPolicy {
14
+ mode: "loopback" | "host";
15
+ }
16
+ export declare function runtimeWebAccessPolicyForBindHost(bindHost: string): RuntimeWebAccessPolicy;
13
17
  /**
14
18
  * Authorize a browser request before any Runtime lease or terminal is opened.
15
19
  * Socket address and the original Host header are both checked to resist DNS
@@ -18,4 +22,5 @@ export interface RuntimeWebAuthorizationFailure {
18
22
  export declare function authorizeRuntimeWebRequest(request: IncomingMessage, options?: {
19
23
  mutation?: boolean;
20
24
  capability?: string;
25
+ access?: RuntimeWebAccessPolicy;
21
26
  }): RuntimeWebAuthorizationFailure | undefined;
@@ -1,4 +1,5 @@
1
1
  import { randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { isIP } from "node:net";
2
3
  export const RUNTIME_WEB_CAPABILITY_HEADER = "x-rynx-runtime-capability";
3
4
  export const RUNTIME_WEB_CAPABILITY_QUERY = "capability";
4
5
  /**
@@ -6,17 +7,26 @@ export const RUNTIME_WEB_CAPABILITY_QUERY = "capability";
6
7
  * browser WebSocket upgrades. It is not a daemon-management credential.
7
8
  */
8
9
  export const RUNTIME_WEB_CAPABILITY = randomBytes(32).toString("base64url");
10
+ export function runtimeWebAccessPolicyForBindHost(bindHost) {
11
+ const normalized = normalizeAddress(bindHost.trim().replace(/^\[|\]$/g, ""))?.toLowerCase();
12
+ return {
13
+ mode: normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost"
14
+ ? "loopback"
15
+ : "host",
16
+ };
17
+ }
9
18
  /**
10
19
  * Authorize a browser request before any Runtime lease or terminal is opened.
11
20
  * Socket address and the original Host header are both checked to resist DNS
12
21
  * rebinding; mutation metadata and the capability provide the CSRF fence.
13
22
  */
14
23
  export function authorizeRuntimeWebRequest(request, options = {}) {
15
- if (!isLoopbackAddress(request.socket.remoteAddress)) {
24
+ const access = options.access ?? { mode: "loopback" };
25
+ if (access.mode === "loopback" && !isLoopbackAddress(request.socket.remoteAddress)) {
16
26
  return { status: 403, error: "loopback_only" };
17
27
  }
18
28
  const host = originalHostHeader(request);
19
- if (!host || !isLoopbackHostAuthority(host)) {
29
+ if (!host || !isAllowedHostAuthority(request, host, access)) {
20
30
  return { status: 403, error: "invalid_host" };
21
31
  }
22
32
  if (!options.mutation)
@@ -30,9 +40,12 @@ export function authorizeRuntimeWebRequest(request, options = {}) {
30
40
  return undefined;
31
41
  }
32
42
  function isLoopbackAddress(value) {
33
- const normalized = value?.startsWith("::ffff:") ? value.slice("::ffff:".length) : value;
43
+ const normalized = normalizeAddress(value);
34
44
  return normalized === "127.0.0.1" || normalized === "::1";
35
45
  }
46
+ function normalizeAddress(value) {
47
+ return value?.startsWith("::ffff:") ? value.slice("::ffff:".length) : value;
48
+ }
36
49
  /** Read the HTTP/1 Host header exactly as received; duplicate Hosts fail closed. */
37
50
  function originalHostHeader(request) {
38
51
  let host;
@@ -46,6 +59,17 @@ function originalHostHeader(request) {
46
59
  }
47
60
  return host;
48
61
  }
62
+ function isAllowedHostAuthority(request, host, access) {
63
+ if (isLoopbackHostAuthority(host)) {
64
+ return isLoopbackAddress(request.socket.remoteAddress);
65
+ }
66
+ if (access.mode !== "host")
67
+ return false;
68
+ const hostAddress = ipHostAuthorityAddress(host);
69
+ const localAddress = normalizeAddress(request.socket.localAddress);
70
+ return hostAddress !== undefined && localAddress !== undefined &&
71
+ hostAddress.toLowerCase() === localAddress.toLowerCase();
72
+ }
49
73
  function isLoopbackHostAuthority(value) {
50
74
  if (value.length === 0 || value !== value.trim())
51
75
  return false;
@@ -57,6 +81,22 @@ function isLoopbackHostAuthority(value) {
57
81
  const port = Number(match[1]);
58
82
  return Number.isInteger(port) && port >= 1 && port <= 65_535;
59
83
  }
84
+ function ipHostAuthorityAddress(value) {
85
+ if (value.length === 0 || value !== value.trim())
86
+ return undefined;
87
+ const match = /^(?:([0-9.]+)|\[([0-9a-f:.]+)\])(?::(\d{1,5}))?$/i.exec(value);
88
+ if (!match)
89
+ return undefined;
90
+ const address = match[1] ?? match[2];
91
+ if (isIP(address) === 0)
92
+ return undefined;
93
+ if (match[3] !== undefined) {
94
+ const port = Number(match[3]);
95
+ if (!Number.isInteger(port) || port < 1 || port > 65_535)
96
+ return undefined;
97
+ }
98
+ return address;
99
+ }
60
100
  function isSameOriginBrowserMutation(request, host) {
61
101
  const fetchSite = singleHeader(request.headers["sec-fetch-site"]).trim().toLowerCase();
62
102
  if (fetchSite && fetchSite !== "same-origin")