@myagentroam/node 0.11.47 → 0.11.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/connector.js CHANGED
@@ -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';
@@ -200,7 +200,8 @@ export class NodeConnector {
200
200
  publish: (snapshot) => this.restartHandoff.publish(snapshot),
201
201
  diagnose: (stage, error) => nodeLog('node.shutdown.failed', {
202
202
  stage,
203
- errorCode: safeErrorCode(error, 'NODE_SHUTDOWN_FAILED')
203
+ errorCode: safeErrorCode(error, 'NODE_SHUTDOWN_FAILED'),
204
+ ...(stage === 'capture' ? plannedRestartCaptureDiagnostic(error) : undefined)
204
205
  })
205
206
  });
206
207
  get stopped() {
@@ -651,25 +652,29 @@ export class NodeConnector {
651
652
  }),
652
653
  resources: async (signal) => {
653
654
  signal.throwIfAborted();
654
- const currentSessionId = this.runtime.getAgentSessionById(sessionId)?.id;
655
- if (!currentSessionId)
655
+ const currentSession = this.runtime.getAgentSessionById(sessionId);
656
+ if (!currentSession)
656
657
  return [];
658
+ const currentSessionId = currentSession.id;
657
659
  const run = this.runtime.activeSessionRun(currentSessionId);
658
660
  if (!run || !run.initiatedByUserId)
659
661
  return [];
660
- const result = await this.agentNetworkService.list({
661
- sessionId: currentSessionId,
662
- runId: run.id,
663
- requestUserId: run.initiatedByUserId
664
- }, { scope: 'children', limit: 100 });
662
+ const result = await this.sessionCatalogService.operations()['session.list']({
663
+ workspaceId: currentSession.workspaceId,
664
+ registeredOnly: true,
665
+ view: 'marctl',
666
+ parentSessionId: currentSessionId,
667
+ limit: 100
668
+ });
665
669
  signal.throwIfAborted();
666
670
  const data = result;
667
- if (!data.sessions?.length && !data.truncated)
671
+ const truncated = data.nextCursor != null;
672
+ if (!data.sessions?.length && !truncated)
668
673
  return [];
669
674
  return [
670
675
  `Child Sessions (use agent_list for details): ${JSON.stringify({
671
676
  sessions: data.sessions ?? [],
672
- truncated: data.truncated === true
677
+ truncated
673
678
  }).replace(/[<>&]/g, (c) => c === '<' ? '\\u003c' : c === '>' ? '\\u003e' : '\\u0026')}`
674
679
  ];
675
680
  }
@@ -1093,31 +1098,7 @@ export class NodeConnector {
1093
1098
  goals: this.goalRuntime,
1094
1099
  runs: this.runWorkbenchService,
1095
1100
  observations: this.agentNetworkObservations,
1096
- listSessions: async (input) => this.sessionCatalogService.operations()['session.list'](input),
1097
1101
  history: async (input) => this.sessionQueryService.operations()['session.turns.list'](input),
1098
- profiles: async (environment, models) => {
1099
- const summaries = Array.isArray(models)
1100
- ? models.flatMap((model, index) => {
1101
- if (!isPlainRecord(model))
1102
- return [];
1103
- const { apiKey: _apiKey, credential: _credential, imageGeneration: _images, access: _access, ...summary } = model;
1104
- return [
1105
- {
1106
- ...summary,
1107
- sortOrder: typeof summary.sortOrder === 'number' ? summary.sortOrder : index
1108
- }
1109
- ];
1110
- })
1111
- : undefined;
1112
- const result = await this.runnerService.operations()['runner.profiles.list']({
1113
- secretEnvironment: environment,
1114
- marAgentModels: summaries,
1115
- waitForRefresh: true
1116
- });
1117
- if (!isPlainRecord(result) || !Array.isArray(result.profiles))
1118
- throw new Error('RUNNER_PROFILE_INVALID');
1119
- return result.profiles;
1120
- },
1121
1102
  ensureObservations: (actor) => {
1122
1103
  const existing = this.agentNetworkObservations.get(actor.observationId ?? actor.sessionId);
1123
1104
  if (existing)
@@ -20,10 +20,10 @@ 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
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 children|descendants|workspace|profile], 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.',
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
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
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.',
26
- 'List accepts --agent <id> for a relation root, --query, --parent, --limit and --cursor. Use --scope profile [--runner <runner>] for Runner models; configured profiles do not guarantee account availability.',
26
+ '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
27
  'Only direct-child updates are collected automatically. Sending to a non-child never subscribes; an explicit wait observes it only for that call.',
28
28
  'Use agents config, rename, prompt and schedule for management. marctl nodes, ssh and scp use authorized Nodes.',
29
29
  marctlSafety
@@ -38,6 +38,7 @@ const goal = {
38
38
  }
39
39
  ]
40
40
  };
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.';
41
42
  const tools = [
42
43
  {
43
44
  action: 'start',
@@ -49,7 +50,7 @@ const tools = [
49
50
  runner,
50
51
  modelId: {
51
52
  ...text,
52
- description: 'Model ID from the target Runner Profile models[].id, not a display name. For MAR Agent this is an internal configuration ID, not an upstream model ID; omit to use the Runner default.'
53
+ description: 'Model configuration ID; omit to use the target Runner default.'
53
54
  },
54
55
  effort: text,
55
56
  title: text,
@@ -60,7 +61,7 @@ const tools = [
60
61
  },
61
62
  {
62
63
  action: 'send',
63
- description: 'Send input, decide an approval or update a Goal; goal:null leaves it unchanged, goal:{clear:true} clears it. Messages default to REPLACE_CURRENT (steer); use QUEUE only when deferred delivery is explicitly requested. QUEUE is consumed only after current work and earlier queued inputs finish; an active Goal must exit first.',
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.`,
64
65
  properties: {
65
66
  agentId: text,
66
67
  content: { type: 'string' },
@@ -73,10 +74,10 @@ const tools = [
73
74
  required: ['approvalId', 'decision'],
74
75
  properties: { approvalId: text, decision: { type: 'string', enum: ['approve', 'reject'] } }
75
76
  },
76
- deliveryIntent: {
77
+ delivery: {
77
78
  type: 'string',
78
- enum: ['SEND', 'QUEUE', 'REPLACE_CURRENT'],
79
- description: 'Message delivery defaults to REPLACE_CURRENT (steer); explicit SEND remains supported.'
79
+ enum: ['STEER', 'QUEUE'],
80
+ description: deliveryGuidance
80
81
  },
81
82
  clientMessageId: text
82
83
  },
@@ -101,20 +102,19 @@ const tools = [
101
102
  },
102
103
  {
103
104
  action: 'list',
104
- description: 'List direct children by default, discover Workspace Sessions, inspect descendants or query a Runner profile.',
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.",
105
106
  properties: {
106
- scope: { type: 'string', enum: ['workspace', 'children', 'descendants', 'profile'] },
107
- agentId: {
108
- ...text,
109
- description: 'Root for children or descendants; defaults to the current Session.'
107
+ scope: {
108
+ type: 'string',
109
+ enum: ['tree', 'active'],
110
+ description: 'Defaults to tree: the containing tree, including ancestors, peers and descendants.'
110
111
  },
111
- runner: {
112
- ...runner,
113
- description: 'Runner for profile scope; defaults to the current Runner, not a model name.'
112
+ query: {
113
+ type: 'string',
114
+ maxLength: 160,
115
+ description: 'Optional case-insensitive title fragment within the scope.'
114
116
  },
115
- query: { type: 'string', description: 'Search Sessions; not a model selector.' },
116
- limit: { type: 'integer', minimum: 1, maximum: 100 },
117
- cursor: text
117
+ limit: { type: 'integer', minimum: 1, maximum: 100, description: 'Defaults to 100.' }
118
118
  }
119
119
  },
120
120
  {
@@ -131,7 +131,9 @@ const tools = [
131
131
  ];
132
132
  const delegationPrompt = [
133
133
  '# Delegation and coordination',
134
- 'Delegate bounded, self-contained tasks when independent progress or context savings materially helps. Keep simple or tightly coupled work local.',
134
+ 'Handle focused or tightly coupled work locally by default.',
135
+ 'Delegate a clearly separable subtask when parallel progress or context savings materially outweigh coordination overhead.',
136
+ 'Do not split work merely because agents are available.',
135
137
  'Give each child an objective, necessary context, expected output, and a read-only boundary or non-overlapping write scope.',
136
138
  'Follow up through the direct delegate by default; inspect deeper Sessions only when a concrete issue needs independent verification.',
137
139
  'While children run, continue meaningful non-overlapping work. Do not redo delegated work. Wait only when the next step needs the result.',
@@ -140,7 +142,8 @@ const delegationPrompt = [
140
142
  const collaborationPrompt = [
141
143
  '# Workspace collaboration',
142
144
  "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 for meaningful progress, blockers, or changes that affect the recipient's work; combine related updates and avoid acknowledgment-only exchanges."
145
+ '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.',
146
+ '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.'
144
147
  ].join('\n\n');
145
148
  const managementPrompt = [
146
149
  '# Workspace management',
@@ -154,13 +157,27 @@ export function agentNetworkExtension(invoke, currentExecution) {
154
157
  context.hooks.intercept('prompt.sections', (input, _call, next) => {
155
158
  const available = new Set(input.facts?.tools);
156
159
  const enabled = input.facts?.mode !== 'compact';
160
+ const delegate = enabled && available.has('agent_start');
161
+ const models = delegate
162
+ ? (context.models
163
+ ?.list()
164
+ .filter((model) => model.conversationModel && model.subagentModel)
165
+ .map((model) => ({
166
+ id: model.id,
167
+ name: model.name,
168
+ supportedEfforts: model.reasoningEfforts,
169
+ defaultEffort: model.defaultReasoningEffort
170
+ })) ?? [])
171
+ : [];
157
172
  return next({
158
173
  ...input,
159
174
  sections: [
160
175
  ...input.sections,
161
176
  {
162
177
  id: 'mar.node.agent-network:delegation',
163
- text: enabled && available.has('agent_start') ? delegationPrompt : null
178
+ text: delegate
179
+ ? `${delegationPrompt}\n\nAvailable subagent models:\n${JSON.stringify({ runner: 'mar-agent', models })}`
180
+ : null
164
181
  },
165
182
  {
166
183
  id: 'mar.node.agent-network:collaboration',
@@ -190,7 +207,10 @@ export function agentNetworkExtension(invoke, currentExecution) {
190
207
  'effort',
191
208
  'waitFor',
192
209
  'waitMs',
193
- 'scope'
210
+ 'scope',
211
+ 'query',
212
+ 'limit',
213
+ 'cursor'
194
214
  ],
195
215
  inputSchema: {
196
216
  type: 'object',
@@ -203,13 +223,20 @@ export function agentNetworkExtension(invoke, currentExecution) {
203
223
  if (!executionId)
204
224
  throw new Error('AGENT_ACTOR_NOT_ACTIVE');
205
225
  const args = input;
206
- const operationInput = tool.action === 'send' &&
207
- args.deliveryIntent === undefined &&
208
- ((typeof args.content === 'string' && !!args.content.trim()) ||
226
+ let operationInput = args;
227
+ if (tool.action === 'send') {
228
+ const { delivery, ...sendInput } = args;
229
+ const hasMessage = (typeof args.content === 'string' && !!args.content.trim()) ||
209
230
  (Array.isArray(args.attachments) && args.attachments.length > 0) ||
210
- (Array.isArray(args.attachmentIds) && args.attachmentIds.length > 0))
211
- ? { ...args, deliveryIntent: 'REPLACE_CURRENT' }
212
- : args;
231
+ (Array.isArray(args.attachmentIds) && args.attachmentIds.length > 0);
232
+ operationInput =
233
+ delivery !== undefined || hasMessage
234
+ ? {
235
+ ...sendInput,
236
+ deliveryIntent: delivery === 'QUEUE' ? 'QUEUE' : 'REPLACE_CURRENT'
237
+ }
238
+ : sendInput;
239
+ }
213
240
  const onResolved = call.publishEventData
214
241
  ? async (sessions) => {
215
242
  const rows = sessions.map((session) => ({
@@ -103,7 +103,7 @@ export function agentOperationEvent(action, input, result) {
103
103
  receipt.modelCount = value.models.length;
104
104
  }
105
105
  if (action === 'view' && isPlainRecord(value.session)) {
106
- for (const key of ['status', 'model', 'effort']) {
106
+ for (const key of ['status', 'modelId', 'effort']) {
107
107
  const field = text(value.session[key]);
108
108
  if (field)
109
109
  receipt[key] = field;
@@ -12,8 +12,8 @@ export function actorForAuthenticatedTerminal(actor, workspaceId) {
12
12
  terminalCalls.set(authenticated, { workspaceId });
13
13
  return authenticated;
14
14
  }
15
- import { sessionPage } from '../session/pagination.js';
16
- import { marctlSessionSummary, marctlSessionDetail } from './session-recent-activity.js';
15
+ import { marctlSessionDetail } from './session-recent-activity.js';
16
+ import { listAgentNetworkSessions } from './agent-network-list.js';
17
17
  /** Bind the collaboration use case to the existing Session, Run and Goal owners. */
18
18
  export function createNodeAgentNetwork(options) {
19
19
  async function actorSession(actor) {
@@ -44,9 +44,35 @@ export function createNodeAgentNetwork(options) {
44
44
  throw new Error('SESSION_NOT_FOUND');
45
45
  return session;
46
46
  }
47
- function origin(parent) {
47
+ function ancestorDistance(source, targetId) {
48
+ const seen = new Set([source.id]);
49
+ let parentId = source.parentSessionId;
50
+ let distance = 1;
51
+ while (parentId) {
52
+ if (seen.has(parentId))
53
+ throw Object.assign(new Error('Session ancestry contains a cycle.'), {
54
+ code: 'SESSION_RELATION_INVALID'
55
+ });
56
+ seen.add(parentId);
57
+ if (parentId === targetId)
58
+ return distance;
59
+ const parent = options.runtime.getAgentSession(parentId);
60
+ if (parent?.id === targetId)
61
+ return distance;
62
+ parentId = parent
63
+ ? parent.parentSessionId
64
+ : options.database()?.getSessionRecord(parentId)?.metadata.parentSessionId;
65
+ distance++;
66
+ }
67
+ return 0;
68
+ }
69
+ function origin(parent, targetId) {
70
+ const distance = targetId === undefined ? 0 : ancestorDistance(parent, targetId);
48
71
  return {
49
72
  sessionId: parent.id,
73
+ ...(distance === 0
74
+ ? {}
75
+ : { relation: distance === 1 ? 'child' : 'descendant' }),
50
76
  name: parent.customTitle?.trim() || parent.runnerTitle?.trim() || parent.id
51
77
  };
52
78
  }
@@ -119,6 +145,18 @@ export function createNodeAgentNetwork(options) {
119
145
  options.projection.resolve(record.id, true)));
120
146
  return resolved.filter((session) => session !== undefined);
121
147
  },
148
+ validateSend: async (actor, session) => {
149
+ if (terminalCalls.has(actor))
150
+ return;
151
+ const source = await actorSession(actor);
152
+ if (ancestorDistance(source, session.id) === 0)
153
+ return;
154
+ const run = options.runtime.activeSessionRun(session.id);
155
+ if (!run || run.status === 'CANCELLING')
156
+ throw Object.assign(new Error('Cannot send messages to an inactive ancestor session.'), {
157
+ code: 'AGENT_ANCESTOR_NOT_ACTIVE'
158
+ });
159
+ },
122
160
  observe: (actor) => {
123
161
  options.ensureObservations(actor);
124
162
  },
@@ -243,7 +281,7 @@ export function createNodeAgentNetwork(options) {
243
281
  : { personalInstructions: actor.personalInstructions }),
244
282
  ...(actor.usageGrant === undefined ? {} : { usageGrant: actor.usageGrant }),
245
283
  preserveActiveRun: true,
246
- ...(parent ? { messageOrigin: origin(parent) } : {}),
284
+ ...(parent ? { messageOrigin: origin(parent, session.id) } : {}),
247
285
  ...(message === undefined
248
286
  ? {}
249
287
  : {
@@ -267,7 +305,7 @@ export function createNodeAgentNetwork(options) {
267
305
  ...(signal ? { [SESSION_ADMISSION_SIGNAL]: signal } : {}),
268
306
  sessionId: session.id,
269
307
  __suppressNotifications: true,
270
- ...(parent ? { __marctlOrigin: origin(parent) } : {})
308
+ ...(parent ? { __marctlOrigin: origin(parent, session.id) } : {})
271
309
  }));
272
310
  return publicReceipt(result);
273
311
  },
@@ -313,75 +351,12 @@ export function createNodeAgentNetwork(options) {
313
351
  const workspaceId = terminal?.workspaceId ?? source?.workspaceId;
314
352
  if (!workspaceId)
315
353
  throw new Error('SESSION_INVALID');
316
- const current = source ? marctlSessionSummary(options.runtime, source) : null;
317
- const scope = input.scope ?? (terminal ? 'workspace' : 'children');
318
- if (scope === 'profile') {
319
- const runner = input.runner ?? source?.runner;
320
- if (!runner)
321
- throw new Error('RUNNER_REQUIRED');
322
- const profiles = await options.profiles(actor.secretEnvironment ?? {}, actor.runnerConfiguration?.marAgentModels);
323
- const profile = profiles.find((item) => item.runner === runner);
324
- if (!profile)
325
- throw new Error('RUNNER_PROFILE_NOT_FOUND');
326
- const models = profile.runner !== 'mar-agent'
327
- ? profile.models
328
- : profile.models.filter((model) => {
329
- const configurations = [
330
- actor.runnerConfiguration?.marAgentModel,
331
- ...(Array.isArray(actor.runnerConfiguration?.marAgentModels)
332
- ? actor.runnerConfiguration.marAgentModels
333
- : [])
334
- ];
335
- return configurations.some((value) => isPlainRecord(value) &&
336
- value.id === model.id &&
337
- value.enabled === true &&
338
- value.conversationModel === true &&
339
- value.subagentModel === true);
340
- });
341
- return { current, runner: profile.runner, models, capabilities: profile.capabilities };
342
- }
343
- if (scope === 'workspace') {
344
- const result = await options.listSessions({
345
- workspaceId,
346
- view: 'marctl',
347
- registeredOnly: true,
348
- ...(input.query === undefined ? {} : { query: input.query }),
349
- ...(input.parent === undefined ? {} : { parentSessionId: input.parent }),
350
- ...(input.limit === undefined ? {} : { limit: input.limit }),
351
- ...(input.cursor === undefined ? {} : { cursor: input.cursor })
352
- });
353
- if (!isPlainRecord(result))
354
- throw new Error('SESSION_LIST_INVALID_RESULT');
355
- return { ...result, current };
356
- }
357
- if (scope !== 'children' && scope !== 'descendants')
358
- throw new Error('AGENT_LIST_INVALID');
359
- const root = input.agentId === undefined || input.agentId === source?.id
360
- ? source
361
- : await target(actor, input.agentId);
362
- if (!root)
363
- throw new Error('AGENT_TARGET_REQUIRED');
364
- const database = options.database();
365
- if (!database)
366
- throw new Error('DATABASE_UNAVAILABLE');
367
- const records = scope === 'children'
368
- ? database.listChildSessionRecords(root.workspaceId, root.id)
369
- : database.sessionTreeRecords(root.workspaceId, root.id).slice(1);
370
- const sessions = (await Promise.all(records.map((record) => options.runtime.getAgentSession(record.id) ??
371
- options.projection.resolve(record.id, true))))
372
- .filter((session) => session !== undefined)
373
- .filter((session) => !input.query ||
374
- (session.customTitle ?? session.runnerTitle ?? '')
375
- .normalize('NFKC')
376
- .toLocaleLowerCase()
377
- .includes(input.query.normalize('NFKC').toLocaleLowerCase()));
378
- const page = sessionPage(sessions, { limit: input.limit, cursor: input.cursor });
379
- return {
380
- current,
381
- sessions: page.sessions.map((session) => marctlSessionSummary(options.runtime, session)),
382
- nextCursor: page.nextCursor,
383
- truncated: page.nextCursor !== null
384
- };
354
+ return listAgentNetworkSessions(source, input, {
355
+ workspaceId,
356
+ runtime: options.runtime,
357
+ database: options.database(),
358
+ projection: options.projection
359
+ });
385
360
  },
386
361
  view: async (actor, input, onResolved) => {
387
362
  const source = terminalCalls.has(actor) ? undefined : await actorSession(actor);
@@ -145,8 +145,7 @@ export function agentNetworkExecutionCommand(input) {
145
145
  const session = input.runtime.getAgentSession(actor.sessionId);
146
146
  if (actor.requestUserId !== data.__requestUserId || session?.workspaceId !== data.workspaceId)
147
147
  throw new Error('MARCTL_EXECUTION_CREDENTIAL_INVALID');
148
- const profileQuery = data.action === 'list' && data.input.scope === 'profile' && data.input.runner === 'mar-agent';
149
- const resources = (['start', 'send'].includes(String(data.action)) || profileQuery) &&
148
+ const resources = ['start', 'send'].includes(String(data.action)) &&
150
149
  isPlainRecord(data.__marctlExecutionResources)
151
150
  ? data.__marctlExecutionResources
152
151
  : undefined;
@@ -0,0 +1,71 @@
1
+ import { marctlSessionSummary } from './session-recent-activity.js';
2
+ /** Read only registered relationships and live Node facts; never discover or resume a Runner. */
3
+ export async function listAgentNetworkSessions(source, input, options) {
4
+ if (Object.keys(input).some((key) => !['scope', 'query', 'limit'].includes(key)) ||
5
+ (input.scope !== undefined && !['tree', 'active'].includes(input.scope)) ||
6
+ (input.query !== undefined && (typeof input.query !== 'string' || input.query.length > 160)) ||
7
+ (input.limit !== undefined &&
8
+ (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 100)))
9
+ throw Object.assign(new Error('Invalid Agent list input.'), { code: 'AGENT_LIST_INVALID' });
10
+ const scope = input.scope ?? (source ? 'tree' : 'active');
11
+ const known = new Map(options.runtime
12
+ .listAgentSessions()
13
+ .filter((session) => session.workspaceId === options.workspaceId)
14
+ .map((session) => [session.id, session]));
15
+ if (source)
16
+ known.set(source.id, source);
17
+ let sessions;
18
+ if (scope === 'tree') {
19
+ if (!source)
20
+ throw new Error('AGENT_TARGET_REQUIRED');
21
+ if (!options.database)
22
+ throw new Error('DATABASE_UNAVAILABLE');
23
+ const parents = new Map(options.database
24
+ .listSessionRecords(options.workspaceId)
25
+ .map((record) => [record.id, record.metadata.parentSessionId ?? null]));
26
+ for (const session of known.values())
27
+ parents.set(session.id, session.parentSessionId ?? null);
28
+ let root = source.id;
29
+ const ancestors = new Set([root]);
30
+ while (true) {
31
+ const parent = parents.get(root);
32
+ if (!parent || !parents.has(parent))
33
+ break;
34
+ if (ancestors.has(parent))
35
+ throw Object.assign(new Error('Session ancestry contains a cycle.'), {
36
+ code: 'SESSION_RELATION_INVALID'
37
+ });
38
+ ancestors.add(parent);
39
+ root = parent;
40
+ }
41
+ const children = new Map();
42
+ for (const [id, parent] of parents) {
43
+ if (parent === null)
44
+ continue;
45
+ const siblings = children.get(parent) ?? [];
46
+ siblings.push(id);
47
+ children.set(parent, siblings);
48
+ }
49
+ const ids = [root];
50
+ for (let i = 0; i < ids.length; i++)
51
+ ids.push(...(children.get(ids[i]) ?? []).sort());
52
+ sessions = (await Promise.all(ids.map((id) => known.get(id) ?? options.projection.resolve(id, true)))).filter((session) => session !== undefined && session.workspaceId === options.workspaceId);
53
+ }
54
+ else {
55
+ sessions = [...known.values()].sort((left, right) => right.lastActivityAt - left.lastActivityAt || left.id.localeCompare(right.id));
56
+ }
57
+ const query = input.query?.trim().normalize('NFKC').toLocaleLowerCase() ?? '';
58
+ const matches = sessions
59
+ .map((session) => {
60
+ const { id, title, runner, status } = marctlSessionSummary(options.runtime, session);
61
+ return { id, parentSessionId: session.parentSessionId ?? null, title, runner, status };
62
+ })
63
+ .filter((session) => (scope !== 'active' || session.status !== 'IDLE') &&
64
+ (!query || session.title.normalize('NFKC').toLocaleLowerCase().includes(query)));
65
+ const limit = input.limit ?? 100;
66
+ return {
67
+ currentAgentId: source?.id ?? null,
68
+ sessions: matches.slice(0, limit),
69
+ truncated: matches.length > limit
70
+ };
71
+ }
@@ -90,6 +90,8 @@ export class AgentNetworkService {
90
90
  await this.options.authorize(actor, session, 'send');
91
91
  if (goal.kind !== 'unchanged')
92
92
  await this.options.authorize(actor, session, 'goal');
93
+ if (hasMessage || goal.kind === 'set')
94
+ await this.options.validateSend(actor, session);
93
95
  if (input.approval !== undefined)
94
96
  await this.options.validateApproval(actor, session, input.approval);
95
97
  signal?.throwIfAborted();
@@ -11,7 +11,11 @@ export function withSessionMessageOrigin(input, origin, parentSessionId) {
11
11
  ? parsed
12
12
  : {
13
13
  ...parsed,
14
- relation: parsed.sessionId === parentSessionId ? 'parent' : 'other'
14
+ relation: parsed.sessionId === parentSessionId
15
+ ? 'parent'
16
+ : parsed.relation === 'child' || parsed.relation === 'descendant'
17
+ ? parsed.relation
18
+ : 'other'
15
19
  };
16
20
  const tag = serializeComposerTag('mar_session_origin', snapshot);
17
21
  const parts = parseComposerTags(input);
@@ -45,10 +49,12 @@ export function prepareComposerResourceInput(input, cwd, workspacePath) {
45
49
  if (part.kind !== 'mar_references')
46
50
  return part.raw;
47
51
  return serializeComposerTag('mar_references', {
48
- references: part.payload.references.map((reference) => ({
49
- ...reference,
50
- runnerPath: relative(cwd, resolve(workspacePath, reference.path)).replaceAll('\\', '/')
51
- }))
52
+ references: part.payload.references.map((reference) => reference.type === 'session'
53
+ ? reference
54
+ : {
55
+ ...reference,
56
+ runnerPath: relative(cwd, resolve(workspacePath, reference.path)).replaceAll('\\', '/')
57
+ })
52
58
  });
53
59
  })
54
60
  .join('');
@@ -84,6 +84,44 @@ const legacyPlannedRestartSchema = plannedRestartSchema.extend({
84
84
  })
85
85
  }))
86
86
  });
87
+ // Only this producer can associate log-safe metadata with an original failure.
88
+ const captureDiagnostics = new WeakMap();
89
+ const captureFields = new Set([
90
+ ...Object.keys(plannedRestartSchema.shape),
91
+ ...Object.keys(sessionSchema.shape),
92
+ ...Object.keys(sessionSchema.shape.session.shape),
93
+ ...Object.keys(goalSchema.shape),
94
+ ...Object.keys(goalSchema.shape.goal.shape),
95
+ ...Object.keys(messageSchema.shape),
96
+ ...Object.keys(attachmentSchema.shape)
97
+ ]);
98
+ export function plannedRestartCaptureDiagnostic(error) {
99
+ return error instanceof Error ? captureDiagnostics.get(error) : undefined;
100
+ }
101
+ function parseCapture(schema, input, reason) {
102
+ const result = schema.safeParse(input);
103
+ if (result.success)
104
+ return result.data;
105
+ captureDiagnostics.set(result.error, {
106
+ reason,
107
+ issues: result.error.issues.slice(0, 12).map((issue) => ({
108
+ code: issue.code,
109
+ // Only schema-owned fields and array offsets, never unknown keys or input values.
110
+ path: issue.path
111
+ .slice(0, 8)
112
+ .map((part) => typeof part === 'number'
113
+ ? `[${part}]`
114
+ : captureFields.has(String(part))
115
+ ? String(part)
116
+ : '*')
117
+ .join('.')
118
+ .replaceAll('.[', '[')
119
+ .slice(0, 160)
120
+ })),
121
+ issuesTruncated: result.error.issues.length > 12
122
+ });
123
+ throw result.error;
124
+ }
87
125
  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
126
  /** A one-shot orderly-shutdown handoff, not a crash-recovery journal. */
89
127
  export class PlannedRestartHandoff {
@@ -197,8 +235,13 @@ export class PlannedRestartHandoff {
197
235
  : toMessage(currentId, pending.clientMessageId ?? active.clientMessageId, pending.restartMessage.content, [...pending.restartMessage.attachments], pending.restartMessage.initiatedByUserId ?? active?.initiatedByUserId, active?.suppressNotifications);
198
236
  if (current === undefined && items.length === 0 && goal === undefined && !awaitingGoal)
199
237
  continue;
200
- if (session.externalSessionId === null)
201
- throw new Error('RESTART_SESSION_NOT_READY');
238
+ if (session.externalSessionId === null) {
239
+ const error = Object.assign(new Error('RESTART_SESSION_NOT_READY'), {
240
+ code: 'RESTART_SESSION_NOT_READY'
241
+ });
242
+ captureDiagnostics.set(error, { reason: 'session_not_ready' });
243
+ throw error;
244
+ }
202
245
  const ownerUserId = active?.initiatedByUserId ??
203
246
  items[0]?.initiatedByUserId ??
204
247
  this.owners.get(session.id) ??
@@ -220,18 +263,20 @@ export class PlannedRestartHandoff {
220
263
  ...(notificationSource?.suppressNotifications === true
221
264
  ? { suppressNotifications: true }
222
265
  : {}),
223
- ...(goal === undefined ? {} : { goal: goalSchema.parse(goal) }),
266
+ ...(goal === undefined
267
+ ? {}
268
+ : { goal: parseCapture(goalSchema, goal, 'goal_schema_invalid') }),
224
269
  ...(current === undefined ? {} : { current }),
225
270
  queue: items
226
271
  });
227
272
  }
228
- return plannedRestartSchema.parse({
273
+ return parseCapture(plannedRestartSchema, {
229
274
  schemaVersion: 2,
230
275
  id: randomUUID(),
231
276
  ...this.options.identity(),
232
277
  createdAt: Date.now(),
233
278
  sessions
234
- });
279
+ }, 'snapshot_schema_invalid');
235
280
  }
236
281
  async publish(snapshot) {
237
282
  for (const entry of snapshot.sessions) {
@@ -1,5 +1,6 @@
1
1
  import { runnerDefaultConfigurationSchema } from '@myagentroam/protocol';
2
2
  import { parseSecretEnvironment } from '../mcp/secret-environment.js';
3
+ import { isPlainRecord } from '../util/records.js';
3
4
  export class RunnerService {
4
5
  database;
5
6
  capabilities;
@@ -85,7 +86,10 @@ export class RunnerService {
85
86
  };
86
87
  }
87
88
  saveDefaults(data) {
88
- const parsed = runnerDefaultConfigurationSchema.safeParse(data);
89
+ if (!isPlainRecord(data))
90
+ throw new Error('RUNNER_DEFAULT_INVALID');
91
+ const { __workspaceWatchSessionHash: _sessionHash, __requestUserId: _userId, __usageGrant: _usageGrant, ...configuration } = data;
92
+ const parsed = runnerDefaultConfigurationSchema.safeParse(configuration);
89
93
  if (!parsed.success)
90
94
  throw new Error('RUNNER_DEFAULT_INVALID');
91
95
  if (parsed.data.runner === 'mar-agent' &&
@@ -107,6 +107,18 @@ export async function readRestrictedDiff(workspacePath, fromRef, toRef) {
107
107
  }
108
108
  return { binary: false, text: result.text, truncated: result.truncated };
109
109
  }
110
+ /** Returns a bounded, deterministic refs snapshot, or undefined when it cannot be compared. */
111
+ export async function readGitRefsSnapshot(workspacePath, timeoutMs) {
112
+ if (timeoutMs <= 0)
113
+ return undefined;
114
+ try {
115
+ const result = await runReadOnlyGit(workspacePath, ['for-each-ref', '--sort=refname', '--format=%(refname)%00%(objectname)%00%(symref)'], { timeoutMs: Math.min(timeoutMs, GIT_TIMEOUT_MS), outputLimit: GIT_LARGE_OUTPUT_LIMIT });
116
+ return result.truncated ? undefined : result.output;
117
+ }
118
+ catch {
119
+ return undefined;
120
+ }
121
+ }
110
122
  /** Fetches refs only; failures never expose remote URLs or authentication diagnostics. */
111
123
  export async function fetchGitRepository(workspacePath, timeoutMs) {
112
124
  if (timeoutMs <= 0)
@@ -2,7 +2,7 @@ import { lstat } from 'node:fs/promises';
2
2
  import { basename, posix, resolve } from 'node:path';
3
3
  import { publicErrorMessage } from '@myagentroam/protocol';
4
4
  import { nodeLog } from '../operational.js';
5
- import { GitCommandError, fetchGitRepository, isWorkspaceChangeVisible, listGitWorktrees, listGitSubmodules, readCurrentChangesSummary, resolveGitRepository, resolveWorkspaceGitRoot } from './git.js';
5
+ import { GitCommandError, fetchGitRepository, readGitRefsSnapshot, isWorkspaceChangeVisible, listGitWorktrees, listGitSubmodules, readCurrentChangesSummary, resolveGitRepository, resolveWorkspaceGitRoot } from './git.js';
6
6
  const CACHE_TTL_MS = 5_000;
7
7
  const REPOSITORY_READ_CONCURRENCY = 4;
8
8
  const WORKTREE_READ_CONCURRENCY = 4;
@@ -37,7 +37,6 @@ export class WorkspaceChangeService {
37
37
  if (selected !== undefined)
38
38
  paths = [repositoryPath];
39
39
  else {
40
- this.invalidate(workspace.id);
41
40
  const current = (await this.current(workspace));
42
41
  // Linked worktrees share the root's refs and must not fetch them twice.
43
42
  paths = current.repositories
@@ -49,15 +48,25 @@ export class WorkspaceChangeService {
49
48
  const results = await mapWithConcurrency(paths, REPOSITORY_READ_CONCURRENCY, async (path) => {
50
49
  try {
51
50
  if (performance.now() >= deadline)
52
- return false;
51
+ return { success: false, updated: false };
53
52
  const repository = selected ?? (await resolveGitRepository(root, path));
54
- return await fetchGitRepository(repository, Math.max(0, Math.floor(deadline - performance.now())));
53
+ const remaining = () => Math.max(0, Math.floor(deadline - performance.now()));
54
+ const before = await readGitRefsSnapshot(repository, remaining());
55
+ const success = await fetchGitRepository(repository, remaining());
56
+ const after = await readGitRefsSnapshot(repository, remaining());
57
+ return {
58
+ success,
59
+ updated: before === undefined || after === undefined || !before.equals(after)
60
+ };
55
61
  }
56
62
  catch {
57
- return false;
63
+ return { success: false, updated: false };
58
64
  }
59
65
  });
60
- return { fetchFailed: results.some((success) => !success) };
66
+ return {
67
+ fetchFailed: results.some((result) => !result.success),
68
+ updated: results.some((result) => result.updated)
69
+ };
61
70
  }
62
71
  finally {
63
72
  // Fetch is request/user scoped, never coalesced with another user's credentials.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.11.47",
3
+ "version": "0.11.49",
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.49",
41
42
  "@myagentroam/code-navigation": "0.11.1",
42
- "@myagentroam/marctl": "0.11.8",
43
- "@myagentroam/agent": "0.11.47",
44
- "@myagentroam/protocol": "0.11.47"
43
+ "@myagentroam/marctl": "0.11.9",
44
+ "@myagentroam/protocol": "0.11.49"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/ws": "^8.18.1"