@myagentroam/node 0.9.140 → 0.9.142

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,15 +306,10 @@ 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
314
  workspaceFileService = new WorkspaceFileService(this.workspaceState, (workspaceId) => {
320
315
  this.workspaceWorkbenchService?.invalidateWorkspaceFiles(workspaceId);
@@ -1131,6 +1126,7 @@ export class NodeConnector {
1131
1126
  projection: this.nativeSessionProjectionService,
1132
1127
  cachedNative: () => [...this.sessionState.directNative.values()],
1133
1128
  cachedWorkspace: (workspaceId) => this.workspaceCoordinator.cachedSessions(workspaceId),
1129
+ discoveryPending: (workspaceId) => this.workspaceSessionService.discoveryPending(workspaceId),
1134
1130
  discoverWorkspace: (workspaceId) => this.nativeSessionProjectionService.discoverWorkspace(workspaceId),
1135
1131
  present: (session) => this.sessionPresentationService.present(session),
1136
1132
  projectionForNative: (session) => this.nativeSessionProjectionService.projectionForNative(session),
@@ -2185,7 +2181,7 @@ export class NodeConnector {
2185
2181
  return;
2186
2182
  this.workbenchEventService.publish(event);
2187
2183
  };
2188
- discoverWorkspaceSessions = (workspaceId) => this.nativeSessionProjectionService.discoverWorkspace(workspaceId);
2184
+ discoverWorkspaceSessions = (workspaceId, onRunnerDiscovered) => this.nativeSessionProjectionService.discoverWorkspace(workspaceId, onRunnerDiscovered);
2189
2185
  invalidateWorkspaceWatchTopics = (workspaceId) => this.workspaceCoordinator.invalidate(workspaceId);
2190
2186
  reconcilePersistedRuns = () => undefined;
2191
2187
  startCapabilityRefresh = () => this.connectionLifecycle.startCapabilityRefresh();
@@ -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,
@@ -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()];
@@ -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;
@@ -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,
@@ -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;
@@ -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;
@@ -561,7 +561,14 @@ export class NodeRuntimeState {
561
561
  return false;
562
562
  if (id !== run.id && this.currentExecutionId(run.id) !== id)
563
563
  return false;
564
- this.runPerformance.set(run.id, { ...performance });
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
+ });
565
572
  return true;
566
573
  }
567
574
  sessionPerformance(sessionId) {
@@ -283,14 +283,11 @@ export class ContinuousRunCoordinator {
283
283
  }
284
284
  this.dispatchedExecutions.add(executionId);
285
285
  try {
286
- const goalMcp = pending.goal === undefined
287
- ? undefined
288
- : await this.options.goalMcp?.(pending);
289
- const scheduledMcp = await this.options.scheduledMcp?.(pending);
290
- const mailMcp = await this.options.mailMcp?.({
291
- ...pending,
292
- runId: executionId
293
- });
286
+ // Capabilities belong to this Execution, never the Run or a claimed QueueItem.
287
+ const executionInput = { ...pending, runId: executionId };
288
+ const goalMcp = pending.goal === undefined ? undefined : await this.options.goalMcp?.(executionInput);
289
+ const scheduledMcp = await this.options.scheduledMcp?.(executionInput);
290
+ const mailMcp = await this.options.mailMcp?.(executionInput);
294
291
  if (!this.goalCurrent(pending)) {
295
292
  this.dispatchedExecutions.delete(executionId);
296
293
  this.rejectStaleGoal(executionId);
@@ -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
+ }
@@ -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(),
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.9.140",
3
+ "version": "0.9.142",
4
4
  "description": "MyAgentRoam Node runtime CLI.",
5
5
  "license": "Apache-2.0 AND GPL-2.0-or-later",
6
6
  "type": "module",
@@ -35,9 +35,9 @@
35
35
  "ws": "^8.21.3",
36
36
  "zod": "4.4.3",
37
37
  "@myagentroam/marctl": "0.9.120",
38
- "@myagentroam/protocol": "0.9.140",
39
- "@myagentroam/code-navigation": "0.9.129",
40
- "@myagentroam/agent": "0.9.140"
38
+ "@myagentroam/agent": "0.9.142",
39
+ "@myagentroam/protocol": "0.9.142",
40
+ "@myagentroam/code-navigation": "0.9.129"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/ws": "^8.18.1"