@myagentroam/node 0.9.122 → 0.9.125

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 (31) hide show
  1. package/dist/codex-app-server.js +6 -79
  2. package/dist/connector.js +217 -6
  3. package/dist/database.js +2 -1
  4. package/dist/migrations/v014.js +107 -0
  5. package/dist/runner/abstract-runner.js +11 -5
  6. package/dist/runner/claude-code-runner.js +3 -0
  7. package/dist/runner/codex/conversation-parser.js +142 -2
  8. package/dist/runner/codex/managed-run-controller.js +26 -18
  9. package/dist/runner/codex-runner.js +127 -57
  10. package/dist/runner/opencode-runner.js +14 -12
  11. package/dist/runtime-state.js +6 -1
  12. package/dist/service/continuous-run-coordinator.js +8 -2
  13. package/dist/service/conversation-history-service.js +51 -10
  14. package/dist/service/managed-runner-session-service.js +2 -1
  15. package/dist/service/native-session-projection-service.js +3 -1
  16. package/dist/service/node-control-message-service.js +2 -0
  17. package/dist/service/run-event-service.js +18 -2
  18. package/dist/service/runner-interaction-service.js +9 -8
  19. package/dist/service/scheduled-run-authorization-control.js +72 -0
  20. package/dist/service/scheduled-session-coordinator.js +95 -0
  21. package/dist/service/scheduled-session-recurrence.js +72 -0
  22. package/dist/service/scheduled-session-store.js +234 -0
  23. package/dist/service/scheduled-task-mcp-server.js +179 -0
  24. package/dist/service/scheduled-task-mcp-stdio.js +63 -0
  25. package/dist/service/session-catalog-service.js +3 -1
  26. package/dist/service/session-lifecycle-service.js +6 -0
  27. package/dist/service/session-message-service.js +25 -2
  28. package/dist/service/session-presentation-service.js +19 -2
  29. package/dist/service/session-schedule-service.js +153 -0
  30. package/dist/util/runner-native-session-parsers.js +19 -11
  31. package/package.json +4 -4
@@ -1,10 +1,4 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
- import { randomUUID } from 'node:crypto';
3
- import { existsSync } from 'node:fs';
4
- import { unlink } from 'node:fs/promises';
5
- import { connect } from 'node:net';
6
- import { tmpdir } from 'node:os';
7
- import { join } from 'node:path';
8
2
  import { createInterface } from 'node:readline';
9
3
  import { minimalRunnerEnvironment, platformCliInvocation, runRunnerProbe } from './operational.js';
10
4
  const REQUEST_TIMEOUT_MS = 15_000;
@@ -45,15 +39,13 @@ export function isCodexRolloutMissingError(error) {
45
39
  /^thread not loaded:\s*/i.test(error.rpcMessage)));
46
40
  }
47
41
  /**
48
- * Local JSON-RPC client for a Node-supervised Codex App Server. Linux/macOS
49
- * use a private Unix socket by default; Windows uses stdio. Neither is exposed
50
- * to MAR Server or the browser.
42
+ * Local JSON-RPC client for a Node-supervised Codex App Server over stdio.
43
+ * The transport is not exposed to MAR Server or the browser.
51
44
  */
52
45
  export class CodexAppServerClient {
53
46
  options;
54
47
  child;
55
48
  transport;
56
- socketPath;
57
49
  nextId = 1;
58
50
  pending = new Map();
59
51
  pendingDrainWaiters = new Set();
@@ -105,43 +97,15 @@ export class CodexAppServerClient {
105
97
  }
106
98
  async startManaged() {
107
99
  const command = this.options.command ?? 'codex';
108
- const mode = this.transportMode();
109
- this.socketPath =
110
- mode === 'unix' ? (this.options.socketPath ?? privateSocketPath()) : undefined;
111
- const baseArgs = this.options.args ?? ['app-server'];
112
- const args = [
113
- ...baseArgs,
114
- ...(mode === 'unix' ? ['--listen', `unix://${this.socketPath}`] : [])
115
- ];
100
+ const args = this.options.args ?? ['app-server'];
116
101
  const spawnProcess = this.options.spawnProcess ?? defaultSpawn;
117
- let child = spawnProcess(command, args, {
102
+ const child = spawnProcess(command, args, {
118
103
  ...(this.options.cwd === undefined ? {} : { cwd: this.options.cwd }),
119
104
  env: { ...minimalRunnerEnvironment(), ...this.environment }
120
105
  });
121
106
  this.child = child;
122
- let socket;
123
- if (mode === 'unix') {
124
- try {
125
- socket = await connectUnix(this.socketPath, (this.options.transport ?? 'auto') === 'auto'
126
- ? Math.min(this.options.timeoutMs ?? REQUEST_TIMEOUT_MS, 1_000)
127
- : (this.options.timeoutMs ?? REQUEST_TIMEOUT_MS));
128
- }
129
- catch (error) {
130
- if ((this.options.transport ?? 'auto') !== 'auto')
131
- throw error;
132
- child.kill();
133
- if (this.socketPath !== undefined && existsSync(this.socketPath))
134
- await unlink(this.socketPath);
135
- this.socketPath = undefined;
136
- child = spawnProcess(command, baseArgs, {
137
- ...(this.options.cwd === undefined ? {} : { cwd: this.options.cwd }),
138
- env: { ...minimalRunnerEnvironment(), ...this.environment }
139
- });
140
- this.child = child;
141
- }
142
- }
143
- this.transport = socket ?? stdioTransport(child);
144
- const lines = createInterface({ input: socket ?? child.stdout });
107
+ this.transport = stdioTransport(child);
108
+ const lines = createInterface({ input: child.stdout });
145
109
  lines.on('line', (line) => this.handleLine(line));
146
110
  const managedChild = child;
147
111
  child.once('error', (error) => {
@@ -180,9 +144,6 @@ export class CodexAppServerClient {
180
144
  this.transport = undefined;
181
145
  this.child?.kill();
182
146
  this.child = undefined;
183
- if (this.socketPath !== undefined && existsSync(this.socketPath))
184
- void unlink(this.socketPath);
185
- this.socketPath = undefined;
186
147
  this.failAll(new Error('CODEX_APP_SERVER_STOPPED'));
187
148
  }
188
149
  onNotification(listener) {
@@ -331,15 +292,6 @@ export class CodexAppServerClient {
331
292
  }
332
293
  this.resolvePendingDrain();
333
294
  }
334
- transportMode() {
335
- const requested = this.options.transport ?? 'auto';
336
- if (requested === 'auto')
337
- return process.platform === 'linux' || process.platform === 'darwin' ? 'unix' : 'stdio';
338
- if (requested === 'unix' && process.platform !== 'linux' && process.platform !== 'darwin') {
339
- throw new Error('CODEX_UNIX_SOCKET_PLATFORM_UNSUPPORTED');
340
- }
341
- return requested;
342
- }
343
295
  }
344
296
  function codexThreadConfiguration(cwd, configuration) {
345
297
  return {
@@ -472,28 +424,3 @@ function stdioTransport(child) {
472
424
  destroy: () => child.stdout.destroy()
473
425
  };
474
426
  }
475
- export function privateSocketPath(directory = tmpdir(), processId = process.pid, entropy = randomUUID()) {
476
- // macOS has a shorter Unix-domain socket pathname limit than Linux, and its
477
- // per-user TMPDIR is comparatively long. The process ID plus 64 random bits
478
- // keeps concurrent sockets distinct while leaving room for that directory.
479
- return join(directory, `m-${processId}-${entropy.replaceAll('-', '').slice(0, 16)}.sock`);
480
- }
481
- function connectUnix(socketPath, timeoutMs) {
482
- return new Promise((resolve, reject) => {
483
- const deadline = Date.now() + timeoutMs;
484
- const attempt = () => {
485
- const socket = connect(socketPath);
486
- const retry = (error) => {
487
- socket.destroy();
488
- if (Date.now() >= deadline) {
489
- reject(new Error(`CODEX_UNIX_SOCKET_CONNECT_FAILED:${error.message}`));
490
- return;
491
- }
492
- setTimeout(attempt, 25);
493
- };
494
- socket.once('connect', () => resolve(socket));
495
- socket.once('error', retry);
496
- };
497
- attempt();
498
- });
499
- }
package/dist/connector.js CHANGED
@@ -61,6 +61,11 @@ import { RunnerInteractionService } from './service/runner-interaction-service.j
61
61
  import { SessionLifecycleService } from './service/session-lifecycle-service.js';
62
62
  import { ExternalSessionResumeService } from './service/external-session-resume-service.js';
63
63
  import { SessionMessageService } from './service/session-message-service.js';
64
+ import { SessionScheduleService } from './service/session-schedule-service.js';
65
+ import { ScheduledSessionCoordinator } from './service/scheduled-session-coordinator.js';
66
+ import { ScheduledSessionStore } from './service/scheduled-session-store.js';
67
+ import { ScheduledRunAuthorizationControl } from './service/scheduled-run-authorization-control.js';
68
+ import { ScheduledTaskMcpServer } from './service/scheduled-task-mcp-server.js';
64
69
  import { SessionCatalogService } from './service/session-catalog-service.js';
65
70
  import { SessionCommandService } from './service/session-command-service.js';
66
71
  import { SessionPresentationService } from './service/session-presentation-service.js';
@@ -151,6 +156,8 @@ export class NodeConnector {
151
156
  nodeGeneration = crypto.randomUUID().replace(/-/g, '');
152
157
  controlChannel;
153
158
  controlMessages;
159
+ scheduledRunAuthorization;
160
+ scheduledSessions;
154
161
  nodeRequests;
155
162
  connectionLifecycle;
156
163
  runtimeCredentials = new RuntimeCredentialControlService({
@@ -253,6 +260,7 @@ export class NodeConnector {
253
260
  goalSubmissionAttempts = new Map();
254
261
  goalContinuationEpochs = new Map();
255
262
  goalExecutionProgress = new Set();
263
+ sessionScheduleService;
256
264
  goalMcpServer = new GoalMcpServer(this.goalRuntime, (sessionId) => {
257
265
  const goal = this.goalRuntime.get(sessionId);
258
266
  const session = this.runtime.getAgentSession(sessionId);
@@ -264,6 +272,22 @@ export class NodeConnector {
264
272
  }
265
273
  this.publishGoalState(session, goal);
266
274
  });
275
+ scheduledTaskMcpServer = new ScheduledTaskMcpServer(() => {
276
+ if (this.database === undefined)
277
+ throw new Error('NODE_DATABASE_UNAVAILABLE');
278
+ return new ScheduledSessionStore(this.database.raw);
279
+ }, (sessionId) => {
280
+ const session = this.runtime.getAgentSession(sessionId);
281
+ if (session !== undefined)
282
+ this.emitSessionWorkbenchEvent({
283
+ session: this.sessionPresentationService.present(session)
284
+ });
285
+ this.scheduledSessions.changed();
286
+ }, (sessionId, proposalId) => this.emitScheduledProposalNotification(sessionId, proposalId), (input) => {
287
+ if (this.sessionScheduleService === undefined)
288
+ throw new Error('SCHEDULE_SERVICE_UNAVAILABLE');
289
+ return this.sessionScheduleService.createFromMcp(input);
290
+ }, (sessionId) => this.runtime.getAgentSession(sessionId)?.externalSessionId ?? sessionId);
267
291
  runnerUsageReader;
268
292
  terminalManager;
269
293
  upgradeCoordinator;
@@ -314,6 +338,17 @@ export class NodeConnector {
314
338
  expireImages: (runId) => this.runAttachmentService.expire(runId),
315
339
  scheduleContextRefresh: (sessionId) => this.sessionContextService.schedule(sessionId),
316
340
  refreshWorkspaceSessions: (workspaceId) => this.workspaceWorkbenchService.ensureTopicSnapshot(workspaceId, 'sessions'),
341
+ onTerminalRun: (run, status) => {
342
+ if (run.source !== 'SCHEDULED' || this.database === undefined)
343
+ return;
344
+ const store = new ScheduledSessionStore(this.database.raw);
345
+ store.finish(run.clientMessageId, status === 'SUCCEEDED' ? 'SUCCEEDED' : 'FAILED', status === 'SUCCEEDED' ? undefined : `SCHEDULE_RUN_${status}`, run.id);
346
+ if (status !== 'SUCCEEDED') {
347
+ const task = store.get(run.sessionId);
348
+ if (task !== undefined)
349
+ this.emitScheduledNotification(task, run.clientMessageId, `SCHEDULE_RUN_${status}`, 'FAILED');
350
+ }
351
+ },
317
352
  dispatchQueue: async (sessionId, delayMs = 0) => {
318
353
  if (this.upgradeCoordinator.pending())
319
354
  return;
@@ -386,6 +421,7 @@ export class NodeConnector {
386
421
  releaseExecution: (executionId) => {
387
422
  this.goalExecutionProgress.delete(executionId);
388
423
  this.goalMcpServer.revoke(executionId);
424
+ this.scheduledTaskMcpServer.revoke(executionId);
389
425
  this.continuousRunCoordinator.remove(executionId);
390
426
  }
391
427
  });
@@ -463,7 +499,7 @@ export class NodeConnector {
463
499
  options.fakeRunner === undefined ? undefined : new FakeTestRunner(options.fakeRunner);
464
500
  this.fakeScript = options.fakeScript;
465
501
  this.runAttachmentService = new RunAttachmentService((session) => this.runners.require(session.runner).nativeImageRoots(session));
466
- this.codexClient = new CodexRunner(options.codexClient, options.codexProfileClientFactory);
502
+ this.codexClient = new CodexRunner(options.codexClient, options.codexProfileClientFactory, options.codexRunClientFactory);
467
503
  this.codexRunController = new CodexManagedRunController(this.codexClient, {
468
504
  available: () => this.capabilities.codex.available,
469
505
  intercepted: () => this.fakeRunner !== undefined,
@@ -483,7 +519,7 @@ export class NodeConnector {
483
519
  emit: (runId, eventType, payload, status) => this.runEventBridge.emitRun(runId, eventType, payload, status),
484
520
  cancel: (runId, threadId, turnId, cwd, status) => this.runnerInteractionService.cancelCodexTurn(runId, threadId, turnId, cwd, status),
485
521
  createApproval: (input) => this.runtime.createApproval(input),
486
- publishApproval: (input) => this.runEventBridge.publishApproval(input),
522
+ publishApproval: (input) => this.publishApprovalForRun(input),
487
523
  emitItem: (runId, item) => this.emitConversationItem(runId, item),
488
524
  appendImages: (runId, images) => this.runAttachmentService.append(runId, images.filter(isRunnerImageAttachment)),
489
525
  syncTitle: (sessionId, nativeSessionId, cwd) => this.managedRunnerSessions.refreshTitle(sessionId, nativeSessionId, cwd),
@@ -574,7 +610,7 @@ export class NodeConnector {
574
610
  expireApproval: (approvalId) => {
575
611
  this.runtime.expireApproval(approvalId);
576
612
  },
577
- publishApproval: (input) => this.runEventBridge.publishApproval(input),
613
+ publishApproval: (input) => this.publishApprovalForRun(input),
578
614
  commandState: (sessionId, commandId) => this.commandStates.get(sessionId, commandId),
579
615
  setCommandState: (sessionId, state) => this.commandStates.set(sessionId, state),
580
616
  clearCommandState: (sessionId, commandId) => this.commandStates.clear(sessionId, commandId),
@@ -622,7 +658,7 @@ export class NodeConnector {
622
658
  emitRun: (runId, eventType, payload, status) => this.runEventBridge.emitRun(runId, eventType, payload, status),
623
659
  emitItem: (runId, item) => this.emitConversationItem(runId, item),
624
660
  approvalTimeoutMs: this.claudeApprovalTimeoutMs,
625
- publishApproval: (input) => this.runEventBridge.publishApproval(input)
661
+ publishApproval: (input) => this.publishApprovalForRun(input)
626
662
  });
627
663
  this.runnerChannelMessages = new RunnerChannelMessageService({
628
664
  interactions: this.runnerInteractionService,
@@ -763,6 +799,38 @@ export class NodeConnector {
763
799
  },
764
800
  secrets: resolved.secrets
765
801
  };
802
+ },
803
+ scheduledMcp: async (input) => {
804
+ if (this.database === undefined)
805
+ return undefined;
806
+ const task = new ScheduledSessionStore(this.database.raw).get(input.sessionId);
807
+ const run = this.runtime.getRun(input.runId);
808
+ if (run?.initiatedByUserId === undefined)
809
+ return undefined;
810
+ const resolved = await this.scheduledTaskMcpServer.installation({
811
+ executionId: input.runId,
812
+ sessionId: input.sessionId,
813
+ workspaceId: input.workspaceId,
814
+ ...(task === undefined ? {} : { taskId: task.id, revision: task.revision }),
815
+ actorUserId: run.initiatedByUserId,
816
+ readonly: run.source === 'SCHEDULED'
817
+ });
818
+ return {
819
+ installation: {
820
+ targetKind: 'WORKSPACE',
821
+ targetId: input.workspaceId,
822
+ catalogEntryId: 'mar-internal-schedule',
823
+ serverName: 'mar_schedule',
824
+ displayName: 'MAR scheduled task',
825
+ version: '1.0.0',
826
+ enabled: true,
827
+ runtime: { transport: 'HTTP', url: 'https://127.0.0.1.invalid/mcp', headers: {} },
828
+ configuration: resolved.installation,
829
+ installedAt: Date.now(),
830
+ ...resolved.installation
831
+ },
832
+ secrets: resolved.secrets
833
+ };
766
834
  }
767
835
  });
768
836
  this.workspaceQueueWorkbenchService = new WorkspaceQueueWorkbenchService({
@@ -1077,7 +1145,7 @@ export class NodeConnector {
1077
1145
  runtime: this.runtime,
1078
1146
  state: this.runState,
1079
1147
  emitRun: (runId, type, payload, status) => this.runEventBridge.emitRun(runId, type, payload, status),
1080
- publishApproval: (input) => this.runEventBridge.publishApproval(input),
1148
+ publishApproval: (input) => this.publishApprovalForRun(input),
1081
1149
  disconnect: () => this.controlChannel.close(4010, 'FAKE_RUNNER_DISCONNECT')
1082
1150
  });
1083
1151
  this.fakeRunner?.bindControl({
@@ -1122,6 +1190,7 @@ export class NodeConnector {
1122
1190
  onClose: (code, reason) => {
1123
1191
  this.directTransferService?.close();
1124
1192
  this.runtimeCredentials.disconnected();
1193
+ this.scheduledRunAuthorization.disconnected();
1125
1194
  this.terminalChannel.disconnect();
1126
1195
  if (code === 4000 && reason === 'NODE_CONNECTION_REPLACED')
1127
1196
  nodeLog('node.connection.replaced', {
@@ -1139,6 +1208,7 @@ export class NodeConnector {
1139
1208
  inspectWorkspace: (envelope) => this.workspaceCoordinator.inspect(envelope),
1140
1209
  handleNodeRequest: (envelope) => this.nodeRequests.handle(envelope),
1141
1210
  handleRuntimeCredentialResponse: (envelope) => this.runtimeCredentials.handle(envelope),
1211
+ handleScheduledRunAuthorizeResponse: (envelope) => this.scheduledRunAuthorization.handle(envelope),
1142
1212
  handleDirectTransfer: (envelope) => this.handleDirectTransfer(envelope),
1143
1213
  handleTerminalDirect: (envelope) => this.handleTerminalDirect(envelope),
1144
1214
  reconcileRuns: (envelope) => this.runEventBridge.reconcile(envelope),
@@ -1200,6 +1270,7 @@ export class NodeConnector {
1200
1270
  },
1201
1271
  onConnected: () => {
1202
1272
  this.runtimeCredentials.connected();
1273
+ this.scheduledRunAuthorization.connected();
1203
1274
  this.recentRunProjectionService.publishSnapshot();
1204
1275
  }
1205
1276
  });
@@ -1208,6 +1279,46 @@ export class NodeConnector {
1208
1279
  throw new Error('NODE_DATABASE_UNAVAILABLE');
1209
1280
  return this.database;
1210
1281
  };
1282
+ this.scheduledRunAuthorization = new ScheduledRunAuthorizationControl((envelope) => this.controlChannel.sendEnvelope(envelope));
1283
+ this.scheduledSessions = new ScheduledSessionCoordinator({
1284
+ store: () => new ScheduledSessionStore(requireDatabase().raw),
1285
+ busy: (task) => this.runtime.activeSessionRun(task.sessionId) !== undefined ||
1286
+ this.runtime.sessionQueue(task.sessionId).itemIds.length > 0,
1287
+ authorize: async (task, occurrenceId) => {
1288
+ const session = this.runtime.getAgentSession(task.sessionId) ??
1289
+ (await this.nativeSessionProjectionService.resolveCurrent(task.sessionId));
1290
+ if (session === undefined ||
1291
+ session.workspaceId !== task.workspaceId ||
1292
+ session.runner !== task.runner)
1293
+ throw new Error('SCHEDULE_SESSION_NOT_FOUND');
1294
+ const configuration = this.nativeSessionProjectionService.resumedConfiguration(session);
1295
+ return this.scheduledRunAuthorization.authorize({
1296
+ taskId: task.id,
1297
+ revision: task.revision,
1298
+ occurrenceId,
1299
+ sessionId: task.sessionId,
1300
+ workspaceId: task.workspaceId,
1301
+ ownerUserId: task.ownerUserId,
1302
+ runner: task.runner,
1303
+ model: configuration.model
1304
+ });
1305
+ },
1306
+ submit: async (task, occurrenceId, context) => {
1307
+ const result = (await this.sessionMessageService.submitScheduled({
1308
+ sessionId: task.sessionId,
1309
+ occurrenceId,
1310
+ objective: task.objective,
1311
+ initiatedByUserId: task.ownerUserId,
1312
+ secretEnvironment: context.secretEnvironment,
1313
+ personalInstructions: context.personalInstructions,
1314
+ marAgentModel: context.marAgentModel,
1315
+ marAgentModels: context.marAgentModels,
1316
+ marAgentRuntimeScopeId: context.marAgentRuntimeScopeId
1317
+ }));
1318
+ return { runId: result.run.id };
1319
+ },
1320
+ notify: (task, occurrenceId, code, status) => this.emitScheduledNotification(task, occurrenceId, code, status)
1321
+ });
1211
1322
  this.runnerService = new RunnerService(requireDatabase, () => this.capabilities, this.runners, this.runnerUsageReader);
1212
1323
  this.workbenchManifestService = new WorkbenchManifestService({
1213
1324
  capabilities: () => this.capabilities,
@@ -1273,6 +1384,23 @@ export class NodeConnector {
1273
1384
  this.operationRouter.registerAll(this.nativeSessionWatchService.operations());
1274
1385
  this.operationRouter.registerAll(this.sessionQueryService.operations());
1275
1386
  this.operationRouter.registerAll(this.sessionLifecycleService.operations());
1387
+ this.sessionScheduleService = new SessionScheduleService({
1388
+ database: requireDatabase,
1389
+ runtime: this.runtime,
1390
+ projection: this.nativeSessionProjectionService,
1391
+ awaitNativeSessionId: (sessionId) => {
1392
+ const nativeId = this.runtime.getAgentSession(sessionId)?.externalSessionId;
1393
+ return nativeId === null || nativeId === undefined
1394
+ ? this.sessionIdentityService.wait(sessionId)
1395
+ : Promise.resolve(nativeId);
1396
+ },
1397
+ present: (session) => this.sessionPresentationService.present(session),
1398
+ emitSession: (session) => this.emitSessionWorkbenchEvent({
1399
+ session: this.sessionPresentationService.present(session)
1400
+ }),
1401
+ changed: () => this.scheduledSessions.changed()
1402
+ });
1403
+ this.operationRouter.registerAll(this.sessionScheduleService.operations());
1276
1404
  this.operationRouter.registerAll(this.sessionMessageService.operations());
1277
1405
  this.operationRouter.registerAll(this.sessionCatalogService.operations());
1278
1406
  this.operationRouter.registerAll(this.sessionCommandService.operations());
@@ -1453,6 +1581,7 @@ export class NodeConnector {
1453
1581
  await assertLocalSqlitePath(nodeDatabasePath(this.config, this.configPath));
1454
1582
  this.database ??= new NodeDatabase(nodeDatabasePath(this.config, this.configPath), () => this.config?.nodeId ?? 'unregistered');
1455
1583
  this.database.closeRunningTerminalSessions();
1584
+ this.scheduledSessions.start();
1456
1585
  await this.initializeConversationHosting();
1457
1586
  this.computerUseEnablement.initialize();
1458
1587
  this.directTransferService ??= this.createDirectTransferService();
@@ -1786,11 +1915,14 @@ export class NodeConnector {
1786
1915
  this.workspaceChangeService.clear();
1787
1916
  const runnerDrain = this.runners.stop();
1788
1917
  const goalMcpDrain = this.goalMcpServer.close();
1918
+ const scheduledMcpDrain = this.scheduledTaskMcpServer.close();
1789
1919
  // close() transitions metadata before awaiting process-tree termination,
1790
1920
  // so the lifecycle is persisted before this synchronous shutdown returns.
1791
1921
  const terminalDrain = this.terminalManager.shutdown();
1792
1922
  this.terminalChannel.stop();
1793
1923
  this.runtimeCredentials.close();
1924
+ this.scheduledSessions.stop();
1925
+ this.scheduledRunAuthorization.close();
1794
1926
  this.directTransferService?.dispose();
1795
1927
  this.directTransferService = undefined;
1796
1928
  this.controlChannel.stop();
@@ -1801,7 +1933,8 @@ export class NodeConnector {
1801
1933
  runnerDrain,
1802
1934
  terminalDrain,
1803
1935
  uploadDrain,
1804
- goalMcpDrain
1936
+ goalMcpDrain,
1937
+ scheduledMcpDrain
1805
1938
  ]);
1806
1939
  this.stopping = (async () => {
1807
1940
  try {
@@ -1818,6 +1951,84 @@ export class NodeConnector {
1818
1951
  }
1819
1952
  /** @internal Thin compatibility delegates; all behavior remains in the extracted services. */
1820
1953
  handleMessage = (raw) => this.controlMessages.handle(raw);
1954
+ publishApprovalForRun = (input) => {
1955
+ const run = this.runtime.getRun(this.runtime.canonicalRunId(input.runId));
1956
+ if (run?.source !== 'SCHEDULED') {
1957
+ this.runEventBridge.publishApproval(input);
1958
+ return;
1959
+ }
1960
+ this.runEventBridge.emitRun(input.runId, 'approval.request', input);
1961
+ queueMicrotask(() => {
1962
+ try {
1963
+ this.runtime.decideApproval(input.approvalId, 'APPROVED');
1964
+ this.runEventBridge.emitRun(input.runId, 'approval.decided', {
1965
+ approvalId: input.approvalId,
1966
+ decision: 'APPROVED',
1967
+ source: 'SCHEDULED'
1968
+ });
1969
+ this.runners.decideApproval({
1970
+ runId: input.runId,
1971
+ approvalId: input.approvalId,
1972
+ decision: 'APPROVED'
1973
+ });
1974
+ }
1975
+ catch {
1976
+ // A cancelled execution cannot receive an approval after its pending request expires.
1977
+ }
1978
+ });
1979
+ };
1980
+ emitScheduledNotification = (task, occurrenceId, code, status) => {
1981
+ const occurredAt = Date.now();
1982
+ const session = this.runtime.getAgentSession(task.sessionId);
1983
+ this.emitWorkbenchEvent('conversation', {
1984
+ event: {
1985
+ runId: occurrenceId,
1986
+ sequence: 1,
1987
+ eventType: 'notification',
1988
+ payload: {
1989
+ targetType: 'WORKSPACE_SESSION',
1990
+ userId: task.ownerUserId,
1991
+ workspaceId: task.workspaceId,
1992
+ sessionId: task.sessionId,
1993
+ sessionTitle: session?.customTitle ?? session?.runnerTitle ?? task.sessionId,
1994
+ runId: occurrenceId,
1995
+ notificationId: `schedule:${task.id}:${occurrenceId}`,
1996
+ kind: status === 'FAILED' ? 'SCHEDULE_FAILED' : 'SCHEDULE_SKIPPED',
1997
+ code,
1998
+ occurredAt
1999
+ },
2000
+ createdAt: occurredAt
2001
+ }
2002
+ });
2003
+ };
2004
+ emitScheduledProposalNotification = (sessionId, proposalId) => {
2005
+ if (this.database === undefined)
2006
+ return;
2007
+ const task = new ScheduledSessionStore(this.database.raw).get(sessionId);
2008
+ if (task === undefined)
2009
+ return;
2010
+ const occurredAt = Date.now();
2011
+ const session = this.runtime.getAgentSession(sessionId);
2012
+ this.emitWorkbenchEvent('conversation', {
2013
+ event: {
2014
+ runId: proposalId,
2015
+ sequence: 1,
2016
+ eventType: 'notification',
2017
+ payload: {
2018
+ targetType: 'WORKSPACE_SESSION',
2019
+ userId: task.ownerUserId,
2020
+ workspaceId: task.workspaceId,
2021
+ sessionId,
2022
+ sessionTitle: session?.customTitle ?? session?.runnerTitle ?? sessionId,
2023
+ runId: proposalId,
2024
+ notificationId: `schedule-proposal:${task.id}:${proposalId}`,
2025
+ kind: 'SCHEDULE_PROPOSAL',
2026
+ occurredAt
2027
+ },
2028
+ createdAt: occurredAt
2029
+ }
2030
+ });
2031
+ };
1821
2032
  emitRunEvent = (runId, eventType, payload, status) => this.runEventBridge.emitRun(runId, eventType, payload, status);
1822
2033
  emitConversationItem = (runId, item) => {
1823
2034
  if (this.continuousRunCoordinator.pending(runId)?.goal !== undefined &&
package/dist/database.js CHANGED
@@ -5,8 +5,9 @@ import { DatabaseSync } from 'node:sqlite';
5
5
  import { runnerDefaultConfigurationSchema } from '@myagentroam/protocol';
6
6
  import { mcpCatalogEntrySchema, mcpInstallationConfigurationSchema, mcpRuntimeDescriptorSchema } from '@myagentroam/protocol';
7
7
  import { assertNodeSchemaV13, NODE_SCHEMA_V13, NODE_SCHEMA_VERSION_V13 } from './migrations/v013.js';
8
+ import { NODE_SCHEMA_MIGRATION_V14 } from './migrations/v014.js';
8
9
  // Future released migrations must be contiguous, immutable and begin at v14; Demo is excluded.
9
- export const NODE_SCHEMA_MIGRATIONS = [];
10
+ export const NODE_SCHEMA_MIGRATIONS = [NODE_SCHEMA_MIGRATION_V14];
10
11
  const LEGACY_NODE_SCHEMA_V12 = NODE_SCHEMA_VERSION_V13 - 1;
11
12
  export class NodeDatabase {
12
13
  now;
@@ -0,0 +1,107 @@
1
+ export const NODE_SCHEMA_MIGRATION_V14 = {
2
+ version: 14,
3
+ sql: `CREATE TABLE scheduled_session_tasks (
4
+ id TEXT PRIMARY KEY,
5
+ session_id TEXT NOT NULL UNIQUE REFERENCES agent_sessions(id) ON DELETE CASCADE,
6
+ workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
7
+ runner TEXT NOT NULL CHECK (runner IN ('mar-agent','codex','claude-code','opencode')),
8
+ owner_user_id INTEGER NOT NULL CHECK (owner_user_id > 0),
9
+ objective TEXT NOT NULL CHECK (length(objective) BETWEEN 1 AND 20000),
10
+ cron TEXT NOT NULL,
11
+ timezone TEXT NOT NULL,
12
+ enabled INTEGER NOT NULL CHECK (enabled IN (0,1)),
13
+ revision INTEGER NOT NULL CHECK (revision > 0),
14
+ next_run_at INTEGER CHECK (next_run_at IS NULL OR next_run_at >= 0),
15
+ created_at INTEGER NOT NULL CHECK (created_at >= 0),
16
+ updated_at INTEGER NOT NULL CHECK (updated_at >= 0)
17
+ ) STRICT;
18
+ CREATE INDEX scheduled_session_tasks_due
19
+ ON scheduled_session_tasks(enabled, next_run_at);
20
+ CREATE TABLE scheduled_session_occurrences (
21
+ id TEXT PRIMARY KEY,
22
+ task_id TEXT NOT NULL REFERENCES scheduled_session_tasks(id) ON DELETE CASCADE,
23
+ due_at INTEGER NOT NULL CHECK (due_at >= 0),
24
+ status TEXT NOT NULL CHECK (status IN ('CLAIMED','SUBMITTED','SUCCEEDED','SKIPPED','FAILED')),
25
+ code TEXT,
26
+ run_id TEXT,
27
+ created_at INTEGER NOT NULL CHECK (created_at >= 0),
28
+ updated_at INTEGER NOT NULL CHECK (updated_at >= 0),
29
+ UNIQUE(task_id, due_at)
30
+ ) STRICT;
31
+ CREATE INDEX scheduled_session_occurrences_recent
32
+ ON scheduled_session_occurrences(task_id, due_at DESC);
33
+ CREATE TABLE scheduled_session_proposals (
34
+ id TEXT PRIMARY KEY,
35
+ task_id TEXT NOT NULL REFERENCES scheduled_session_tasks(id) ON DELETE CASCADE,
36
+ proposed_by_user_id INTEGER NOT NULL CHECK (proposed_by_user_id > 0),
37
+ base_revision INTEGER NOT NULL CHECK (base_revision > 0),
38
+ objective TEXT CHECK (objective IS NULL OR length(objective) BETWEEN 1 AND 20000),
39
+ cron TEXT,
40
+ timezone TEXT,
41
+ status TEXT NOT NULL CHECK (status IN ('PENDING','ACCEPTED','REJECTED','STALE')),
42
+ created_at INTEGER NOT NULL CHECK (created_at >= 0),
43
+ resolved_at INTEGER
44
+ ) STRICT;
45
+ CREATE INDEX scheduled_session_proposals_pending
46
+ ON scheduled_session_proposals(task_id, status, created_at DESC);`,
47
+ assert(database) {
48
+ const tables = {
49
+ scheduled_session_tasks: [
50
+ 'id',
51
+ 'session_id',
52
+ 'workspace_id',
53
+ 'runner',
54
+ 'owner_user_id',
55
+ 'objective',
56
+ 'cron',
57
+ 'timezone',
58
+ 'enabled',
59
+ 'revision',
60
+ 'next_run_at',
61
+ 'created_at',
62
+ 'updated_at'
63
+ ],
64
+ scheduled_session_occurrences: [
65
+ 'id',
66
+ 'task_id',
67
+ 'due_at',
68
+ 'status',
69
+ 'code',
70
+ 'run_id',
71
+ 'created_at',
72
+ 'updated_at'
73
+ ],
74
+ scheduled_session_proposals: [
75
+ 'id',
76
+ 'task_id',
77
+ 'proposed_by_user_id',
78
+ 'base_revision',
79
+ 'objective',
80
+ 'cron',
81
+ 'timezone',
82
+ 'status',
83
+ 'created_at',
84
+ 'resolved_at'
85
+ ]
86
+ };
87
+ for (const [table, required] of Object.entries(tables)) {
88
+ const row = database
89
+ .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name=?")
90
+ .get(table);
91
+ const columns = new Set(database.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name));
92
+ if (row === undefined ||
93
+ !row.sql.endsWith('STRICT') ||
94
+ required.some((column) => !columns.has(column)))
95
+ throw new Error('NODE_DATABASE_SCHEMA_INVALID');
96
+ }
97
+ for (const name of [
98
+ 'scheduled_session_tasks_due',
99
+ 'scheduled_session_occurrences_recent',
100
+ 'scheduled_session_proposals_pending'
101
+ ])
102
+ if (database
103
+ .prepare("SELECT name FROM sqlite_master WHERE type='index' AND name=?")
104
+ .get(name) === undefined)
105
+ throw new Error('NODE_DATABASE_SCHEMA_INVALID');
106
+ }
107
+ };
@@ -173,7 +173,7 @@ function resolvedMcpServers(raw, secrets) {
173
173
  const record = value;
174
174
  if (typeof record['serverName'] !== 'string' || record['enabled'] !== true)
175
175
  continue;
176
- if (record['__marGoal'] === true &&
176
+ if ((record['__marGoal'] === true || record['__marScheduled'] === true) &&
177
177
  typeof record['command'] === 'string' &&
178
178
  Array.isArray(record['args']) &&
179
179
  record['args'].every((value) => typeof value === 'string') &&
@@ -186,10 +186,16 @@ function resolvedMcpServers(raw, secrets) {
186
186
  transport: 'STDIO',
187
187
  command: record['command'],
188
188
  args: record['args'],
189
- environment: {
190
- MAR_GOAL_MCP_ENDPOINT: record['endpoint'],
191
- MAR_GOAL_MCP_TOKEN: secrets[record['secretName']]
192
- }
189
+ environment: record['__marGoal'] === true
190
+ ? {
191
+ MAR_GOAL_MCP_ENDPOINT: record['endpoint'],
192
+ MAR_GOAL_MCP_TOKEN: secrets[record['secretName']]
193
+ }
194
+ : {
195
+ MAR_SCHEDULE_MCP_ENDPOINT: record['endpoint'],
196
+ MAR_SCHEDULE_MCP_TOKEN: secrets[record['secretName']],
197
+ MAR_SCHEDULE_MCP_READ_ONLY: record['readOnly'] === true ? '1' : '0'
198
+ }
193
199
  });
194
200
  continue;
195
201
  }
@@ -230,6 +230,9 @@ export class ClaudeCodeRunner extends AbstractRunner {
230
230
  externalSessionId: item.externalSessionId,
231
231
  cwd: item.cwd,
232
232
  ...(item.title === undefined ? {} : { title: item.title }),
233
+ ...('titleOrigin' in item && item.titleOrigin !== undefined
234
+ ? { titleOrigin: item.titleOrigin }
235
+ : {}),
233
236
  ...(isNativeHistory(item) ? { lastActivityAt: latestNativeActivity(item) } : {})
234
237
  });
235
238
  const index = sessions.findIndex((candidate) => candidate.id === session.id);