@myagentroam/node 0.11.48 → 0.11.50

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.
@@ -98,7 +98,7 @@ export class NodeLifecycle {
98
98
  await this.shutdown.closeResume();
99
99
  if (failure === undefined && handoff) {
100
100
  stage = 'capture';
101
- snapshot = this.shutdown.capture();
101
+ snapshot = await this.shutdown.capture();
102
102
  }
103
103
  }
104
104
  catch (error) {
@@ -23,7 +23,6 @@ export class NodeOperationAdmission {
23
23
  if (this.stopped())
24
24
  throw new Error('NODE_STOPPED');
25
25
  const input = isPlainRecord(data) ? data : {};
26
- const sessionId = typeof input.sessionId === 'string' ? input.sessionId : undefined;
27
26
  if (this.restart.blocksOperation(operation, input))
28
27
  throw new Error('SESSION_RESTART_PENDING');
29
28
  if (!this.databaseAvailable())
@@ -34,12 +33,7 @@ export class NodeOperationAdmission {
34
33
  const task = this.git.execute(operation, data, (value) => this.router.execute(operation, value));
35
34
  this.inFlight.add(task);
36
35
  try {
37
- const result = await task;
38
- if ((operation === 'session.command.execute' || operation === 'session.message') &&
39
- sessionId !== undefined &&
40
- Number.isSafeInteger(input.__requestUserId))
41
- this.restart.rememberUser(sessionId, input.__requestUserId);
42
- return result;
36
+ return await task;
43
37
  }
44
38
  finally {
45
39
  this.inFlight.delete(task);
package/dist/connector.js CHANGED
@@ -67,7 +67,7 @@ import { SessionScheduleService } from './session/session-schedule-service.js';
67
67
  import { ScheduledSessionCoordinator } from './service/scheduled-session-coordinator.js';
68
68
  import { ScheduledSessionStore } from './service/scheduled-session-store.js';
69
69
  import { RunAuthorizationControl } from './service/run-authorization-control.js';
70
- import { PlannedRestartHandoff } from './service/planned-restart-handoff.js';
70
+ import { PlannedRestartHandoff, plannedRestartCaptureDiagnostic } from './service/planned-restart-handoff.js';
71
71
  import { NodeUpgradeCredentialControl } from './service/node-upgrade-credential-control.js';
72
72
  import { RestartSessionRestorer } from './service/restart-session-restorer.js';
73
73
  import { ScheduleMcpBridge } from './service/schedule-mcp-bridge.js';
@@ -159,7 +159,6 @@ export class NodeConnector {
159
159
  controlChannel;
160
160
  controlMessages;
161
161
  scheduledRunAuthorization;
162
- restartAuthorization;
163
162
  restartHandoff;
164
163
  operationAdmission;
165
164
  executionLifecycle;
@@ -195,12 +194,18 @@ export class NodeConnector {
195
194
  await this.sessionMessageService.drain();
196
195
  },
197
196
  closeResume: () => this.externalSessionResumeService.close(),
198
- capture: () => (this.config?.nodeId ? this.restartHandoff.capture() : undefined),
197
+ capture: async () => {
198
+ if (!this.config?.nodeId)
199
+ return;
200
+ await this.marAgentRuntimeModels.waitForPendingCredentials();
201
+ return this.restartHandoff.capture();
202
+ },
199
203
  dispose: () => this.disposeRuntime(),
200
204
  publish: (snapshot) => this.restartHandoff.publish(snapshot),
201
205
  diagnose: (stage, error) => nodeLog('node.shutdown.failed', {
202
206
  stage,
203
- errorCode: safeErrorCode(error, 'NODE_SHUTDOWN_FAILED')
207
+ errorCode: safeErrorCode(error, 'NODE_SHUTDOWN_FAILED'),
208
+ ...(stage === 'capture' ? plannedRestartCaptureDiagnostic(error) : undefined)
204
209
  })
205
210
  });
206
211
  get stopped() {
@@ -1127,15 +1132,6 @@ export class NodeConnector {
1127
1132
  coordinator: this.continuousRunCoordinator,
1128
1133
  messages: this.sessionMessageService,
1129
1134
  resolve: (id) => this.externalSessionResumeService.resolveForMessage(id),
1130
- authorize: async (request) => {
1131
- const context = await this.restartAuthorization.authorize(request);
1132
- return {
1133
- secretEnvironment: context.secretEnvironment,
1134
- personalInstructions: context.personalInstructions,
1135
- usageGrant: context.usageGrant,
1136
- executionContext: this.runners.require(request.runner).prepareExecutionContext(context)
1137
- };
1138
- },
1139
1135
  inputState,
1140
1136
  withGrant: (context, work) => this.modelUsage.withGrant(context.usageGrant, work),
1141
1137
  publishGoal: (session, goal) => this.goalCoordinator.publishGoalState(session, goal),
@@ -1145,13 +1141,16 @@ export class NodeConnector {
1145
1141
  this.restartHandoff = new PlannedRestartHandoff({
1146
1142
  directory: () => nodeDataDirectory(this.config, this.configPath),
1147
1143
  identity: () => ({ nodeId: this.config.nodeId, serverUrl: this.config.serverUrl }),
1144
+ credential: () => this.config.credential,
1148
1145
  runtime: this.runtime,
1149
1146
  coordinator: this.continuousRunCoordinator,
1150
1147
  goals: this.goalRuntime,
1148
+ goalExecution: (id) => this.goalCoordinator.restartExecution(id),
1149
+ executionContext: (id, context, modelId) => this.marAgentRunController.restartExecutionContext(id, context, modelId === undefined ? this.runtime.getAgentSession(id)?.modelId : modelId),
1151
1150
  commandActive: (id, command) => this.commandStates.get(id, command)?.active === true,
1152
1151
  inputState,
1153
1152
  restore: (entry, id) => restorer.restore(entry, id),
1154
- diagnose: (code, sessionId) => nodeLog('node.restart.diagnostic', { code, sessionId })
1153
+ diagnose: (code, sessionId, errorCode) => nodeLog('node.restart.diagnostic', { code, sessionId, errorCode })
1155
1154
  });
1156
1155
  this.sessionCatalogService = new SessionCatalogService({
1157
1156
  resolveExecutionDirectory,
@@ -1292,7 +1291,6 @@ export class NodeConnector {
1292
1291
  this.runtimeCredentials.disconnected();
1293
1292
  this.chatgptLogins.close();
1294
1293
  this.scheduledRunAuthorization.disconnected();
1295
- this.restartAuthorization.disconnected();
1296
1294
  this.mailNotificationControl.disconnected();
1297
1295
  this.marctlExecutionCredentials.disconnected();
1298
1296
  this.upgradeCredentials.disconnect();
@@ -1352,8 +1350,7 @@ export class NodeConnector {
1352
1350
  handleNodeRequest: (envelope) => this.nodeRequests.handle(envelope),
1353
1351
  cancelNodeWait: (requestId) => this.nodeRequests.cancelWait(requestId),
1354
1352
  handleRuntimeCredentialResponse: (envelope) => this.runtimeCredentials.handle(envelope),
1355
- handleScheduledRunAuthorizeResponse: (envelope) => this.scheduledRunAuthorization.handle(envelope) ||
1356
- this.restartAuthorization.handle(envelope),
1353
+ handleScheduledRunAuthorizeResponse: (envelope) => this.scheduledRunAuthorization.handle(envelope),
1357
1354
  handleMailNotificationResponse: (envelope) => this.mailNotificationControl.handle(envelope),
1358
1355
  handleMarctlExecutionCredentialResponse: (envelope) => this.marctlExecutionCredentials.handle(envelope),
1359
1356
  handleUpgradeCredentialResponse: (envelope) => this.upgradeCredentials.handle(envelope),
@@ -1451,7 +1448,6 @@ export class NodeConnector {
1451
1448
  this.runtimeCredentials.connected();
1452
1449
  this.scheduledRunAuthorization.connected();
1453
1450
  this.scheduledSessions.connected();
1454
- this.restartAuthorization.connected();
1455
1451
  if (!this.stopped)
1456
1452
  void this.restartHandoff.recover();
1457
1453
  this.mailNotificationControl.connected();
@@ -1465,7 +1461,6 @@ export class NodeConnector {
1465
1461
  return this.database;
1466
1462
  };
1467
1463
  this.scheduledRunAuthorization = new RunAuthorizationControl((envelope) => this.controlChannel.sendEnvelope(envelope));
1468
- this.restartAuthorization = new RunAuthorizationControl((envelope) => this.controlChannel.sendEnvelope(envelope), 'restart');
1469
1464
  this.scheduledSessions = new ScheduledSessionCoordinator({
1470
1465
  store: () => new ScheduledSessionStore(requireDatabase().raw),
1471
1466
  busy: (task) => this.stopped ||
@@ -1810,7 +1805,6 @@ export class NodeConnector {
1810
1805
  this.chatgptLogins.close();
1811
1806
  const scheduledDrain = this.scheduledSessions.stop();
1812
1807
  this.scheduledRunAuthorization.close();
1813
- this.restartAuthorization.close();
1814
1808
  this.mailNotificationControl.close();
1815
1809
  this.marctlExecutionCredentials.disconnected();
1816
1810
  this.upgradeCredentials.disconnect();
@@ -6,7 +6,7 @@ export function hasMarctlContext(environment) {
6
6
  !!environment['MAR_CTL_WORKSPACE_ID']);
7
7
  }
8
8
  export const marctlManagementDescription = 'Use marctl to manage MAR Agent session settings (model, effort, title, persistent prompts, and schedules), discover authorized MAR Nodes, and execute commands or transfer files on those Nodes. Use native agent_* tools for other Agent lifecycle and collaboration operations.';
9
- const marctlSafety = 'Acceptance is not completion. If a request result is unknown, inspect history before retrying. Session IDs and parent relationships do not grant authorization.';
9
+ const marctlSafety = 'If a request result is unknown, inspect history before retrying. Session IDs and parent relationships do not grant authorization.';
10
10
  export const marctlManagementInstructions = [
11
11
  '## MAR Workspace management',
12
12
  'Use native agent_* tools for Agent discovery, creation, communication, observation, waiting and stopping. Do not switch to marctl when a native operation fails.',
@@ -19,10 +19,12 @@ export const marctlDescription = 'Use marctl agents to create, send to, wait for
19
19
  export const marctlInstructions = [
20
20
  '## MAR Workspace CLI',
21
21
  'Run marctl from the shell. Commands use the authenticated Node and Workspace; marctl help agents, nodes, ssh or scp shows options.',
22
- 'Use agents start --title <title> -m <text> [--runner <runner>] [--model-id <id>] [--effort <effort>] [--goal <objective>]. An Agent must supply a nonblank title; a manual terminal may omit it. The default Runner is the current Session Runner; a terminal without a current Session must specify --runner or an explicit --parent. Start returns without waiting.',
23
- 'Use agents send <id> -m <text> [--steer|--queue], agents wait [<id>] [--wait-for message|completion] [--timeout <duration>], agents stop <id> --execution <execution-id>, agents list [--scope tree|active], and agents view <id> [--history]. Wait defaults to direct-child completion for 30 seconds; --timeout 0ms takes a snapshot. Stop clears the target Session Goal.',
24
- 'Prefer agents send <id> --steer -m <text> for timely communication or course correction. Use --queue only for deliberately deferred input: it is consumed after current work and earlier queued inputs finish, and with an active Goal it is not consumed until that Goal exits. Acceptance is not a guarantee of immediate processing.',
25
- 'Start and send accept repeated --image <local-path> for PNG, JPEG, GIF or WebP; limits are five images, 5 MiB each and 10 MiB total. Send can update a Goal alone with --goal <objective> or --clear-goal; omitted Goal leaves it unchanged. Decide a direct child approval with send <id> --approval <approval-id> --decision approve|reject.',
22
+ 'Use agents start --title <title> -m <text> [--runner <runner>] [--model-id <id>] [--effort <effort>] [--goal <objective>]. An Agent must supply a nonblank title; a manual terminal may omit it. The default Runner is the current Session Runner; a terminal without a current Session must specify --runner or an explicit --parent. Start returns without waiting for completion.',
23
+ 'Use agents send <id> -m <text> [--steer|--queue], agents wait [<id>] [--wait-for message|completion] [--timeout <duration>], agents stop <id> --execution <execution-id>, agents list [--scope tree|active], and agents view <id> [--history]. --timeout 0ms takes a snapshot. Stop clears the target Session Goal.',
24
+ 'While children run, prefer advancing meaningful, non-overlapping task work rather than duplicating delegated work. Waiting is generally useful once no meaningful local task work remains and a required result is needed to unblock or finish the task.',
25
+ 'Prefer agents send <id> --steer -m <text> for timely communication or course correction. --queue is best reserved for deferred input explicitly requested by the user or recipient: it is consumed after current work and earlier queued inputs finish, and with an active Goal it is not consumed until that Goal exits. Acceptance is not a guarantee of immediate processing.',
26
+ 'An accepted message normally needs no separate acknowledgment. Requests for receipt-only replies and immediate agents view checks solely to confirm receipt are usually unnecessary.',
27
+ 'Send can update a Goal alone with --goal <objective> or --clear-goal; omitted Goal leaves it unchanged. Decide an authorized Agent approval with send <id> --approval <approval-id> --decision approve|reject.',
26
28
  'List accepts only --scope tree|active, optional --query <title-fragment> and --limit <1..100> (default 100). With a current Session it defaults to the containing collaboration tree, including idle Sessions; without one it defaults to active Workspace Sessions (running or sleeping), and explicit tree is rejected. Returns currentAgentId (null without a Session), flat sessions with id, parentSessionId, title, runner and status, and truncated; no cursor or pagination. Reuse known IDs directly.',
27
29
  'Only direct-child updates are collected automatically. Sending to a non-child never subscribes; an explicit wait observes it only for that call.',
28
30
  'Use agents config, rename, prompt and schedule for management. marctl nodes, ssh and scp use authorized Nodes.',
@@ -1,3 +1,4 @@
1
+ import { AgentError } from '@myagentroam/agent';
1
2
  import { agentNetworkWaitModelOutput } from './agent-network-wait-output.js';
2
3
  import { agentOperationEvent } from './agent-operation-presentation.js';
3
4
  const text = { type: 'string', minLength: 1 };
@@ -5,22 +6,6 @@ const runner = {
5
6
  ...text,
6
7
  description: 'Runner type (for example mar-agent), not a model name; defaults to the current Runner.'
7
8
  };
8
- const attachmentIds = {
9
- type: 'array',
10
- items: text,
11
- maxItems: 32,
12
- description: 'Existing composer attachment IDs.'
13
- };
14
- const attachments = {
15
- type: 'array',
16
- maxItems: 5,
17
- items: {
18
- type: 'object',
19
- additionalProperties: false,
20
- required: ['name', 'mime', 'dataBase64'],
21
- properties: { name: text, mime: text, dataBase64: text }
22
- }
23
- };
24
9
  const goal = {
25
10
  anyOf: [
26
11
  { type: 'null' },
@@ -38,15 +23,13 @@ const goal = {
38
23
  }
39
24
  ]
40
25
  };
41
- const deliveryGuidance = 'Default to STEER. Use QUEUE only when the user or recipient explicitly requests deferred delivery. Do not queue merely to avoid interrupting the recipient or because the message is a report.';
26
+ const deliveryGuidance = 'STEER is the default and preferred for timely communication. QUEUE is best reserved for deferred delivery explicitly requested by the user or recipient, not merely to avoid interruption or send a report.';
42
27
  const tools = [
43
28
  {
44
29
  action: 'start',
45
- description: 'Start a child Agent with a message or image and optional Goal; returns without waiting.',
30
+ description: 'Start a child Agent with a task and optional Goal; returns without waiting for completion.',
46
31
  properties: {
47
- content: { type: 'string' },
48
- attachments,
49
- attachmentIds,
32
+ content: text,
50
33
  runner,
51
34
  modelId: {
52
35
  ...text,
@@ -57,16 +40,14 @@ const tools = [
57
40
  goal,
58
41
  clientMessageId: text
59
42
  },
60
- required: ['title']
43
+ required: ['title', 'content']
61
44
  },
62
45
  {
63
46
  action: 'send',
64
- description: `Send input, decide an approval or update a Goal; goal:null leaves it unchanged, goal:{clear:true} clears it. ${deliveryGuidance} QUEUE is consumed only after current work and earlier queued inputs finish; an active Goal must exit first.`,
47
+ description: 'Send a message to an Agent, update its Goal, or decide an approval.',
65
48
  properties: {
66
49
  agentId: text,
67
50
  content: { type: 'string' },
68
- attachments,
69
- attachmentIds,
70
51
  goal,
71
52
  approval: {
72
53
  type: 'object',
@@ -77,7 +58,7 @@ const tools = [
77
58
  delivery: {
78
59
  type: 'string',
79
60
  enum: ['STEER', 'QUEUE'],
80
- description: deliveryGuidance
61
+ description: `${deliveryGuidance} QUEUE is consumed only after current work and earlier queued inputs finish; an active Goal must exit first.`
81
62
  },
82
63
  clientMessageId: text
83
64
  },
@@ -85,7 +66,7 @@ const tools = [
85
66
  },
86
67
  {
87
68
  action: 'wait',
88
- description: 'Wait for completion or messages; defaults to direct children, completion and 30 seconds. Use waitMs 0 for a snapshot.',
69
+ description: 'Wait for Agent messages or execution completion.',
89
70
  properties: {
90
71
  agentId: text,
91
72
  agentIds: { type: 'array', items: text, minItems: 1, maxItems: 8 },
@@ -102,7 +83,7 @@ const tools = [
102
83
  },
103
84
  {
104
85
  action: 'list',
105
- description: "List the current Session's collaboration tree by default, or active Workspace Sessions. Active includes running and sleeping. Reuse known Session IDs directly. Returns currentAgentId, flat sessions (id, parentSessionId, title, runner, status) and truncated; no pagination.",
86
+ description: 'List the current collaboration tree or active Agents in the Workspace.',
106
87
  properties: {
107
88
  scope: {
108
89
  type: 'string',
@@ -119,7 +100,7 @@ const tools = [
119
100
  },
120
101
  {
121
102
  action: 'view',
122
- description: 'View an Agent session, Goal and recent history.',
103
+ description: "View an Agent's state, Goal and public history.",
123
104
  properties: {
124
105
  agentId: text,
125
106
  includeHistory: { type: 'boolean' },
@@ -131,17 +112,20 @@ const tools = [
131
112
  ];
132
113
  const delegationPrompt = [
133
114
  '# Delegation and coordination',
134
- 'Delegate bounded, self-contained tasks when independent progress or context savings materially helps. Keep simple or tightly coupled work local.',
135
- 'Give each child an objective, necessary context, expected output, and a read-only boundary or non-overlapping write scope.',
136
- 'Follow up through the direct delegate by default; inspect deeper Sessions only when a concrete issue needs independent verification.',
137
- 'While children run, continue meaningful non-overlapping work. Do not redo delegated work. Wait only when the next step needs the result.',
115
+ 'Prefer handling focused or tightly coupled work locally.',
116
+ 'Consider delegating a clearly separable subtask when parallel progress or context savings materially outweigh coordination overhead.',
117
+ 'Avoid splitting work merely because agents are available.',
118
+ 'A useful handoff includes an objective, necessary context, expected output, and a read-only boundary or non-overlapping write scope.',
119
+ 'Prefer following up through the direct delegate; deeper Session inspection is usually useful only when a concrete issue needs independent verification.',
120
+ 'While children run, prefer advancing meaningful, non-overlapping task work rather than duplicating delegated work. Waiting is generally useful once no meaningful local task work remains and a required result is needed to unblock or finish the task.',
138
121
  'Verify consequential findings and changes. Join or stop task-required child work before final delivery, and retain responsibility for the final result.'
139
122
  ].join('\n\n');
140
123
  const collaborationPrompt = [
141
124
  '# Workspace collaboration',
142
- "Use native agent_* tools to collaborate within the Agent Network when another Session's context or ongoing work materially helps. Keep requests focused and avoid unnecessary cross-Session coordination. Peer Sessions retain their own work and lifecycle; do not treat them as task-owned children or stop them merely for task cleanup. Explicitly wait when you need progress or completion.",
143
- 'Reuse known Session IDs rather than rediscovering them. Send updates only when requested or when the recipient needs to decide or change course; combine related updates and avoid acknowledgment-only exchanges.',
144
- 'For delegated work, do not provide routine progress updates or acknowledgments unless requested. Contact the parent before completion only when requested or for blockers, scope conflicts, or information that requires the parent to decide or change course. Return a concise final handoff; do not also send a duplicate completion message.'
125
+ "Use native agent_* tools to collaborate within the Agent Network when another Session's context or ongoing work materially helps. Prefer focused requests and avoid unnecessary cross-Session coordination. Peer Sessions retain their own work and lifecycle; do not treat them as task-owned children or stop them merely for task cleanup.",
126
+ 'Prefer reusing known Session IDs rather than rediscovering them. Updates are most useful when requested or when the recipient needs to decide or change course; prefer combining related updates.',
127
+ 'An accepted message normally needs no separate acknowledgment. Requests for receipt-only replies and immediate agent_view calls solely to confirm receipt are usually unnecessary.',
128
+ 'For delegated work, prefer a concise final handoff over routine progress reports or duplicate completion messages. Early contact is most useful for requested updates, blockers, scope conflicts, or information needed for a decision.'
145
129
  ].join('\n\n');
146
130
  const managementPrompt = [
147
131
  '# Workspace management',
@@ -152,6 +136,15 @@ export function agentNetworkExtension(invoke, currentExecution) {
152
136
  id: 'mar.node.agent-network',
153
137
  apiVersion: '1.0',
154
138
  attachSession(context) {
139
+ // Reject retired input before generic unknown-field normalization can discard it.
140
+ context.hooks.intercept('tool.invoke', (input, _call, next) => {
141
+ if ((input.name === 'agent_start' || input.name === 'agent_send') &&
142
+ input.arguments !== null &&
143
+ typeof input.arguments === 'object' &&
144
+ ('attachments' in input.arguments || 'attachmentIds' in input.arguments))
145
+ throw new AgentError('MAR_AGENT_TOOL_INPUT_INVALID', 'Agent collaboration accepts text input only.');
146
+ return next(input);
147
+ }, { reentry: 'allow' });
155
148
  context.hooks.intercept('prompt.sections', (input, _call, next) => {
156
149
  const available = new Set(input.facts?.tools);
157
150
  const enabled = input.facts?.mode !== 'compact';
@@ -224,9 +217,7 @@ export function agentNetworkExtension(invoke, currentExecution) {
224
217
  let operationInput = args;
225
218
  if (tool.action === 'send') {
226
219
  const { delivery, ...sendInput } = args;
227
- const hasMessage = (typeof args.content === 'string' && !!args.content.trim()) ||
228
- (Array.isArray(args.attachments) && args.attachments.length > 0) ||
229
- (Array.isArray(args.attachmentIds) && args.attachmentIds.length > 0);
220
+ const hasMessage = typeof args.content === 'string' && !!args.content.trim();
230
221
  operationInput =
231
222
  delivery !== undefined || hasMessage
232
223
  ? {
@@ -15,6 +15,7 @@ import { MarAgentConversationNormalizer, normalizeMarAgentUsage } from './conver
15
15
  import { parseRunnerImageAttachments } from '../../session/attachments.js';
16
16
  import { runnerErrorProjection } from '../runner-error.js';
17
17
  import { MarAgentRuntimeModelFactory, sdkModelConfigurations, closeImageCredentialProviders } from './runtime-model-factory.js';
18
+ import { snapshotRuntimeModel } from './runtime-model-factory.js';
18
19
  import { createImageGenerationExtensions } from './image-generation-extensions.js';
19
20
  import { createWebToolsExtensions } from './web-tools-extensions.js';
20
21
  import { combineExecutionExtensions } from './execution-extensions.js';
@@ -94,6 +95,43 @@ export class MarAgentManagedRunController {
94
95
  releaseSession(sessionId) {
95
96
  return this.#runtimes.releaseSession(sessionId);
96
97
  }
98
+ restartExecutionContext(sessionId, context, selectedModelId) {
99
+ const runtime = this.#runtimes.findRuntime(sessionId);
100
+ const input = marAgentEnvelopeInput(context);
101
+ if (runtime === undefined || typeof input?.runtimeScopeId !== 'string')
102
+ return context;
103
+ const scope = input.runtimeScopeId;
104
+ const snapshots = new Map(runtime.models.flatMap((model) => {
105
+ const snapshot = snapshotRuntimeModel(model, scope);
106
+ return snapshot === undefined ? [] : [[model.id, snapshot]];
107
+ }));
108
+ if (snapshots.size === 0)
109
+ return context;
110
+ const refresh = (value) => {
111
+ if (value === null || typeof value !== 'object' || !('id' in value))
112
+ return value;
113
+ return typeof value.id === 'string' ? (snapshots.get(value.id) ?? value) : value;
114
+ };
115
+ const models = Array.isArray(input.models) ? input.models.map(refresh) : input.models;
116
+ const selected = Array.isArray(models)
117
+ ? models.find((model) => model !== null &&
118
+ typeof model === 'object' &&
119
+ 'id' in model &&
120
+ model.id === selectedModelId)
121
+ : undefined;
122
+ const compact = input.compact;
123
+ return {
124
+ runner: 'mar-agent',
125
+ runnerInput: {
126
+ ...input,
127
+ model: selected ?? refresh(input.model),
128
+ models,
129
+ compact: compact !== null && typeof compact === 'object' && 'model' in compact
130
+ ? { ...compact, model: refresh(compact.model) }
131
+ : compact
132
+ }
133
+ };
134
+ }
97
135
  async executeCommand(session, command, input, context) {
98
136
  if (command.id === 'plan') {
99
137
  const current = this.host.commandState(session.id, 'plan');
@@ -21,14 +21,42 @@ class PendingCredentialResolution {
21
21
  this.initialReason = initialReason;
22
22
  }
23
23
  }
24
+ const restartConfigurations = new WeakMap();
25
+ export function snapshotRuntimeModel(model, scope) {
26
+ const state = restartConfigurations.get(model);
27
+ if (state === undefined || state.scope !== scope)
28
+ return;
29
+ const configuration = structuredClone(state.configuration);
30
+ for (const credential of [configuration.credential, configuration.imageGeneration?.credential]) {
31
+ if (credential?.type !== 'RUNTIME_TICKET')
32
+ continue;
33
+ const key = `${credential.credentialId}\0${credential.ticket.runtimeScopeId}\0${credential.ticket.purpose ?? 'MODEL'}`;
34
+ const current = state.tickets.get(key);
35
+ if (current === undefined || current.released)
36
+ return;
37
+ credential.ticket = structuredClone(current.ticket);
38
+ }
39
+ return configuration;
40
+ }
24
41
  const CREDENTIAL_REFRESH_WINDOW_MS = 60_000;
25
42
  const CREDENTIAL_REFRESH_RETRY_MS = 30_000;
26
43
  export class MarAgentRuntimeModelFactory {
27
- control;
28
44
  now;
45
+ control;
46
+ pendingCredentials = new Set();
29
47
  constructor(control, now = Date.now) {
30
- this.control = control;
31
48
  this.now = now;
49
+ this.control = {
50
+ resolve: (...args) => {
51
+ const pending = control.resolve(...args);
52
+ this.pendingCredentials.add(pending);
53
+ void pending.then(() => this.pendingCredentials.delete(pending), () => this.pendingCredentials.delete(pending));
54
+ return pending;
55
+ }
56
+ };
57
+ }
58
+ async waitForPendingCredentials() {
59
+ await Promise.allSettled([...this.pendingCredentials]);
32
60
  }
33
61
  create(configurations, context) {
34
62
  const tickets = new Map();
@@ -36,6 +64,14 @@ export class MarAgentRuntimeModelFactory {
36
64
  try {
37
65
  return configurations.map((raw) => {
38
66
  const configuration = marAgentRuntimeModelConfigurationSchema.parse(raw);
67
+ const remember = (model) => {
68
+ restartConfigurations.set(model, {
69
+ configuration,
70
+ scope: context.runtimeScopeId,
71
+ tickets
72
+ });
73
+ return model;
74
+ };
39
75
  const imageGeneration = configuration.imageGeneration === undefined ||
40
76
  configuration.imageGeneration.credential === undefined
41
77
  ? undefined
@@ -89,10 +125,10 @@ export class MarAgentRuntimeModelFactory {
89
125
  };
90
126
  const { credential } = configuration;
91
127
  if (credential.type === 'INLINE_API_KEY')
92
- return {
128
+ return remember({
93
129
  ...model,
94
130
  apiKey: new SecretValue(credential.value)
95
- };
131
+ });
96
132
  if (credential?.type !== 'RUNTIME_TICKET')
97
133
  throw new AgentError('MAR_AGENT_RUNTIME_MODEL_CREDENTIAL_INVALID', 'The runtime model credential is invalid.');
98
134
  if (context.runtimeScopeId === undefined ||
@@ -100,7 +136,7 @@ export class MarAgentRuntimeModelFactory {
100
136
  !credential.ticket.allowedModelIds.includes(configuration.id))
101
137
  throw new AgentError('MAR_AGENT_RUNTIME_CREDENTIAL_SCOPE_INVALID', 'The runtime credential does not authorize this execution or model.');
102
138
  const provider = runtimeProvider(credential, configuration.id, context, tickets, providers, this.control, this.now);
103
- return {
139
+ return remember({
104
140
  ...model,
105
141
  ...(configuration.imageGeneration !== undefined &&
106
142
  configuration.imageGeneration.credential === undefined
@@ -117,7 +153,7 @@ export class MarAgentRuntimeModelFactory {
117
153
  }
118
154
  : {}),
119
155
  credentialProvider: provider
120
- };
156
+ });
121
157
  });
122
158
  }
123
159
  catch (error) {
@@ -221,9 +221,7 @@ export function createNodeAgentNetwork(options) {
221
221
  ...(!terminal && parent ? { __marctlOrigin: origin(parent) } : {}),
222
222
  ...(input.title === undefined ? {} : { title: input.title }),
223
223
  ...(model ? { modelId: model } : {}),
224
- ...(input.effort === undefined ? {} : { effort: input.effort }),
225
- ...(input.attachments === undefined ? {} : { attachments: input.attachments }),
226
- ...(input.attachmentIds === undefined ? {} : { attachmentIds: input.attachmentIds })
224
+ ...(input.effort === undefined ? {} : { effort: input.effort })
227
225
  };
228
226
  signal?.throwIfAborted();
229
227
  const result = await options.withGrant(actor, () => input.goal.kind === 'set'
@@ -236,10 +234,6 @@ export function createNodeAgentNetwork(options) {
236
234
  suppressNotifications: true,
237
235
  ...(!terminal && parent ? { messageOrigin: origin(parent) } : {}),
238
236
  clientMessageId: input.clientMessageId,
239
- ...(input.attachments === undefined ? {} : { attachments: input.attachments }),
240
- ...(input.attachmentIds === undefined
241
- ? {}
242
- : { attachmentIds: input.attachmentIds }),
243
237
  ...(actor.requestUserId === undefined
244
238
  ? {}
245
239
  : { initiatedByUserId: actor.requestUserId }),
@@ -287,11 +281,7 @@ export function createNodeAgentNetwork(options) {
287
281
  : {
288
282
  messageContent: message.content,
289
283
  clientMessageId: message.clientMessageId,
290
- messageIntent: message.deliveryIntent,
291
- ...(message.attachments === undefined ? {} : { attachments: message.attachments }),
292
- ...(message.attachmentIds === undefined
293
- ? {}
294
- : { attachmentIds: message.attachmentIds })
284
+ messageIntent: message.deliveryIntent
295
285
  })
296
286
  }));
297
287
  return publicReceipt(result);
@@ -20,12 +20,9 @@ export class AgentNetworkService {
20
20
  const goal = parseAgentGoal(input.goal);
21
21
  const content = input.content ?? '';
22
22
  if (typeof content !== 'string' ||
23
- (!content.trim() && !input.attachments?.length && !input.attachmentIds?.length) ||
24
- (input.attachments !== undefined && !Array.isArray(input.attachments)) ||
25
- (input.attachmentIds !== undefined &&
26
- (!Array.isArray(input.attachmentIds) ||
27
- input.attachmentIds.some((id) => typeof id !== 'string' || !id))) ||
28
- (!!input.attachments?.length && !!input.attachmentIds?.length) ||
23
+ !content.trim() ||
24
+ 'attachments' in input ||
25
+ 'attachmentIds' in input ||
29
26
  !validOptionalString(input.runner) ||
30
27
  'model' in input ||
31
28
  !validOptionalString(input.modelId) ||
@@ -62,16 +59,9 @@ export class AgentNetworkService {
62
59
  if (typeof input.agentId !== 'string' ||
63
60
  !input.agentId ||
64
61
  typeof content !== 'string' ||
65
- (!content.trim() &&
66
- !input.attachments?.length &&
67
- !input.attachmentIds?.length &&
68
- goal.kind === 'unchanged' &&
69
- input.approval === undefined) ||
70
- (input.attachments !== undefined && !Array.isArray(input.attachments)) ||
71
- (input.attachmentIds !== undefined &&
72
- (!Array.isArray(input.attachmentIds) ||
73
- input.attachmentIds.some((id) => typeof id !== 'string' || !id))) ||
74
- (!!input.attachments?.length && !!input.attachmentIds?.length) ||
62
+ (!content.trim() && goal.kind === 'unchanged' && input.approval === undefined) ||
63
+ 'attachments' in input ||
64
+ 'attachmentIds' in input ||
75
65
  !validOptionalString(input.clientMessageId) ||
76
66
  (input.approval !== undefined &&
77
67
  (!isPlainRecord(input.approval) ||
@@ -83,7 +73,7 @@ export class AgentNetworkService {
83
73
  if (input.deliveryIntent !== undefined &&
84
74
  !['SEND', 'QUEUE', 'REPLACE_CURRENT'].includes(input.deliveryIntent))
85
75
  throw new Error('AGENT_SEND_INVALID');
86
- const hasMessage = !!content.trim() || !!input.attachments?.length || !!input.attachmentIds?.length;
76
+ const hasMessage = !!content.trim();
87
77
  const session = await this.options.target(actor, input.agentId);
88
78
  signal?.throwIfAborted();
89
79
  if (hasMessage)
@@ -122,9 +112,7 @@ export class AgentNetworkService {
122
112
  ? {
123
113
  clientMessageId: input.clientMessageId ?? randomUUID(),
124
114
  content,
125
- deliveryIntent: input.deliveryIntent ?? 'SEND',
126
- ...(input.attachments === undefined ? {} : { attachments: input.attachments }),
127
- ...(input.attachmentIds === undefined ? {} : { attachmentIds: input.attachmentIds })
115
+ deliveryIntent: input.deliveryIntent ?? 'SEND'
128
116
  }
129
117
  : undefined;
130
118
  let goalResult;
@@ -580,6 +580,9 @@ export class ContinuousRunCoordinator {
580
580
  return;
581
581
  }
582
582
  let executionId = this.options.runtime.currentExecutionId(active.id);
583
+ // Immediate commands own a root Runner handle without a normal message Execution.
584
+ if (executionId === undefined && this.options.runtime.isExecutionActive(active.id))
585
+ return;
583
586
  if (executionId !== undefined && this.dispatchedExecutions.has(executionId))
584
587
  return;
585
588
  let pending = executionId === undefined
@@ -4,6 +4,7 @@ import { isPlainRecord } from '../util/records.js';
4
4
  import { safeErrorCode } from '../util/safe-error.js';
5
5
  import { NODE_MCP } from '../mcp/naming.js';
6
6
  import { composerResourceInputTags } from './composer-message-input.js';
7
+ import { captureRestartExecution } from './restart-execution-state.js';
7
8
  /** Owns transient Goal orchestration, not the domain state or execution queue. */
8
9
  export class GoalContinuationCoordinator {
9
10
  options;
@@ -47,6 +48,10 @@ export class GoalContinuationCoordinator {
47
48
  freeze() {
48
49
  this.frozen = true;
49
50
  }
51
+ restartExecution(sessionId) {
52
+ const retry = this.goalRetryTimers.get(sessionId);
53
+ return captureRestartExecution(retry?.context, retry?.initiatedByUserId);
54
+ }
50
55
  dispose() {
51
56
  this.frozen = true;
52
57
  this.disposed = true;
@@ -107,6 +112,9 @@ export class GoalContinuationCoordinator {
107
112
  const continuationEpoch = this.continuationEpoch(pending.sessionId);
108
113
  const retry = {
109
114
  sessionId: pending.sessionId,
115
+ context: pending,
116
+ initiatedByUserId: this.options.runtime.activeSessionRun(pending.sessionId)
117
+ ?.initiatedByUserId,
110
118
  timer: setTimeout(() => {
111
119
  this.goalRetryTimers.delete(retry.sessionId);
112
120
  void this.enqueueGoalContinuation({
@@ -497,6 +505,8 @@ export class GoalContinuationCoordinator {
497
505
  clearTimeout(previousRetry.timer);
498
506
  const retry = {
499
507
  sessionId: attempt.sessionId,
508
+ context: input,
509
+ initiatedByUserId: input.initiatedByUserId,
500
510
  timer: setTimeout(() => {
501
511
  if (!this.goalGenerationCurrent(retry.sessionId, generation))
502
512
  return;
@@ -3,6 +3,8 @@ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
3
  import { resolve } from 'node:path';
4
4
  import { z } from 'zod';
5
5
  import { parseComposerTags } from '@myagentroam/protocol';
6
+ import { safeErrorCode } from '../util/safe-error.js';
7
+ import { captureRestartExecution, openRestartState, restartExecutionSchema, sealRestartState } from './restart-execution-state.js';
6
8
  const identifier = z.string().min(1).max(512);
7
9
  const count = z.number().int().nonnegative();
8
10
  const attachmentSchema = z
@@ -18,7 +20,7 @@ const messageSchema = z
18
20
  clientMessageId: identifier,
19
21
  content: z.string().max(1_000_000),
20
22
  attachments: z.array(attachmentSchema).max(5),
21
- initiatedByUserId: z.number().int().positive(),
23
+ execution: restartExecutionSchema.optional(),
22
24
  immediate: z.boolean(),
23
25
  suppressNotifications: z.boolean().optional()
24
26
  })
@@ -56,7 +58,7 @@ const sessionSchema = z
56
58
  access: z.string().nullable()
57
59
  })
58
60
  .strict(),
59
- ownerUserId: z.number().int().positive(),
61
+ execution: restartExecutionSchema.optional(),
60
62
  paused: z.boolean(),
61
63
  plan: z.boolean(),
62
64
  awaitingGoal: z.boolean(),
@@ -68,7 +70,7 @@ const sessionSchema = z
68
70
  .strict();
69
71
  export const plannedRestartSchema = z
70
72
  .object({
71
- schemaVersion: z.literal(2),
73
+ schemaVersion: z.literal(3),
72
74
  id: identifier,
73
75
  nodeId: identifier,
74
76
  serverUrl: z.string().min(1),
@@ -76,14 +78,44 @@ export const plannedRestartSchema = z
76
78
  sessions: z.array(sessionSchema)
77
79
  })
78
80
  .strict();
79
- const legacyPlannedRestartSchema = plannedRestartSchema.extend({
80
- schemaVersion: z.literal(1),
81
- sessions: z.array(sessionSchema.extend({
82
- session: sessionSchema.shape.session.omit({ modelId: true }).extend({
83
- model: z.string().nullable()
84
- })
85
- }))
86
- });
81
+ // Only this producer can associate log-safe metadata with an original failure.
82
+ const captureDiagnostics = new WeakMap();
83
+ const captureFields = new Set([
84
+ ...Object.keys(plannedRestartSchema.shape),
85
+ ...Object.keys(sessionSchema.shape),
86
+ ...Object.keys(sessionSchema.shape.session.shape),
87
+ ...Object.keys(goalSchema.shape),
88
+ ...Object.keys(goalSchema.shape.goal.shape),
89
+ ...Object.keys(messageSchema.shape),
90
+ ...Object.keys(attachmentSchema.shape)
91
+ ]);
92
+ export function plannedRestartCaptureDiagnostic(error) {
93
+ return error instanceof Error ? captureDiagnostics.get(error) : undefined;
94
+ }
95
+ function parseCapture(schema, input, reason) {
96
+ const result = schema.safeParse(input);
97
+ if (result.success)
98
+ return result.data;
99
+ captureDiagnostics.set(result.error, {
100
+ reason,
101
+ issues: result.error.issues.slice(0, 12).map((issue) => ({
102
+ code: issue.code,
103
+ // Only schema-owned fields and array offsets, never unknown keys or input values.
104
+ path: issue.path
105
+ .slice(0, 8)
106
+ .map((part) => typeof part === 'number'
107
+ ? `[${part}]`
108
+ : captureFields.has(String(part))
109
+ ? String(part)
110
+ : '*')
111
+ .join('.')
112
+ .replaceAll('.[', '[')
113
+ .slice(0, 160)
114
+ })),
115
+ issuesTruncated: result.error.issues.length > 12
116
+ });
117
+ throw result.error;
118
+ }
87
119
  export const RESTART_NOTICE = 'The previous execution was interrupted by a system restart. Check the session history and current workspace, then continue unfinished work. Verify completed or uncertain actions before proceeding; do not blindly repeat actions with external side effects.';
88
120
  /** A one-shot orderly-shutdown handoff, not a crash-recovery journal. */
89
121
  export class PlannedRestartHandoff {
@@ -91,7 +123,6 @@ export class PlannedRestartHandoff {
91
123
  pending;
92
124
  blocked = new Set();
93
125
  recovery;
94
- owners = new Map();
95
126
  constructor(options) {
96
127
  this.options = options;
97
128
  }
@@ -120,9 +151,6 @@ export class PlannedRestartHandoff {
120
151
  'session.metadata.update'
121
152
  ].includes(operation));
122
153
  }
123
- rememberUser(sessionId, userId) {
124
- this.owners.set(sessionId, userId);
125
- }
126
154
  async load() {
127
155
  this.recovery = undefined;
128
156
  this.pending = undefined;
@@ -137,18 +165,7 @@ export class PlannedRestartHandoff {
137
165
  return;
138
166
  }
139
167
  try {
140
- const saved = JSON.parse(text);
141
- const legacy = legacyPlannedRestartSchema.safeParse(saved);
142
- const value = plannedRestartSchema.parse(legacy.success
143
- ? {
144
- ...legacy.data,
145
- schemaVersion: 2,
146
- sessions: legacy.data.sessions.map((entry) => {
147
- const { model, ...session } = entry.session;
148
- return { ...entry, session: { ...session, modelId: model } };
149
- })
150
- }
151
- : saved);
168
+ const value = plannedRestartSchema.parse(openRestartState(text, this.options.credential()));
152
169
  const identity = this.options.identity();
153
170
  if (value.nodeId !== identity.nodeId || value.serverUrl !== identity.serverUrl)
154
171
  throw new Error('RESTART_HANDOFF_IDENTITY_MISMATCH');
@@ -174,12 +191,23 @@ export class PlannedRestartHandoff {
174
191
  const awaitingGoal = goal === undefined && this.options.commandActive(session.id, 'goal');
175
192
  const currentId = active && (runtime.currentExecutionId(active.id) ?? active.id);
176
193
  const pending = currentId === undefined ? undefined : coordinator.pending(currentId);
177
- const toMessage = (id, clientMessageId, content, attachments, owner, suppressNotifications) => ({
194
+ const checkpoint = (execution, activeInput = false) => {
195
+ if (execution !== undefined && activeInput)
196
+ execution.configuration = {
197
+ modelId: session.modelId,
198
+ effort: session.effort,
199
+ access: session.access
200
+ };
201
+ if (execution !== undefined && this.options.executionContext !== undefined)
202
+ execution.executionContext = this.options.executionContext(session.id, execution.executionContext, execution.configuration?.modelId);
203
+ return execution;
204
+ };
205
+ const toMessage = (id, clientMessageId, content, attachments, originatingUser, suppressNotifications, activeInput = false) => ({
178
206
  id,
179
207
  clientMessageId,
180
208
  content: withoutAttachmentTags(content),
181
209
  attachments,
182
- initiatedByUserId: owner,
210
+ execution: checkpoint(captureRestartExecution(coordinator.pending(id), originatingUser), activeInput),
183
211
  immediate: coordinator.isImmediate(id),
184
212
  ...(suppressNotifications === true ? { suppressNotifications: true } : {})
185
213
  });
@@ -194,16 +222,20 @@ export class PlannedRestartHandoff {
194
222
  });
195
223
  const current = pending?.restartMessage === undefined || pending.goal !== undefined
196
224
  ? undefined
197
- : toMessage(currentId, pending.clientMessageId ?? active.clientMessageId, pending.restartMessage.content, [...pending.restartMessage.attachments], pending.restartMessage.initiatedByUserId ?? active?.initiatedByUserId, active?.suppressNotifications);
225
+ : toMessage(currentId, pending.clientMessageId ?? active.clientMessageId, pending.restartMessage.content, [...pending.restartMessage.attachments], pending.restartMessage.initiatedByUserId ?? active?.initiatedByUserId, active?.suppressNotifications, true);
198
226
  if (current === undefined && items.length === 0 && goal === undefined && !awaitingGoal)
199
227
  continue;
200
- if (session.externalSessionId === null)
201
- throw new Error('RESTART_SESSION_NOT_READY');
202
- const ownerUserId = active?.initiatedByUserId ??
203
- items[0]?.initiatedByUserId ??
204
- this.owners.get(session.id) ??
205
- runtime.listRunsForSession(session.id).find((run) => run.initiatedByUserId !== undefined)
206
- ?.initiatedByUserId;
228
+ if (session.externalSessionId === null) {
229
+ const error = Object.assign(new Error('RESTART_SESSION_NOT_READY'), {
230
+ code: 'RESTART_SESSION_NOT_READY'
231
+ });
232
+ captureDiagnostics.set(error, { reason: 'session_not_ready' });
233
+ throw error;
234
+ }
235
+ const goalItem = goalItemId === undefined ? undefined : runtime.getQueueItem(goalItemId);
236
+ const execution = captureRestartExecution(goalItem === undefined ? undefined : coordinator.pending(goalItem.id), goalItem?.initiatedByUserId) ??
237
+ captureRestartExecution(pending, active?.initiatedByUserId) ??
238
+ this.options.goalExecution?.(session.id);
207
239
  sessions.push({
208
240
  session: {
209
241
  id: session.externalSessionId,
@@ -213,25 +245,29 @@ export class PlannedRestartHandoff {
213
245
  effort: session.effort,
214
246
  access: session.access
215
247
  },
216
- ownerUserId: ownerUserId,
248
+ ...(execution === undefined
249
+ ? {}
250
+ : { execution: checkpoint(execution, goalItem === undefined) }),
217
251
  paused: queue.paused,
218
252
  plan: this.options.commandActive(session.id, 'plan'),
219
253
  awaitingGoal,
220
254
  ...(notificationSource?.suppressNotifications === true
221
255
  ? { suppressNotifications: true }
222
256
  : {}),
223
- ...(goal === undefined ? {} : { goal: goalSchema.parse(goal) }),
257
+ ...(goal === undefined
258
+ ? {}
259
+ : { goal: parseCapture(goalSchema, goal, 'goal_schema_invalid') }),
224
260
  ...(current === undefined ? {} : { current }),
225
261
  queue: items
226
262
  });
227
263
  }
228
- return plannedRestartSchema.parse({
229
- schemaVersion: 2,
264
+ return parseCapture(plannedRestartSchema, {
265
+ schemaVersion: 3,
230
266
  id: randomUUID(),
231
267
  ...this.options.identity(),
232
268
  createdAt: Date.now(),
233
269
  sessions
234
- });
270
+ }, 'snapshot_schema_invalid');
235
271
  }
236
272
  async publish(snapshot) {
237
273
  for (const entry of snapshot.sessions) {
@@ -251,7 +287,7 @@ export class PlannedRestartHandoff {
251
287
  const temporary = `${this.path()}.${process.pid}.tmp`;
252
288
  await mkdir(this.options.directory(), { recursive: true, mode: 0o700 });
253
289
  try {
254
- await writeFile(temporary, JSON.stringify(plannedRestartSchema.parse(snapshot)), {
290
+ await writeFile(temporary, sealRestartState(plannedRestartSchema.parse(snapshot), this.options.credential()), {
255
291
  mode: 0o600
256
292
  });
257
293
  await rename(temporary, this.path());
@@ -276,12 +312,12 @@ export class PlannedRestartHandoff {
276
312
  await rename(this.path(), this.path(true));
277
313
  for (const entry of value.sessions) {
278
314
  try {
279
- this.rememberUser(entry.session.id, entry.ownerUserId);
280
315
  await this.options.restore(entry, value.id);
281
316
  }
282
- catch {
317
+ catch (error) {
283
318
  failed = true;
284
- this.options.diagnose('RESTART_SESSION_RESTORE_FAILED', entry.session.id);
319
+ const errorCode = safeErrorCode(error, 'RESTART_SESSION_RESTORE_FAILED');
320
+ this.options.diagnose('RESTART_SESSION_RESTORE_FAILED', entry.session.id, errorCode === 'RESTART_SESSION_RESTORE_FAILED' ? undefined : errorCode);
285
321
  }
286
322
  finally {
287
323
  this.blocked.delete(entry.session.id);
@@ -0,0 +1,77 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
2
+ import { modelUsageGrantSchema } from '@myagentroam/protocol';
3
+ import { z } from 'zod';
4
+ export const restartExecutionSchema = z
5
+ .object({
6
+ configuration: z
7
+ .object({
8
+ modelId: z.string().nullable(),
9
+ effort: z.string().nullable(),
10
+ access: z.string().nullable()
11
+ })
12
+ .strict()
13
+ .optional(),
14
+ executionContext: z
15
+ .object({ runner: z.literal('mar-agent'), runnerInput: z.unknown().optional() })
16
+ .strict(),
17
+ secretEnvironment: z.record(z.string(), z.string()),
18
+ personalInstructions: z.string().optional(),
19
+ usageGrant: z
20
+ .custom((value) => modelUsageGrantSchema.safeParse(value).success)
21
+ .optional(),
22
+ initiatedByUserId: z.number().int().positive().optional()
23
+ })
24
+ .strict();
25
+ export function captureRestartExecution(input, initiatedByUserId) {
26
+ if (input === undefined)
27
+ return;
28
+ return restartExecutionSchema.parse({
29
+ ...(input.modelId === undefined || input.effort === undefined || input.access === undefined
30
+ ? {}
31
+ : { configuration: { modelId: input.modelId, effort: input.effort, access: input.access } }),
32
+ executionContext: structuredClone(input.executionContext),
33
+ secretEnvironment: { ...input.secretEnvironment },
34
+ ...(input.personalInstructions === undefined
35
+ ? {}
36
+ : { personalInstructions: input.personalInstructions }),
37
+ ...(input.usageGrant === undefined ? {} : { usageGrant: { ...input.usageGrant } }),
38
+ ...(initiatedByUserId === undefined ? {} : { initiatedByUserId })
39
+ });
40
+ }
41
+ const sealedSchema = z
42
+ .object({
43
+ format: z.literal('mar-node-restart'),
44
+ version: z.literal(1),
45
+ nonce: z.string().length(16),
46
+ tag: z.string().length(24),
47
+ ciphertext: z.string().min(1)
48
+ })
49
+ .strict();
50
+ function encryptionKey(credential) {
51
+ if (!credential)
52
+ throw new Error('RESTART_HANDOFF_KEY_UNAVAILABLE');
53
+ return createHash('sha256').update('mar-node:orderly-restart:v1\0').update(credential).digest();
54
+ }
55
+ /** The existing local Node credential protects this one-shot execution checkpoint. */
56
+ export function sealRestartState(value, credential) {
57
+ const nonce = randomBytes(12);
58
+ const cipher = createCipheriv('aes-256-gcm', encryptionKey(credential), nonce);
59
+ const ciphertext = Buffer.concat([cipher.update(JSON.stringify(value), 'utf8'), cipher.final()]);
60
+ return JSON.stringify({
61
+ format: 'mar-node-restart',
62
+ version: 1,
63
+ nonce: nonce.toString('base64'),
64
+ tag: cipher.getAuthTag().toString('base64'),
65
+ ciphertext: ciphertext.toString('base64')
66
+ });
67
+ }
68
+ export function openRestartState(text, credential) {
69
+ const sealed = sealedSchema.parse(JSON.parse(text));
70
+ const decipher = createDecipheriv('aes-256-gcm', encryptionKey(credential), Buffer.from(sealed.nonce, 'base64'));
71
+ decipher.setAuthTag(Buffer.from(sealed.tag, 'base64'));
72
+ const plaintext = Buffer.concat([
73
+ decipher.update(Buffer.from(sealed.ciphertext, 'base64')),
74
+ decipher.final()
75
+ ]);
76
+ return JSON.parse(plaintext.toString('utf8'));
77
+ }
@@ -1,6 +1,7 @@
1
1
  import { parseComposerTags, serializeComposerTag } from '@myagentroam/protocol';
2
2
  import { parseMessageAttachments } from '../session/attachments.js';
3
3
  import { RUNNER_EXECUTION_CONTEXT } from '../runner/execution-context.js';
4
+ import { resolvedPersonalInstructions } from '../runner/personal-instructions.js';
4
5
  import { RESTART_NOTICE } from './planned-restart-handoff.js';
5
6
  /** Rebuild inputs through the existing preparation and FIFO boundary. */
6
7
  export class RestartSessionRestorer {
@@ -9,24 +10,24 @@ export class RestartSessionRestorer {
9
10
  this.options = options;
10
11
  }
11
12
  async restore(entry, handoffId) {
12
- const contexts = new Map();
13
- const authorize = async (ownerUserId) => {
14
- if (!contexts.has(ownerUserId))
15
- contexts.set(ownerUserId, await this.options.authorize({
16
- handoffId,
17
- sessionId: entry.session.id,
18
- workspaceId: entry.session.workspaceId,
19
- ownerUserId,
20
- runner: 'mar-agent',
21
- modelId: entry.session.modelId
22
- }));
23
- return contexts.get(ownerUserId);
13
+ const execution = (context) => {
14
+ if (context === undefined)
15
+ throw new Error('RESTART_EXECUTION_CONTEXT_UNAVAILABLE');
16
+ return context;
24
17
  };
25
- await authorize(entry.ownerUserId);
26
- for (const input of [...entry.queue, ...(entry.current ? [entry.current] : [])]) {
18
+ const current = entry.current &&
19
+ entry.goal?.goal.status !== 'active' &&
20
+ !entry.queue.some((item) => item.immediate)
21
+ ? entry.current
22
+ : undefined;
23
+ const currentState = current && (await this.options.inputState(entry, current));
24
+ const inputs = [...entry.queue, ...(current && currentState !== 'completed' ? [current] : [])];
25
+ for (const input of inputs)
27
26
  parseMessageAttachments(input.attachments);
28
- await authorize(input.initiatedByUserId);
29
- }
27
+ for (const input of inputs)
28
+ execution(input.execution);
29
+ if (entry.goal?.goal.status === 'active')
30
+ execution(entry.execution);
30
31
  let session = await this.options.resolve(entry.session.id);
31
32
  if (session.runner !== 'mar-agent' ||
32
33
  session.workspaceId !== entry.session.workspaceId ||
@@ -66,7 +67,7 @@ export class RestartSessionRestorer {
66
67
  closeInput: '/goal --clear'
67
68
  });
68
69
  const submit = async (input, content = input.content) => {
69
- const context = contexts.get(input.initiatedByUserId);
70
+ const context = execution(input.execution);
70
71
  await this.options.withGrant(context, () => this.options.messages.restoreQueued({
71
72
  sessionId: session.id,
72
73
  content,
@@ -76,28 +77,31 @@ export class RestartSessionRestorer {
76
77
  : {}),
77
78
  attachments: input.attachments,
78
79
  __queueItemId: input.id,
79
- __requestUserId: input.initiatedByUserId,
80
+ ...(context.initiatedByUserId === undefined
81
+ ? {}
82
+ : { __requestUserId: context.initiatedByUserId }),
80
83
  __suppressNotifications: input.suppressNotifications === true,
81
84
  secretEnvironment: context.secretEnvironment,
82
- personalInstructions: context.personalInstructions,
85
+ ...(context.personalInstructions === undefined
86
+ ? {}
87
+ : {
88
+ personalInstructions: resolvedPersonalInstructions(context.personalInstructions)
89
+ }),
83
90
  [RUNNER_EXECUTION_CONTEXT]: context.executionContext,
84
91
  __usageGrant: context.usageGrant
85
- }));
92
+ }, context.configuration));
86
93
  if (input.immediate)
87
94
  coordinator.restoreImmediate(input.id);
88
95
  };
89
96
  try {
90
- if (entry.current &&
91
- entry.goal?.goal.status !== 'active' &&
92
- !entry.queue.some((item) => item.immediate)) {
93
- const state = await this.options.inputState(entry, entry.current);
94
- if (state === 'paused')
97
+ if (current) {
98
+ if (currentState === 'paused')
95
99
  entry.paused = true;
96
- if (state === 'missing')
97
- await submit(entry.current);
98
- else if (state !== 'completed')
100
+ if (currentState === 'missing')
101
+ await submit(current);
102
+ else if (currentState !== 'completed')
99
103
  await submit({
100
- ...entry.current,
104
+ ...current,
101
105
  id: `restart:${handoffId}:${session.id}`,
102
106
  clientMessageId: `restart:${handoffId}:${session.id}`,
103
107
  attachments: []
@@ -106,7 +110,7 @@ export class RestartSessionRestorer {
106
110
  for (const input of entry.queue)
107
111
  await submit(input);
108
112
  if (entry.goal?.goal.status === 'active') {
109
- const context = contexts.get(entry.ownerUserId);
113
+ const context = execution(entry.execution);
110
114
  await this.options.withGrant(context, () => this.options.messages.submitGoal({
111
115
  sessionId: session.id,
112
116
  kind: 'continue',
@@ -114,10 +118,15 @@ export class RestartSessionRestorer {
114
118
  content: `${goals.prompt(session.id, 'continue')}
115
119
  ${RESTART_NOTICE}`,
116
120
  goal: this.options.goalReference(session.id),
117
- initiatedByUserId: entry.ownerUserId,
121
+ ...(context.initiatedByUserId === undefined
122
+ ? {}
123
+ : { initiatedByUserId: context.initiatedByUserId }),
118
124
  suppressNotifications: entry.suppressNotifications === true,
119
125
  secretEnvironment: context.secretEnvironment,
120
126
  restoreQueued: true,
127
+ ...(context.configuration === undefined
128
+ ? {}
129
+ : { restartConfiguration: context.configuration }),
121
130
  ...(context.usageGrant === undefined ? {} : { usageGrant: context.usageGrant }),
122
131
  ...(typeof context.personalInstructions === 'string'
123
132
  ? { personalInstructions: context.personalInstructions }
@@ -1,17 +1,15 @@
1
- import { createEnvelope, scheduledRunAuthorizeRequestSchema, scheduledRunAuthorizeResponseSchema, restartRunAuthorizeRequestSchema } from '@myagentroam/protocol';
1
+ import { createEnvelope, scheduledRunAuthorizeRequestSchema, scheduledRunAuthorizeResponseSchema } from '@myagentroam/protocol';
2
2
  export class RunAuthorizationControl {
3
3
  send;
4
- purpose;
5
4
  #pending = new Map();
6
5
  #generation = 0;
7
6
  #connected = false;
8
7
  #closed = false;
9
- constructor(send, purpose = 'schedule') {
8
+ constructor(send) {
10
9
  this.send = send;
11
- this.purpose = purpose;
12
10
  }
13
11
  code(suffix) {
14
- return `${this.purpose.toUpperCase()}_${suffix}`;
12
+ return `SCHEDULE_${suffix}`;
15
13
  }
16
14
  connected() {
17
15
  this.disconnected();
@@ -28,7 +26,7 @@ export class RunAuthorizationControl {
28
26
  this.disconnected();
29
27
  }
30
28
  handle(envelope) {
31
- if (envelope.type !== `${this.purpose}.authorize.response`)
29
+ if (envelope.type !== 'schedule.authorize.response')
32
30
  return false;
33
31
  const pending = envelope.replyTo === undefined ? undefined : this.#pending.get(envelope.replyTo);
34
32
  if (pending === undefined || pending.generation !== this.#generation)
@@ -43,9 +41,7 @@ export class RunAuthorizationControl {
43
41
  authorize(request) {
44
42
  if (!this.#connected || this.#closed)
45
43
  return Promise.reject(new Error(this.code('SERVER_UNAVAILABLE')));
46
- const envelope = createEnvelope(`${this.purpose}.authorize`, (this.purpose === 'schedule'
47
- ? scheduledRunAuthorizeRequestSchema
48
- : restartRunAuthorizeRequestSchema).parse(request));
44
+ const envelope = createEnvelope('schedule.authorize', scheduledRunAuthorizeRequestSchema.parse(request));
49
45
  return new Promise((resolve, reject) => {
50
46
  const settle = (action) => {
51
47
  const pending = this.#pending.get(envelope.id);
@@ -81,6 +81,9 @@ export function marctlSessionProgress(runtime, sessionId) {
81
81
  const { latestActivity: _latest, ...progress } = activity(runtime, sessionId);
82
82
  return { ...progress, ...pendingApprovals(runtime, sessionId) };
83
83
  }
84
+ export function sessionActivityStatus(runtime, sessionId) {
85
+ return activity(runtime, sessionId).status;
86
+ }
84
87
  /** Summarizes current approval facts without exposing tool input or native payloads. */
85
88
  function pendingApprovals(runtime, sessionId) {
86
89
  const run = runtime.activeSessionRun(sessionId);
@@ -12,6 +12,7 @@ const boundaries = new Set([
12
12
  'approval_request',
13
13
  'user_input_request',
14
14
  'plan',
15
+ 'context_compaction',
15
16
  'turn_summary'
16
17
  ]);
17
18
  function record(value) {
@@ -64,12 +65,9 @@ export function projectConversationWorkProcesses(turn) {
64
65
  let current;
65
66
  const assigned = new Map();
66
67
  const items = ordered.map((item) => {
67
- const payload = record(item.payload);
68
68
  let group;
69
69
  let isStart = false;
70
- const independent = boundaries.has(item.kind) ||
71
- (item.kind === 'context_compaction' && payload.mode !== 'BACKGROUND') ||
72
- (item.kind === 'error' && item.parentItemId === null);
70
+ const independent = boundaries.has(item.kind) || (item.kind === 'error' && item.parentItemId === null);
73
71
  if (visible(item)) {
74
72
  if (independent) {
75
73
  current = undefined;
@@ -5,7 +5,7 @@ import { nodeLog } from '../operational.js';
5
5
  import { isTerminalRunStatus } from './runtime/type-guards.js';
6
6
  import { sessionPage } from './pagination.js';
7
7
  import { latestSessionActivityAt } from './session-presentation-service.js';
8
- import { marctlSessionSummary } from '../service/session-recent-activity.js';
8
+ import { marctlSessionSummary, sessionActivityStatus } from '../service/session-recent-activity.js';
9
9
  import { WorkspaceSessionCleanupBudget, WorkspaceSessionCleanupDeadline } from '../service/workspace-session-cleanup-budget.js';
10
10
  export class SessionCatalogService {
11
11
  options;
@@ -92,7 +92,8 @@ export class SessionCatalogService {
92
92
  session.metadata.runnerTitle ??
93
93
  'Untitled session',
94
94
  runner: session.runner,
95
- activity: live && 'activity' in live ? live.activity : 'IDLE'
95
+ activity: live && 'activity' in live ? live.activity : 'IDLE',
96
+ status: sessionActivityStatus(this.options.runtime, session.id)
96
97
  });
97
98
  const children = [...database.listChildSessionRecords(input.workspaceId, session.id)];
98
99
  if (input.includeAncestors === true)
@@ -14,6 +14,7 @@ import { prepareComposerResourceInput, composerResourceInputTags, withSessionMes
14
14
  const INTERNAL_GOAL = Symbol('internalGoal');
15
15
  const INTERNAL_SCHEDULE = Symbol('internalSchedule');
16
16
  const INTERNAL_RESTORE = Symbol('internalRestore');
17
+ const RESTORE_CONFIGURATION = Symbol('restoreConfiguration');
17
18
  export class SessionMessageService {
18
19
  options;
19
20
  pendingMessages = new Map();
@@ -28,8 +29,12 @@ export class SessionMessageService {
28
29
  setAccepting(accepting) {
29
30
  this.accepting = accepting;
30
31
  }
31
- restoreQueued(input) {
32
- return this.message({ ...input, [INTERNAL_RESTORE]: true });
32
+ restoreQueued(input, configuration) {
33
+ return this.message({
34
+ ...input,
35
+ [INTERNAL_RESTORE]: true,
36
+ ...(configuration === undefined ? {} : { [RESTORE_CONFIGURATION]: configuration })
37
+ });
33
38
  }
34
39
  operations() {
35
40
  return {
@@ -42,6 +47,9 @@ export class SessionMessageService {
42
47
  return this.message({
43
48
  [INTERNAL_GOAL]: true,
44
49
  [INTERNAL_RESTORE]: input.restoreQueued === true,
50
+ ...(input.restoreQueued !== true || input.restartConfiguration === undefined
51
+ ? {}
52
+ : { [RESTORE_CONFIGURATION]: input.restartConfiguration }),
45
53
  sessionId: input.sessionId,
46
54
  __suppressNotifications: input.suppressNotifications === true ||
47
55
  (input.kind === 'continue' &&
@@ -146,10 +154,11 @@ export class SessionMessageService {
146
154
  };
147
155
  const executionContext = input[RUNNER_EXECUTION_CONTEXT] ??
148
156
  this.options.runners.require(session.runner).prepareExecutionContext(input);
157
+ const restoredConfiguration = input[RESTORE_CONFIGURATION];
149
158
  if (session.parentSessionId != null)
150
159
  this.options.runners
151
160
  .require(session.runner)
152
- .assertChildModel(session.modelId, executionContext);
161
+ .assertChildModel((restoredConfiguration ?? session).modelId, executionContext);
153
162
  const sourceContent = withSessionMessageOrigin(content, input.__marctlOrigin, session.parentSessionId);
154
163
  const goalMessage = internalGoal || internalSchedule || internalRestore
155
164
  ? undefined
@@ -297,9 +306,9 @@ export class SessionMessageService {
297
306
  cwd,
298
307
  workspacePath,
299
308
  externalSessionId: currentSession.externalSessionId,
300
- modelId: currentSession.modelId,
301
- effort: currentSession.effort,
302
- access: currentSession.access,
309
+ modelId: (restoredConfiguration ?? currentSession).modelId,
310
+ effort: (restoredConfiguration ?? currentSession).effort,
311
+ access: (restoredConfiguration ?? currentSession).access,
303
312
  collaborationMode: planActive ? 'plan' : 'default',
304
313
  secretEnvironment,
305
314
  ...(personalInstructions === undefined ? {} : { personalInstructions }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.11.48",
3
+ "version": "0.11.50",
4
4
  "description": "MyAgentRoam Node runtime CLI.",
5
5
  "license": "Apache-2.0 AND GPL-2.0-or-later",
6
6
  "type": "module",
@@ -38,10 +38,10 @@
38
38
  "sharp": "0.35.5",
39
39
  "ws": "^8.21.3",
40
40
  "zod": "4.4.3",
41
- "@myagentroam/agent": "0.11.48",
41
+ "@myagentroam/agent": "0.11.50",
42
+ "@myagentroam/marctl": "0.11.10",
42
43
  "@myagentroam/code-navigation": "0.11.1",
43
- "@myagentroam/protocol": "0.11.48",
44
- "@myagentroam/marctl": "0.11.9"
44
+ "@myagentroam/protocol": "0.11.50"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/ws": "^8.18.1"