@myagentroam/node 0.11.46 → 0.11.48

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
@@ -651,25 +651,29 @@ export class NodeConnector {
651
651
  }),
652
652
  resources: async (signal) => {
653
653
  signal.throwIfAborted();
654
- const currentSessionId = this.runtime.getAgentSessionById(sessionId)?.id;
655
- if (!currentSessionId)
654
+ const currentSession = this.runtime.getAgentSessionById(sessionId);
655
+ if (!currentSession)
656
656
  return [];
657
+ const currentSessionId = currentSession.id;
657
658
  const run = this.runtime.activeSessionRun(currentSessionId);
658
659
  if (!run || !run.initiatedByUserId)
659
660
  return [];
660
- const result = await this.agentNetworkService.list({
661
- sessionId: currentSessionId,
662
- runId: run.id,
663
- requestUserId: run.initiatedByUserId
664
- }, { scope: 'children', limit: 100 });
661
+ const result = await this.sessionCatalogService.operations()['session.list']({
662
+ workspaceId: currentSession.workspaceId,
663
+ registeredOnly: true,
664
+ view: 'marctl',
665
+ parentSessionId: currentSessionId,
666
+ limit: 100
667
+ });
665
668
  signal.throwIfAborted();
666
669
  const data = result;
667
- if (!data.sessions?.length && !data.truncated)
670
+ const truncated = data.nextCursor != null;
671
+ if (!data.sessions?.length && !truncated)
668
672
  return [];
669
673
  return [
670
674
  `Child Sessions (use agent_list for details): ${JSON.stringify({
671
675
  sessions: data.sessions ?? [],
672
- truncated: data.truncated === true
676
+ truncated
673
677
  }).replace(/[<>&]/g, (c) => c === '<' ? '\\u003c' : c === '>' ? '\\u003e' : '\\u0026')}`
674
678
  ];
675
679
  }
@@ -1093,31 +1097,7 @@ export class NodeConnector {
1093
1097
  goals: this.goalRuntime,
1094
1098
  runs: this.runWorkbenchService,
1095
1099
  observations: this.agentNetworkObservations,
1096
- listSessions: async (input) => this.sessionCatalogService.operations()['session.list'](input),
1097
1100
  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
1101
  ensureObservations: (actor) => {
1122
1102
  const existing = this.agentNetworkObservations.get(actor.observationId ?? actor.sessionId);
1123
1103
  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
  {
@@ -140,7 +140,8 @@ const delegationPrompt = [
140
140
  const collaborationPrompt = [
141
141
  '# Workspace collaboration',
142
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 for meaningful progress, blockers, or changes that affect the recipient's work; combine related updates and avoid acknowledgment-only exchanges."
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.'
144
145
  ].join('\n\n');
145
146
  const managementPrompt = [
146
147
  '# Workspace management',
@@ -154,13 +155,27 @@ export function agentNetworkExtension(invoke, currentExecution) {
154
155
  context.hooks.intercept('prompt.sections', (input, _call, next) => {
155
156
  const available = new Set(input.facts?.tools);
156
157
  const enabled = input.facts?.mode !== 'compact';
158
+ const delegate = enabled && available.has('agent_start');
159
+ const models = delegate
160
+ ? (context.models
161
+ ?.list()
162
+ .filter((model) => model.conversationModel && model.subagentModel)
163
+ .map((model) => ({
164
+ id: model.id,
165
+ name: model.name,
166
+ supportedEfforts: model.reasoningEfforts,
167
+ defaultEffort: model.defaultReasoningEffort
168
+ })) ?? [])
169
+ : [];
157
170
  return next({
158
171
  ...input,
159
172
  sections: [
160
173
  ...input.sections,
161
174
  {
162
175
  id: 'mar.node.agent-network:delegation',
163
- text: enabled && available.has('agent_start') ? delegationPrompt : null
176
+ text: delegate
177
+ ? `${delegationPrompt}\n\nAvailable subagent models:\n${JSON.stringify({ runner: 'mar-agent', models })}`
178
+ : null
164
179
  },
165
180
  {
166
181
  id: 'mar.node.agent-network:collaboration',
@@ -190,7 +205,10 @@ export function agentNetworkExtension(invoke, currentExecution) {
190
205
  'effort',
191
206
  'waitFor',
192
207
  'waitMs',
193
- 'scope'
208
+ 'scope',
209
+ 'query',
210
+ 'limit',
211
+ 'cursor'
194
212
  ],
195
213
  inputSchema: {
196
214
  type: 'object',
@@ -203,14 +221,35 @@ export function agentNetworkExtension(invoke, currentExecution) {
203
221
  if (!executionId)
204
222
  throw new Error('AGENT_ACTOR_NOT_ACTIVE');
205
223
  const args = input;
206
- const operationInput = tool.action === 'send' &&
207
- args.deliveryIntent === undefined &&
208
- ((typeof args.content === 'string' && !!args.content.trim()) ||
224
+ let operationInput = args;
225
+ if (tool.action === 'send') {
226
+ const { delivery, ...sendInput } = args;
227
+ const hasMessage = (typeof args.content === 'string' && !!args.content.trim()) ||
209
228
  (Array.isArray(args.attachments) && args.attachments.length > 0) ||
210
- (Array.isArray(args.attachmentIds) && args.attachmentIds.length > 0))
211
- ? { ...args, deliveryIntent: 'REPLACE_CURRENT' }
212
- : args;
213
- const result = await invoke(executionId, tool.action, operationInput, call);
229
+ (Array.isArray(args.attachmentIds) && args.attachmentIds.length > 0);
230
+ operationInput =
231
+ delivery !== undefined || hasMessage
232
+ ? {
233
+ ...sendInput,
234
+ deliveryIntent: delivery === 'QUEUE' ? 'QUEUE' : 'REPLACE_CURRENT'
235
+ }
236
+ : sendInput;
237
+ }
238
+ const onResolved = call.publishEventData
239
+ ? async (sessions) => {
240
+ const rows = sessions.map((session) => ({
241
+ agentId: session.id,
242
+ title: session.customTitle ?? session.runnerTitle ?? undefined
243
+ }));
244
+ const result = tool.action === 'wait'
245
+ ? { agents: rows }
246
+ : tool.action === 'view'
247
+ ? { session: rows[0] }
248
+ : (rows[0] ?? {});
249
+ await call.publishEventData(agentOperationEvent(tool.action, {}, result).eventData);
250
+ }
251
+ : undefined;
252
+ const result = await invoke(executionId, tool.action, operationInput, call, onResolved);
214
253
  const modelResult = tool.action === 'wait' ? agentNetworkWaitModelOutput(result) : result;
215
254
  return {
216
255
  data: result,
@@ -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;
@@ -6,7 +6,7 @@ export function normalizeMarAgentUsage(usage) {
6
6
  }
7
7
  import { boundedConversationItemId, compactRunnerText, compactRunnerValue } from '../normalization.js';
8
8
  import { runnerErrorProjection } from '../runner-error.js';
9
- import { toolPresentationSchema } from '@myagentroam/protocol';
9
+ import { toolPresentationSchema, agentOperationPresentationSchema } from '@myagentroam/protocol';
10
10
  import { publicOutputSnapshot, publicToolOutput } from '../normalization.js';
11
11
  import { boundToolOutput } from '@myagentroam/protocol';
12
12
  import { agentOperationAction, agentOperationPresentation } from './agent-operation-presentation.js';
@@ -170,6 +170,20 @@ export class MarAgentConversationNormalizer {
170
170
  ...this.#codeModeToolResults(event)
171
171
  ];
172
172
  }
173
+ if (event.type === 'tool.updated') {
174
+ const state = this.#tools.get(event.callId);
175
+ if (!state?.active ||
176
+ state.toolName !== event.toolName ||
177
+ !agentOperationAction(event.toolName))
178
+ return [];
179
+ const operation = agentOperationPresentationSchema.safeParse(record(event.data)?.agentOperation);
180
+ if (!operation.success || operation.data.action !== agentOperationAction(event.toolName))
181
+ return [];
182
+ this.#tools.set(event.callId, { ...state, presentationData: event.data });
183
+ return [
184
+ genericToolItem(state.itemId, state.toolName, 'IN_PROGRESS', state.input, null, null, false, event.data, state.subagentTitle, state.subagentDetails, undefined, undefined, state.inputTruncated)
185
+ ];
186
+ }
173
187
  if (event.type === 'sleep.started') {
174
188
  const state = this.#tools.get(event.callId);
175
189
  if (state?.toolName !== 'sleep')
@@ -673,7 +687,7 @@ export class MarAgentConversationNormalizer {
673
687
  }
674
688
  const item = genericToolItem(state?.itemId ?? itemId('tool', event.callId), event.toolName, 'COMPLETED', state?.input, generatedImages.length === 0
675
689
  ? event.summary
676
- : generatedImages.map((image) => image.name).join(', '), null, event.outputTruncation !== undefined, event.data, agentId === undefined ? state?.subagentTitle : this.#subagentTitles.get(agentId), details, state?.sleepStartedAt, event.outputTruncation, state?.inputTruncated, event.summaryTruncated === true);
690
+ : generatedImages.map((image) => image.name).join(', '), null, event.outputTruncation !== undefined, mergeAgentOperationData(event.toolName, state?.presentationData, event.data), agentId === undefined ? state?.subagentTitle : this.#subagentTitles.get(agentId), details, state?.sleepStartedAt, event.outputTruncation, state?.inputTruncated, event.summaryTruncated === true);
677
691
  return [
678
692
  {
679
693
  ...item,
@@ -704,7 +718,11 @@ export class MarAgentConversationNormalizer {
704
718
  return [
705
719
  genericToolItem(state?.itemId ?? itemId('tool', event.callId), event.toolName, event.code === 'MAR_AGENT_CANCELLED' || event.code === 'MAR_AGENT_EXECUTION_CANCELLED'
706
720
  ? 'CANCELLED'
707
- : 'FAILED', state?.input, event.message ?? event.code, event.code, event.truncated === true, event.data === undefined ? state?.presentationData : event.data, state?.subagentTitle, state?.subagentDetails, state?.sleepStartedAt, event.outputTruncation, state?.inputTruncated)
721
+ : 'FAILED', state?.input, event.message ?? event.code, event.code, event.truncated === true, agentOperationAction(event.toolName)
722
+ ? mergeAgentOperationData(event.toolName, state?.presentationData, event.data)
723
+ : event.data === undefined
724
+ ? state?.presentationData
725
+ : event.data, state?.subagentTitle, state?.subagentDetails, state?.sleepStartedAt, event.outputTruncation, state?.inputTruncated)
708
726
  ];
709
727
  }
710
728
  #codeModeToolResults(event) {
@@ -1027,6 +1045,28 @@ function codeExecutionError(data) {
1027
1045
  message: compactRunnerText(error.message, 1000)
1028
1046
  };
1029
1047
  }
1048
+ function mergeAgentOperationData(toolName, previous, current) {
1049
+ if (!agentOperationAction(toolName))
1050
+ return current;
1051
+ const before = agentOperationPresentationSchema.safeParse(record(previous)?.agentOperation);
1052
+ if (!before.success || before.data.action !== agentOperationAction(toolName))
1053
+ return current;
1054
+ const after = agentOperationPresentationSchema.safeParse(record(current)?.agentOperation);
1055
+ if (!after.success || after.data.action !== before.data.action) {
1056
+ return { ...record(current), agentOperation: before.data };
1057
+ }
1058
+ return {
1059
+ ...record(current),
1060
+ agentOperation: {
1061
+ ...after.data,
1062
+ targets: before.data.targets.map((target) => ({
1063
+ ...after.data.targets.find((candidate) => candidate.agentId === target.agentId),
1064
+ ...target
1065
+ })),
1066
+ ...(before.data.targetsTruncated ? { targetsTruncated: true } : {})
1067
+ }
1068
+ };
1069
+ }
1030
1070
  function genericToolItem(id, toolName, status, input, outputSummary = null, errorCode = null, truncated = false, output, subagentTitle, knownSubagentDetails, sleepStartedAt, outputTruncation, inputTruncated = false, summaryTruncated = false) {
1031
1071
  if (toolName === 'code_exec') {
1032
1072
  const code = typeof input === 'string' ? input : record(input)?.code;
@@ -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,80 +351,18 @@ 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
- view: async (actor, input) => {
361
+ view: async (actor, input, onResolved) => {
387
362
  const source = terminalCalls.has(actor) ? undefined : await actorSession(actor);
388
363
  const session = input.agentId === source?.id ? source : await target(actor, input.agentId);
389
364
  assertSessionControlOwner(options.runtime, session.id, actor.requestUserId);
365
+ await onResolved?.([session]);
390
366
  return {
391
367
  session: marctlSessionDetail(options.runtime, session, options.runtime.sessionQueue(session.id).itemIds.length),
392
368
  goal: publicGoal(options.goals.get(session.id)) ?? null,
@@ -4,9 +4,9 @@ import { randomUUID } from 'node:crypto';
4
4
  import { modelUsageGrantSchema } from '@myagentroam/protocol';
5
5
  /** Bind each native invocation to the actual live Node Execution and its captured resources. */
6
6
  export function agentNetworkInvoke(input) {
7
- return async (executionId, action, payload, context) => {
7
+ return async (executionId, action, payload, context, onResolved) => {
8
8
  const actor = executionAgentActor({ ...input, executionId });
9
- return invokeAgentNetwork(input.service, actor, action, payload, context);
9
+ return invokeAgentNetwork(input.service, actor, action, payload, context, undefined, onResolved);
10
10
  };
11
11
  }
12
12
  /** Read actor resources from the current Node-owned Execution, not caller arguments. */
@@ -51,7 +51,7 @@ export function executionAgentActor(input) {
51
51
  };
52
52
  }
53
53
  /** Shared command dispatch; callers must bind actor authority at their trusted boundary. */
54
- export async function invokeAgentNetwork(service, actor, action, payload, context, requestSignal) {
54
+ export async function invokeAgentNetwork(service, actor, action, payload, context, requestSignal, onResolved) {
55
55
  const signal = context?.signal ?? requestSignal;
56
56
  switch (action) {
57
57
  case 'start':
@@ -63,16 +63,16 @@ export async function invokeAgentNetwork(service, actor, action, payload, contex
63
63
  typeof payload.title === 'string' &&
64
64
  !payload.title.trim()) {
65
65
  const { title: _title, ...withoutTitle } = payload;
66
- return service.start(actor, withoutTitle, signal);
66
+ return service.start(actor, withoutTitle, signal, onResolved);
67
67
  }
68
68
  return service.start(actor, {
69
69
  ...payload,
70
70
  ...(typeof payload.title === 'string' ? { title: payload.title.trim() } : {})
71
- }, signal);
71
+ }, signal, onResolved);
72
72
  case 'send':
73
- return service.send(actor, payload, signal);
73
+ return service.send(actor, payload, signal, onResolved);
74
74
  case 'stop':
75
- return service.stop(actor, payload);
75
+ return service.stop(actor, payload, onResolved);
76
76
  case 'wait':
77
77
  return service.wait(actor, {
78
78
  ...payload,
@@ -81,11 +81,11 @@ export async function invokeAgentNetwork(service, actor, action, payload, contex
81
81
  : requestSignal
82
82
  ? { signal: requestSignal }
83
83
  : {})
84
- });
84
+ }, onResolved);
85
85
  case 'list':
86
86
  return service.list(actor, payload);
87
87
  case 'view':
88
- return service.view(actor, payload);
88
+ return service.view(actor, payload, onResolved);
89
89
  }
90
90
  }
91
91
  /** Node broker injects the caller only after validating the Server-issued Execution credential. */
@@ -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
+ }
@@ -12,10 +12,10 @@ export class AgentNetworkService {
12
12
  list(actor, input = {}) {
13
13
  return this.options.list(actor, input);
14
14
  }
15
- view(actor, input) {
16
- return this.options.view(actor, input);
15
+ view(actor, input, onResolved) {
16
+ return this.options.view(actor, input, onResolved);
17
17
  }
18
- async start(actor, input, signal) {
18
+ async start(actor, input, signal, onResolved) {
19
19
  signal?.throwIfAborted();
20
20
  const goal = parseAgentGoal(input.goal);
21
21
  const content = input.content ?? '';
@@ -41,6 +41,7 @@ export class AgentNetworkService {
41
41
  clientMessageId: input.clientMessageId ?? randomUUID(),
42
42
  goal
43
43
  }, signal);
44
+ await onResolved?.([created.session]);
44
45
  try {
45
46
  if (created.session.parentSessionId === actor.sessionId)
46
47
  this.options.observe(actor, created.session);
@@ -54,7 +55,7 @@ export class AgentNetworkService {
54
55
  };
55
56
  }
56
57
  }
57
- async send(actor, input, signal) {
58
+ async send(actor, input, signal, onResolved) {
58
59
  signal?.throwIfAborted();
59
60
  const goal = parseAgentGoal(input.goal);
60
61
  const content = input.content ?? '';
@@ -89,9 +90,12 @@ export class AgentNetworkService {
89
90
  await this.options.authorize(actor, session, 'send');
90
91
  if (goal.kind !== 'unchanged')
91
92
  await this.options.authorize(actor, session, 'goal');
93
+ if (hasMessage || goal.kind === 'set')
94
+ await this.options.validateSend(actor, session);
92
95
  if (input.approval !== undefined)
93
96
  await this.options.validateApproval(actor, session, input.approval);
94
97
  signal?.throwIfAborted();
98
+ await onResolved?.([session]);
95
99
  if (session.parentSessionId === actor.sessionId)
96
100
  this.options.observe(actor, session);
97
101
  const approvalResult = input.approval === undefined
@@ -191,7 +195,7 @@ export class AgentNetworkService {
191
195
  };
192
196
  }
193
197
  }
194
- async stop(actor, input) {
198
+ async stop(actor, input, onResolved) {
195
199
  if (typeof input.agentId !== 'string' ||
196
200
  !input.agentId ||
197
201
  typeof input.executionId !== 'string' ||
@@ -199,6 +203,7 @@ export class AgentNetworkService {
199
203
  throw new Error('AGENT_STOP_INVALID');
200
204
  const session = await this.options.target(actor, input.agentId);
201
205
  await this.options.authorize(actor, session, 'stop');
206
+ await onResolved?.([session]);
202
207
  const goalBefore = this.options.goal(session.id);
203
208
  try {
204
209
  const result = this.options.stop(actor, session, input.executionId);
@@ -224,7 +229,7 @@ export class AgentNetworkService {
224
229
  };
225
230
  }
226
231
  }
227
- async wait(actor, input) {
232
+ async wait(actor, input, onResolved) {
228
233
  validateAgentWaitOptions(input);
229
234
  if ((input.mode !== undefined && input.agentIds === undefined) ||
230
235
  (input.agentId !== undefined && input.agentIds !== undefined) ||
@@ -268,6 +273,7 @@ export class AgentNetworkService {
268
273
  if (!targets.length)
269
274
  throw new Error('AGENT_EXECUTION_NOT_FOUND');
270
275
  const selected = new Set(targets.map((target) => target.agentId));
276
+ await onResolved?.(targets.map((target) => sessions.find((session) => session.id === target.agentId)));
271
277
  const directChildren = new Set();
272
278
  for (const session of sessions)
273
279
  if (selected.has(session.id) && session.parentSessionId === actor.sessionId) {
@@ -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('');
@@ -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' &&
@@ -29,11 +29,19 @@ export class NativeSessionProjectionService {
29
29
  return accepted === undefined ? [] : [accepted];
30
30
  });
31
31
  }
32
+ const candidates = new Map(ordered.map((session) => [session.id, session]));
32
33
  return ordered.flatMap((session) => {
33
34
  const record = database.getSessionRecord(session.id);
34
- if (record === undefined && session.parentSessionId != null)
35
+ if (record === undefined && !this.validParent(session, candidates))
35
36
  return [];
36
- return [{ ...session, parentSessionId: record?.metadata.parentSessionId ?? null }];
37
+ return [
38
+ {
39
+ ...session,
40
+ parentSessionId: record === undefined
41
+ ? (session.parentSessionId ?? null)
42
+ : (record.metadata.parentSessionId ?? null)
43
+ }
44
+ ];
37
45
  });
38
46
  }
39
47
  async discoverCodex(workspaceId) {
@@ -237,29 +245,30 @@ export class NativeSessionProjectionService {
237
245
  createdAt: timestamp
238
246
  };
239
247
  }
248
+ validParent(session, candidates) {
249
+ const database = this.options.database();
250
+ const visited = new Set([session.id]);
251
+ let parentId = session.parentSessionId ?? null;
252
+ let depth = 1;
253
+ while (parentId !== null) {
254
+ if (visited.has(parentId) || ++depth > 3)
255
+ return false;
256
+ visited.add(parentId);
257
+ const stored = database?.getSessionRecord(parentId);
258
+ const candidate = stored === undefined ? candidates.get(parentId) : undefined;
259
+ if ((stored?.workspaceId ?? candidate?.workspaceId) !== session.workspaceId)
260
+ return false;
261
+ parentId =
262
+ stored === undefined
263
+ ? (candidate?.parentSessionId ?? null)
264
+ : (stored.metadata.parentSessionId ?? null);
265
+ }
266
+ return true;
267
+ }
240
268
  remember(sessions) {
241
269
  const database = this.options.database();
242
270
  const candidates = new Map(sessions.map((session) => [session.id, session]));
243
- const validParent = (session) => {
244
- const visited = new Set([session.id]);
245
- let parentId = session.parentSessionId ?? null;
246
- let depth = 1;
247
- while (parentId !== null) {
248
- if (visited.has(parentId) || ++depth > 3)
249
- return false;
250
- visited.add(parentId);
251
- const stored = database?.getSessionRecord(parentId);
252
- const candidate = stored === undefined ? candidates.get(parentId) : undefined;
253
- if ((stored?.workspaceId ?? candidate?.workspaceId) !== session.workspaceId)
254
- return false;
255
- parentId =
256
- stored === undefined
257
- ? (candidate?.parentSessionId ?? null)
258
- : (stored.metadata.parentSessionId ?? null);
259
- }
260
- return true;
261
- };
262
- const valid = new Map(sessions.map((session) => [session.id, validParent(session)]));
271
+ const valid = new Map(sessions.map((session) => [session.id, this.validParent(session, candidates)]));
263
272
  const accepted = sessions.flatMap((proposed) => {
264
273
  const metadata = database === undefined || proposed.externalSessionId === null
265
274
  ? undefined
@@ -322,7 +322,7 @@ export class SessionCatalogService {
322
322
  this.options.invalidateWorkspaceSessions(session.workspaceId);
323
323
  }
324
324
  }
325
- async removeTree(root) {
325
+ async removeTree(root, cleanup) {
326
326
  if (root.externalSessionId === null)
327
327
  throw new Error('NATIVE_TRANSCRIPT_UNAVAILABLE');
328
328
  const database = this.options.database();
@@ -340,13 +340,20 @@ export class SessionCatalogService {
340
340
  const ids = [root.externalSessionId];
341
341
  const seen = new Set(ids);
342
342
  const parents = new Map();
343
+ const settle = (session, deleted) => {
344
+ if (cleanup === undefined)
345
+ return;
346
+ database.deleteSessionRecord(session.id);
347
+ this.options.forget(session, this.options.nativeIds(session));
348
+ cleanup.settled(session, deleted);
349
+ };
343
350
  const releases = [];
344
351
  const locked = new Set();
345
352
  const lock = (id) => {
346
353
  const actual = this.options.runtime.getAgentSession(id)?.id ?? id;
347
354
  if (locked.has(actual))
348
355
  return;
349
- releases.push(this.options.runtime.beginSessionHistoryMutation(actual, { allowQueued: true }));
356
+ releases.push(this.options.runtime.beginSessionHistoryMutation(actual, cleanup !== undefined && actual === root.id ? undefined : { allowQueued: true }));
350
357
  locked.add(actual);
351
358
  };
352
359
  try {
@@ -355,6 +362,9 @@ export class SessionCatalogService {
355
362
  const parent = ids[index];
356
363
  const children = [
357
364
  ...database.listChildSessionRecords(root.workspaceId, parent).map((record) => record.id),
365
+ ...(cleanup?.children.get(parent) ?? [])
366
+ .filter((session) => cleanup?.candidates.has(session.id))
367
+ .map((session) => session.externalSessionId ?? session.id),
358
368
  ...(liveChildren.get(parent) ?? [])
359
369
  ];
360
370
  for (const child of children) {
@@ -371,35 +381,64 @@ export class SessionCatalogService {
371
381
  }
372
382
  const sessions = [];
373
383
  for (const id of ids) {
374
- const session = this.options.runtime.getAgentSession(id) ??
384
+ const session = cleanup?.candidates.get(id) ??
385
+ this.options.runtime.getAgentSession(id) ??
375
386
  (await this.options.projection.resolve(id, true));
376
387
  if (session === undefined || session.workspaceId !== root.workspaceId)
377
388
  throw new Error('SESSION_NOT_FOUND');
378
389
  sessions.push(session);
379
390
  }
380
391
  // Validate every member before performing any irreversible native deletion.
381
- for (const session of sessions)
382
- await this.assertRemovable(session);
383
- const batches = new Map();
392
+ for (const session of sessions) {
393
+ const missing = cleanup !== undefined && session.externalDiscovered === false;
394
+ if (missing && !this.options.runnerAvailable(session.runner))
395
+ throw new Error('NATIVE_TRANSCRIPT_UNAVAILABLE');
396
+ await this.assertRemovable(session, cleanup?.budget, missing);
397
+ }
398
+ if (cleanup !== undefined && this.cleanupRootProtected(root, cleanup.cutoff)) {
399
+ throw new Error('SESSION_WATCHING');
400
+ }
401
+ let batch = [];
402
+ const flushBatch = async () => {
403
+ if (batch.length === 0)
404
+ return;
405
+ cleanup?.budget.check();
406
+ await this.options.runners.require(batch[0].runner).removeNativeMembers(batch);
407
+ for (const session of batch)
408
+ settle(session, true);
409
+ batch = [];
410
+ };
384
411
  for (const session of [...sessions].reverse()) {
412
+ if (cleanup !== undefined &&
413
+ (session.externalSessionId === null || session.externalDiscovered === false)) {
414
+ await flushBatch();
415
+ cleanup.budget.check();
416
+ settle(session, true);
417
+ continue;
418
+ }
385
419
  if (session.externalSessionId === null)
386
420
  continue;
387
421
  const runner = this.options.runners.require(session.runner);
388
422
  if (runner.supportsNativeBatchRemoval?.() === true) {
389
- const members = batches.get(session.runner) ?? [];
390
- members.push(session);
391
- batches.set(session.runner, members);
423
+ if (batch.length > 0 && batch[0].runner !== session.runner)
424
+ await flushBatch();
425
+ batch.push(session);
426
+ }
427
+ else {
428
+ // Preserve descendant-first order across batch and non-batch native owners.
429
+ await flushBatch();
430
+ cleanup?.budget.check();
431
+ const deleted = await this.removeNativeHistories(session, this.options.nativeIds(session));
432
+ settle(session, deleted);
392
433
  }
393
- else
394
- await this.removeNativeHistories(session, this.options.nativeIds(session));
395
434
  }
396
- for (const [runner, members] of batches)
397
- await this.options.runners.require(runner).removeNativeMembers(members);
398
- // Preserve every relationship if any native owner fails, so retries cannot
399
- // lose sight of descendants whose native cleanup was already attempted.
400
- for (const session of sessions.reverse()) {
401
- this.options.database().deleteSessionRecord(session.id);
402
- this.options.forget(session, this.options.nativeIds(session));
435
+ await flushBatch();
436
+ // Manual deletion preserves all relationships if any native owner fails.
437
+ if (cleanup === undefined) {
438
+ for (const session of sessions.reverse()) {
439
+ this.options.database().deleteSessionRecord(session.id);
440
+ this.options.forget(session, this.options.nativeIds(session));
441
+ }
403
442
  }
404
443
  }
405
444
  finally {
@@ -443,6 +482,7 @@ export class SessionCatalogService {
443
482
  let deleted = 0;
444
483
  let skipped = 0;
445
484
  let failed = 0;
485
+ const outcomes = new Map();
446
486
  const deletedByRunner = {};
447
487
  let candidateCount;
448
488
  let timedOut = false;
@@ -458,7 +498,14 @@ export class SessionCatalogService {
458
498
  candidates.set(session.id, session);
459
499
  }
460
500
  this.mergePersisted(candidates, workspaceId, false);
461
- const candidateParents = new Set([...candidates.values()].flatMap((candidate) => candidate.parentSessionId === null ? [] : [candidate.parentSessionId]));
501
+ const children = new Map();
502
+ for (const candidate of candidates.values()) {
503
+ if (candidate.parentSessionId == null)
504
+ continue;
505
+ const siblings = children.get(candidate.parentSessionId) ?? [];
506
+ siblings.push(candidate);
507
+ children.set(candidate.parentSessionId, siblings);
508
+ }
462
509
  candidateCount = candidates.size;
463
510
  for (const session of candidates.values()) {
464
511
  budget.check();
@@ -472,14 +519,6 @@ export class SessionCatalogService {
472
519
  session.lastActivityAt > 0 &&
473
520
  session.lastActivityAt >= cutoff) ||
474
521
  (session.pinOrder !== null && session.pinOrder !== undefined) ||
475
- (session.externalSessionId !== null &&
476
- (this.options.database().listChildSessionRecords(workspaceId, session.externalSessionId)
477
- .length > 0 ||
478
- candidateParents.has(session.externalSessionId) ||
479
- this.options.runtime
480
- .listAgentSessions()
481
- .some((child) => child.workspaceId === workspaceId &&
482
- child.parentSessionId === session.externalSessionId))) ||
483
522
  this.options.isWatching(session.id) ||
484
523
  this.options.isRecovering?.(session.id) ||
485
524
  this.options.runtime.sessionQueue(session.id).itemIds.length > 0 ||
@@ -487,24 +526,31 @@ export class SessionCatalogService {
487
526
  .listRunsForSession(session.id)
488
527
  .some((run) => !isTerminalRunStatus(run.status)) ||
489
528
  this.options.externalActivity(session.id) === 'BUSY') {
490
- skipped += 1;
529
+ outcomes.set(session.id, { session, status: 'skipped' });
491
530
  continue;
492
531
  }
493
532
  try {
494
- if ((await this.removeResolved(session, true, budget)) === 'deleted') {
495
- deleted += 1;
496
- deletedByRunner[session.runner] = (deletedByRunner[session.runner] ?? 0) + 1;
497
- }
498
- else
499
- skipped += 1;
533
+ await this.removeTree(session, {
534
+ candidates,
535
+ children,
536
+ cutoff,
537
+ budget,
538
+ settled: (member, wasDeleted) => {
539
+ candidates.delete(member.id);
540
+ outcomes.set(member.id, {
541
+ session: member,
542
+ status: wasDeleted ? 'deleted' : 'skipped'
543
+ });
544
+ }
545
+ });
500
546
  }
501
547
  catch (error) {
502
548
  if (error instanceof WorkspaceSessionCleanupDeadline)
503
549
  throw error;
504
550
  if (isCleanupSkip(error))
505
- skipped += 1;
551
+ outcomes.set(session.id, { session, status: 'skipped' });
506
552
  else {
507
- failed += 1;
553
+ outcomes.set(session.id, { session, status: 'failed' });
508
554
  nodeLog('workspace.session.cleanup.failed', {
509
555
  workspaceId,
510
556
  sessionId: session.id,
@@ -514,6 +560,7 @@ export class SessionCatalogService {
514
560
  }
515
561
  }
516
562
  }
563
+ budget.check();
517
564
  }
518
565
  catch (error) {
519
566
  if (!(error instanceof WorkspaceSessionCleanupDeadline))
@@ -525,6 +572,16 @@ export class SessionCatalogService {
525
572
  finally {
526
573
  this.options.invalidateWorkspaceSessions(workspaceId);
527
574
  }
575
+ for (const { session, status } of outcomes.values()) {
576
+ if (status === 'deleted') {
577
+ deleted += 1;
578
+ deletedByRunner[session.runner] = (deletedByRunner[session.runner] ?? 0) + 1;
579
+ }
580
+ else if (status === 'skipped')
581
+ skipped += 1;
582
+ else
583
+ failed += 1;
584
+ }
528
585
  const result = {
529
586
  deleted,
530
587
  skipped,
@@ -535,7 +592,7 @@ export class SessionCatalogService {
535
592
  timedOut: true,
536
593
  ...(candidateCount === undefined
537
594
  ? {}
538
- : { remaining: candidateCount - deleted - skipped - failed })
595
+ : { remaining: Math.max(0, candidateCount - outcomes.size) })
539
596
  }
540
597
  : {})
541
598
  };
@@ -559,15 +616,16 @@ export class SessionCatalogService {
559
616
  lastActivityAt: latestSessionActivityAt(projected.lastActivityAt, native.lastActivityAt)
560
617
  };
561
618
  }
562
- async assertRemovable(session) {
619
+ async assertRemovable(session, budget, missing = false) {
563
620
  if (this.options.isRecovering?.(session.id))
564
621
  throw new Error('SESSION_RESTART_PENDING');
565
622
  if (this.options.runtime
566
623
  .listRunsForSession(session.id)
567
624
  .some((run) => !isTerminalRunStatus(run.status)))
568
625
  throw new Error('SESSION_ACTIVE');
569
- if (session.externalSessionId !== null && session.nativeControl === 'EXTERNAL') {
570
- await this.options.refreshActivity(session);
626
+ if (!missing && session.externalSessionId !== null && session.nativeControl === 'EXTERNAL') {
627
+ const refresh = () => this.options.refreshActivity(session);
628
+ await (budget ? budget.read(refresh) : refresh());
571
629
  if (this.options.externalActivity(session.id) === 'BUSY')
572
630
  throw new Error('SESSION_ACTIVE');
573
631
  }
@@ -592,55 +650,22 @@ export class SessionCatalogService {
592
650
  }
593
651
  return deleted;
594
652
  }
595
- async removeResolved(session, requireCleanupSafety = false, budget) {
596
- if (session.externalSessionId === null)
597
- throw new Error('NATIVE_TRANSCRIPT_UNAVAILABLE');
598
- const releaseMutation = this.options.runtime.beginSessionHistoryMutation(session.id, requireCleanupSafety ? undefined : { allowQueued: true });
599
- try {
600
- if (this.options.isRecovering?.(session.id))
601
- throw new Error('SESSION_RESTART_PENDING');
602
- const missingCandidate = requireCleanupSafety && session.externalDiscovered === false;
603
- if (this.options.runtime
653
+ cleanupRootProtected(session, cutoff) {
654
+ // Pin updates and Watch admission do not share the history mutation queue.
655
+ const pinOrder = session.externalSessionId === null
656
+ ? undefined
657
+ : this.options
658
+ .database()
659
+ .getNativeSessionMetadata(session.runner, session.externalSessionId, session.workspaceId)?.pinOrder;
660
+ return ((session.externalDiscovered !== false &&
661
+ latestSessionActivityAt(session.lastActivityAt, this.options.runtime.getAgentSession(session.id)?.lastActivityAt ?? 0) >= cutoff) ||
662
+ (pinOrder !== undefined && pinOrder !== null) ||
663
+ this.options.isWatching(session.id) ||
664
+ this.options.isRecovering?.(session.id) === true ||
665
+ this.options.runtime.sessionQueue(session.id).itemIds.length > 0 ||
666
+ this.options.runtime
604
667
  .listRunsForSession(session.id)
605
- .some((run) => !isTerminalRunStatus(run.status)))
606
- throw new Error('SESSION_ACTIVE');
607
- if (session.nativeControl === 'EXTERNAL' && !missingCandidate) {
608
- const refresh = () => this.options.refreshActivity(session);
609
- await (budget ? budget.read(refresh) : refresh());
610
- if (this.options.externalActivity(session.id) === 'BUSY')
611
- throw new Error('SESSION_ACTIVE');
612
- }
613
- if (requireCleanupSafety) {
614
- if (this.options.isWatching(session.id))
615
- throw new Error('SESSION_WATCHING');
616
- if (this.options.runtime.sessionQueue(session.id).itemIds.length > 0)
617
- throw new Error('SESSION_QUEUE_NOT_EMPTY');
618
- // Pin updates do not share the cleanup mutation queue.
619
- const pinOrder = this.options
620
- .database()
621
- .getNativeSessionMetadata(session.runner, session.externalSessionId, session.workspaceId)?.pinOrder;
622
- if (pinOrder !== undefined && pinOrder !== null)
623
- return 'skipped';
624
- if (this.options.runtime
625
- .listRunsForSession(session.id)
626
- .some((run) => !isTerminalRunStatus(run.status)))
627
- throw new Error('SESSION_ACTIVE');
628
- }
629
- budget?.check();
630
- const nativeIds = this.options.nativeIds(session);
631
- if (missingCandidate) {
632
- this.options.database().deleteSessionRecord(session.id);
633
- this.options.forget(session, nativeIds);
634
- return 'deleted';
635
- }
636
- const deleted = await this.removeNativeHistories(session, nativeIds);
637
- this.options.database().deleteSessionRecord(session.id);
638
- this.options.forget(session, nativeIds);
639
- return deleted ? 'deleted' : 'skipped';
640
- }
641
- finally {
642
- releaseMutation();
643
- }
668
+ .some((run) => !isTerminalRunStatus(run.status)));
644
669
  }
645
670
  }
646
671
  export function rewindTitle(sourceTitle, existingTitles = []) {
@@ -674,6 +699,7 @@ function deduplicateNativeSessions(sessions) {
674
699
  function isCleanupSkip(error) {
675
700
  return (error instanceof Error &&
676
701
  (error.message === 'SESSION_ACTIVE' ||
702
+ error.message === 'SESSION_RESTART_PENDING' ||
677
703
  error.message === 'SESSION_NOT_FOUND' ||
678
704
  error.message === 'SESSION_QUEUE_NOT_EMPTY' ||
679
705
  error.message === 'SESSION_WATCHING' ||
@@ -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.46",
3
+ "version": "0.11.48",
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.46",
42
- "@myagentroam/marctl": "0.11.7",
43
- "@myagentroam/protocol": "0.11.46",
44
- "@myagentroam/code-navigation": "0.11.1"
41
+ "@myagentroam/agent": "0.11.48",
42
+ "@myagentroam/code-navigation": "0.11.1",
43
+ "@myagentroam/protocol": "0.11.48",
44
+ "@myagentroam/marctl": "0.11.9"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/ws": "^8.18.1"