@myagentroam/node 0.10.34 → 0.10.36

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.
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,9 @@ export class NodeConnector {
1435
1476
  onConnected: () => {
1436
1477
  this.runtimeCredentials.connected();
1437
1478
  this.scheduledRunAuthorization.connected();
1479
+ this.restartAuthorization.connected();
1480
+ if (!this.stopped)
1481
+ void this.restartHandoff.recover();
1438
1482
  this.mailNotificationControl.connected();
1439
1483
  this.recentRunProjectionService.publishSnapshot();
1440
1484
  }
@@ -1444,10 +1488,13 @@ export class NodeConnector {
1444
1488
  throw new Error('NODE_DATABASE_UNAVAILABLE');
1445
1489
  return this.database;
1446
1490
  };
1447
- this.scheduledRunAuthorization = new ScheduledRunAuthorizationControl((envelope) => this.controlChannel.sendEnvelope(envelope));
1491
+ this.scheduledRunAuthorization = new RunAuthorizationControl((envelope) => this.controlChannel.sendEnvelope(envelope));
1492
+ this.restartAuthorization = new RunAuthorizationControl((envelope) => this.controlChannel.sendEnvelope(envelope), 'restart');
1448
1493
  this.scheduledSessions = new ScheduledSessionCoordinator({
1449
1494
  store: () => new ScheduledSessionStore(requireDatabase().raw),
1450
- busy: (task) => this.runtime.activeSessionRun(task.sessionId) !== undefined ||
1495
+ busy: (task) => this.stopped ||
1496
+ this.restartHandoff.blocks(task.sessionId) ||
1497
+ this.runtime.activeSessionRun(task.sessionId) !== undefined ||
1451
1498
  this.runtime.sessionQueue(task.sessionId).itemIds.length > 0,
1452
1499
  authorize: async (task, occurrenceId) => {
1453
1500
  const session = this.runtime.getAgentSession(task.sessionId) ??
@@ -1800,6 +1847,8 @@ export class NodeConnector {
1800
1847
  await this.stopping;
1801
1848
  this.stopping = undefined;
1802
1849
  this.stopped = false;
1850
+ this.continuousRunCoordinator.resumeScheduling();
1851
+ this.sessionMessageService.setAccepting(true);
1803
1852
  this.config ??= await loadNodeConfig(this.configPath);
1804
1853
  this.promptCompletion.initializeJournal(nodeDataDirectory(this.config, this.configPath));
1805
1854
  await this.upgradeCoordinator.loadResult(resolve(dirname(nodeDatabasePath(this.config, this.configPath)), 'upgrade-result.json'));
@@ -1816,6 +1865,7 @@ export class NodeConnector {
1816
1865
  });
1817
1866
  this.usageOutbox.recoverInterrupted();
1818
1867
  this.database.closeRunningTerminalSessions();
1868
+ await this.restartHandoff.load();
1819
1869
  this.scheduledSessions.start();
1820
1870
  await this.initializeConversationHosting();
1821
1871
  this.computerUseEnablement.initialize();
@@ -1838,6 +1888,7 @@ export class NodeConnector {
1838
1888
  const upload = await this.workspaceUploadService.createDirect(workspace, {
1839
1889
  path: descriptor.resource.path,
1840
1890
  size: descriptor.size,
1891
+ sha256: descriptor.sha256,
1841
1892
  overwrite: descriptor.overwrite === true,
1842
1893
  ...(descriptor.composerAttachment === undefined
1843
1894
  ? {}
@@ -1846,7 +1897,7 @@ export class NodeConnector {
1846
1897
  return {
1847
1898
  offset: upload.receivedBytes,
1848
1899
  write: (bytes) => this.workspaceUploadService.writeDirect(upload.uploadId, bytes),
1849
- complete: (size) => this.workspaceUploadService.completeDirect(upload.uploadId, size),
1900
+ complete: (size, signal) => this.workspaceUploadService.completeDirect(upload.uploadId, size, signal),
1850
1901
  cancel: () => this.workspaceUploadService.cancel(upload.uploadId)
1851
1902
  };
1852
1903
  },
@@ -2141,10 +2192,32 @@ export class NodeConnector {
2141
2192
  closeInput: '/goal --clear'
2142
2193
  };
2143
2194
  }
2144
- stop() {
2195
+ stop(options = {}) {
2145
2196
  if (this.stopping !== undefined)
2146
2197
  return this.stopping;
2147
2198
  this.stopped = true;
2199
+ this.continuousRunCoordinator.suspend();
2200
+ this.scheduledSessions.stop();
2201
+ this.stopping = (async () => {
2202
+ let snapshot;
2203
+ try {
2204
+ // In-flight recovery must rebuild accepted inputs before preparation closes.
2205
+ await this.restartHandoff.wait();
2206
+ this.sessionMessageService.setAccepting(false);
2207
+ await Promise.allSettled([...this.inFlightOperations]);
2208
+ await this.sessionMessageService.drain();
2209
+ if (options.handoff && this.config?.nodeId)
2210
+ snapshot = this.restartHandoff.capture();
2211
+ }
2212
+ finally {
2213
+ await this.disposeRuntime();
2214
+ }
2215
+ if (snapshot !== undefined)
2216
+ await this.restartHandoff.publish(snapshot);
2217
+ })();
2218
+ return this.stopping;
2219
+ }
2220
+ disposeRuntime() {
2148
2221
  this.recentRunProjectionService.dispose();
2149
2222
  this.upgradeCoordinator.stop();
2150
2223
  this.connectionLifecycle.stop();
@@ -2202,6 +2275,7 @@ export class NodeConnector {
2202
2275
  this.chatgptLogins.close();
2203
2276
  this.scheduledSessions.stop();
2204
2277
  this.scheduledRunAuthorization.close();
2278
+ this.restartAuthorization.close();
2205
2279
  this.mailNotificationControl.close();
2206
2280
  const mailMcpDrain = this.mailNotificationMcpServer.close();
2207
2281
  const webToolDrain = this.webToolService.close();
@@ -2220,21 +2294,24 @@ export class NodeConnector {
2220
2294
  mailMcpDrain,
2221
2295
  webToolDrain
2222
2296
  ]);
2223
- this.stopping = (async () => {
2297
+ return (async () => {
2298
+ let failed;
2224
2299
  try {
2225
2300
  await conversationHostingMaintenanceDrain;
2226
2301
  }
2227
2302
  finally {
2228
2303
  await hostedConversationRuntime?.dispose().catch(() => undefined);
2229
- await drains;
2304
+ const results = await drains;
2230
2305
  await this.gitExecutionService?.close();
2231
2306
  this.usageOutbox?.disconnected();
2232
2307
  this.usageOutbox = undefined;
2233
2308
  this.modelUsage.clear();
2234
2309
  database?.close();
2310
+ failed = results.find((result) => result.status === 'rejected');
2235
2311
  }
2312
+ if (failed !== undefined)
2313
+ throw failed.reason;
2236
2314
  })();
2237
- return this.stopping;
2238
2315
  }
2239
2316
  /** @internal Thin compatibility delegates; all behavior remains in the extracted services. */
2240
2317
  handleMessage = (raw) => this.controlMessages.handle(raw);
@@ -2375,12 +2452,30 @@ export class NodeConnector {
2375
2452
  startCapabilityRefresh = () => this.connectionLifecycle.startCapabilityRefresh();
2376
2453
  refreshCapabilities = () => this.connectionLifecycle.refreshCapabilities();
2377
2454
  async executeNodeOperation(operation, data) {
2455
+ if (this.stopped)
2456
+ throw new Error('NODE_STOPPED');
2457
+ const input = isPlainRecord(data) ? data : {};
2458
+ const sessionId = typeof input.sessionId === 'string' ? input.sessionId : undefined;
2459
+ if (this.restartHandoff.blocksOperation(operation, input))
2460
+ throw new Error('SESSION_RESTART_PENDING');
2378
2461
  if (this.database === undefined)
2379
2462
  throw new Error('NODE_DATABASE_UNAVAILABLE');
2380
2463
  if (this.upgradeCoordinator.blocks(operation))
2381
2464
  throw new Error('NODE_UPGRADE_PENDING');
2382
2465
  nodeLog('node.operation', { nodeId: this.config?.nodeId, operation });
2383
- return this.gitOperationService.execute(operation, data, (input) => this.operationRouter.execute(operation, input));
2466
+ const task = this.gitOperationService.execute(operation, data, (input) => this.operationRouter.execute(operation, input));
2467
+ this.inFlightOperations.add(task);
2468
+ try {
2469
+ const result = await task;
2470
+ if ((operation === 'session.command.execute' || operation === 'session.message') &&
2471
+ sessionId !== undefined &&
2472
+ Number.isSafeInteger(input.__requestUserId))
2473
+ this.restartHandoff.rememberUser(sessionId, input.__requestUserId);
2474
+ return result;
2475
+ }
2476
+ finally {
2477
+ this.inFlightOperations.delete(task);
2478
+ }
2384
2479
  }
2385
2480
  gitExecution() {
2386
2481
  this.gitExecutionService ??= new GitExecutionService({
package/dist/database.js CHANGED
@@ -1,5 +1,6 @@
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';
3
4
  import { normalizeSessionPrompt } from './util/personal-instructions.js';
4
5
  import { randomUUID } from 'node:crypto';
5
6
  import { closeSync, existsSync, mkdirSync, openSync, rmSync } from 'node:fs';
@@ -19,7 +20,8 @@ export const NODE_SCHEMA_MIGRATIONS = [
19
20
  NODE_SCHEMA_MIGRATION_V15,
20
21
  NODE_SCHEMA_MIGRATION_V16,
21
22
  NODE_SCHEMA_MIGRATION_V17,
22
- NODE_SCHEMA_MIGRATION_V18
23
+ NODE_SCHEMA_MIGRATION_V18,
24
+ NODE_SCHEMA_MIGRATION_V19
23
25
  ];
24
26
  const LEGACY_NODE_SCHEMA_V12 = NODE_SCHEMA_VERSION_V13 - 1;
25
27
  const OBSOLETE_NODE_SCHEMA_V16 = 16;
@@ -288,8 +290,10 @@ export class NodeDatabase {
288
290
  const model = input.model === undefined ? (current?.model ?? null) : input.model;
289
291
  const effort = input.effort === undefined ? (current?.effort ?? null) : input.effort;
290
292
  const access = input.access === undefined ? (current?.access ?? null) : input.access;
293
+ const parentSessionId = current?.parentSessionId ?? input.parentSessionId ?? null;
291
294
  const metadata = JSON.stringify({
292
295
  version: 1,
296
+ parentSessionId,
293
297
  customTitle,
294
298
  sessionPrompt,
295
299
  runnerTitle,
@@ -328,6 +332,7 @@ export class NodeDatabase {
328
332
  }
329
333
  return {
330
334
  customTitle,
335
+ parentSessionId,
331
336
  sessionPrompt,
332
337
  runnerTitle,
333
338
  runnerTitlePending,
@@ -341,17 +346,13 @@ export class NodeDatabase {
341
346
  listSessionRecords(workspaceId) {
342
347
  return this.raw
343
348
  .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
- }));
349
+ .all(workspaceId).map(sessionRecord);
350
+ }
351
+ getSessionRecord(sessionId) {
352
+ const row = this.raw
353
+ .prepare('SELECT id, workspace_id, runner, pin_order, metadata FROM agent_sessions WHERE id = ?')
354
+ .get(sessionId);
355
+ return row === undefined ? undefined : sessionRecord(row);
355
356
  }
356
357
  movePinnedSession(sessionId, direction) {
357
358
  const current = this.raw
@@ -565,6 +566,19 @@ function mcpInstallation(row) {
565
566
  installedAt: numberValue(row, 'installed_at')
566
567
  };
567
568
  }
569
+ function sessionRecord(row) {
570
+ return {
571
+ id: stringValue(row, 'id'),
572
+ workspaceId: stringValue(row, 'workspace_id'),
573
+ runner: stringValue(row, 'runner'),
574
+ pinOrder: nullableNumber(row, 'pin_order'),
575
+ metadata: {
576
+ ...sessionMetadata(row),
577
+ pinnedAt: row['pin_order'] === null ? null : 1,
578
+ pinOrder: nullableNumber(row, 'pin_order')
579
+ }
580
+ };
581
+ }
568
582
  function sessionMetadata(row) {
569
583
  const raw = JSON.parse(stringValue(row, 'metadata'));
570
584
  if (raw['version'] !== 1)
@@ -572,6 +586,7 @@ function sessionMetadata(row) {
572
586
  const value = (key) => typeof raw[key] === 'string' ? raw[key] : null;
573
587
  return {
574
588
  customTitle: value('customTitle'),
589
+ parentSessionId: value('parentSessionId'),
575
590
  sessionPrompt: value('sessionPrompt'),
576
591
  runnerTitle: value('runnerTitle'),
577
592
  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
+ };
@@ -579,6 +579,34 @@ function genericToolItem(id, toolName, status, input, outputSummary = null, erro
579
579
  truncated: truncated || source.length > 10_000 || output.length > 10_000
580
580
  }, status);
581
581
  }
582
+ if (toolName === 'code_exec_wait') {
583
+ const inputRecord = record(input);
584
+ const action = typeof inputRecord?.action === 'string' ? inputRecord.action : 'poll';
585
+ const inputSummary = compactRunnerValue(input, 10_000);
586
+ const result = output === undefined ? null : compactRunnerValue(output, 10_000);
587
+ const resultSummary = [outputSummary, result?.text]
588
+ .filter((value, index, values) => typeof value === 'string' && value.length > 0 && values.indexOf(value) === index)
589
+ .join('\n');
590
+ const details = [
591
+ `Action: ${compactRunnerText(action, 128)}`,
592
+ `Input: ${inputSummary.text || '{}'}`,
593
+ resultSummary.length === 0 ? '' : `Result: ${compactRunnerText(resultSummary, 10_000)}`,
594
+ errorCode === null ? '' : `Error: ${compactRunnerText(errorCode, 80)}`
595
+ ]
596
+ .filter(Boolean)
597
+ .join('\n');
598
+ return commandItem({
599
+ itemId: id,
600
+ command: toolName,
601
+ cwd: null,
602
+ outputPreview: details,
603
+ exitCode: null,
604
+ truncated: truncated ||
605
+ inputSummary.truncated ||
606
+ result?.truncated === true ||
607
+ resultSummary.length > 10_000
608
+ }, status);
609
+ }
582
610
  const subagent = marAgentSubagentProjection(toolName, input, output, subagentTitle, knownSubagentDetails);
583
611
  const toolCategory = isImageGenerationTool(toolName) ? 'IMAGE_GENERATION' : undefined;
584
612
  const inputSummary = compactRunnerValue(input, 10_000);
@@ -18,18 +18,20 @@ export function marctlHostSkill(environment = {}) {
18
18
  const marctlInstructions = [
19
19
  '## MAR Workspace CLI',
20
20
  'Use exec to run marctl. Reuse loaded guidance while it remains in context. Consult local marctl help sessions for unclear options and marctl --version when version matters; neither needs a network request. Capabilities and permissions still apply.',
21
- 'Use sessions current --json only when the current identity or scope is unknown. For a known target ID, use sessions show <session-id> --json only when fresh state is needed, without current/list first. Use sessions list --limit 10 --json to discover an unknown target; list has no name-search option. Stop once found and follow nextCursor only if needed. Do not scan the Workspace to prove capacity: check assigned work, recorded worker IDs and overlapping write scopes.',
21
+ 'Use sessions current --json only when the current identity or scope is unknown. For a known target ID, use sessions show <session-id> --json only when fresh state is needed, without current/list first. Use sessions list --limit 10 --json to discover an unknown target; narrow with --query <title> or --parent <session-id>. These query Node-known Sessions without scanning Runners. Use sessions graph <session-id> --json only for a creation tree (bounded to 100 nodes with truncated); start automatically records the current Session as parent, not task ownership. Stop once found and follow nextCursor only if needed. Do not scan the Workspace to prove capacity: check assigned work, recorded worker IDs and overlapping write scopes.',
22
22
  'MAR binds sessions to the current Node and Workspace. No --node or --workspace override exists. Use returned native Session IDs, not titles, for later operations. A Workspace terminal may have no current Session. Missing scope, an old Node, expired authorization or an unavailable Runner is an explicit error, not permission to bypass the API.',
23
23
  '## Create and coordinate independent Sessions',
24
+ 'Change an existing Session with sessions config <session-id> --model <id> --effort high --json; either option can be set alone. Use show for current settings; active execution is not restarted.',
24
25
  'Prefer -m / --message for short text; use --message-file for longer UTF-8 text or an existing file (- reads stdin). These options are mutually exclusive. Example: marctl sessions start --runner codex --title "fixer#42 Handle empty totals" -m "Implement issue #42 and report test evidence" --json. Other Runner names are mar-agent, claude-code and opencode. An omitted model uses the target Runner configuration, not the Coordinator model.',
25
26
  'For start/send, repeat --image <local-path> to attach PNG, JPEG, GIF or WebP images, with or without text. Images are read on this CLI host, not by remote path. Limits: 5 images, 5 MiB each, 10 MiB total. Actual Runner/model image capabilities apply; goal set accepts text only.',
26
- 'Create workers only as tasks need them, named fixer#<task-id> <description> or reviewer#<task-or-change-id> <description>. These are conventions, not product roles or a fixed pool. Put role, scope, acceptance criteria and delivery requirements in the first message. Session Prompt is edited in the Web UI only; marctl has no editing command, and sends to existing Sessions use normal prompt injection.',
27
- 'Reuse recorded Session/Run IDs and receipts. Refresh volatile Run, queue and Goal state with sessions show only when the next action depends on it. For results use sessions history <session-id> --limit 10 --json; follow nextCursor only for missing evidence. Select needed JSON fields and relevant assistant results instead of dumping full Session lists or tool transcripts. Hidden reasoning, credentials and raw model requests are not collaboration artifacts.',
28
- 'Send with sessions send <session-id> -m "Review the updated patch" --json. Default send does not interrupt and can resume a paused queue; --delivery queue preserves a paused queue. Otherwise eligible queued input can start when idle. --delivery replace-current cancels the current Run and prioritizes the new input after release; it is not live steering. There are no queue-item edit/delete/reorder commands; show includes queue state. start/send return clientMessageId and the accepted/queued result. A queued result may refer to the currently active Run: associate queueItemId/clientMessageId with history before deciding that input finished.',
27
+ 'Create workers only as tasks need them, named fixer#<task-id> <description> or reviewer#<task-or-change-id> <description>. These are conventions, not product roles or a fixed pool. Put role, scope, acceptance criteria and delivery requirements in the first message. Follow the repository workflow for development and review. Session Prompt is edited in the Web UI only; marctl has no editing command, and sends to existing Sessions use normal prompt injection.',
28
+ 'Reuse recorded Session/Run IDs and receipts. Refresh volatile Run, queue and Goal state with sessions show only when the next action depends on it. Select needed JSON fields instead of dumping full Session lists or tool transcripts. Hidden reasoning, credentials and raw model requests are not collaboration artifacts.',
29
+ 'Read results with sessions history <session-id> --limit 10 --json (default: latest 10 SEGMENTs, not messages; --limit 1..100). Extract needed assistant_message payload.text from segments[].items[]; keep segment runId and user_message.payload.clientMessageId for queued-input correlation. Reuse the returned page; follow --cursor <nextCursor> only for missing evidence.',
30
+ 'Send urgent corrections with sessions send <session-id> --forced -m "Use the corrected acceptance criteria" --json. Always choose exactly one of --forced or --queue, even when idle; missing or conflicting modes are rejected locally. --forced inserts: send immediately when idle; if busy, interrupt the current execution and prioritize the new input after release, not live steering. Use --queue to wait for an eligible FIFO turn without interrupting or resuming a paused queue; eligible input can start immediately when idle. For non-urgent follow-ups, use sessions send <session-id> --queue -m "Include test evidence" --json. There are no queue-item edit/delete/reorder commands; show includes queue state. start/send return clientMessageId and the accepted/queued result. A queued result may refer to the currently active Run: associate queueItemId/clientMessageId with history before deciding that input finished.',
29
31
  'Wait with sessions wait <session-id> --run <run-id> --timeout 5m --json through the existing Watch. Prefer --timeout 5m when blocked on task completion; use 30s for a short deadline. Continue useful independent work before waiting. If exec returns a running process or cell, reuse its process/cell handle rather than starting another wait or polling show/list. Timeout or cancelled waiting does not stop the task. Use sessions stop <session-id> --run <run-id> --json only to stop that active Run; ownership rules still apply.',
30
32
  'Goal get/set/clear use the existing Session Goal semantics; see marctl help sessions. A Coordinator can work from ordinary messages or its existing schedule without a Goal. Worker completion never automatically wakes it. Actively wait/read results, or finish and reconcile on the next user message or scheduled input.',
31
33
  '## Verify, recover and respect authorization',
32
- 'An accepted message or SUCCEEDED Run is not proof of business completion. Inspect artifacts and test evidence. If review finds a defect, send a focused correction to the recorded Fixer Session, then request another review with the recorded Reviewer ID.',
34
+ 'An accepted message or SUCCEEDED Run is not proof of business completion. Inspect artifacts and test evidence.',
33
35
  'Network loss can leave a mutation outcome unknown. Preserve the reported clientMessageId and inspect current state/history before retrying; do not blindly resend or create another Session. Server/Node restart does not guarantee recovery of in-memory Runs or queues.',
34
36
  'MAR injects user credentials for 24 hours from issue, without automatic renewal. Never print, copy into task files or persist authorization environment values. CLI calls use the real initiating user, including scheduled provenance; worker roles do not elevate access or inherit an auto-approval mode.',
35
37
  'Sessions share Workspace files and the Git index. Agree on non-overlapping changes and respect repository locking rules. There is no automatic worktree isolation, workflow engine, reliable callback or completion receipt store.',
@@ -369,6 +369,38 @@ export class MarAgentRunner extends AbstractRunner {
369
369
  await catalog.close().catch(() => undefined);
370
370
  }
371
371
  }
372
+ async restartInputState(sessionId, clientMessageId) {
373
+ const catalog = await openMarAgentSessionCatalog({ homeDirectory: this.homeDirectory });
374
+ try {
375
+ let cursor;
376
+ const states = new Map();
377
+ do {
378
+ const page = await catalog.history(sessionId, {
379
+ ...(cursor === undefined ? {} : { cursor }),
380
+ limit: 1000
381
+ });
382
+ for (const entry of page.entries) {
383
+ if (entry.kind !== 'event' || entry.event.turnId === undefined)
384
+ continue;
385
+ const event = entry.event;
386
+ if (event.type === 'execution.completed')
387
+ states.set(event.turnId, event.permissionStop === undefined ? 'completed' : 'paused');
388
+ else if (event.type === 'execution.failed')
389
+ states.set(event.turnId, 'completed');
390
+ else if (event.type === 'execution.cancelled')
391
+ states.set(event.turnId, 'interrupted');
392
+ }
393
+ const user = page.entries.find((entry) => entry.kind === 'user' && entry.clientMessageId === clientMessageId);
394
+ if (user?.kind === 'user')
395
+ return states.get(user.turnId) ?? 'interrupted';
396
+ cursor = page.nextCursor;
397
+ } while (cursor !== undefined);
398
+ return 'missing';
399
+ }
400
+ finally {
401
+ await catalog.close();
402
+ }
403
+ }
372
404
  async readOfficialConversation(session, input) {
373
405
  if (session.externalSessionId === null)
374
406
  return undefined;
@@ -84,6 +84,7 @@ export class NodeRuntimeState {
84
84
  // A first user prompt is only a process-local temporary title. A
85
85
  // Runner/native title can replace it on the next discovery refresh.
86
86
  titleSource: customTitle === null ? 'AUTO' : 'CUSTOM',
87
+ parentSessionId: input.parentSessionId ?? null,
87
88
  lastActivityAt: input.lastActivityAt ?? now,
88
89
  createdAt: input.createdAt ?? now
89
90
  };
@@ -163,9 +164,9 @@ export class NodeRuntimeState {
163
164
  }
164
165
  updateAgentSessionConfiguration(input) {
165
166
  return this.replaceSession(input.sessionId, {
166
- model: input.model,
167
- effort: input.effort,
168
- access: input.access
167
+ ...(input.model === undefined ? {} : { model: input.model }),
168
+ ...(input.effort === undefined ? {} : { effort: input.effort }),
169
+ ...(input.access === undefined ? {} : { access: input.access })
169
170
  });
170
171
  }
171
172
  updateAgentSessionAccess(sessionId, access) {
@@ -338,6 +339,7 @@ export class NodeRuntimeState {
338
339
  version: 0,
339
340
  clientMessageId: input.clientMessageId,
340
341
  ...(input.source === undefined ? {} : { source: input.source }),
342
+ ...(input.suppressNotifications === true ? { suppressNotifications: true } : {}),
341
343
  ...(input.initiatedByUserId === undefined
342
344
  ? {}
343
345
  : { initiatedByUserId: input.initiatedByUserId }),
@@ -416,6 +418,7 @@ export class NodeRuntimeState {
416
418
  runner: session.runner,
417
419
  clientMessageId: input.clientMessageId,
418
420
  content: input.content,
421
+ ...(input.suppressNotifications === true ? { suppressNotifications: true } : {}),
419
422
  attachments: input.attachments ?? [],
420
423
  ...(input.initiatedByUserId === undefined
421
424
  ? {}
@@ -449,11 +452,14 @@ export class NodeRuntimeState {
449
452
  if (itemId === undefined)
450
453
  return undefined;
451
454
  const item = this.requireQueueItem(itemId);
452
- const run = active.initiatedByUserId === undefined && item.initiatedByUserId !== undefined
453
- ? { ...active, initiatedByUserId: item.initiatedByUserId }
454
- : active;
455
- if (run !== active)
456
- this.runs.set(run.id, run);
455
+ const run = {
456
+ ...active,
457
+ ...(active.initiatedByUserId === undefined && item.initiatedByUserId !== undefined
458
+ ? { initiatedByUserId: item.initiatedByUserId }
459
+ : {}),
460
+ suppressNotifications: item.suppressNotifications === true
461
+ };
462
+ this.runs.set(run.id, run);
457
463
  this.queuedItems.set(sessionId, queue.slice(1));
458
464
  this.bumpQueueVersion(sessionId);
459
465
  this.queueItems.delete(itemId);
@@ -482,6 +488,7 @@ export class NodeRuntimeState {
482
488
  clientMessageId: item.clientMessageId,
483
489
  content: content ?? item.content,
484
490
  recentUserMessage: item.content,
491
+ ...(item.suppressNotifications === true ? { suppressNotifications: true } : {}),
485
492
  ...(item.attachments.length === 0 ? {} : { attachments: item.attachments }),
486
493
  ...(item.initiatedByUserId === undefined ? {} : { initiatedByUserId: item.initiatedByUserId })
487
494
  });
@@ -607,8 +614,6 @@ export class NodeRuntimeState {
607
614
  return { kind: 'run', run: this.requireRun(submission.id) };
608
615
  const item = this.requireQueueItem(submission.id);
609
616
  const run = this.activeSessionRun(sessionId);
610
- if (run === undefined)
611
- throw new Error('RUN_NOT_FOUND');
612
617
  return { kind: 'queue', item, run };
613
618
  }
614
619
  messagePreparationFailure(sessionId, clientMessageId) {