@myagentroam/node 0.9.139 → 0.9.141

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.
@@ -49,6 +49,7 @@ export class ClaudeAgentSdkAdapter {
49
49
  prompt,
50
50
  options: {
51
51
  cwd: input.cwd,
52
+ ...(channelEnabled ? { includePartialMessages: true } : {}),
52
53
  // Claude SDK defaults CLAUDE_CODE_ENTRYPOINT to sdk-ts. Keep the
53
54
  // transcript discoverable by the same VS Code session filters used by
54
55
  // the external Claude Code extension.
package/dist/connector.js CHANGED
@@ -306,17 +306,12 @@ export class NodeConnector {
306
306
  workspaceState = new WorkspaceDomainState();
307
307
  workspaceWatchService = new WorkspaceWatchService(this.workspaceState, (workspaceId) => this.workspaceSessionService.ensureWatch(workspaceId), (workspaceId) => this.workspaceSessionService.stopWatch(workspaceId), (workspaceId) => this.workspaceWorkbenchService?.releaseWorkspace(workspaceId));
308
308
  workspaceSessionService = new WorkspaceSessionService(this.workspaceState, {
309
- discover: (workspaceId) => this.discoverWorkspaceSessions(workspaceId),
309
+ discover: (workspaceId, onRunnerDiscovered) => this.discoverWorkspaceSessions(workspaceId, onRunnerDiscovered),
310
310
  listPage: (workspaceId) => this.executeNodeOperation('session.list', { workspaceId, limit: 20 }),
311
311
  active: (workspaceId) => this.workspaceWatchService.topicActive(workspaceId, 'sessions'),
312
- invalidate: (workspaceId) => void this.workspaceWorkbenchService?.invalidateTopic(workspaceId, 'sessions'),
313
- onPending: (workspaceId, waitMs) => nodeLog('native.session.discovery.pending', {
314
- nodeId: this.config?.nodeId,
315
- workspaceId,
316
- waitMs
317
- })
312
+ invalidate: (workspaceId) => void this.workspaceWorkbenchService?.invalidateTopic(workspaceId, 'sessions')
318
313
  });
319
- workspaceFileService = new WorkspaceFileService(this.workspaceState, () => this.config?.allowedRoots ?? [], (workspaceId) => {
314
+ workspaceFileService = new WorkspaceFileService(this.workspaceState, (workspaceId) => {
320
315
  this.workspaceWorkbenchService?.invalidateWorkspaceFiles(workspaceId);
321
316
  this.workspaceCoordinator.invalidate(workspaceId);
322
317
  });
@@ -462,6 +457,7 @@ export class NodeConnector {
462
457
  sequence: this.workbenchEventService.snapshotSequence(),
463
458
  session: this.sessionPresentationService.present(session),
464
459
  runs: this.runtime.listRunsForSession(session.id),
460
+ performance: this.runtime.sessionPerformance(session.id),
465
461
  queue: this.workspaceQueueWorkbenchService.snapshot(session.id),
466
462
  context,
467
463
  conversation: this.conversationSegmentService.projectInitial(session, page, limit)
@@ -1130,6 +1126,7 @@ export class NodeConnector {
1130
1126
  projection: this.nativeSessionProjectionService,
1131
1127
  cachedNative: () => [...this.sessionState.directNative.values()],
1132
1128
  cachedWorkspace: (workspaceId) => this.workspaceCoordinator.cachedSessions(workspaceId),
1129
+ discoveryPending: (workspaceId) => this.workspaceSessionService.discoveryPending(workspaceId),
1133
1130
  discoverWorkspace: (workspaceId) => this.nativeSessionProjectionService.discoverWorkspace(workspaceId),
1134
1131
  present: (session) => this.sessionPresentationService.present(session),
1135
1132
  projectionForNative: (session) => this.nativeSessionProjectionService.projectionForNative(session),
@@ -2184,7 +2181,7 @@ export class NodeConnector {
2184
2181
  return;
2185
2182
  this.workbenchEventService.publish(event);
2186
2183
  };
2187
- discoverWorkspaceSessions = (workspaceId) => this.nativeSessionProjectionService.discoverWorkspace(workspaceId);
2184
+ discoverWorkspaceSessions = (workspaceId, onRunnerDiscovered) => this.nativeSessionProjectionService.discoverWorkspace(workspaceId, onRunnerDiscovered);
2188
2185
  invalidateWorkspaceWatchTopics = (workspaceId) => this.workspaceCoordinator.invalidate(workspaceId);
2189
2186
  reconcilePersistedRuns = () => undefined;
2190
2187
  startCapabilityRefresh = () => this.connectionLifecycle.startCapabilityRefresh();
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  // Keep the CLI implementation in its own package; the Node install exposes the same entrypoint.
3
- const entrypoint = '@myagentroam/marmgr/bin/marmgr.mjs';
3
+ const entrypoint = '@myagentroam/marctl/bin/marctl.mjs';
4
4
  await import(entrypoint);
5
5
  export {};
package/dist/marnodes.js CHANGED
@@ -1,4 +1,4 @@
1
1
  #!/usr/bin/env node
2
- const entrypoint = '@myagentroam/marmgr/bin/marnodes.mjs';
2
+ const entrypoint = '@myagentroam/marctl/bin/marnodes.mjs';
3
3
  await import(entrypoint);
4
4
  export {};
package/dist/marscp.js CHANGED
@@ -1,4 +1,4 @@
1
1
  #!/usr/bin/env node
2
- const entrypoint = '@myagentroam/marmgr/bin/marscp.mjs';
2
+ const entrypoint = '@myagentroam/marctl/bin/marscp.mjs';
3
3
  await import(entrypoint);
4
4
  export {};
package/dist/marssh.js CHANGED
@@ -1,4 +1,4 @@
1
1
  #!/usr/bin/env node
2
- const entrypoint = '@myagentroam/marmgr/bin/marssh.mjs';
2
+ const entrypoint = '@myagentroam/marctl/bin/marssh.mjs';
3
3
  await import(entrypoint);
4
4
  export {};
@@ -172,17 +172,17 @@ export class AbstractRunner {
172
172
  function resolvedMcpServers(raw, secrets) {
173
173
  if (!Array.isArray(raw))
174
174
  return [];
175
- const marmgrEnvironment = {
175
+ const marctlEnvironment = {
176
176
  MAR_USERNAME: secrets['MAR_USERNAME'] ?? '',
177
- ...(secrets['MAR_MGR_SERVER_URL'] === undefined
177
+ ...(secrets['MAR_CTL_SERVER_URL'] === undefined
178
178
  ? {}
179
- : { MAR_MGR_SERVER_URL: secrets['MAR_MGR_SERVER_URL'] }),
180
- ...(secrets['MAR_MGR_USER_TOKEN'] === undefined
179
+ : { MAR_CTL_SERVER_URL: secrets['MAR_CTL_SERVER_URL'] }),
180
+ ...(secrets['MAR_CTL_USER_TOKEN'] === undefined
181
181
  ? {}
182
- : { MAR_MGR_USER_TOKEN: secrets['MAR_MGR_USER_TOKEN'] }),
183
- MAR_MGR_CONTEXT_REQUIRED: '1'
182
+ : { MAR_CTL_USER_TOKEN: secrets['MAR_CTL_USER_TOKEN'] }),
183
+ MAR_CTL_CONTEXT_REQUIRED: '1'
184
184
  };
185
- const mcpEnvironment = (provided) => nodeExecutionEnvironment({ ...provided, ...marmgrEnvironment }, { PATH: process.env['PATH'], Path: process.env['Path'] });
185
+ const mcpEnvironment = (provided) => nodeExecutionEnvironment({ ...provided, ...marctlEnvironment }, { PATH: process.env['PATH'], Path: process.env['Path'] });
186
186
  const result = [];
187
187
  for (const value of raw) {
188
188
  if (value === null || typeof value !== 'object' || Array.isArray(value))
@@ -7,6 +7,7 @@ import { claudeContentBlocks } from '../../util/node-operation-parsers.js';
7
7
  import { parseSecretEnvironment } from '../../util/secret-environment.js';
8
8
  import { safeErrorCode } from '../../util/safe-error.js';
9
9
  import { runnerErrorProjection } from '../../util/runner-error.js';
10
+ import { ResponseLatencyTracker } from '../../service/response-latency-tracker.js';
10
11
  export class ClaudeManagedRunController {
11
12
  runner;
12
13
  host;
@@ -162,6 +163,13 @@ export class ClaudeManagedRunController {
162
163
  };
163
164
  try {
164
165
  const gate = new ClaudeChannelGate(sessionId);
166
+ const responseLatency = new ResponseLatencyTracker();
167
+ let responseMessageId;
168
+ const observeResponse = (id, incremental = false) => {
169
+ const latencyMs = responseLatency.response(id, incremental);
170
+ if (latencyMs !== undefined)
171
+ this.host.emit(runId, 'run.performance', { latencyMs });
172
+ };
165
173
  handle = this.runner.startChannel({
166
174
  prompt: payload.input,
167
175
  cwd,
@@ -173,7 +181,7 @@ export class ClaudeManagedRunController {
173
181
  ...(typeof payload.access === 'string' ? { access: payload.access } : {}),
174
182
  environment: nodeExecutionEnvironment({
175
183
  ...secretEnvironment,
176
- MAR_MGR_CONTEXT_REQUIRED: '1'
184
+ MAR_CTL_CONTEXT_REQUIRED: '1'
177
185
  }, { PATH: process.env['PATH'], Path: process.env['Path'] }),
178
186
  ...(typeof payload.personalInstructions === 'string'
179
187
  ? { personalInstructions: payload.personalInstructions }
@@ -191,12 +199,19 @@ export class ClaudeManagedRunController {
191
199
  dataBase64: attachment.dataBase64
192
200
  }))
193
201
  }),
194
- onPermission: (toolName, toolInput, options) => toolName === 'AskUserQuestion'
195
- ? (toolKinds.set(options.toolUseID, 'user_input_request'),
196
- this.host.requestUserInput(runId, toolInput, options.toolUseID))
197
- : this.host.requestApproval(runId, toolName, toolInput),
202
+ onPermission: (toolName, toolInput, options) => {
203
+ const latencyMs = responseLatency.toolStarted(options.toolUseID);
204
+ if (latencyMs !== undefined)
205
+ this.host.emit(runId, 'run.performance', { latencyMs });
206
+ return toolName === 'AskUserQuestion'
207
+ ? (toolKinds.set(options.toolUseID, 'user_input_request'),
208
+ this.host.requestUserInput(runId, toolInput, options.toolUseID))
209
+ : this.host.requestApproval(runId, toolName, toolInput);
210
+ },
198
211
  onChannelReply: (content) => this.host.emit(runId, 'channel.reply', { content }),
199
212
  onMessage: (message) => {
213
+ if (terminal)
214
+ return;
200
215
  const record = message;
201
216
  const nativeSessionId = typeof record.session_id === 'string' ? record.session_id : undefined;
202
217
  const current = this.runner.execution.runs.get(runId);
@@ -221,8 +236,43 @@ export class ClaudeManagedRunController {
221
236
  });
222
237
  }
223
238
  }
239
+ if (record.type === 'stream_event') {
240
+ // Observe semantic timing without forwarding raw thinking or input fragments.
241
+ const event = record.event;
242
+ const block = event?.content_block;
243
+ const delta = event?.delta;
244
+ if (event?.type === 'message_start') {
245
+ const message = event.message;
246
+ responseMessageId = typeof message?.id === 'string' ? message.id : undefined;
247
+ }
248
+ if (event?.type === 'content_block_start' &&
249
+ block?.type === 'tool_use' &&
250
+ typeof block.id === 'string') {
251
+ const latencyMs = responseLatency.toolStarted(block.id);
252
+ if (latencyMs !== undefined)
253
+ this.host.emit(runId, 'run.performance', { latencyMs });
254
+ // A later full snapshot of this streamed message is not a new response.
255
+ responseLatency.response(responseMessageId);
256
+ }
257
+ if ((event?.type === 'content_block_start' && block?.type === 'thinking') ||
258
+ (event?.type === 'content_block_delta' &&
259
+ ((delta?.type === 'text_delta' &&
260
+ typeof delta.text === 'string' &&
261
+ delta.text.length > 0) ||
262
+ (delta?.type === 'thinking_delta' &&
263
+ typeof delta.thinking === 'string' &&
264
+ delta.thinking.length > 0) ||
265
+ (delta?.type === 'input_json_delta' &&
266
+ typeof delta.partial_json === 'string' &&
267
+ delta.partial_json.length > 0))))
268
+ observeResponse(responseMessageId, event?.type === 'content_block_delta');
269
+ }
224
270
  if (record.type === 'assistant') {
225
271
  const text = extractClaudeText(record);
272
+ if (text.length > 0 || extractClaudeThinkingSummaries(record).length > 0)
273
+ observeResponse(typeof record.message?.id === 'string'
274
+ ? record.message.id
275
+ : undefined);
226
276
  const planText = claudePlanText(text);
227
277
  if (planText !== undefined)
228
278
  this.host.emitItem(runId, {
@@ -242,6 +292,9 @@ export class ClaudeManagedRunController {
242
292
  });
243
293
  }
244
294
  for (const tool of extractClaudeToolUses(record)) {
295
+ const latencyMs = responseLatency.toolStarted(tool.id);
296
+ if (latencyMs !== undefined)
297
+ this.host.emit(runId, 'run.performance', { latencyMs });
245
298
  if (tool.name === 'AskUserQuestion') {
246
299
  toolKinds.set(tool.id, 'user_input_request');
247
300
  continue;
@@ -305,6 +358,7 @@ export class ClaudeManagedRunController {
305
358
  }
306
359
  if (record.type === 'user') {
307
360
  for (const result of extractClaudeToolResults(record)) {
361
+ responseLatency.toolCompleted(result.toolUseId);
308
362
  const kind = toolKinds.get(result.toolUseId) ?? 'tool_call';
309
363
  if (kind === 'user_input_request')
310
364
  continue;
@@ -336,7 +390,7 @@ export class ClaudeManagedRunController {
336
390
  Number.isFinite(value) &&
337
391
  value >= 0 &&
338
392
  Number.isSafeInteger(Math.round(value)));
339
- if (nativeTtft !== undefined)
393
+ if (nativeTtft !== undefined && !responseLatency.hasObservedResponse)
340
394
  this.host.emit(runId, 'run.performance', { latencyMs: Math.round(nativeTtft) });
341
395
  const subtype = typeof record.subtype === 'string' ? record.subtype : 'unknown';
342
396
  const completedNativeSessionId = this.runner.execution.runs.get(runId)?.nativeSessionId ?? nativeSessionId;
@@ -3,6 +3,7 @@ import { extractId } from '../../util/runner-native-session-parsers.js';
3
3
  import { parseSecretEnvironment } from '../../util/secret-environment.js';
4
4
  import { safeErrorCode } from '../../util/safe-error.js';
5
5
  import { runnerErrorProjection } from '../../util/runner-error.js';
6
+ import { ResponseLatencyTracker } from '../../service/response-latency-tracker.js';
6
7
  import { boundedConversationItemId, codexImageAttachments, codexLiveConversationItem, codexUserInputQuestions, compactRunnerText, isPlainRecord } from './conversation-parser.js';
7
8
  export function resolveCodexNotificationRun(runs, input) {
8
9
  const entries = [...runs.entries()];
@@ -66,7 +67,7 @@ export class CodexManagedRunController {
66
67
  throw new Error('RUNNER_COMMAND_UNAVAILABLE');
67
68
  if (command.id === 'compact') {
68
69
  const runId = crypto.randomUUID();
69
- this.runner.prepareEnvironment(nodeExecutionEnvironment({ ...context.secretEnvironment, MAR_MGR_CONTEXT_REQUIRED: '1' }, { PATH: process.env['PATH'], Path: process.env['Path'] }), runId);
70
+ this.runner.prepareEnvironment(nodeExecutionEnvironment({ ...context.secretEnvironment, MAR_CTL_CONTEXT_REQUIRED: '1' }, { PATH: process.env['PATH'], Path: process.env['Path'] }), runId);
70
71
  try {
71
72
  await this.runner.start(runId);
72
73
  return await this.compact(session, threadId, runId, context.initiatedByUserId);
@@ -199,6 +200,15 @@ export class CodexManagedRunController {
199
200
  text: params.delta,
200
201
  ...(itemId === undefined ? {} : { itemId })
201
202
  });
203
+ if (params.delta.length > 0)
204
+ this.observeResponse(runId, itemId, true);
205
+ return;
206
+ }
207
+ if (notification.method === 'item/reasoning/textDelta' && typeof params.delta === 'string') {
208
+ if (params.delta.length > 0)
209
+ this.observeResponse(runId, typeof params.itemId === 'string'
210
+ ? boundedConversationItemId('codex', params.itemId)
211
+ : undefined, true);
202
212
  return;
203
213
  }
204
214
  if (notification.method === 'item/reasoning/summaryTextDelta' &&
@@ -215,6 +225,8 @@ export class CodexManagedRunController {
215
225
  merge: true,
216
226
  append: 'sections'
217
227
  });
228
+ if (params.delta.length > 0)
229
+ this.observeResponse(runId, boundedConversationItemId('codex', params.itemId), true);
218
230
  return;
219
231
  }
220
232
  if (notification.method === 'item/commandExecution/outputDelta' &&
@@ -246,8 +258,24 @@ export class CodexManagedRunController {
246
258
  isPlainRecord(params.item)) {
247
259
  const attachments = this.host.appendImages(runId, codexImageAttachments(params.item));
248
260
  const item = codexLiveConversationItem(params.item, notification.method === 'item/completed', attachments);
249
- if (item !== undefined)
261
+ if (item !== undefined) {
250
262
  this.host.emitItem(runId, item);
263
+ const tracker = activeRun.responseLatency;
264
+ const tool = ['tool_call', 'command_execution', 'file_change'].includes(item.kind);
265
+ if (tool) {
266
+ if (notification.method === 'item/completed')
267
+ tracker?.toolCompleted(item.itemId);
268
+ else {
269
+ const latencyMs = tracker?.toolStarted(item.itemId);
270
+ if (latencyMs !== undefined)
271
+ this.host.emit(runId, 'run.performance', { latencyMs });
272
+ }
273
+ }
274
+ else if (['assistant_message', 'reasoning_summary', 'plan'].includes(item.kind)) {
275
+ // A started reasoning item is itself a semantic response, even before its summary.
276
+ this.observeResponse(runId, item.itemId);
277
+ }
278
+ }
251
279
  return;
252
280
  }
253
281
  if (notification.method === 'turn/completed') {
@@ -273,6 +301,13 @@ export class CodexManagedRunController {
273
301
  this.host.removeActive(runId);
274
302
  }
275
303
  }
304
+ observeResponse(runId, itemId, incremental = false) {
305
+ const latencyMs = this.runner.execution.runs
306
+ .get(runId)
307
+ ?.responseLatency?.response(itemId, incremental);
308
+ if (latencyMs !== undefined)
309
+ this.host.emit(runId, 'run.performance', { latencyMs });
310
+ }
276
311
  requestApproval(runId, notification) {
277
312
  const approvalId = `codex:${runId}:${notification.requestId}`;
278
313
  const expiresAt = Date.now() + 10 * 60_000;
@@ -386,7 +421,7 @@ export class CodexManagedRunController {
386
421
  return;
387
422
  }
388
423
  try {
389
- this.runner.prepareEnvironment(nodeExecutionEnvironment({ ...secretEnvironment, MAR_MGR_CONTEXT_REQUIRED: '1' }, { PATH: process.env['PATH'], Path: process.env['Path'] }), runId);
424
+ this.runner.prepareEnvironment(nodeExecutionEnvironment({ ...secretEnvironment, MAR_CTL_CONTEXT_REQUIRED: '1' }, { PATH: process.env['PATH'], Path: process.env['Path'] }), runId);
390
425
  }
391
426
  catch (error) {
392
427
  this.host.emit(runId, 'run.rejected', { code: safeErrorCode(error, 'SECRET_ENVIRONMENT_INVALID') }, 'FAILED');
@@ -458,7 +493,8 @@ export class CodexManagedRunController {
458
493
  threadId,
459
494
  cwd,
460
495
  goalMcp: hasGoalMcp(payload.mcpServers),
461
- scheduleMcp: hasScheduleMcp(payload.mcpServers)
496
+ scheduleMcp: hasScheduleMcp(payload.mcpServers),
497
+ responseLatency: new ResponseLatencyTracker()
462
498
  });
463
499
  turnResult = await this.runner.startTurn(threadId, input, cwd, turnConfiguration, materializedAttachments, runId);
464
500
  break;
@@ -480,6 +516,7 @@ export class CodexManagedRunController {
480
516
  }
481
517
  const turnId = extractId(turnResult, 'turn');
482
518
  this.runner.execution.runs.set(runId, {
519
+ ...this.runner.execution.runs.get(runId),
483
520
  sessionId: publicSessionId,
484
521
  threadId,
485
522
  turnId,
@@ -141,9 +141,14 @@ export class MarAgentConversationNormalizer {
141
141
  }
142
142
  #toolStarted(event) {
143
143
  const ownItemId = itemId('tool', event.callId);
144
- if (event.toolName === 'exec') {
144
+ if (event.toolName === 'exec' || event.toolName === 'exec_wait') {
145
145
  const input = record(event.input) ?? {};
146
- const action = input.action == null && typeof input.command === 'string' ? 'start' : input.action;
146
+ const action = input.action ??
147
+ (event.toolName === 'exec_wait'
148
+ ? 'poll'
149
+ : typeof input.command === 'string'
150
+ ? 'start'
151
+ : undefined);
147
152
  if (action === 'start' && typeof input.command === 'string') {
148
153
  const command = {
149
154
  itemId: ownItemId,
@@ -227,7 +232,8 @@ export class MarAgentConversationNormalizer {
227
232
  }
228
233
  #toolCompleted(event) {
229
234
  const state = this.#tools.get(event.callId);
230
- if (event.toolName === 'exec' && state?.commandOrigin !== undefined) {
235
+ if ((event.toolName === 'exec' || event.toolName === 'exec_wait') &&
236
+ state?.commandOrigin !== undefined) {
231
237
  const command = this.#commands.get(state.commandOrigin);
232
238
  const result = execResult(event.data, event.summary, event.truncated === true);
233
239
  if (command !== undefined && result !== undefined) {
@@ -293,7 +299,8 @@ export class MarAgentConversationNormalizer {
293
299
  }
294
300
  #toolFailed(event) {
295
301
  const state = this.#tools.get(event.callId);
296
- if (event.toolName === 'exec' && state?.commandOrigin !== undefined) {
302
+ if ((event.toolName === 'exec' || event.toolName === 'exec_wait') &&
303
+ state?.commandOrigin !== undefined) {
297
304
  const command = this.#commands.get(state.commandOrigin);
298
305
  const input = record(state.input);
299
306
  if (command !== undefined &&
@@ -273,7 +273,9 @@ export class MarAgentManagedRunController {
273
273
  throw new Error('MAR_AGENT_SESSION_RUNTIME_ACTIVE');
274
274
  runtime.activeRunId = input.runId;
275
275
  await runtime.mcpLease.pool.refreshForExecution();
276
- const executionMcpServers = input.mcpServers.filter((server) => server.lifecycle === 'EXECUTION');
276
+ const executionMcpServers = input.mcpServers
277
+ .filter(isExecutionScopedMcp)
278
+ .map((server) => ({ ...server, lifecycle: 'EXECUTION' }));
277
279
  computerUseBinding = await this.#computerUse?.bind({
278
280
  runId: input.runId,
279
281
  sessionId: runtime.session.id,
@@ -311,6 +313,9 @@ export class MarAgentManagedRunController {
311
313
  });
312
314
  executionMcpBinding = runtime.host.bindExecutionExtensions({
313
315
  ...imageExtensions,
316
+ environment: input.secretEnvironment?.['MAR_CTL_USER_TOKEN'] === undefined
317
+ ? {}
318
+ : { MAR_CTL_USER_TOKEN: input.secretEnvironment['MAR_CTL_USER_TOKEN'] },
314
319
  skills: [...(imageExtensions.skills ?? []), computerUseHostSkill]
315
320
  }, {
316
321
  tools: [...nodeTools, ...userTools],
@@ -500,7 +505,7 @@ export class MarAgentManagedRunController {
500
505
  async #createRuntime(input, sessionKey, fingerprint, hostConfiguration) {
501
506
  const dynamicCredentials = input.models.some((model) => model.credential?.type === 'RUNTIME_TICKET' ||
502
507
  model.imageGeneration?.credential?.type === 'RUNTIME_TICKET');
503
- const sharedMcpServers = input.mcpServers.filter((server) => server.lifecycle !== 'EXECUTION');
508
+ const sharedMcpServers = input.mcpServers.filter((server) => !isExecutionScopedMcp(server));
504
509
  const mcpLease = await this.#mcpClients.acquire(sharedMcpServers, input.cwd, {
505
510
  // Native Session IDs are assigned after the first lease; do not let that promotion
506
511
  // change a confirmed actor's discovery identity when its warm runtime is recreated.
@@ -706,6 +711,10 @@ function contextUsageChanged(event) {
706
711
  (event.type === 'usage.updated' &&
707
712
  (event.contextInputTokens !== undefined || event.inputTokens !== undefined)));
708
713
  }
714
+ function isExecutionScopedMcp(server) {
715
+ return (server.lifecycle === 'EXECUTION' ||
716
+ (server.transport === 'STDIO' && server.environment?.['MAR_CTL_USER_TOKEN'] !== undefined));
717
+ }
709
718
  function sessionRuntimeFingerprint(input, hostConfiguration) {
710
719
  return createHash('sha256')
711
720
  .update(JSON.stringify({
@@ -713,7 +722,7 @@ function sessionRuntimeFingerprint(input, hostConfiguration) {
713
722
  defaultModelId: input.model.id,
714
723
  models: input.models.map(runtimeModelFingerprint),
715
724
  personalInstructions: input.personalInstructions ?? '',
716
- mcpServers: input.mcpServers.filter((server) => server.lifecycle !== 'EXECUTION'),
725
+ mcpServers: input.mcpServers.filter((server) => !isExecutionScopedMcp(server)),
717
726
  environment: Object.entries(hostConfiguration.environment)
718
727
  .filter((entry) => entry[1] !== undefined)
719
728
  .sort(([left], [right]) => left.localeCompare(right)),
@@ -727,8 +736,10 @@ export function allowsUntrustedWebFetchTls(environment) {
727
736
  async function resolveMarAgentHostRuntimeConfiguration(secretEnvironment = {}) {
728
737
  const environment = nodeExecutionEnvironment({
729
738
  ...secretEnvironment,
730
- MAR_MGR_CONTEXT_REQUIRED: '1'
739
+ MAR_CTL_CONTEXT_REQUIRED: '1'
731
740
  });
741
+ // CLI credentials are bound to each execution, never retained as Host configuration.
742
+ delete environment['MAR_CTL_USER_TOKEN'];
732
743
  const ripgrep = await availableBundledRipgrep();
733
744
  return {
734
745
  environment: ripgrep === undefined ? environment : prependBundledRipgrepToPath(environment, ripgrep),
@@ -188,17 +188,49 @@ export class MarAgentMcpClientPool {
188
188
  : this.#controller.signal
189
189
  });
190
190
  const items = Array.isArray(result.content) ? result.content : [];
191
+ const images = [];
192
+ const remaining = [];
191
193
  const content = items
192
194
  .map((item) => {
193
- if (item === null || typeof item !== 'object')
195
+ if (item === null || typeof item !== 'object') {
196
+ remaining.push(item);
194
197
  return '[unknown]';
198
+ }
195
199
  const value = item;
196
- return value.type === 'text' && typeof value.text === 'string'
197
- ? value.text
198
- : `[${String(value.type ?? 'unknown')}]`;
200
+ if (value.type === 'text' && typeof value.text === 'string') {
201
+ const { text, ...metadata } = value;
202
+ if (Object.keys(metadata).some((key) => key !== 'type'))
203
+ remaining.push(metadata);
204
+ return text;
205
+ }
206
+ if (value.type === 'image' &&
207
+ typeof value.mimeType === 'string' &&
208
+ typeof value.data === 'string') {
209
+ // Native MCP pixels belong only in the SDK image channel, not duplicated JSON text.
210
+ images.push({ mimeType: value.mimeType, dataBase64: value.data });
211
+ const { data: _pixels, ...metadata } = value;
212
+ if (Object.keys(metadata).some((key) => key !== 'type' && key !== 'mimeType'))
213
+ remaining.push(metadata);
214
+ }
215
+ else {
216
+ remaining.push(item);
217
+ }
218
+ return `[${String(value.type ?? 'unknown')}]`;
199
219
  })
200
220
  .join('\n');
201
- return { content, data: result.structuredContent ?? items };
221
+ const structured = result.structuredContent;
222
+ const data = structured !== undefined && structured !== null
223
+ ? remaining.length
224
+ ? { structuredContent: structured, content: remaining }
225
+ : structured
226
+ : remaining.length
227
+ ? remaining
228
+ : undefined;
229
+ return {
230
+ content,
231
+ ...(data === undefined ? {} : { data }),
232
+ ...(images.length ? { images } : {})
233
+ };
202
234
  }
203
235
  async close() {
204
236
  if (this.#closed)
@@ -4,6 +4,7 @@ import { runnerErrorProjection } from '../../util/runner-error.js';
4
4
  import { boundedConversationItemId, compactRunnerText } from '../codex/conversation-parser.js';
5
5
  import { openCodePatchToolPayload, openCodeQuestions } from './conversation-parser.js';
6
6
  import { normalizeOpenCodeTool } from './tool-normalizer.js';
7
+ import { ResponseLatencyTracker } from '../../service/response-latency-tracker.js';
7
8
  export class OpenCodeManagedRunController {
8
9
  runner;
9
10
  host;
@@ -50,7 +51,7 @@ export class OpenCodeManagedRunController {
50
51
  this.host.hasActiveSessionLease(session.id))
51
52
  throw new Error('RUNNER_COMMAND_UNAVAILABLE');
52
53
  const runId = crypto.randomUUID();
53
- const client = this.runner.managedClient(nodeExecutionEnvironment({ ...environment, MAR_MGR_CONTEXT_REQUIRED: '1' }, { PATH: process.env['PATH'], Path: process.env['Path'] }));
54
+ const client = this.runner.managedClient(nodeExecutionEnvironment({ ...environment, MAR_CTL_CONTEXT_REQUIRED: '1' }, { PATH: process.env['PATH'], Path: process.env['Path'] }));
54
55
  this.host.adopt({
55
56
  runId,
56
57
  sessionId: session.id,
@@ -111,7 +112,7 @@ export class OpenCodeManagedRunController {
111
112
  try {
112
113
  environment = nodeExecutionEnvironment({
113
114
  ...parseSecretEnvironment(payload.secretEnvironment),
114
- MAR_MGR_CONTEXT_REQUIRED: '1'
115
+ MAR_CTL_CONTEXT_REQUIRED: '1'
115
116
  }, { PATH: process.env['PATH'], Path: process.env['Path'] });
116
117
  mcpSecretEnvironment = parseSecretEnvironment(payload.mcpSecretEnvironment);
117
118
  mcp = this.runner.mcpConfiguration(payload.mcpInstallations, {
@@ -265,6 +266,13 @@ export class OpenCodeManagedRunController {
265
266
  const attachmentPaths = input.attachments.length === 0
266
267
  ? []
267
268
  : await this.host.persistAttachments(input.cwd, input.runId, input.attachments);
269
+ const execution = this.runner.execution.get(input.runId);
270
+ if (execution !== undefined) {
271
+ execution.responseLatency = new ResponseLatencyTracker();
272
+ execution.responsePartKinds = new Map();
273
+ execution.responseStartedAt = Date.now();
274
+ execution.responseMessages = new Set();
275
+ }
268
276
  await client.prompt({
269
277
  directory: input.cwd,
270
278
  sessionID: nativeSessionId,
@@ -312,6 +320,13 @@ export class OpenCodeManagedRunController {
312
320
  const roles = this.messageRoles.get(runId) ?? new Map();
313
321
  roles.set(info.id, info.role);
314
322
  this.messageRoles.set(runId, roles);
323
+ const execution = this.runner.execution.get(runId);
324
+ const created = record(info.time)?.created;
325
+ if (info.role === 'assistant' &&
326
+ execution?.responseStartedAt !== undefined &&
327
+ typeof created === 'number' &&
328
+ created >= execution.responseStartedAt)
329
+ execution.responseMessages?.add(info.id);
315
330
  }
316
331
  if (info?.role === 'assistant' &&
317
332
  typeof info.id === 'string' &&
@@ -334,6 +349,19 @@ export class OpenCodeManagedRunController {
334
349
  }
335
350
  return;
336
351
  }
352
+ if (event?.type === 'message.part.delta') {
353
+ const kind = typeof properties?.partID === 'string'
354
+ ? this.runner.execution.get(runId)?.responsePartKinds?.get(properties.partID)
355
+ : undefined;
356
+ if ((kind === 'text' || kind === 'reasoning') &&
357
+ properties?.field === 'text' &&
358
+ typeof properties.delta === 'string' &&
359
+ properties.delta.length > 0 &&
360
+ typeof properties.messageID === 'string' &&
361
+ this.currentResponseMessage(runId, properties.messageID))
362
+ this.observeResponse(runId, boundedConversationItemId('opencode', properties.partID), true);
363
+ return;
364
+ }
337
365
  if (event?.type === 'message.part.updated') {
338
366
  const part = record(properties?.part);
339
367
  if (part?.type === 'compaction' &&
@@ -356,9 +384,32 @@ export class OpenCodeManagedRunController {
356
384
  const parentId = this.messageParents.get(runId)?.get(part.messageID);
357
385
  if (parentId !== undefined && this.compactionPartIds.get(runId)?.has(parentId))
358
386
  return;
387
+ if (typeof part.id === 'string' && typeof part.type === 'string')
388
+ this.runner.execution.get(runId)?.responsePartKinds?.set(part.id, part.type);
359
389
  const item = openCodePart(part, this.runner.execution.get(runId)?.agent === 'plan');
360
390
  if (item !== undefined)
361
391
  this.host.emitItem(runId, item);
392
+ if (typeof part.id === 'string') {
393
+ const itemId = boundedConversationItemId('opencode', part.id);
394
+ const tracker = this.currentResponseMessage(runId, part.messageID)
395
+ ? this.runner.execution.get(runId)?.responseLatency
396
+ : undefined;
397
+ if (part.type === 'tool') {
398
+ const state = record(part.state);
399
+ if (state?.status === 'completed' || state?.status === 'error')
400
+ tracker?.toolCompleted(itemId);
401
+ else if (state?.status === 'pending' || state?.status === 'running') {
402
+ const latencyMs = tracker?.toolStarted(itemId);
403
+ if (latencyMs !== undefined)
404
+ this.host.emit(runId, 'run.performance', { latencyMs });
405
+ }
406
+ }
407
+ else if (tracker !== undefined &&
408
+ (part.type === 'text' || part.type === 'reasoning') &&
409
+ typeof part.text === 'string' &&
410
+ part.text.length > 0)
411
+ this.observeResponse(runId, itemId);
412
+ }
362
413
  return;
363
414
  }
364
415
  if (event?.type === 'permission.asked')
@@ -366,6 +417,19 @@ export class OpenCodeManagedRunController {
366
417
  if (event?.type === 'question.asked')
367
418
  this.requestQuestion(runId, properties);
368
419
  }
420
+ currentResponseMessage(runId, messageId) {
421
+ const parent = this.messageParents.get(runId)?.get(messageId);
422
+ return (this.runner.execution.get(runId)?.responseMessages?.has(messageId) === true &&
423
+ this.messageRoles.get(runId)?.get(messageId) === 'assistant' &&
424
+ (parent === undefined || !this.compactionPartIds.get(runId)?.has(parent)));
425
+ }
426
+ observeResponse(runId, itemId, incremental = false) {
427
+ const latencyMs = this.runner.execution
428
+ .get(runId)
429
+ ?.responseLatency?.response(itemId, incremental);
430
+ if (latencyMs !== undefined)
431
+ this.host.emit(runId, 'run.performance', { latencyMs });
432
+ }
369
433
  requestPermission(runId, properties) {
370
434
  const execution = this.runner.execution.get(runId);
371
435
  const requestId = typeof properties?.id === 'string' ? properties.id : undefined;
@@ -13,6 +13,7 @@ export class NodeRuntimeState {
13
13
  static MAX_COMPLETED_TURNS_PER_SESSION = 10;
14
14
  sessions = new Map();
15
15
  runs = new Map();
16
+ runPerformance = new Map();
16
17
  recentUserMessages = new Map();
17
18
  messageSubmissions = new Map();
18
19
  failedMessageSubmissions = new Map();
@@ -553,6 +554,28 @@ export class NodeRuntimeState {
553
554
  const canonical = this.executions.get(id)?.runId ?? id;
554
555
  return this.runs.get(canonical);
555
556
  }
557
+ /** Only the current execution may replace an active Run's ephemeral metrics. */
558
+ updateRunPerformance(id, performance) {
559
+ const run = this.getRun(id);
560
+ if (run === undefined || terminal(run.status) || performance.runId !== run.id)
561
+ return false;
562
+ if (id !== run.id && this.currentExecutionId(run.id) !== id)
563
+ return false;
564
+ this.runPerformance.set(run.id, {
565
+ ...this.runPerformance.get(run.id),
566
+ runId: performance.runId,
567
+ ...(performance.latencyMs === undefined ? {} : { latencyMs: performance.latencyMs }),
568
+ ...(performance.tokensPerSecond === undefined
569
+ ? {}
570
+ : { tokensPerSecond: performance.tokensPerSecond })
571
+ });
572
+ return true;
573
+ }
574
+ sessionPerformance(sessionId) {
575
+ const run = this.activeSessionRun(sessionId);
576
+ const performance = run === undefined ? undefined : this.runPerformance.get(run.id);
577
+ return performance === undefined ? undefined : { ...performance };
578
+ }
556
579
  findMessageRun(sessionId, clientMessageId) {
557
580
  const submission = this.messageSubmissions.get(`${sessionId}\u0000${clientMessageId}`);
558
581
  return submission?.kind === 'run' ? this.runs.get(submission.id) : undefined;
@@ -599,6 +622,8 @@ export class NodeRuntimeState {
599
622
  updatedAt: now
600
623
  };
601
624
  this.runs.set(runId, value);
625
+ if (terminal(next))
626
+ this.runPerformance.delete(runId);
602
627
  this.emitRunChanged(value, this.requireSession(value.sessionId));
603
628
  if (terminal(next) && this.leases.get(value.sessionId) === value.id)
604
629
  this.leases.delete(value.sessionId);
@@ -894,6 +919,7 @@ export class NodeRuntimeState {
894
919
  }
895
920
  this.pruneRetainedExecutionTurns(run.sessionId, retainedTurnIds);
896
921
  this.runs.delete(runId);
922
+ this.runPerformance.delete(runId);
897
923
  this.recentUserMessages.delete(runId);
898
924
  this.runSequences.delete(runId);
899
925
  for (const [key, candidate] of this.messageSubmissions)
@@ -931,6 +957,7 @@ export class NodeRuntimeState {
931
957
  this.leases.delete(sessionId);
932
958
  for (const runId of runIds) {
933
959
  this.runs.delete(runId);
960
+ this.runPerformance.delete(runId);
934
961
  this.recentUserMessages.delete(runId);
935
962
  this.runSequences.delete(runId);
936
963
  }
@@ -994,6 +1021,7 @@ export class NodeRuntimeState {
994
1021
  }
995
1022
  for (const runId of runIds) {
996
1023
  this.runs.delete(runId);
1024
+ this.runPerformance.delete(runId);
997
1025
  this.recentUserMessages.delete(runId);
998
1026
  this.runSequences.delete(runId);
999
1027
  }
@@ -33,12 +33,20 @@ export class NativeSessionProjectionService {
33
33
  this.remember(sessions);
34
34
  return sessions.map(this.options.present);
35
35
  }
36
- async discoverWorkspace(workspaceId) {
37
- const discovered = await Promise.all(this.options.runners
36
+ async discoverWorkspace(workspaceId, onRunnerDiscovered) {
37
+ const discovered = await Promise.allSettled(this.options.runners
38
38
  .advertised()
39
39
  .filter((runner) => runner.available(this.options.capabilities()))
40
- .map((runner) => this.discover(runner.name, workspaceId)));
41
- const sessions = deduplicateDirectSessions(discovered.flat());
40
+ .map(async (runner) => {
41
+ const name = runner.name;
42
+ const sessions = await this.discover(name, workspaceId);
43
+ onRunnerDiscovered?.(name, sessions);
44
+ return sessions;
45
+ }));
46
+ const failed = discovered.find((result) => result.status === 'rejected');
47
+ if (failed?.status === 'rejected')
48
+ throw failed.reason;
49
+ const sessions = deduplicateDirectSessions(discovered.flatMap((result) => (result.status === 'fulfilled' ? result.value : [])));
42
50
  this.remember(sessions);
43
51
  return sessions;
44
52
  }
@@ -0,0 +1,47 @@
1
+ /** Monotonic, observation-only timing; native event semantics belong to each Runner. */
2
+ export class ResponseLatencyTracker {
3
+ now;
4
+ waitingAt;
5
+ pendingTools = new Set();
6
+ completedTools = new Set();
7
+ observedResponses = new Set();
8
+ hasObservedResponse = false;
9
+ constructor(now = () => performance.now()) {
10
+ this.now = now;
11
+ this.waitingAt = now();
12
+ }
13
+ response(id, incremental = false) {
14
+ if (id !== undefined) {
15
+ // A new delta is semantic output even when the Runner reuses a response item.
16
+ if (!incremental && this.observedResponses.has(id))
17
+ return undefined;
18
+ this.observedResponses.add(id);
19
+ if (this.observedResponses.size > 1_024)
20
+ this.observedResponses.delete(this.observedResponses.values().next().value);
21
+ }
22
+ if (this.waitingAt === undefined || this.pendingTools.size > 0)
23
+ return undefined;
24
+ const latencyMs = Math.max(0, Math.round(this.now() - this.waitingAt));
25
+ this.waitingAt = undefined;
26
+ this.hasObservedResponse = true;
27
+ return latencyMs;
28
+ }
29
+ toolStarted(id) {
30
+ if (this.completedTools.has(id) || this.pendingTools.has(id))
31
+ return undefined;
32
+ const latencyMs = this.response(id);
33
+ this.pendingTools.add(id);
34
+ this.waitingAt = undefined;
35
+ return latencyMs;
36
+ }
37
+ toolCompleted(id) {
38
+ if (this.completedTools.has(id))
39
+ return;
40
+ this.completedTools.add(id);
41
+ if (this.completedTools.size > 1_024)
42
+ this.completedTools.delete(this.completedTools.values().next().value);
43
+ this.pendingTools.delete(id);
44
+ if (this.pendingTools.size === 0)
45
+ this.waitingAt = this.now();
46
+ }
47
+ }
@@ -15,7 +15,7 @@ export class RunEventService {
15
15
  if (run === undefined || !isPlainRecord(payload))
16
16
  return;
17
17
  const parsed = runPerformanceSchema.safeParse({ ...payload, runId: run.id });
18
- if (parsed.success) {
18
+ if (parsed.success && this.options.runtime.updateRunPerformance(runId, parsed.data)) {
19
19
  this.flushText(runId);
20
20
  this.options.emitWorkbench('conversation', {
21
21
  sessionId: run.sessionId,
@@ -57,7 +57,7 @@ export class SessionCatalogService {
57
57
  this.options.projectionForNative(candidate) === undefined)
58
58
  ].map((session) => [session.id, session]));
59
59
  if (workspaceId !== undefined)
60
- this.mergePersisted(merged, workspaceId);
60
+ this.mergePersisted(merged, workspaceId, query === undefined && this.options.discoveryPending(workspaceId));
61
61
  const sessions = [...merged.values()].filter((session) => {
62
62
  if (query === undefined)
63
63
  return true;
@@ -70,7 +70,7 @@ export class SessionCatalogService {
70
70
  ? sessions
71
71
  : sessions.filter((session) => session.workspaceId === workspaceId), input);
72
72
  }
73
- mergePersisted(merged, workspaceId) {
73
+ mergePersisted(merged, workspaceId, discoveryPending) {
74
74
  const database = this.options.database();
75
75
  const workspace = database.getWorkspace(workspaceId);
76
76
  if (workspace === undefined)
@@ -78,6 +78,9 @@ export class SessionCatalogService {
78
78
  for (const persisted of database.listSessionRecords(workspaceId)) {
79
79
  if (merged.has(persisted.id))
80
80
  continue;
81
+ // A metadata record alone does not establish a discovered native Session.
82
+ if (discoveryPending)
83
+ continue;
81
84
  merged.set(persisted.id, this.options.present({
82
85
  id: persisted.id,
83
86
  nodeId: this.options.nodeId(),
@@ -61,7 +61,7 @@ export class TerminalService {
61
61
  const options = {
62
62
  ...input,
63
63
  workspaceId: typeof input.workspaceId === 'string' ? input.workspaceId : null,
64
- environment: { ...secretEnvironment, MAR_MGR_CONTEXT_REQUIRED: '1' },
64
+ environment: { ...secretEnvironment, MAR_CTL_CONTEXT_REQUIRED: '1' },
65
65
  ...(cwd === undefined ? {} : { cwd })
66
66
  };
67
67
  const terminal = commandMode
@@ -1,16 +1,13 @@
1
- import { tmpdir } from 'node:os';
2
1
  import { isAbsolute, relative, resolve, win32 } from 'node:path';
3
2
  import { realpath } from 'node:fs/promises';
4
- import { listWorkspaceFiles, mutateWorkspaceFile, normalizeFilePreviewPath, readAllowedFileContentRange, readWorkspaceFileContentRange, readAllowedTextFile, readWorkspaceTextFile, writeWorkspaceTextFile, WorkspaceFileIndex } from '../workspace.js';
3
+ import { listWorkspaceFiles, mutateWorkspaceFile, normalizeFilePreviewPath, readAbsoluteFileContentRange, readWorkspaceFileContentRange, readAbsoluteTextFile, readWorkspaceTextFile, writeWorkspaceTextFile, WorkspaceFileIndex } from '../workspace.js';
5
4
  const INDEX_CACHE_LIMIT = 32;
6
5
  export class WorkspaceFileService {
7
6
  state;
8
- allowedRoots;
9
7
  onInvalidated;
10
8
  mutationTails = new Map();
11
- constructor(state, allowedRoots, onInvalidated) {
9
+ constructor(state, onInvalidated) {
12
10
  this.state = state;
13
- this.allowedRoots = allowedRoots;
14
11
  this.onInvalidated = onInvalidated;
15
12
  }
16
13
  async list(workspace, input) {
@@ -38,7 +35,7 @@ export class WorkspaceFileService {
38
35
  if (workspacePath !== workspace.path && isFilePreviewAbsolutePath(path))
39
36
  throw new Error('FILE_PATH_ESCAPE');
40
37
  return isFilePreviewAbsolutePath(path)
41
- ? readAllowedTextFile(path, this.previewRoots(), offset, limit)
38
+ ? readAbsoluteTextFile(path, offset, limit)
42
39
  : readWorkspaceTextFile(workspacePath, path, offset, limit);
43
40
  }
44
41
  async readContent(workspace, input, workspacePath = workspace.path) {
@@ -69,7 +66,7 @@ export class WorkspaceFileService {
69
66
  const offset = typeof input.offset === 'number' ? input.offset : 0;
70
67
  const limit = typeof input.limit === 'number' ? input.limit : 512 * 1024;
71
68
  return isFilePreviewAbsolutePath(path)
72
- ? readAllowedFileContentRange(path, this.previewRoots(), offset, limit)
69
+ ? readAbsoluteFileContentRange(path, offset, limit)
73
70
  : readWorkspaceFileContentRange(workspacePath, path, offset, limit);
74
71
  }
75
72
  readDirect(workspace, input, workspacePath = workspace.path) {
@@ -218,9 +215,6 @@ export class WorkspaceFileService {
218
215
  index.dispose();
219
216
  this.state.fileIndexes.clear();
220
217
  }
221
- previewRoots() {
222
- return [...new Set([...this.allowedRoots(), tmpdir()])];
223
- }
224
218
  }
225
219
  function isFilePreviewAbsolutePath(path) {
226
220
  return isAbsolute(path) || win32.isAbsolute(path);
@@ -1,10 +1,12 @@
1
+ import { nodeLog } from '../operational.js';
1
2
  const WATCH_INTERVAL_MS = 30_000;
2
3
  const CACHE_TTL_MS = 15_000;
3
- const INITIAL_WAIT_MS = 500;
4
4
  export class WorkspaceSessionService {
5
5
  state;
6
6
  options;
7
7
  cacheGenerations = new Map();
8
+ initialReads = new Map();
9
+ incompleteDiscoveries = new Set();
8
10
  constructor(state, options) {
9
11
  this.state = state;
10
12
  this.options = options;
@@ -22,32 +24,90 @@ export class WorkspaceSessionService {
22
24
  this.state.sessionWatchTimers.delete(workspaceId);
23
25
  this.state.sessionWatchSignatures.delete(workspaceId);
24
26
  }
27
+ discoveryPending(workspaceId) {
28
+ // Partial cold batches still await native identity confirmation for the remaining Runners.
29
+ return this.incompleteDiscoveries.has(workspaceId);
30
+ }
25
31
  async cached(workspaceId) {
26
32
  const cached = this.state.sessionCache.get(workspaceId);
27
33
  if (cached !== undefined && cached.expiresAt > Date.now())
28
34
  return cached.sessions;
29
- let initialDiscoveryTimedOut = false;
30
35
  let discovery = this.state.sessionCacheReads.get(workspaceId);
31
36
  if (discovery === undefined) {
32
37
  const generation = this.cacheGenerations.get(workspaceId) ?? 0;
33
- const read = this.options
34
- .discover(workspaceId)
35
- .then((sessions) => {
38
+ let resolveInitial;
39
+ let rejectInitial;
40
+ const initial = new Promise((resolve, reject) => {
41
+ resolveInitial = resolve;
42
+ rejectInitial = reject;
43
+ });
44
+ if (cached === undefined) {
45
+ this.initialReads.set(workspaceId, initial);
46
+ this.incompleteDiscoveries.add(workspaceId);
47
+ }
48
+ // A cached caller does not await this promise, but failures still belong to the read.
49
+ void initial.catch(() => undefined);
50
+ let acceptingBatches = true;
51
+ let batchPublished = false;
52
+ let notifyCompletion = false;
53
+ const publish = (sessions, notify = true) => {
36
54
  if ((this.cacheGenerations.get(workspaceId) ?? 0) !== generation)
37
- return sessions;
55
+ return;
38
56
  const previous = this.state.sessionCache.get(workspaceId)?.sessions;
39
57
  this.state.sessionCache.set(workspaceId, {
40
58
  sessions,
41
59
  expiresAt: Date.now() + CACHE_TTL_MS
42
60
  });
43
- if (initialDiscoveryTimedOut ||
44
- (previous !== undefined && sessionSignature(previous) !== sessionSignature(sessions)))
61
+ if (notify &&
62
+ previous !== undefined &&
63
+ sessionSignature(previous) !== sessionSignature(sessions))
45
64
  this.options.invalidate(workspaceId);
65
+ };
66
+ const read = this.options
67
+ .discover(workspaceId, (runner, sessions) => {
68
+ if (!acceptingBatches || (this.cacheGenerations.get(workspaceId) ?? 0) !== generation)
69
+ return;
70
+ const previous = this.state.sessionCache.get(workspaceId)?.sessions;
71
+ const merged = [
72
+ ...(previous ?? []).filter((session) => session.runner !== runner),
73
+ ...sessions
74
+ ];
75
+ // An empty early Runner must not turn an undiscovered Workspace into an empty list.
76
+ if (previous === undefined && merged.length === 0)
77
+ return;
78
+ publish(merged);
79
+ batchPublished = true;
80
+ resolveInitial(merged);
81
+ })
82
+ .then((sessions) => {
83
+ if ((this.cacheGenerations.get(workspaceId) ?? 0) === generation)
84
+ this.incompleteDiscoveries.delete(workspaceId);
85
+ const previous = this.state.sessionCache.get(workspaceId)?.sessions;
86
+ notifyCompletion =
87
+ batchPublished ||
88
+ (previous !== undefined && sessionSignature(previous) !== sessionSignature(sessions));
89
+ publish(sessions, false);
90
+ resolveInitial(sessions);
46
91
  return sessions;
92
+ })
93
+ .catch((error) => {
94
+ rejectInitial(error);
95
+ if ((this.cacheGenerations.get(workspaceId) ?? 0) === generation) {
96
+ nodeLog('native.session.discovery.failed', { workspaceId });
97
+ const partial = this.state.sessionCache.get(workspaceId);
98
+ if (partial !== undefined)
99
+ this.state.sessionCache.set(workspaceId, { ...partial, expiresAt: 0 });
100
+ }
101
+ throw error;
47
102
  })
48
103
  .finally(() => {
104
+ acceptingBatches = false;
49
105
  if (this.state.sessionCacheReads.get(workspaceId) === read)
50
106
  this.state.sessionCacheReads.delete(workspaceId);
107
+ if (this.initialReads.get(workspaceId) === initial)
108
+ this.initialReads.delete(workspaceId);
109
+ if (notifyCompletion && (this.cacheGenerations.get(workspaceId) ?? 0) === generation)
110
+ this.options.invalidate(workspaceId);
51
111
  if (!this.state.sessionCacheReads.has(workspaceId) &&
52
112
  !this.state.sessionCache.has(workspaceId) &&
53
113
  (this.cacheGenerations.get(workspaceId) ?? 0) !== generation)
@@ -55,15 +115,12 @@ export class WorkspaceSessionService {
55
115
  });
56
116
  discovery = read;
57
117
  this.state.sessionCacheReads.set(workspaceId, discovery);
118
+ // Later Runner failures cannot become unhandled after the first batch was returned.
119
+ void discovery.catch(() => undefined);
58
120
  }
59
121
  if (cached !== undefined)
60
122
  return cached.sessions;
61
- const discovered = await within(discovery, INITIAL_WAIT_MS);
62
- if (discovered !== undefined)
63
- return discovered;
64
- initialDiscoveryTimedOut = true;
65
- this.options.onPending?.(workspaceId, INITIAL_WAIT_MS);
66
- return [];
123
+ return this.initialReads.get(workspaceId) ?? discovery;
67
124
  }
68
125
  clear() {
69
126
  for (const timer of this.state.sessionWatchTimers.values())
@@ -78,6 +135,8 @@ export class WorkspaceSessionService {
78
135
  this.bumpCacheGeneration(workspaceId);
79
136
  this.state.sessionCache.clear();
80
137
  this.state.sessionCacheReads.clear();
138
+ this.initialReads.clear();
139
+ this.incompleteDiscoveries.clear();
81
140
  }
82
141
  invalidate(workspaceId) {
83
142
  this.invalidateCache(workspaceId);
@@ -87,6 +146,8 @@ export class WorkspaceSessionService {
87
146
  this.bumpCacheGeneration(workspaceId);
88
147
  this.state.sessionCache.delete(workspaceId);
89
148
  this.state.sessionCacheReads.delete(workspaceId);
149
+ this.initialReads.delete(workspaceId);
150
+ this.incompleteDiscoveries.delete(workspaceId);
90
151
  }
91
152
  bumpCacheGeneration(workspaceId) {
92
153
  this.cacheGenerations.set(workspaceId, (this.cacheGenerations.get(workspaceId) ?? 0) + 1);
@@ -146,9 +207,3 @@ function sessionSignature(sessions) {
146
207
  session.externalDiscovered
147
208
  ]));
148
209
  }
149
- async function within(promise, timeoutMs) {
150
- return Promise.race([
151
- promise,
152
- new Promise((resolve) => setTimeout(resolve, timeoutMs))
153
- ]);
154
- }
package/dist/workspace.js CHANGED
@@ -757,15 +757,15 @@ export async function readWorkspaceFileContentRange(workspaceRoot, requestedPath
757
757
  await handle.close();
758
758
  }
759
759
  }
760
- /** Reads one absolute file range only when the resolved target remains within a preview root. */
761
- export async function readAllowedFileContentRange(requestedPath, allowedRoots, offset = 0, limit = FILE_READ_LIMIT) {
760
+ /** Reads one regular absolute file range using the Node account's filesystem permissions. */
761
+ export async function readAbsoluteFileContentRange(requestedPath, offset = 0, limit = FILE_READ_LIMIT) {
762
762
  if (!Number.isSafeInteger(offset) ||
763
763
  offset < 0 ||
764
764
  !Number.isSafeInteger(limit) ||
765
765
  limit < 1 ||
766
766
  limit > FILE_READ_LIMIT)
767
767
  throw new Error('FILE_RANGE_INVALID');
768
- const { file, metadata } = await resolveAllowedFile(requestedPath, allowedRoots);
768
+ const { file, metadata } = await resolveAbsoluteFile(requestedPath);
769
769
  if (offset > metadata.size)
770
770
  throw new Error('FILE_RANGE_INVALID');
771
771
  const length = Math.min(limit, metadata.size - offset);
@@ -792,9 +792,9 @@ function fileSourceRevision(metadata) {
792
792
  .update([metadata.dev, metadata.ino, metadata.size, metadata.mtimeMs, metadata.ctimeMs].join(':'))
793
793
  .digest('hex');
794
794
  }
795
- /** Reads one absolute file only when the resolved target remains within a preview root. */
796
- export async function readAllowedTextFile(requestedPath, allowedRoots, offset = 0, limit = FILE_READ_LIMIT) {
797
- const { file, metadata } = await resolveAllowedFile(requestedPath, allowedRoots);
795
+ /** Reads one regular absolute file using the Node account's filesystem permissions. */
796
+ export async function readAbsoluteTextFile(requestedPath, offset = 0, limit = FILE_READ_LIMIT) {
797
+ const { file, metadata } = await resolveAbsoluteFile(requestedPath);
798
798
  if (metadata.size > FILE_READ_LIMIT * 16)
799
799
  throw new Error('FILE_TOO_LARGE');
800
800
  if (!Number.isSafeInteger(offset) ||
@@ -859,7 +859,7 @@ export function normalizeFilePreviewPath(requestedPath) {
859
859
  throw new Error('FILE_PATH_ESCAPE');
860
860
  return normalized;
861
861
  }
862
- async function resolveAllowedFile(requestedPath, allowedRoots) {
862
+ async function resolveAbsoluteFile(requestedPath) {
863
863
  if (!isAbsolute(requestedPath))
864
864
  throw new Error('FILE_PATH_ESCAPE');
865
865
  const file = await realpath(requestedPath).catch((error) => {
@@ -867,9 +867,6 @@ async function resolveAllowedFile(requestedPath, allowedRoots) {
867
867
  throw new Error('FILE_NOT_FOUND');
868
868
  throw error;
869
869
  });
870
- const roots = await Promise.all(allowedRoots.map((root) => realpath(root)));
871
- if (!isWithinAllowedRoot(file, roots))
872
- throw new Error('FILE_PATH_ESCAPE');
873
870
  const metadata = await lstat(file);
874
871
  if (!metadata.isFile())
875
872
  throw new Error('FILE_NOT_REGULAR');
@@ -1019,7 +1016,9 @@ export function normalizeWorkspacePath(input, platform = hostPlatform()) {
1019
1016
  if (!win32.isAbsolute(normalized)) {
1020
1017
  throw new Error('WORKSPACE_PATH_NOT_ABSOLUTE');
1021
1018
  }
1022
- return normalized.replace(/\\+$/, '').toLowerCase();
1019
+ // A drive root must remain absolute instead of becoming drive-relative.
1020
+ const root = win32.parse(normalized).root;
1021
+ return (normalized === root ? root : normalized.replace(/\\+$/, '')).toLowerCase();
1023
1022
  }
1024
1023
  if (platform !== 'linux' && platform !== 'darwin') {
1025
1024
  throw new Error('WORKSPACE_PLATFORM_UNSUPPORTED');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.9.139",
3
+ "version": "0.9.141",
4
4
  "description": "MyAgentRoam Node runtime CLI.",
5
5
  "license": "Apache-2.0 AND GPL-2.0-or-later",
6
6
  "type": "module",
@@ -13,7 +13,7 @@
13
13
  ],
14
14
  "bin": {
15
15
  "mar-node": "./dist/main.js",
16
- "marmgr": "./dist/marmgr.js",
16
+ "marctl": "./dist/marctl.js",
17
17
  "marnodes": "./dist/marnodes.js",
18
18
  "marssh": "./dist/marssh.js",
19
19
  "marscp": "./dist/marscp.js"
@@ -34,10 +34,10 @@
34
34
  "npm": "11.12.1",
35
35
  "ws": "^8.21.3",
36
36
  "zod": "4.4.3",
37
- "@myagentroam/marmgr": "0.9.119",
38
- "@myagentroam/agent": "0.9.139",
39
- "@myagentroam/code-navigation": "0.9.129",
40
- "@myagentroam/protocol": "0.9.139"
37
+ "@myagentroam/marctl": "0.9.120",
38
+ "@myagentroam/agent": "0.9.141",
39
+ "@myagentroam/protocol": "0.9.141",
40
+ "@myagentroam/code-navigation": "0.9.129"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/ws": "^8.18.1"