@myagentroam/node 0.10.35 → 0.10.37

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 (33) hide show
  1. package/dist/connector.js +115 -18
  2. package/dist/database.js +29 -12
  3. package/dist/main.js +18 -2
  4. package/dist/migrations/v019.js +9 -0
  5. package/dist/migrations/v020.js +46 -0
  6. package/dist/runner/mar-agent/computer-use-probe.js +126 -0
  7. package/dist/runner/mar-agent/computer-use.js +50 -20
  8. package/dist/runner/mar-agent/conversation-normalizer.js +28 -0
  9. package/dist/runner/mar-agent/marctl-skill.js +3 -2
  10. package/dist/runner/mar-agent-runner.js +32 -0
  11. package/dist/runtime-state.js +15 -10
  12. package/dist/service/continuous-run-coordinator.js +58 -5
  13. package/dist/service/direct-transfer-service.js +58 -11
  14. package/dist/service/external-session-resume-service.js +2 -1
  15. package/dist/service/goal-runtime.js +13 -0
  16. package/dist/service/hosted-conversation-node-service.js +5 -4
  17. package/dist/service/native-session-projection-service.js +77 -11
  18. package/dist/service/planned-restart-handoff.js +279 -0
  19. package/dist/service/restart-session-restorer.js +141 -0
  20. package/dist/service/{scheduled-run-authorization-control.js → run-authorization-control.js} +17 -10
  21. package/dist/service/run-event-service.js +2 -0
  22. package/dist/service/scheduled-session-coordinator.js +13 -0
  23. package/dist/service/scheduled-session-store.js +24 -0
  24. package/dist/service/session-catalog-service.js +57 -4
  25. package/dist/service/session-command-service.js +1 -0
  26. package/dist/service/session-lifecycle-service.js +25 -8
  27. package/dist/service/session-message-service.js +80 -15
  28. package/dist/service/session-presentation-service.js +2 -0
  29. package/dist/service/session-query-service.js +14 -9
  30. package/dist/service/workspace-queue-workbench-service.js +15 -8
  31. package/dist/service/workspace-upload-service.js +128 -29
  32. package/dist/supervisor.js +7 -3
  33. package/package.json +4 -4
package/dist/connector.js CHANGED
@@ -68,7 +68,9 @@ import { composerResourceInputTags } from './service/composer-message-input.js';
68
68
  import { SessionScheduleService } from './service/session-schedule-service.js';
69
69
  import { ScheduledSessionCoordinator } from './service/scheduled-session-coordinator.js';
70
70
  import { ScheduledSessionStore } from './service/scheduled-session-store.js';
71
- import { ScheduledRunAuthorizationControl } from './service/scheduled-run-authorization-control.js';
71
+ import { RunAuthorizationControl } from './service/run-authorization-control.js';
72
+ import { PlannedRestartHandoff } from './service/planned-restart-handoff.js';
73
+ import { RestartSessionRestorer } from './service/restart-session-restorer.js';
72
74
  import { ScheduledTaskMcpServer } from './service/scheduled-task-mcp-server.js';
73
75
  import { MailNotificationControl } from './service/mail-notification-control.js';
74
76
  import { MailNotificationMcpServer, resolveMailNotificationCapability } from './service/mail-notification-mcp-server.js';
@@ -169,6 +171,9 @@ export class NodeConnector {
169
171
  controlChannel;
170
172
  controlMessages;
171
173
  scheduledRunAuthorization;
174
+ restartAuthorization;
175
+ restartHandoff;
176
+ inFlightOperations = new Set();
172
177
  mailNotificationControl = new MailNotificationControl((envelope) => this.sendEnvelope(envelope));
173
178
  scheduledSessions;
174
179
  nodeRequests;
@@ -397,6 +402,8 @@ export class NodeConnector {
397
402
  this.goalRuntime.get(sessionId)?.status === 'active',
398
403
  executionTerminating: (executionId, status, permissionStop) => {
399
404
  this.gitOperationService.executionFinished(executionId);
405
+ if (this.stopped)
406
+ return;
400
407
  const pending = this.continuousRunCoordinator.pending(executionId);
401
408
  if (pending?.goal === undefined)
402
409
  return;
@@ -779,7 +786,8 @@ export class NodeConnector {
779
786
  goal: (sessionId) => this.goalRuntime.get(sessionId),
780
787
  runtime: this.runtime,
781
788
  runners: this.runners,
782
- resolve: (sessionId) => this.nativeSessionProjectionService.resolve(sessionId),
789
+ resolve: (sessionId, registeredOnly) => this.nativeSessionProjectionService.resolve(sessionId, registeredOnly),
790
+ similar: (sessionId, workspaceId) => this.nativeSessionProjectionService.similarSessionDetails(sessionId, workspaceId),
783
791
  present: (session) => this.sessionPresentationService.present(session),
784
792
  history: (session, input) => this.conversationHistoryService.read(session, input),
785
793
  segments: (session, input) => this.conversationSegmentService.read(session, input),
@@ -937,6 +945,7 @@ export class NodeConnector {
937
945
  });
938
946
  this.workspaceQueueWorkbenchService = new WorkspaceQueueWorkbenchService({
939
947
  runtime: this.runtime,
948
+ resolve: (sessionId, registeredOnly) => this.nativeSessionProjectionService.resolve(sessionId, registeredOnly),
940
949
  coordinator: this.continuousRunCoordinator,
941
950
  emit: (workspaceId, sessionId) => this.runEventBridge.emitQueue(workspaceId, sessionId),
942
951
  prepareInput: (runner, input, collaborationMode) => this.runners.require(runner).prepareMessageInput(input, collaborationMode)
@@ -947,15 +956,18 @@ export class NodeConnector {
947
956
  void this.runAttachmentService.releaseSession(sessionId);
948
957
  },
949
958
  persist: (session, nativeSessionId) => {
950
- if (session.model === null || session.effort === null || session.access === null)
951
- return;
952
959
  this.database?.saveNativeSessionMetadata({
960
+ ...(session.parentSessionId == null ? {} : { parentSessionId: session.parentSessionId }),
953
961
  runner: session.runner,
954
962
  externalSessionId: nativeSessionId,
955
963
  workspaceId: session.workspaceId,
956
- model: session.model,
957
- effort: session.effort,
958
- access: session.access
964
+ ...(session.model === null ? {} : { model: session.model }),
965
+ ...(session.effort === null ? {} : { effort: session.effort }),
966
+ ...(session.access === null ? {} : { access: session.access }),
967
+ ...(session.customTitle === null ? {} : { customTitle: session.customTitle }),
968
+ ...(session.runnerTitle === null
969
+ ? {}
970
+ : { runnerTitle: session.runnerTitle.slice(0, 160) })
959
971
  });
960
972
  },
961
973
  migrateCommands: (previousId, nextId) => this.commandStates.promoteSessionIdentity(previousId, nextId),
@@ -1073,7 +1085,7 @@ export class NodeConnector {
1073
1085
  goalAccessChanged: prepared.changed
1074
1086
  };
1075
1087
  },
1076
- goal: async ({ session, objective, initiatedByUserId, secretEnvironment, personalInstructions, marAgentModel, marAgentModels, marAgentCompact, marAgentRuntimeScopeId, usageGrant }) => {
1088
+ goal: async ({ session, objective, initiatedByUserId, suppressNotifications, secretEnvironment, personalInstructions, marAgentModel, marAgentModels, marAgentCompact, marAgentRuntimeScopeId, usageGrant }) => {
1077
1089
  this.invalidateGoalContinuation(session.id);
1078
1090
  const prepared = this.prepareGoalSession(session);
1079
1091
  const goalSession = prepared.session;
@@ -1093,6 +1105,7 @@ export class NodeConnector {
1093
1105
  objective: goal.objective,
1094
1106
  deliveryIntent: replacing ? 'REPLACE_CURRENT' : 'SEND',
1095
1107
  goal: goalRef,
1108
+ ...(suppressNotifications === true ? { suppressNotifications: true } : {}),
1096
1109
  ...(usageGrant === undefined ? {} : { usageGrant }),
1097
1110
  ...(initiatedByUserId === undefined ? {} : { initiatedByUserId }),
1098
1111
  secretEnvironment,
@@ -1190,6 +1203,31 @@ export class NodeConnector {
1190
1203
  },
1191
1204
  goalPreparationFailed: (session, goal, activated) => this.handleGoalPreparationFailed(session, goal, activated)
1192
1205
  });
1206
+ const inputState = (entry, message) => this.marAgentClient.restartInputState(entry.session.id, message.clientMessageId);
1207
+ const restorer = new RestartSessionRestorer({
1208
+ runtime: this.runtime,
1209
+ goals: this.goalRuntime,
1210
+ commands: this.commandStates,
1211
+ coordinator: this.continuousRunCoordinator,
1212
+ messages: this.sessionMessageService,
1213
+ resolve: (id) => this.externalSessionResumeService.resolveForMessage(id),
1214
+ authorize: (request) => this.restartAuthorization.authorize(request),
1215
+ inputState,
1216
+ withGrant: (context, work) => this.modelUsage.withGrant(context.usageGrant, work),
1217
+ publishGoal: (session, goal) => this.publishGoalState(session, goal),
1218
+ publishQueue: (session) => this.continuousRunCoordinator.publish(session.id)
1219
+ });
1220
+ this.restartHandoff = new PlannedRestartHandoff({
1221
+ directory: () => nodeDataDirectory(this.config, this.configPath),
1222
+ identity: () => ({ nodeId: this.config.nodeId, serverUrl: this.config.serverUrl }),
1223
+ runtime: this.runtime,
1224
+ coordinator: this.continuousRunCoordinator,
1225
+ goals: this.goalRuntime,
1226
+ commandActive: (id, command) => this.commandStates.get(id, command)?.active === true,
1227
+ inputState,
1228
+ restore: (entry, id) => restorer.restore(entry, id),
1229
+ diagnose: (code, sessionId) => nodeLog('node.restart.diagnostic', { code, sessionId })
1230
+ });
1193
1231
  this.sessionCatalogService = new SessionCatalogService({
1194
1232
  database: () => {
1195
1233
  if (this.database === undefined)
@@ -1216,6 +1254,7 @@ export class NodeConnector {
1216
1254
  refreshActivity: (session) => this.conversationHistoryService.refreshExternalActivity(session),
1217
1255
  externalActivity: (sessionId) => this.sessionState.externalActivity.get(sessionId),
1218
1256
  isWatching: (sessionId) => this.nativeSessionWatchService.isWatching(sessionId),
1257
+ isRecovering: (sessionId) => this.restartHandoff.blocks(sessionId),
1219
1258
  history: (session, input) => this.conversationHistoryService.read(session, input),
1220
1259
  invalidateHistory: (sessionId, workspaceId) => {
1221
1260
  this.nativeSessionWatchService.invalidate(sessionId);
@@ -1323,10 +1362,11 @@ export class NodeConnector {
1323
1362
  onClose: (code, reason) => {
1324
1363
  this.promptCompletion?.disconnect();
1325
1364
  this.usageOutbox?.disconnected();
1326
- this.directTransferService?.close();
1365
+ this.directTransferService?.close('DIRECT_TRANSFER_CONTROL_UNAVAILABLE');
1327
1366
  this.runtimeCredentials.disconnected();
1328
1367
  this.chatgptLogins.close();
1329
1368
  this.scheduledRunAuthorization.disconnected();
1369
+ this.restartAuthorization.disconnected();
1330
1370
  this.mailNotificationControl.disconnected();
1331
1371
  this.mailNotificationMcpServer.revokeAll();
1332
1372
  this.terminalChannel.disconnect();
@@ -1364,7 +1404,8 @@ export class NodeConnector {
1364
1404
  inspectWorkspace: (envelope) => this.workspaceCoordinator.inspect(envelope),
1365
1405
  handleNodeRequest: (envelope) => this.nodeRequests.handle(envelope),
1366
1406
  handleRuntimeCredentialResponse: (envelope) => this.runtimeCredentials.handle(envelope),
1367
- handleScheduledRunAuthorizeResponse: (envelope) => this.scheduledRunAuthorization.handle(envelope),
1407
+ handleScheduledRunAuthorizeResponse: (envelope) => this.scheduledRunAuthorization.handle(envelope) ||
1408
+ this.restartAuthorization.handle(envelope),
1368
1409
  handleMailNotificationResponse: (envelope) => this.mailNotificationControl.handle(envelope),
1369
1410
  handleDirectTransfer: (envelope) => this.handleDirectTransfer(envelope),
1370
1411
  handleTerminalDirect: (envelope) => this.handleTerminalDirect(envelope),
@@ -1435,6 +1476,10 @@ export class NodeConnector {
1435
1476
  onConnected: () => {
1436
1477
  this.runtimeCredentials.connected();
1437
1478
  this.scheduledRunAuthorization.connected();
1479
+ this.scheduledSessions.connected();
1480
+ this.restartAuthorization.connected();
1481
+ if (!this.stopped)
1482
+ void this.restartHandoff.recover();
1438
1483
  this.mailNotificationControl.connected();
1439
1484
  this.recentRunProjectionService.publishSnapshot();
1440
1485
  }
@@ -1444,10 +1489,13 @@ export class NodeConnector {
1444
1489
  throw new Error('NODE_DATABASE_UNAVAILABLE');
1445
1490
  return this.database;
1446
1491
  };
1447
- this.scheduledRunAuthorization = new ScheduledRunAuthorizationControl((envelope) => this.controlChannel.sendEnvelope(envelope));
1492
+ this.scheduledRunAuthorization = new RunAuthorizationControl((envelope) => this.controlChannel.sendEnvelope(envelope));
1493
+ this.restartAuthorization = new RunAuthorizationControl((envelope) => this.controlChannel.sendEnvelope(envelope), 'restart');
1448
1494
  this.scheduledSessions = new ScheduledSessionCoordinator({
1449
1495
  store: () => new ScheduledSessionStore(requireDatabase().raw),
1450
- busy: (task) => this.runtime.activeSessionRun(task.sessionId) !== undefined ||
1496
+ busy: (task) => this.stopped ||
1497
+ this.restartHandoff.blocks(task.sessionId) ||
1498
+ this.runtime.activeSessionRun(task.sessionId) !== undefined ||
1451
1499
  this.runtime.sessionQueue(task.sessionId).itemIds.length > 0,
1452
1500
  authorize: async (task, occurrenceId) => {
1453
1501
  const session = this.runtime.getAgentSession(task.sessionId) ??
@@ -1800,6 +1848,8 @@ export class NodeConnector {
1800
1848
  await this.stopping;
1801
1849
  this.stopping = undefined;
1802
1850
  this.stopped = false;
1851
+ this.continuousRunCoordinator.resumeScheduling();
1852
+ this.sessionMessageService.setAccepting(true);
1803
1853
  this.config ??= await loadNodeConfig(this.configPath);
1804
1854
  this.promptCompletion.initializeJournal(nodeDataDirectory(this.config, this.configPath));
1805
1855
  await this.upgradeCoordinator.loadResult(resolve(dirname(nodeDatabasePath(this.config, this.configPath)), 'upgrade-result.json'));
@@ -1816,6 +1866,8 @@ export class NodeConnector {
1816
1866
  });
1817
1867
  this.usageOutbox.recoverInterrupted();
1818
1868
  this.database.closeRunningTerminalSessions();
1869
+ await this.restartHandoff.load();
1870
+ this.scheduledSessions.recoverInterrupted();
1819
1871
  this.scheduledSessions.start();
1820
1872
  await this.initializeConversationHosting();
1821
1873
  this.computerUseEnablement.initialize();
@@ -1838,6 +1890,7 @@ export class NodeConnector {
1838
1890
  const upload = await this.workspaceUploadService.createDirect(workspace, {
1839
1891
  path: descriptor.resource.path,
1840
1892
  size: descriptor.size,
1893
+ sha256: descriptor.sha256,
1841
1894
  overwrite: descriptor.overwrite === true,
1842
1895
  ...(descriptor.composerAttachment === undefined
1843
1896
  ? {}
@@ -1846,7 +1899,7 @@ export class NodeConnector {
1846
1899
  return {
1847
1900
  offset: upload.receivedBytes,
1848
1901
  write: (bytes) => this.workspaceUploadService.writeDirect(upload.uploadId, bytes),
1849
- complete: (size) => this.workspaceUploadService.completeDirect(upload.uploadId, size),
1902
+ complete: (size, signal) => this.workspaceUploadService.completeDirect(upload.uploadId, size, signal),
1850
1903
  cancel: () => this.workspaceUploadService.cancel(upload.uploadId)
1851
1904
  };
1852
1905
  },
@@ -2141,10 +2194,32 @@ export class NodeConnector {
2141
2194
  closeInput: '/goal --clear'
2142
2195
  };
2143
2196
  }
2144
- stop() {
2197
+ stop(options = {}) {
2145
2198
  if (this.stopping !== undefined)
2146
2199
  return this.stopping;
2147
2200
  this.stopped = true;
2201
+ this.continuousRunCoordinator.suspend();
2202
+ this.scheduledSessions.stop();
2203
+ this.stopping = (async () => {
2204
+ let snapshot;
2205
+ try {
2206
+ // In-flight recovery must rebuild accepted inputs before preparation closes.
2207
+ await this.restartHandoff.wait();
2208
+ this.sessionMessageService.setAccepting(false);
2209
+ await Promise.allSettled([...this.inFlightOperations]);
2210
+ await this.sessionMessageService.drain();
2211
+ if (options.handoff && this.config?.nodeId)
2212
+ snapshot = this.restartHandoff.capture();
2213
+ }
2214
+ finally {
2215
+ await this.disposeRuntime();
2216
+ }
2217
+ if (snapshot !== undefined)
2218
+ await this.restartHandoff.publish(snapshot);
2219
+ })();
2220
+ return this.stopping;
2221
+ }
2222
+ disposeRuntime() {
2148
2223
  this.recentRunProjectionService.dispose();
2149
2224
  this.upgradeCoordinator.stop();
2150
2225
  this.connectionLifecycle.stop();
@@ -2202,6 +2277,7 @@ export class NodeConnector {
2202
2277
  this.chatgptLogins.close();
2203
2278
  this.scheduledSessions.stop();
2204
2279
  this.scheduledRunAuthorization.close();
2280
+ this.restartAuthorization.close();
2205
2281
  this.mailNotificationControl.close();
2206
2282
  const mailMcpDrain = this.mailNotificationMcpServer.close();
2207
2283
  const webToolDrain = this.webToolService.close();
@@ -2220,21 +2296,24 @@ export class NodeConnector {
2220
2296
  mailMcpDrain,
2221
2297
  webToolDrain
2222
2298
  ]);
2223
- this.stopping = (async () => {
2299
+ return (async () => {
2300
+ let failed;
2224
2301
  try {
2225
2302
  await conversationHostingMaintenanceDrain;
2226
2303
  }
2227
2304
  finally {
2228
2305
  await hostedConversationRuntime?.dispose().catch(() => undefined);
2229
- await drains;
2306
+ const results = await drains;
2230
2307
  await this.gitExecutionService?.close();
2231
2308
  this.usageOutbox?.disconnected();
2232
2309
  this.usageOutbox = undefined;
2233
2310
  this.modelUsage.clear();
2234
2311
  database?.close();
2312
+ failed = results.find((result) => result.status === 'rejected');
2235
2313
  }
2314
+ if (failed !== undefined)
2315
+ throw failed.reason;
2236
2316
  })();
2237
- return this.stopping;
2238
2317
  }
2239
2318
  /** @internal Thin compatibility delegates; all behavior remains in the extracted services. */
2240
2319
  handleMessage = (raw) => this.controlMessages.handle(raw);
@@ -2375,12 +2454,30 @@ export class NodeConnector {
2375
2454
  startCapabilityRefresh = () => this.connectionLifecycle.startCapabilityRefresh();
2376
2455
  refreshCapabilities = () => this.connectionLifecycle.refreshCapabilities();
2377
2456
  async executeNodeOperation(operation, data) {
2457
+ if (this.stopped)
2458
+ throw new Error('NODE_STOPPED');
2459
+ const input = isPlainRecord(data) ? data : {};
2460
+ const sessionId = typeof input.sessionId === 'string' ? input.sessionId : undefined;
2461
+ if (this.restartHandoff.blocksOperation(operation, input))
2462
+ throw new Error('SESSION_RESTART_PENDING');
2378
2463
  if (this.database === undefined)
2379
2464
  throw new Error('NODE_DATABASE_UNAVAILABLE');
2380
2465
  if (this.upgradeCoordinator.blocks(operation))
2381
2466
  throw new Error('NODE_UPGRADE_PENDING');
2382
2467
  nodeLog('node.operation', { nodeId: this.config?.nodeId, operation });
2383
- return this.gitOperationService.execute(operation, data, (input) => this.operationRouter.execute(operation, input));
2468
+ const task = this.gitOperationService.execute(operation, data, (input) => this.operationRouter.execute(operation, input));
2469
+ this.inFlightOperations.add(task);
2470
+ try {
2471
+ const result = await task;
2472
+ if ((operation === 'session.command.execute' || operation === 'session.message') &&
2473
+ sessionId !== undefined &&
2474
+ Number.isSafeInteger(input.__requestUserId))
2475
+ this.restartHandoff.rememberUser(sessionId, input.__requestUserId);
2476
+ return result;
2477
+ }
2478
+ finally {
2479
+ this.inFlightOperations.delete(task);
2480
+ }
2384
2481
  }
2385
2482
  gitExecution() {
2386
2483
  this.gitExecutionService ??= new GitExecutionService({
package/dist/database.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { NODE_SCHEMA_MIGRATION_V17 } from './migrations/v017.js';
2
2
  import { NODE_SCHEMA_MIGRATION_V18 } from './migrations/v018.js';
3
+ import { NODE_SCHEMA_MIGRATION_V19 } from './migrations/v019.js';
4
+ import { NODE_SCHEMA_MIGRATION_V20 } from './migrations/v020.js';
3
5
  import { normalizeSessionPrompt } from './util/personal-instructions.js';
4
6
  import { randomUUID } from 'node:crypto';
5
7
  import { closeSync, existsSync, mkdirSync, openSync, rmSync } from 'node:fs';
@@ -19,7 +21,9 @@ export const NODE_SCHEMA_MIGRATIONS = [
19
21
  NODE_SCHEMA_MIGRATION_V15,
20
22
  NODE_SCHEMA_MIGRATION_V16,
21
23
  NODE_SCHEMA_MIGRATION_V17,
22
- NODE_SCHEMA_MIGRATION_V18
24
+ NODE_SCHEMA_MIGRATION_V18,
25
+ NODE_SCHEMA_MIGRATION_V19,
26
+ NODE_SCHEMA_MIGRATION_V20
23
27
  ];
24
28
  const LEGACY_NODE_SCHEMA_V12 = NODE_SCHEMA_VERSION_V13 - 1;
25
29
  const OBSOLETE_NODE_SCHEMA_V16 = 16;
@@ -288,8 +292,10 @@ export class NodeDatabase {
288
292
  const model = input.model === undefined ? (current?.model ?? null) : input.model;
289
293
  const effort = input.effort === undefined ? (current?.effort ?? null) : input.effort;
290
294
  const access = input.access === undefined ? (current?.access ?? null) : input.access;
295
+ const parentSessionId = current?.parentSessionId ?? input.parentSessionId ?? null;
291
296
  const metadata = JSON.stringify({
292
297
  version: 1,
298
+ parentSessionId,
293
299
  customTitle,
294
300
  sessionPrompt,
295
301
  runnerTitle,
@@ -328,6 +334,7 @@ export class NodeDatabase {
328
334
  }
329
335
  return {
330
336
  customTitle,
337
+ parentSessionId,
331
338
  sessionPrompt,
332
339
  runnerTitle,
333
340
  runnerTitlePending,
@@ -341,17 +348,13 @@ export class NodeDatabase {
341
348
  listSessionRecords(workspaceId) {
342
349
  return this.raw
343
350
  .prepare('SELECT id, workspace_id, runner, pin_order, metadata FROM agent_sessions WHERE workspace_id = ? ORDER BY pin_order IS NULL, pin_order, id')
344
- .all(workspaceId).map((row) => ({
345
- id: stringValue(row, 'id'),
346
- workspaceId: stringValue(row, 'workspace_id'),
347
- runner: stringValue(row, 'runner'),
348
- pinOrder: nullableNumber(row, 'pin_order'),
349
- metadata: {
350
- ...sessionMetadata(row),
351
- pinnedAt: row['pin_order'] === null ? null : 1,
352
- pinOrder: nullableNumber(row, 'pin_order')
353
- }
354
- }));
351
+ .all(workspaceId).map(sessionRecord);
352
+ }
353
+ getSessionRecord(sessionId) {
354
+ const row = this.raw
355
+ .prepare('SELECT id, workspace_id, runner, pin_order, metadata FROM agent_sessions WHERE id = ?')
356
+ .get(sessionId);
357
+ return row === undefined ? undefined : sessionRecord(row);
355
358
  }
356
359
  movePinnedSession(sessionId, direction) {
357
360
  const current = this.raw
@@ -565,6 +568,19 @@ function mcpInstallation(row) {
565
568
  installedAt: numberValue(row, 'installed_at')
566
569
  };
567
570
  }
571
+ function sessionRecord(row) {
572
+ return {
573
+ id: stringValue(row, 'id'),
574
+ workspaceId: stringValue(row, 'workspace_id'),
575
+ runner: stringValue(row, 'runner'),
576
+ pinOrder: nullableNumber(row, 'pin_order'),
577
+ metadata: {
578
+ ...sessionMetadata(row),
579
+ pinnedAt: row['pin_order'] === null ? null : 1,
580
+ pinOrder: nullableNumber(row, 'pin_order')
581
+ }
582
+ };
583
+ }
568
584
  function sessionMetadata(row) {
569
585
  const raw = JSON.parse(stringValue(row, 'metadata'));
570
586
  if (raw['version'] !== 1)
@@ -572,6 +588,7 @@ function sessionMetadata(row) {
572
588
  const value = (key) => typeof raw[key] === 'string' ? raw[key] : null;
573
589
  return {
574
590
  customTitle: value('customTitle'),
591
+ parentSessionId: value('parentSessionId'),
575
592
  sessionPrompt: value('sessionPrompt'),
576
593
  runnerTitle: value('runnerTitle'),
577
594
  runnerTitlePending: raw['runnerTitlePending'] === true,
package/dist/main.js CHANGED
@@ -7,6 +7,7 @@ import { getNodeHealth } from './health.js';
7
7
  import { browserCachePath, removeServiceDefinition, writeServiceDefinition } from './service.js';
8
8
  import { superviseNode } from './supervisor.js';
9
9
  import { configureNodeLog } from './rotating-log.js';
10
+ import { nodeLog } from './operational.js';
10
11
  const [command = 'status', ...args] = process.argv.slice(2);
11
12
  const config = readOption(args, '--config') ?? nodeConfigPath();
12
13
  const output = readOption(args, '--output') ?? defaultServicePath();
@@ -17,11 +18,26 @@ if (command === 'run') {
17
18
  const { NodeConnector } = await import('./connector.js');
18
19
  configureNodeLog(nodeDataDirectory(await loadNodeConfig(config), config));
19
20
  const connector = new NodeConnector({ configPath: config });
21
+ let shuttingDown = false;
20
22
  const shutdown = () => {
21
- void connector.stop().finally(() => process.exit(0));
23
+ if (shuttingDown)
24
+ return;
25
+ shuttingDown = true;
26
+ const deadline = setTimeout(() => {
27
+ nodeLog('node.restart.diagnostic', { code: 'RESTART_HANDOFF_SHUTDOWN_TIMEOUT' });
28
+ process.exit(1);
29
+ }, 25_000);
30
+ void connector.stop({ handoff: true }).then(() => {
31
+ clearTimeout(deadline);
32
+ process.exit(0);
33
+ }, () => {
34
+ clearTimeout(deadline);
35
+ nodeLog('node.restart.diagnostic', { code: 'RESTART_HANDOFF_SHUTDOWN_FAILED' });
36
+ process.exit(1);
37
+ });
22
38
  };
23
39
  for (const signal of ['SIGINT', 'SIGTERM']) {
24
- process.once(signal, shutdown);
40
+ process.on(signal, shutdown);
25
41
  }
26
42
  await connector.start();
27
43
  }
@@ -0,0 +1,9 @@
1
+ import { NODE_SCHEMA_MIGRATION_V18 } from './v018.js';
2
+ export const NODE_SCHEMA_MIGRATION_V19 = {
3
+ version: 19,
4
+ sql: `UPDATE agent_sessions
5
+ SET metadata = json_set(metadata, '$.parentSessionId', NULL)
6
+ WHERE json_type(metadata, '$.parentSessionId') IS NULL;`,
7
+ preflight: (database) => NODE_SCHEMA_MIGRATION_V18.assert(database),
8
+ assert: (database) => NODE_SCHEMA_MIGRATION_V18.assert(database)
9
+ };
@@ -0,0 +1,46 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { NODE_SCHEMA_V13 } from './v013.js';
3
+ import { NODE_SCHEMA_MIGRATION_V14 } from './v014.js';
4
+ import { NODE_SCHEMA_MIGRATION_V15 } from './v015.js';
5
+ import { NODE_SCHEMA_MIGRATION_V16 } from './v016.js';
6
+ import { NODE_SCHEMA_MIGRATION_V17 } from './v017.js';
7
+ import { NODE_SCHEMA_MIGRATION_V18 } from './v018.js';
8
+ import { NODE_SCHEMA_MIGRATION_V19 } from './v019.js';
9
+ const sql = `CREATE INDEX scheduled_session_occurrences_unfinished
10
+ ON scheduled_session_occurrences(status) WHERE status IN ('CLAIMED','SUBMITTED');`;
11
+ export const NODE_SCHEMA_MIGRATION_V20 = {
12
+ version: 20,
13
+ sql,
14
+ preflight: (database) => NODE_SCHEMA_MIGRATION_V19.assert(database),
15
+ assert(database) {
16
+ const reference = new DatabaseSync(':memory:');
17
+ try {
18
+ reference.exec(NODE_SCHEMA_V13);
19
+ for (const migration of [
20
+ NODE_SCHEMA_MIGRATION_V14,
21
+ NODE_SCHEMA_MIGRATION_V15,
22
+ NODE_SCHEMA_MIGRATION_V16,
23
+ NODE_SCHEMA_MIGRATION_V17,
24
+ NODE_SCHEMA_MIGRATION_V18,
25
+ NODE_SCHEMA_MIGRATION_V19
26
+ ])
27
+ reference.exec(migration.sql);
28
+ reference.exec(sql);
29
+ const schema = (db) => db
30
+ .prepare("SELECT type,name,sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY type,name")
31
+ .all()
32
+ .map((row) => [
33
+ row.type,
34
+ row.name,
35
+ row.name === 'node_schema_migrations'
36
+ ? String(row.sql).replace(/\s+/g, '')
37
+ : String(row.sql).replace(/\s+/g, ' ').trim()
38
+ ]);
39
+ if (JSON.stringify(schema(reference)) !== JSON.stringify(schema(database)))
40
+ throw new Error('NODE_DATABASE_SCHEMA_INVALID:scheduled_session_occurrences_unfinished');
41
+ }
42
+ finally {
43
+ reference.close();
44
+ }
45
+ }
46
+ };
@@ -0,0 +1,126 @@
1
+ /** Read-only platform evidence; never inject input or replace clipboard contents. */
2
+ export const PYTHON_PROBE_SOURCE = String.raw `
3
+ import ctypes
4
+ import json
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+
10
+ result = dict(supportsScreenshot=False, supportsPrimaryDisplay=False,
11
+ supportsInput=False, supportsUnicodePaste=False,
12
+ reasonCode="COMPUTER_RUNTIME_UNAVAILABLE")
13
+
14
+ def signature(library, name, arguments, returns):
15
+ function = getattr(library, name)
16
+ function.argtypes = arguments
17
+ function.restype = returns
18
+ return function
19
+
20
+ def windows_desktop():
21
+ user = ctypes.WinDLL("user32", use_last_error=True)
22
+ kernel = ctypes.WinDLL("kernel32", use_last_error=True)
23
+ handle, integer = ctypes.c_void_p, ctypes.c_uint32
24
+ open_desktop = signature(user, "OpenInputDesktop", [integer, ctypes.c_int, integer], handle)
25
+ close_desktop = signature(user, "CloseDesktop", [handle], ctypes.c_int)
26
+ thread_id = signature(kernel, "GetCurrentThreadId", [], integer)
27
+ thread_desktop = signature(user, "GetThreadDesktop", [integer], handle)
28
+ station = signature(user, "GetProcessWindowStation", [], handle)
29
+ information = signature(user, "GetUserObjectInformationW",
30
+ [handle, ctypes.c_int, handle, integer, handle], ctypes.c_int)
31
+ def name(object_handle):
32
+ buffer = ctypes.create_unicode_buffer(256)
33
+ needed = integer()
34
+ if not information(object_handle, 2, buffer, ctypes.sizeof(buffer), ctypes.byref(needed)):
35
+ return None
36
+ return buffer.value
37
+ # DESKTOP_READOBJECTS | DESKTOP_WRITEOBJECTS. Do not switch or attach desktops.
38
+ desktop = open_desktop(0, False, 0x0001 | 0x0080)
39
+ if not desktop:
40
+ return False
41
+ try:
42
+ desktop_name = name(desktop)
43
+ return (name(station()) == "WinSta0" and desktop_name is not None
44
+ and desktop_name == name(thread_desktop(thread_id())))
45
+ finally:
46
+ close_desktop(desktop)
47
+
48
+ def input_supported(gui):
49
+ if sys.platform == "win32":
50
+ return windows_desktop()
51
+ if sys.platform == "darwin":
52
+ services = ctypes.CDLL("/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices")
53
+ graphics = ctypes.CDLL("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")
54
+ trusted = signature(services, "AXIsProcessTrusted", [], ctypes.c_bool)
55
+ post_access = signature(graphics, "CGPreflightPostEventAccess", [], ctypes.c_bool)
56
+ return bool(trusted() and post_access())
57
+ if sys.platform == "linux":
58
+ display = gui._pyautogui_x11._display
59
+ if not display.has_extension("XTEST"):
60
+ return False
61
+ display.xtest_get_version(2, 2)
62
+ return True
63
+ return False
64
+
65
+ def clipboard_supported():
66
+ import pyperclip
67
+ copy, paste = pyperclip.determine_clipboard()
68
+ if not callable(copy) or not callable(paste) or not copy or not paste:
69
+ return False
70
+ names = (getattr(copy, "__name__", ""), getattr(paste, "__name__", ""))
71
+ if sys.platform == "win32" and names == ("copy_windows", "paste_windows"):
72
+ user = ctypes.WinDLL("user32", use_last_error=True)
73
+ open_clipboard = signature(user, "OpenClipboard", [ctypes.c_void_p], ctypes.c_int)
74
+ close_clipboard = signature(user, "CloseClipboard", [], ctypes.c_int)
75
+ if not open_clipboard(None):
76
+ return False
77
+ # Opening and closing does not empty, read, or take ownership of the clipboard.
78
+ return bool(close_clipboard())
79
+ if sys.platform == "darwin":
80
+ import AppKit
81
+ board = AppKit.NSPasteboard.generalPasteboard()
82
+ if board is None:
83
+ return False
84
+ # Newer systems expose pasteboard policy. Do not provoke an access prompt.
85
+ if hasattr(board, "accessBehavior"):
86
+ allowed = getattr(AppKit, "NSPasteboardAccessBehaviorAlwaysAllow", None)
87
+ if allowed is None or board.accessBehavior() != allowed:
88
+ return False
89
+ if names == ("copy_osx_pyobjc", "paste_osx_pyobjc"):
90
+ return True
91
+ return (names == ("copy_osx_pbcopy", "paste_osx_pbcopy")
92
+ and shutil.which("pbcopy") is not None and shutil.which("pbpaste") is not None)
93
+ if sys.platform == "linux":
94
+ commands = {("copy_xclip", "paste_xclip"): ["xclip", "-version"],
95
+ ("copy_xsel", "paste_xsel"): ["xsel", "--version"]}
96
+ command = commands.get(names)
97
+ executable = shutil.which(command[0]) if command is not None else None
98
+ if executable is None:
99
+ return False
100
+ # X11 access was established above. Verify the selected backend can run,
101
+ # without consuming a one-shot selection or requesting clipboard contents.
102
+ subprocess.run([executable, *command[1:]], stdin=subprocess.DEVNULL,
103
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True, timeout=2)
104
+ return True
105
+ return False
106
+
107
+ try:
108
+ if sys.platform not in ("win32", "darwin", "linux") or (sys.platform == "linux" and
109
+ (not os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))):
110
+ result["reasonCode"] = "COMPUTER_DESKTOP_UNSUPPORTED"
111
+ else:
112
+ import pyautogui
113
+ image = pyautogui.screenshot()
114
+ if image.width > 0 and image.height > 0:
115
+ result["supportsScreenshot"] = result["supportsPrimaryDisplay"] = True
116
+ result["reasonCode"] = "COMPUTER_INPUT_PERMISSION_DENIED"
117
+ if input_supported(pyautogui):
118
+ result["supportsInput"] = True
119
+ result["reasonCode"] = "COMPUTER_RUNTIME_UNAVAILABLE"
120
+ if clipboard_supported():
121
+ result["supportsUnicodePaste"] = True
122
+ result["reasonCode"] = None
123
+ except Exception:
124
+ pass
125
+ print(json.dumps(result))
126
+ `;