@rynx-ai/server 0.1.10 → 0.1.11-beta.10

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 (30) hide show
  1. package/dist/control-api.d.ts +17 -52
  2. package/dist/control-api.js +755 -341
  3. package/dist/control-web/assets/{highlighted-body-OFNGDK62-DgFZpTNw.js → highlighted-body-OFNGDK62-IdclXfSt.js} +1 -1
  4. package/dist/control-web/assets/index-5n6D-i-c.css +32 -0
  5. package/dist/control-web/assets/index-DhsO7WjD.js +709 -0
  6. package/dist/control-web/assets/{mermaid-GHXKKRXX-BDikE3W1.js → mermaid-GHXKKRXX-BqMPrbIZ.js} +3 -3
  7. package/dist/control-web/index.html +2 -2
  8. package/dist/control-web-dist.d.ts +1 -1
  9. package/dist/control-web-dist.js +4 -2
  10. package/dist/machine-session-service.d.ts +135 -27
  11. package/dist/machine-session-service.js +410 -58
  12. package/dist/remote-runtime-dispatcher.d.ts +12 -2
  13. package/dist/remote-runtime-dispatcher.js +56 -0
  14. package/dist/remote-runtime-session-projection.js +1 -0
  15. package/dist/server.d.ts +59 -29
  16. package/dist/server.js +158 -143
  17. package/dist/session-browser-service.d.ts +8 -0
  18. package/dist/session-browser-service.js +37 -20
  19. package/dist/session-portal.d.ts +42 -0
  20. package/dist/session-portal.js +746 -0
  21. package/dist/session-runtime-index.d.ts +7 -0
  22. package/dist/session-runtime-index.js +42 -6
  23. package/dist/session-terminal-host.d.ts +6 -0
  24. package/dist/session-terminal-host.js +56 -41
  25. package/dist/terminal-ws.js +8 -6
  26. package/package.json +7 -7
  27. package/dist/channel-manager.d.ts +0 -60
  28. package/dist/channel-manager.js +0 -106
  29. package/dist/control-web/assets/index-CnVtOmLv.css +0 -32
  30. package/dist/control-web/assets/index-Dlywgy58.js +0 -661
@@ -7,9 +7,9 @@
7
7
  * Direct adapters can therefore expose the same behavior without copying state
8
8
  * into a Control Plane database.
9
9
  */
10
- import { SESSION_PROVIDER_IDS, getRuntimeProfile, isSessionProviderId, newSessionId, normalizeSessionTitle, } from "@rynx-ai/core";
10
+ import { createHash, randomUUID } from "node:crypto";
11
+ import { SESSION_PROVIDER_IDS, getRuntimeProfile, isSessionProviderId, newSessionItemId, newSessionId, normalizeSessionTitle, } from "@rynx-ai/core";
11
12
  import { REMOTE_RUNTIME_SESSION_DEFAULT_PAGE_SIZE, REMOTE_RUNTIME_SESSION_MAX_CURSOR_CHARS, REMOTE_RUNTIME_SESSION_MAX_ID_CHARS, REMOTE_RUNTIME_SESSION_MAX_PAGE_SIZE, } from "@rynx-ai/protocol/remote-runtime-rpc";
12
- import { ensureCodexResumeRollout, } from "@rynx-ai/runtime";
13
13
  const DEFAULT_LIST_LIMIT = REMOTE_RUNTIME_SESSION_DEFAULT_PAGE_SIZE;
14
14
  const MAX_LIST_LIMIT = REMOTE_RUNTIME_SESSION_MAX_PAGE_SIZE;
15
15
  const DEFAULT_SNAPSHOT_ITEM_LIMIT = REMOTE_RUNTIME_SESSION_DEFAULT_PAGE_SIZE;
@@ -47,8 +47,11 @@ export class MachineSessionService {
47
47
  snapshotPageMaxBytes;
48
48
  directoryScanLimit;
49
49
  pendingMessageRetryMs;
50
+ admissionOpen;
51
+ admissionReserve;
50
52
  pendingDeliveries = new Map();
51
53
  cancelledPendingDeliveries = new Set();
54
+ forkTasks = new Map();
52
55
  constructor(ports, options = {}) {
53
56
  this.ports = ports;
54
57
  this.maxListLimit = boundedInteger(options.maxListLimit ?? MAX_LIST_LIMIT, 1, MAX_LIST_LIMIT, "maxListLimit");
@@ -57,7 +60,10 @@ export class MachineSessionService {
57
60
  this.snapshotPageMaxBytes = boundedInteger(options.snapshotPageMaxBytes ?? DEFAULT_SNAPSHOT_PAGE_MAX_BYTES, MIN_PAGE_MAX_BYTES, MAX_PAGE_MAX_BYTES, "snapshotPageMaxBytes");
58
61
  this.directoryScanLimit = boundedInteger(options.directoryScanLimit ?? DEFAULT_DIRECTORY_SCAN_LIMIT, 1, MAX_DIRECTORY_SCAN_LIMIT, "directoryScanLimit");
59
62
  this.pendingMessageRetryMs = boundedInteger(options.pendingMessageRetryMs ?? 1_000, 10, 60_000, "pendingMessageRetryMs");
63
+ this.admissionOpen = options.admissionOpen ?? (() => true);
64
+ this.admissionReserve = options.admissionReserve;
60
65
  void this.resumePendingDeliveries();
66
+ void this.resumeReservedForks();
61
67
  }
62
68
  async list(input = {}) {
63
69
  const limit = requestLimit(input.limit, DEFAULT_LIST_LIMIT, this.maxListLimit, "limit");
@@ -105,14 +111,22 @@ export class MachineSessionService {
105
111
  })).slice(0, limit + 1).map((item) => structuredClone(item));
106
112
  const runtime = cloneRuntimeSnapshot(this.ports.runtimeState.snapshot(sessionId));
107
113
  const empty = snapshotPage(sessionId, [], runtime, false);
108
- const items = candidates.slice(0, limit);
109
- while (items.length > 0) {
110
- const result = snapshotPage(sessionId, items, runtime, candidates.length > items.length);
111
- if (items.length === 1 || jsonByteLength(result) <= this.snapshotPageMaxBytes)
112
- return result;
113
- items.pop();
114
+ const items = [];
115
+ let itemsJsonBytes = 0;
116
+ for (const item of candidates.slice(0, limit)) {
117
+ const nextItemsJsonBytes = itemsJsonBytes + (items.length > 0 ? 1 : 0) + jsonByteLength(item);
118
+ const nextCount = items.length + 1;
119
+ const hasMore = candidates.length > nextCount;
120
+ if (items.length > 0 &&
121
+ snapshotPageJsonByteLength(sessionId, nextItemsJsonBytes, item.id, runtime, hasMore) > this.snapshotPageMaxBytes) {
122
+ break;
123
+ }
124
+ items.push(item);
125
+ itemsJsonBytes = nextItemsJsonBytes;
114
126
  }
115
- return empty;
127
+ return items.length > 0
128
+ ? snapshotPage(sessionId, items, runtime, candidates.length > items.length)
129
+ : empty;
116
130
  }
117
131
  /**
118
132
  * Install a live subscriber synchronously, before returning control to an
@@ -145,11 +159,18 @@ export class MachineSessionService {
145
159
  const statusCache = new Map();
146
160
  const { agents } = await this.agentOptions();
147
161
  const [providers, launchAgents] = await Promise.all([
148
- Promise.all(SESSION_PROVIDER_IDS.map(async (provider) => ({
149
- id: provider,
150
- name: getRuntimeProfile(provider).displayName,
151
- ...await this.runtimeReadiness(provider, statusCache),
152
- }))),
162
+ Promise.all(SESSION_PROVIDER_IDS.map(async (provider) => {
163
+ const execution = this.ports.execution;
164
+ const models = execution?.listModels
165
+ ? await execution.listModels(provider).catch(() => [])
166
+ : undefined;
167
+ return {
168
+ id: provider,
169
+ name: getRuntimeProfile(provider).displayName,
170
+ ...await this.runtimeReadiness(provider, statusCache),
171
+ ...(models === undefined ? {} : { models }),
172
+ };
173
+ })),
153
174
  Promise.all(agents.map(async (agent) => {
154
175
  const execution = this.ports.execution;
155
176
  if (!execution) {
@@ -178,8 +199,53 @@ export class MachineSessionService {
178
199
  ]);
179
200
  return { providers, agents: launchAgents };
180
201
  }
202
+ /** Read the effective model defaults used for the next turn. */
203
+ async executionSettings(sessionIdInput) {
204
+ const sessionId = validOpaqueToken(sessionIdInput, "sessionId", MAX_SESSION_ID_CHARS);
205
+ const meta = this.ports.registry.get(sessionId);
206
+ if (!meta) {
207
+ throw new MachineSessionServiceFailure("not_found", "session not found");
208
+ }
209
+ return executionSettingsResult(meta.execution);
210
+ }
211
+ /** Replace the Turn defaults of an idle Session. Execution settings are
212
+ * passed through to the Provider CLI, which owns availability validation.
213
+ * The runner is closed so every Provider resumes its native context with
214
+ * the new settings on the next turn. */
215
+ async updateExecutionSettings(input) {
216
+ const sessionId = validOpaqueToken(input.sessionId, "sessionId", MAX_SESSION_ID_CHARS);
217
+ const modelId = input.model === null ? null : validRequiredText(input.model, "model");
218
+ const meta = this.ports.registry.get(sessionId);
219
+ if (!meta) {
220
+ throw new MachineSessionServiceFailure("not_found", "session not found");
221
+ }
222
+ await this.assertNotForkReserved(sessionId);
223
+ const runtime = this.ports.runtimeState.snapshot(sessionId);
224
+ const pendingMessage = await this.ports.pendingMessages?.get(sessionId);
225
+ if (runtime.status !== "idle" ||
226
+ runtime.activeResponseIds.length > 0 ||
227
+ runtime.pendingInteractions.length > 0 ||
228
+ pendingMessage) {
229
+ throw new MachineSessionServiceFailure("failed_precondition", "Session must be idle before changing model settings");
230
+ }
231
+ const executionPort = this.requireExecution();
232
+ const reasoningEffort = input.reasoningEffort === null
233
+ ? null
234
+ : validRequiredText(input.reasoningEffort, "reasoningEffort");
235
+ const nextExecution = {
236
+ ...structuredClone(meta.execution),
237
+ model: modelId,
238
+ reasoningEffort,
239
+ };
240
+ this.ports.registry.setExecution(sessionId, nextExecution);
241
+ executionPort.runner.stopRunner(sessionId);
242
+ return executionSettingsResult(nextExecution);
243
+ }
181
244
  /** Create target-owned Session identity from one Agent preset or direct Provider. */
182
245
  async create(input) {
246
+ return this.withAdmission(() => this.createAdmitted(input));
247
+ }
248
+ async createAdmitted(input) {
183
249
  const hasAgent = input.agent !== undefined;
184
250
  const hasProvider = input.provider !== undefined;
185
251
  if (hasAgent === hasProvider) {
@@ -199,21 +265,189 @@ export class MachineSessionService {
199
265
  }
200
266
  target = { provider: input.provider };
201
267
  }
202
- const sessionId = newSessionId();
203
- this.ports.registry.create({
204
- id: sessionId,
205
- source: "console",
206
- ...target,
207
- ...(input.model === undefined ? {} : { model: input.model }),
208
- ...(input.reasoningEffort === undefined ? {} : { reasoningEffort: input.reasoningEffort }),
209
- ...(input.title === undefined ? {} : { title: input.title }),
210
- createdAt: new Date().toISOString(),
268
+ const workspacePort = this.requireWorkspace();
269
+ const workspace = await workspacePort.resolve(input.projectId);
270
+ try {
271
+ const execution = await this.requireExecution().resolve({
272
+ ...target,
273
+ ...(input.model === undefined ? {} : { model: input.model }),
274
+ ...(input.reasoningEffort === undefined ? {} : { reasoningEffort: input.reasoningEffort }),
275
+ ...(input.permissionPreset === undefined ? {} : { permissionPreset: input.permissionPreset }),
276
+ cwd: workspace.cwd,
277
+ });
278
+ const sessionId = newSessionId();
279
+ this.ports.registry.create({
280
+ id: sessionId,
281
+ source: "console",
282
+ workspace,
283
+ execution,
284
+ ...(input.title === undefined ? {} : { title: input.title }),
285
+ createdAt: new Date().toISOString(),
286
+ });
287
+ return { sessionId };
288
+ }
289
+ catch (error) {
290
+ if (input.projectId === undefined) {
291
+ await workspacePort.discardUnbound?.(workspace).catch(() => undefined);
292
+ }
293
+ throw error;
294
+ }
295
+ }
296
+ /** Create one independent Session at the source's stable Provider/canonical
297
+ * boundary. Project and Agent selectors are intentionally absent: the target
298
+ * receives exact copies of the source's already-frozen snapshots. */
299
+ async fork(input) {
300
+ return this.withAdmission(() => this.forkAdmitted(input));
301
+ }
302
+ async forkAdmitted(input) {
303
+ const sourceSessionId = validOpaqueToken(input.sourceSessionId, "sourceSessionId", MAX_SESSION_ID_CHARS);
304
+ const operationId = validOpaqueToken(input.operationId, "operationId", MAX_SESSION_ID_CHARS);
305
+ const title = input.title === undefined
306
+ ? undefined
307
+ : normalizeSessionTitle(input.title.trim());
308
+ if (input.title !== undefined && !title) {
309
+ throw new MachineSessionServiceInputError("title must not be empty");
310
+ }
311
+ const requestHash = createHash("sha256")
312
+ .update(JSON.stringify({ sourceSessionId, title: title ?? null }))
313
+ .digest("hex");
314
+ const operation = await this.requireForks().claim({
315
+ operationId,
316
+ requestHash,
317
+ sourceSessionId,
318
+ ...(title ? { title } : {}),
319
+ });
320
+ if (operation.state === "target_deleted") {
321
+ throw new MachineSessionServiceFailure("conflict", "fork target was deleted");
322
+ }
323
+ if (operation.state === "completed") {
324
+ return { sessionId: operation.targetSessionId, disposition: "replayed" };
325
+ }
326
+ const inflight = this.forkTasks.get(operationId);
327
+ if (inflight)
328
+ return inflight;
329
+ const task = this.performFork(operation).finally(() => {
330
+ if (this.forkTasks.get(operationId) === task) {
331
+ this.forkTasks.delete(operationId);
332
+ }
333
+ });
334
+ this.forkTasks.set(operationId, task);
335
+ return task;
336
+ }
337
+ /** Publish a Provider TUI `/clear` or `/fork` after its native binding has
338
+ * already been persisted. The source snapshots remain the only workspace and
339
+ * execution authority; Provider events cannot alter them during rotation. */
340
+ async recordNativeRotation(input) {
341
+ const sourceSessionId = validOpaqueToken(input.sourceSessionId, "sourceSessionId", MAX_SESSION_ID_CHARS);
342
+ const targetSessionId = validOpaqueToken(input.targetSessionId, "targetSessionId", MAX_SESSION_ID_CHARS);
343
+ if (sourceSessionId === targetSessionId) {
344
+ throw new MachineSessionServiceInputError("source and target Session must differ");
345
+ }
346
+ const source = this.ports.registry.get(sourceSessionId);
347
+ if (!source) {
348
+ throw new MachineSessionServiceFailure("not_found", "source Session not found");
349
+ }
350
+ const existing = this.ports.registry.get(targetSessionId);
351
+ if (existing) {
352
+ if (input.kind === "fork" &&
353
+ existing.forkedFromSessionId === sourceSessionId)
354
+ return;
355
+ throw new MachineSessionServiceFailure("conflict", "target Session already exists");
356
+ }
357
+ const workspace = structuredClone(source.workspace);
358
+ const execution = structuredClone(source.execution);
359
+ const sourceTitle = source.title;
360
+ const now = new Date().toISOString();
361
+ const target = {
362
+ id: targetSessionId,
363
+ source: input.kind,
364
+ workspace,
365
+ execution,
366
+ ...(input.kind === "fork" && sourceTitle ? { title: sourceTitle } : {}),
367
+ ...(input.kind === "fork" ? { forkedFromSessionId: sourceSessionId } : {}),
368
+ createdAt: now,
369
+ updatedAt: now,
370
+ };
371
+ if (input.kind === "clear") {
372
+ this.ports.registry.create(target);
373
+ return;
374
+ }
375
+ const items = (await this.ports.log.snapshot(sourceSessionId))
376
+ .map((item) => structuredClone(item));
377
+ await this.requireForks().publishNativeFork({
378
+ sourceSessionId,
379
+ target,
380
+ items,
211
381
  });
212
- return { sessionId };
382
+ }
383
+ async performFork(operation) {
384
+ const source = this.ports.registry.get(operation.sourceSessionId);
385
+ if (!source) {
386
+ throw new MachineSessionServiceFailure("not_found", "source Session not found");
387
+ }
388
+ const runner = this.requireExecution().runner;
389
+ if (!runner.forkSession) {
390
+ throw new MachineSessionServiceFailure("failed_precondition", "Provider does not support Session fork");
391
+ }
392
+ const workspace = structuredClone(source.workspace);
393
+ const execution = structuredClone(source.execution);
394
+ const sourceTitle = source.title;
395
+ let items;
396
+ try {
397
+ this.assertForkableSource(operation.sourceSessionId);
398
+ const native = await runner.forkSession(operation.sourceSessionId, operation.targetSessionId, {
399
+ workspace: structuredClone(workspace),
400
+ execution: structuredClone(execution),
401
+ beforeProviderFork: async () => {
402
+ this.assertForkableSource(operation.sourceSessionId);
403
+ items = (await this.ports.log.snapshot(operation.sourceSessionId))
404
+ .map((item) => structuredClone(item));
405
+ },
406
+ });
407
+ if (!native.ok) {
408
+ throw new MachineSessionServiceFailure("failed_precondition", "Provider could not fork the Session", native.message);
409
+ }
410
+ if (!items) {
411
+ throw new MachineSessionServiceFailure("outcome_unknown", "Provider fork completed without a canonical fork point");
412
+ }
413
+ const now = new Date().toISOString();
414
+ const targetTitle = operation.title ?? sourceTitle;
415
+ await this.requireForks().commit({
416
+ operationId: operation.operationId,
417
+ target: {
418
+ id: operation.targetSessionId,
419
+ source: "fork",
420
+ workspace: structuredClone(workspace),
421
+ execution: structuredClone(execution),
422
+ ...(targetTitle ? { title: targetTitle } : {}),
423
+ forkedFromSessionId: operation.sourceSessionId,
424
+ createdAt: now,
425
+ updatedAt: now,
426
+ },
427
+ items,
428
+ });
429
+ return { sessionId: operation.targetSessionId, disposition: "created" };
430
+ }
431
+ catch (error) {
432
+ await Promise.resolve(this.requireForks().markError(operation.operationId, error instanceof Error ? error.message : String(error))).catch(() => undefined);
433
+ throw error;
434
+ }
435
+ }
436
+ assertForkableSource(sessionId) {
437
+ const runtime = this.ports.runtimeState.snapshot(sessionId);
438
+ if (runtime.status !== "idle" ||
439
+ runtime.activeResponseIds.length > 0 ||
440
+ runtime.pendingInteractions.length > 0) {
441
+ throw new MachineSessionServiceFailure("failed_precondition", "source Session must be idle before it can be forked");
442
+ }
213
443
  }
214
444
  /** Inject one turn through the target daemon's native single-writer runner. */
215
445
  async sendMessage(sessionIdInput, messageInput) {
446
+ return this.withAdmission(() => this.sendMessageAdmitted(sessionIdInput, messageInput));
447
+ }
448
+ async sendMessageAdmitted(sessionIdInput, messageInput) {
216
449
  const sessionId = validOpaqueToken(sessionIdInput, "sessionId", MAX_SESSION_ID_CHARS);
450
+ await this.assertNotForkReserved(sessionId);
217
451
  const request = typeof messageInput === "string"
218
452
  ? { message: messageInput }
219
453
  : messageInput;
@@ -272,11 +506,54 @@ export class MachineSessionService {
272
506
  if (request.clientMessageId) {
273
507
  await resources?.markMessageInjecting(sessionId, request.clientMessageId);
274
508
  }
509
+ const responseId = this.ports.publishEvent ? `resp_${randomUUID()}` : undefined;
510
+ if (responseId) {
511
+ await this.ports.publishEvent(sessionId, {
512
+ type: "session.input.consumed",
513
+ item: {
514
+ id: newSessionItemId("message"),
515
+ sessionId,
516
+ position: 0,
517
+ responseId,
518
+ status: "completed",
519
+ createdAt: Date.now(),
520
+ type: "message",
521
+ data: {
522
+ role: "user",
523
+ content: prepared.input.content.map((part) => part.type === "text"
524
+ ? { type: "input_text", text: part.text }
525
+ : { ...part.resource }),
526
+ },
527
+ },
528
+ });
529
+ await this.ports.publishEvent(sessionId, {
530
+ type: "session.status",
531
+ sessionId,
532
+ responseId,
533
+ status: "running",
534
+ statusKind: "startup",
535
+ });
536
+ }
537
+ const abandonResponseHandoff = responseId
538
+ ? this.ports.runtimeState.beginResponseHandoff?.(sessionId, responseId)
539
+ : undefined;
540
+ const clearStartup = async (status) => {
541
+ if (!responseId)
542
+ return;
543
+ await this.ports.publishEvent(sessionId, {
544
+ type: "session.status",
545
+ sessionId,
546
+ responseId,
547
+ status,
548
+ });
549
+ };
275
550
  let execution;
276
551
  try {
277
552
  execution = await this.ensureLiveSession(sessionId, meta);
278
553
  }
279
554
  catch (error) {
555
+ abandonResponseHandoff?.();
556
+ await clearStartup("idle");
280
557
  if (request.clientMessageId) {
281
558
  await resources?.markMessageFailedNotStarted(sessionId, request.clientMessageId, error instanceof Error ? error.message : String(error));
282
559
  }
@@ -284,26 +561,36 @@ export class MachineSessionService {
284
561
  }
285
562
  let outcome;
286
563
  try {
287
- outcome = await execution.runner.injectMessage(sessionId, hasMessage && !needsPreparedOperation ? request.message : prepared.input);
564
+ const runtimeInput = responseId
565
+ ? { ...prepared.input, responseId }
566
+ : prepared.input;
567
+ outcome = await execution.runner.injectMessage(sessionId, responseId || needsPreparedOperation ? runtimeInput : request.message);
288
568
  }
289
569
  catch (error) {
570
+ abandonResponseHandoff?.();
571
+ await clearStartup("idle");
290
572
  if (request.clientMessageId) {
291
573
  await Promise.resolve(resources?.markMessageOutcomeUnknown(sessionId, request.clientMessageId, error instanceof Error ? error.message : String(error))).catch(() => undefined);
292
574
  }
293
575
  throw new MachineSessionServiceFailure("outcome_unknown", "live injection outcome is unknown");
294
576
  }
295
577
  if (outcome === "failed") {
578
+ abandonResponseHandoff?.();
579
+ await clearStartup("idle");
296
580
  if (request.clientMessageId) {
297
581
  await resources?.markMessageOutcomeUnknown(sessionId, request.clientMessageId, `live injection ${outcome}`);
298
582
  }
299
583
  throw new MachineSessionServiceFailure("outcome_unknown", `live injection ${outcome}`);
300
584
  }
301
585
  if (outcome !== "injected") {
586
+ abandonResponseHandoff?.();
587
+ await clearStartup("idle");
302
588
  if (request.clientMessageId) {
303
589
  await resources?.markMessageFailedNotStarted(sessionId, request.clientMessageId, `live injection ${outcome}`);
304
590
  }
305
591
  throw new MachineSessionServiceFailure("failed_precondition", `live injection ${outcome}`);
306
592
  }
593
+ await clearStartup("running");
307
594
  if (request.clientMessageId) {
308
595
  try {
309
596
  await resources?.markMessageInjected(sessionId, request.clientMessageId);
@@ -347,7 +634,11 @@ export class MachineSessionService {
347
634
  * delivery worker starts the pane now, waits for a real native thread, then
348
635
  * injects exactly once. `failed` is fenced as outcome_unknown and never retried. */
349
636
  async enqueueMessage(sessionIdInput, messageInput) {
637
+ return this.withAdmission(() => this.enqueueMessageAdmitted(sessionIdInput, messageInput));
638
+ }
639
+ async enqueueMessageAdmitted(sessionIdInput, messageInput) {
350
640
  const sessionId = validOpaqueToken(sessionIdInput, "sessionId", MAX_SESSION_ID_CHARS);
641
+ await this.assertNotForkReserved(sessionId);
351
642
  if (typeof messageInput !== "string" || messageInput.length === 0) {
352
643
  throw new MachineSessionServiceInputError("message must be a non-empty string");
353
644
  }
@@ -377,6 +668,9 @@ export class MachineSessionService {
377
668
  }
378
669
  /** Explicitly restore the target daemon's live runner without starting a turn. */
379
670
  async startTerminal(sessionIdInput) {
671
+ return this.withAdmission(() => this.startTerminalAdmitted(sessionIdInput));
672
+ }
673
+ async startTerminalAdmitted(sessionIdInput) {
380
674
  const sessionId = validOpaqueToken(sessionIdInput, "sessionId", MAX_SESSION_ID_CHARS);
381
675
  const request = await this.liveSessionRequest(sessionId);
382
676
  const start = request.execution.runner.startLiveSession?.bind(request.execution.runner) ??
@@ -397,40 +691,13 @@ export class MachineSessionService {
397
691
  }
398
692
  async liveSessionRequest(sessionId, meta = this.ports.registry.get(sessionId)) {
399
693
  const execution = this.requireExecution();
400
- const consoleMeta = meta?.source === "console" ? meta : undefined;
401
- const bound = consoleMeta ? null : ((await execution.sessionStore?.get(sessionId)) ?? null);
402
- if (!meta && !bound) {
694
+ if (!meta) {
403
695
  throw new MachineSessionServiceFailure("not_found", "session not found");
404
696
  }
405
- const liveRuntime = await execution.runtime.resolveRuntime({
406
- agentName: consoleMeta?.agent,
407
- agentSpec: consoleMeta?.config,
408
- provider: meta?.provider ?? bound?.runtime,
409
- });
410
- if (liveRuntime === "codex" || liveRuntime === "traex") {
411
- const codexRecord = await execution.sessionStore?.get(sessionId);
412
- if (codexRecord?.codexSessionId) {
413
- ensureCodexResumeRollout({
414
- sessionId,
415
- runtime: liveRuntime,
416
- threadId: codexRecord.codexSessionId,
417
- cwd: codexRecord.cwd ?? process.cwd(),
418
- items: await this.ports.log.snapshot(sessionId),
419
- });
420
- }
421
- }
697
+ const liveRuntime = meta.execution.provider;
422
698
  const options = {
423
- ...(bound?.cwd
424
- ? { cwd: bound.cwd }
425
- : consoleMeta?.config?.osEnv?.cwd
426
- ? { cwd: consoleMeta.config.osEnv.cwd }
427
- : {}),
428
- runtime: liveRuntime,
429
- ...(consoleMeta?.reasoningEffort
430
- ? { reasoningEffort: consoleMeta.reasoningEffort }
431
- : {}),
432
- ...(consoleMeta?.agent ? { agentName: consoleMeta.agent } : {}),
433
- ...(consoleMeta?.config ? { agentSpec: consoleMeta.config } : {}),
699
+ workspace: structuredClone(meta.workspace),
700
+ execution: structuredClone(meta.execution),
434
701
  };
435
702
  return { execution, liveRuntime, options };
436
703
  }
@@ -441,6 +708,7 @@ export class MachineSessionService {
441
708
  }
442
709
  async delete(sessionIdInput) {
443
710
  const sessionId = validOpaqueToken(sessionIdInput, "sessionId", MAX_SESSION_ID_CHARS);
711
+ await this.assertNotForkReserved(sessionId);
444
712
  try {
445
713
  await this.ports.lifecycle?.beforeDelete?.(sessionId);
446
714
  }
@@ -455,6 +723,7 @@ export class MachineSessionService {
455
723
  this.ports.registry.remove(sessionId);
456
724
  await this.ports.log.deleteSession(sessionId);
457
725
  this.ports.runtimeState.remove?.(sessionId);
726
+ await this.ports.forks?.markTargetDeleted(sessionId);
458
727
  try {
459
728
  await this.ports.lifecycle?.afterDelete?.(sessionId);
460
729
  }
@@ -469,6 +738,24 @@ export class MachineSessionService {
469
738
  }
470
739
  return this.ports.agents;
471
740
  }
741
+ reserveAdmission() {
742
+ const reservation = this.admissionReserve?.();
743
+ if (reservation)
744
+ return reservation;
745
+ if (!this.admissionReserve && this.admissionOpen()) {
746
+ return { release() { } };
747
+ }
748
+ throw new MachineSessionServiceFailure("failed_precondition", "daemon maintenance is in progress");
749
+ }
750
+ async withAdmission(operation) {
751
+ const reservation = this.reserveAdmission();
752
+ try {
753
+ return await operation();
754
+ }
755
+ finally {
756
+ reservation.release();
757
+ }
758
+ }
472
759
  requireResources() {
473
760
  if (!this.ports.resources) {
474
761
  throw new MachineSessionServiceFailure("failed_precondition", "Session resources are unavailable");
@@ -520,7 +807,9 @@ export class MachineSessionService {
520
807
  if (!pending || pending.state !== "queued")
521
808
  return;
522
809
  let injectionAttempted = false;
810
+ let admission;
523
811
  try {
812
+ admission = this.reserveAdmission();
524
813
  const execution = await this.ensureLiveSession(sessionId);
525
814
  if (this.cancelledPendingDeliveries.has(sessionId))
526
815
  return;
@@ -553,6 +842,9 @@ export class MachineSessionService {
553
842
  return;
554
843
  }
555
844
  }
845
+ finally {
846
+ admission?.release();
847
+ }
556
848
  await retryDelay(this.pendingMessageRetryMs);
557
849
  }
558
850
  }
@@ -592,6 +884,44 @@ export class MachineSessionService {
592
884
  }
593
885
  return this.ports.execution;
594
886
  }
887
+ requireWorkspace() {
888
+ const workspace = this.ports.workspace;
889
+ if (!workspace) {
890
+ throw new MachineSessionServiceFailure("failed_precondition", "Session workspace resolution is unavailable");
891
+ }
892
+ return workspace;
893
+ }
894
+ requireForks() {
895
+ if (!this.ports.forks) {
896
+ throw new MachineSessionServiceFailure("failed_precondition", "Session fork storage is unavailable");
897
+ }
898
+ return this.ports.forks;
899
+ }
900
+ async assertNotForkReserved(sessionId) {
901
+ if (await this.ports.forks?.hasReservedSource(sessionId)) {
902
+ throw new MachineSessionServiceFailure("conflict", "Session fork is in progress");
903
+ }
904
+ }
905
+ async resumeReservedForks() {
906
+ if (!this.ports.forks)
907
+ return;
908
+ try {
909
+ for (const operation of await this.ports.forks.listReserved()) {
910
+ if (this.forkTasks.has(operation.operationId))
911
+ continue;
912
+ const task = this.performFork(operation).finally(() => {
913
+ if (this.forkTasks.get(operation.operationId) === task) {
914
+ this.forkTasks.delete(operation.operationId);
915
+ }
916
+ });
917
+ this.forkTasks.set(operation.operationId, task);
918
+ void task.catch(() => undefined);
919
+ }
920
+ }
921
+ catch {
922
+ // A direct replay or the next daemon restart retries durable operations.
923
+ }
924
+ }
595
925
  }
596
926
  /** One-line title derived from the first user message; no model call. */
597
927
  export function synthesizeSessionTitle(message, limit = 60) {
@@ -688,6 +1018,13 @@ function snapshotPage(sessionId, items, runtime, hasMore) {
688
1018
  ...(hasMore && items.length > 0 ? { nextAfterId: items.at(-1).id } : {}),
689
1019
  };
690
1020
  }
1021
+ /** Exact UTF-8 size of {@link snapshotPage} without repeatedly serializing the
1022
+ * complete item array while searching for a page boundary. */
1023
+ function snapshotPageJsonByteLength(sessionId, itemsJsonBytes, finalItemId, runtime, hasMore) {
1024
+ return Buffer.byteLength(`{"sessionId":${JSON.stringify(sessionId)},"items":[`, "utf8")
1025
+ + itemsJsonBytes
1026
+ + Buffer.byteLength(`],"runtime":${JSON.stringify(runtime)},"hasMore":${hasMore}${hasMore ? `,"nextAfterId":${JSON.stringify(finalItemId)}` : ""}}`, "utf8");
1027
+ }
691
1028
  function sessionSummary(id, meta, log, runtimeState, pendingState) {
692
1029
  const createdAt = meta?.createdAt ?? timestampFromEpoch(log?.createdAt, "createdAt");
693
1030
  const updatedAt = log
@@ -695,8 +1032,7 @@ function sessionSummary(id, meta, log, runtimeState, pendingState) {
695
1032
  : (meta?.updatedAt ?? createdAt);
696
1033
  return {
697
1034
  id,
698
- ...(meta?.provider === undefined ? {} : { provider: meta.provider }),
699
- ...(meta?.agent === undefined ? {} : { agent: meta.agent }),
1035
+ ...(meta?.execution?.provider === undefined ? {} : { provider: meta.execution.provider }),
700
1036
  ...(meta?.title === undefined ? {} : { title: normalizeSessionTitle(meta.title) }),
701
1037
  status: runtimeState.snapshot(id).status,
702
1038
  ...(pendingState === undefined
@@ -711,6 +1047,13 @@ function sessionSummary(id, meta, log, runtimeState, pendingState) {
711
1047
  updatedAt: validTimestamp(updatedAt, "updatedAt"),
712
1048
  };
713
1049
  }
1050
+ function executionSettingsResult(execution) {
1051
+ return {
1052
+ provider: execution.provider,
1053
+ model: execution.model,
1054
+ reasoningEffort: execution.reasoningEffort,
1055
+ };
1056
+ }
714
1057
  function compareMetaRecency(left, right) {
715
1058
  const leftTime = validTimestamp(left.updatedAt ?? left.createdAt, "updatedAt");
716
1059
  const rightTime = validTimestamp(right.updatedAt ?? right.createdAt, "updatedAt");
@@ -805,6 +1148,15 @@ function validOpaqueToken(value, field, maximum) {
805
1148
  }
806
1149
  return value;
807
1150
  }
1151
+ function validRequiredText(value, field) {
1152
+ if (typeof value !== "string" || value.trim().length === 0 || value.length > 512) {
1153
+ throw new MachineSessionServiceInputError(`${field} must be a non-empty string of at most 512 characters`);
1154
+ }
1155
+ if (/[\u0000-\u001f\u007f]/.test(value)) {
1156
+ throw new MachineSessionServiceInputError(`${field} contains invalid control characters`);
1157
+ }
1158
+ return value.trim();
1159
+ }
808
1160
  function validTimestamp(value, field) {
809
1161
  if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) {
810
1162
  throw new MachineSessionServiceInputError(`${field} must be a valid timestamp`);