@myagentroam/node 0.10.48 → 0.10.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/connector.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { normalizeMarAgentUsage } from './runner/mar-agent/conversation-normalizer.js';
2
+ import { isImageAttachment } from './util/node-operation-parsers.js';
2
3
  import { ModelUsageOutbox } from './service/model-usage-outbox.js';
3
4
  import { NodeModelUsageService } from './service/model-usage-service.js';
4
5
  import { dirname, resolve } from 'node:path';
@@ -801,6 +802,41 @@ export class NodeConnector {
801
802
  });
802
803
  this.continuousRunCoordinator = new ContinuousRunCoordinator({
803
804
  runtime: this.runtime,
805
+ steer: (prepared, item, executionId) => {
806
+ const runner = this.runners.optional(prepared.runner);
807
+ if (!runner?.supportsNativeSteer())
808
+ return undefined;
809
+ const images = item.attachments.filter(isImageAttachment).map((image) => ({
810
+ name: image.name,
811
+ mimeType: image.mime,
812
+ dataBase64: image.dataBase64
813
+ }));
814
+ return runner
815
+ .steerManagedRun({
816
+ expectedExecutionId: executionId,
817
+ prompt: item.content,
818
+ modelText: prepared.input,
819
+ clientMessageId: item.clientMessageId,
820
+ model: prepared.model,
821
+ effort: prepared.effort,
822
+ access: prepared.access,
823
+ collaborationMode: prepared.collaborationMode,
824
+ images
825
+ })
826
+ .then((result) => {
827
+ if (result.status !== 'accepted')
828
+ return result;
829
+ const attachments = this.runAttachmentService.append(executionId, item.attachments.filter(isImageAttachment));
830
+ return {
831
+ ...result,
832
+ item: {
833
+ ...result.item,
834
+ merge: true,
835
+ payload: { ...result.item.payload, ...(attachments.length ? { attachments } : {}) }
836
+ }
837
+ };
838
+ });
839
+ },
804
840
  emitQueue: (workspaceId, sessionId) => {
805
841
  this.runEventBridge.emitQueue(workspaceId, sessionId);
806
842
  this.runAttachmentService.refreshSession(sessionId);
@@ -820,37 +856,8 @@ export class NodeConnector {
820
856
  retainEnvironment: (environment) => this.gitExecution().retain(environment),
821
857
  goalCurrent: (input) => input.goal === undefined || this.goalGenerationCurrent(input.sessionId, input.goal),
822
858
  goalActive: (sessionId) => this.goalRuntime.get(sessionId)?.status === 'active',
823
- afterExecutionStarted: async (input) => {
824
- if (input.goal === undefined)
825
- return;
826
- if (!this.goalGenerationCurrent(input.sessionId, input.goal))
827
- return;
828
- const updated = this.goalRuntime.recordTurn(input.sessionId);
829
- const session = this.runtime.getAgentSession(input.sessionId);
830
- const run = this.runtime.getRun(input.runId);
831
- await this.enqueueGoalContinuation({
832
- sessionId: input.sessionId,
833
- goal: { id: input.goal.id, revision: input.goal.revision },
834
- ...(run?.initiatedByUserId === undefined
835
- ? {}
836
- : { initiatedByUserId: run.initiatedByUserId }),
837
- ...(input.usageGrant === undefined ? {} : { usageGrant: input.usageGrant }),
838
- secretEnvironment: input.secretEnvironment,
839
- ...(input.personalInstructions === undefined
840
- ? {}
841
- : { personalInstructions: input.personalInstructions }),
842
- ...(input.marAgentModel === undefined ? {} : { marAgentModel: input.marAgentModel }),
843
- ...(input.marAgentModels === undefined ? {} : { marAgentModels: input.marAgentModels }),
844
- ...(input.marAgentCompact === undefined
845
- ? {}
846
- : { marAgentCompact: input.marAgentCompact }),
847
- ...(input.marAgentRuntimeScopeId === undefined
848
- ? {}
849
- : { marAgentRuntimeScopeId: input.marAgentRuntimeScopeId })
850
- });
851
- if (updated !== undefined && session !== undefined)
852
- this.publishGoalState(session, updated);
853
- },
859
+ afterExecutionStarted: (input) => this.maintainGoalContinuation(input, true),
860
+ afterExecutionSteered: (input) => this.maintainGoalContinuation(input, false),
854
861
  afterExecutionStartFailed: (input, error) => {
855
862
  nodeLog('goal.execution-start.failed', {
856
863
  sessionId: input.sessionId,
@@ -1248,6 +1255,7 @@ export class NodeConnector {
1248
1255
  respondUserInput: (runId, requestId, answers) => this.openCodeRunController.respondUserInput(runId, requestId, answers)
1249
1256
  });
1250
1257
  this.marAgentClient.bindControl({
1258
+ steer: (input) => this.marAgentRunController.steer(input),
1251
1259
  decideApproval: (input) => this.marAgentRunController.decideApproval(input),
1252
1260
  start: (envelope) => this.marAgentRunController.start(envelope),
1253
1261
  cancel: (runId, intent) => this.marAgentRunController.cancel(runId, intent),
@@ -1926,6 +1934,34 @@ export class NodeConnector {
1926
1934
  current.id === goal.id &&
1927
1935
  current.revision === goal.revision);
1928
1936
  }
1937
+ async maintainGoalContinuation(input, started) {
1938
+ if (this.stopped || input.goal === undefined)
1939
+ return;
1940
+ if (!this.goalGenerationCurrent(input.sessionId, input.goal))
1941
+ return;
1942
+ const updated = started ? this.goalRuntime.recordTurn(input.sessionId) : undefined;
1943
+ const session = this.runtime.getAgentSession(input.sessionId);
1944
+ const run = this.runtime.getRun(input.runId);
1945
+ await this.enqueueGoalContinuation({
1946
+ sessionId: input.sessionId,
1947
+ goal: { id: input.goal.id, revision: input.goal.revision },
1948
+ continuationEpoch: input.goal.epoch,
1949
+ ...(run?.initiatedByUserId === undefined ? {} : { initiatedByUserId: run.initiatedByUserId }),
1950
+ ...(input.usageGrant === undefined ? {} : { usageGrant: input.usageGrant }),
1951
+ secretEnvironment: input.secretEnvironment,
1952
+ ...(input.personalInstructions === undefined
1953
+ ? {}
1954
+ : { personalInstructions: input.personalInstructions }),
1955
+ ...(input.marAgentModel === undefined ? {} : { marAgentModel: input.marAgentModel }),
1956
+ ...(input.marAgentModels === undefined ? {} : { marAgentModels: input.marAgentModels }),
1957
+ ...(input.marAgentCompact === undefined ? {} : { marAgentCompact: input.marAgentCompact }),
1958
+ ...(input.marAgentRuntimeScopeId === undefined
1959
+ ? {}
1960
+ : { marAgentRuntimeScopeId: input.marAgentRuntimeScopeId })
1961
+ });
1962
+ if (updated !== undefined && session !== undefined)
1963
+ this.publishGoalState(session, updated);
1964
+ }
1929
1965
  handleNativeGoalUpdate(runId, nativeGoal) {
1930
1966
  const pending = this.continuousRunCoordinator.pending(runId);
1931
1967
  if (pending?.goal === undefined || !this.goalGenerationCurrent(pending.sessionId, pending.goal))
@@ -158,6 +158,12 @@ export class AbstractRunner {
158
158
  cancelManagedRun(runId, intent) {
159
159
  return this.control?.cancel(runId, intent) === true;
160
160
  }
161
+ supportsNativeSteer() {
162
+ return this.control?.steer !== undefined;
163
+ }
164
+ steerManagedRun(input) {
165
+ return this.control?.steer?.(input) ?? Promise.resolve({ status: 'notAccepted' });
166
+ }
161
167
  decideApproval(input) {
162
168
  return this.control?.decideApproval?.(input) === true;
163
169
  }
@@ -7,6 +7,21 @@ export function normalizeMarAgentUsage(usage) {
7
7
  import { boundedConversationItemId, compactRunnerText, compactRunnerValue } from '../../util/node-operation-parsers.js';
8
8
  import { runnerErrorProjection } from '../../util/runner-error.js';
9
9
  export class MarAgentConversationNormalizer {
10
+ input(entry) {
11
+ return {
12
+ itemId: boundedConversationItemId('mar-agent-input', entry.recordId ?? String(entry.sequence)),
13
+ kind: 'user_message',
14
+ status: 'COMPLETED',
15
+ merge: true,
16
+ payload: {
17
+ text: entry.text,
18
+ clientMessageId: entry.clientMessageId ?? null,
19
+ contexts: [],
20
+ delivery: 'ACCEPTED',
21
+ recordId: entry.recordId
22
+ }
23
+ };
24
+ }
10
25
  #tools = new Map();
11
26
  #commands = new Map();
12
27
  #processOrigins = new Map();
@@ -18,6 +33,8 @@ export class MarAgentConversationNormalizer {
18
33
  return itemId('message', nativeItemId);
19
34
  }
20
35
  normalize(event, options = {}) {
36
+ if (event.type === 'input.received')
37
+ return [this.input(event.entry)];
21
38
  if (event.type === 'execution.failed') {
22
39
  const projected = runnerErrorProjection({ code: event.code, message: event.message }, 'MAR_AGENT_RUN_FAILED');
23
40
  return [
@@ -1,5 +1,6 @@
1
1
  import { MarAgentConversationNormalizer } from './conversation-normalizer.js';
2
2
  import { publicGoalHistoryText } from '../../service/goal-runtime.js';
3
+ import { boundedConversationItemId } from '../../util/node-operation-parsers.js';
3
4
  export function projectMarAgentHistory(session, entries, options = {}) {
4
5
  const turns = new Map();
5
6
  const normalizer = new MarAgentConversationNormalizer();
@@ -48,9 +49,11 @@ export function projectMarAgentHistory(session, entries, options = {}) {
48
49
  const turn = ensure(entry.turnId, at);
49
50
  // SDK mailbox messages are persisted as additional turn.user records in the same Turn.
50
51
  // The first input keeps its existing identity; later inputs use their storage sequence.
51
- const id = turn.userItemId === null
52
- ? `${entry.turnId}:user`
53
- : `${entry.turnId}:user:${entry.sequence}`;
52
+ const id = entry.recordId
53
+ ? boundedConversationItemId('mar-agent-input', entry.recordId)
54
+ : turn.userItemId === null
55
+ ? `${entry.turnId}:user`
56
+ : `${entry.turnId}:user:${entry.sequence}`;
54
57
  const images = entry.images;
55
58
  const registeredImages = images === undefined ? [] : (options.registerInputImages?.(entry) ?? []);
56
59
  replace(turn, historyItem(session, turn, id, 'user_message', 'COMPLETED', {
@@ -134,8 +134,9 @@ export class MarAgentHostedConversationRuntime {
134
134
  let titleTask;
135
135
  const metadata = await agent.getSession(input.sessionId).catch(() => undefined);
136
136
  let execution;
137
+ let session;
137
138
  try {
138
- const session = await agent.openSession(input.sessionId);
139
+ session = await agent.openSession(input.sessionId);
139
140
  execution = session.run({
140
141
  prompt: input.prompt,
141
142
  clientMessageId: input.clientMessageId,
@@ -228,6 +229,19 @@ export class MarAgentHostedConversationRuntime {
228
229
  completedAt: null
229
230
  },
230
231
  completion,
232
+ steer: (request) => {
233
+ if (settled ||
234
+ this.#active.get(input.sessionId)?.handle !== handle ||
235
+ (request.modelId !== undefined && request.modelId !== input.modelId) ||
236
+ (request.runtimeScopeId !== undefined && request.runtimeScopeId !== input.runtimeScopeId))
237
+ return Promise.resolve({ status: 'notAccepted' });
238
+ return session.steer({
239
+ expectedExecutionId: execution.executionId,
240
+ prompt: request.prompt,
241
+ modelText: request.modelText,
242
+ clientMessageId: request.clientMessageId
243
+ });
244
+ },
231
245
  cancel: () => {
232
246
  if (settled || this.#active.get(input.sessionId)?.handle !== handle)
233
247
  return false;
@@ -9,7 +9,7 @@ import { nodeExecutionEnvironment } from '../../node-execution-environment.js';
9
9
  import { nodeLog } from '../../operational.js';
10
10
  import { BUNDLED_RIPGREP_VERSION, bundledRipgrepPath, prependBundledRipgrepToPath } from '../../ripgrep-binary.js';
11
11
  import { LocalAgentHost, createMarAgent, generateSessionTitle } from '@myagentroam/agent';
12
- import { marAgentReasoningEffortSchema, marAgentRuntimeModelConfigurationSchema, marAgentRuntimeCompactConfigurationSchema } from '@myagentroam/protocol';
12
+ import { marAgentReasoningEffortSchema, resolveMarAgentDefaultReasoningEffort, marAgentRuntimeModelConfigurationSchema, marAgentRuntimeCompactConfigurationSchema } from '@myagentroam/protocol';
13
13
  import { boundedConversationItemId } from '../codex/conversation-parser.js';
14
14
  import { parseSecretEnvironment } from '../../util/secret-environment.js';
15
15
  import { canonicalJson } from '../../util/canonical-json.js';
@@ -32,6 +32,7 @@ export class MarAgentManagedRunController {
32
32
  host;
33
33
  #mcpClients = new MarAgentMcpClientCache();
34
34
  #executions = new Map();
35
+ #steerTargets = new Map();
35
36
  #sessionRuntimes = new Map();
36
37
  #runtimeAcquisitions = new Map();
37
38
  #runtimeCreations = new Map();
@@ -165,6 +166,10 @@ export class MarAgentManagedRunController {
165
166
  states: this.host.commandStates(session.id)
166
167
  };
167
168
  }
169
+ steer(input) {
170
+ return (this.#steerTargets.get(input.expectedExecutionId)?.(input) ??
171
+ Promise.resolve({ status: 'notAccepted' }));
172
+ }
168
173
  start(envelope) {
169
174
  const payload = envelope.payload;
170
175
  if (payload.runner !== 'mar-agent' ||
@@ -445,6 +450,29 @@ export class MarAgentManagedRunController {
445
450
  ? session.compact(runInput)
446
451
  : session.run(runInput);
447
452
  cancelExecution = () => handle.cancel();
453
+ const steerNormalizer = runtime.normalizer;
454
+ if (input.mode !== 'compact')
455
+ this.#steerTargets.set(input.runId, async (request) => {
456
+ if (controller.signal.aborted ||
457
+ (request.model && request.model !== input.model.id) ||
458
+ (request.effort &&
459
+ request.effort !==
460
+ (reasoningEffort ??
461
+ resolveMarAgentDefaultReasoningEffort(input.model.defaultReasoningEffort, input.model.reasoningEfforts))) ||
462
+ (request.access !== null && request.access !== (input.access ?? 'auto')) ||
463
+ request.collaborationMode !== (input.mode === 'plan' ? 'plan' : 'default'))
464
+ return { status: 'notAccepted' };
465
+ const result = await session.steer({
466
+ expectedExecutionId: handle.executionId,
467
+ prompt: request.prompt,
468
+ modelText: request.modelText,
469
+ clientMessageId: request.clientMessageId,
470
+ images: request.images
471
+ });
472
+ return result.status === 'accepted'
473
+ ? { status: 'accepted', item: steerNormalizer.input(result.entry) }
474
+ : result;
475
+ });
448
476
  this.host.emit(input.runId, 'run.running', {}, 'RUNNING');
449
477
  for await (const event of handle) {
450
478
  if (event.type === 'execution.started' && metadata?.automaticTitlePending && !titleTask) {
@@ -559,6 +587,7 @@ export class MarAgentManagedRunController {
559
587
  await this.#finishRuntimeExecution(input.runId, runtime).catch(() => undefined);
560
588
  this.host.active.delete(input.runId);
561
589
  this.#executions.delete(input.runId);
590
+ this.#steerTargets.delete(input.runId);
562
591
  if (cancelledDuringPreparation)
563
592
  this.host.emit(input.runId, 'run.cancelled', { status: 'CANCELLED' }, 'CANCELLED');
564
593
  }
@@ -1,5 +1,9 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { permissionStopSchema } from '@myagentroam/protocol';
3
+ /** User inputs arrive with a durable Session identity, unlike execution-local output IDs. */
4
+ export function runConversationItemId(runId, itemId, kind) {
5
+ return kind === 'user_message' ? itemId : `${runId}:${itemId}`;
6
+ }
3
7
  /**
4
8
  * Deliberately process-local runtime domain. No method in this class writes
5
9
  * into Node SQLite: a Node restart loses active runs, approvals, leases and
@@ -32,6 +36,7 @@ export class NodeRuntimeState {
32
36
  sessionHistoryMutations = new Set();
33
37
  /** Session FIFO metadata is deliberately process-local with the rest of run state. */
34
38
  queuedItems = new Map();
39
+ receivingQueueItems = new Set();
35
40
  queueVersions = new Map();
36
41
  pausedQueues = new Set();
37
42
  /** Bounded tombstones reject stale internal start envelopes after terminal cleanup. */
@@ -441,6 +446,32 @@ export class NodeRuntimeState {
441
446
  getQueueItem(id) {
442
447
  return this.queueItems.get(id);
443
448
  }
449
+ reserveQueueItem(itemId) {
450
+ this.requireQueueItem(itemId);
451
+ this.assertQueueItemMutable(itemId);
452
+ this.receivingQueueItems.add(itemId);
453
+ return () => {
454
+ this.receivingQueueItems.delete(itemId);
455
+ };
456
+ }
457
+ assertQueueItemMutable(itemId) {
458
+ if (this.receivingQueueItems.has(itemId))
459
+ throw new Error('QUEUE_ITEM_RECEIVING');
460
+ }
461
+ consumeSteeredQueueItem(itemId, runId) {
462
+ const item = this.requireQueueItem(itemId);
463
+ const run = this.getRun(runId);
464
+ if (!run || run.sessionId !== item.sessionId || !this.receivingQueueItems.has(itemId))
465
+ throw new Error('QUEUE_ITEM_INVALID');
466
+ this.queuedItems.set(item.sessionId, (this.queuedItems.get(item.sessionId) ?? []).filter((id) => id !== itemId));
467
+ this.queueItems.delete(itemId);
468
+ this.bumpQueueVersion(item.sessionId);
469
+ this.messageSubmissions.set(`${item.sessionId}\u0000${item.clientMessageId}`, {
470
+ kind: 'run',
471
+ id: run.id
472
+ });
473
+ this.recentUserMessages.set(run.id, item.content);
474
+ }
444
475
  claimNextQueueItem(sessionId, content) {
445
476
  if (this.pausedQueues.has(sessionId))
446
477
  return undefined;
@@ -451,6 +482,8 @@ export class NodeRuntimeState {
451
482
  const itemId = queue[0];
452
483
  if (itemId === undefined)
453
484
  return undefined;
485
+ if (this.receivingQueueItems.has(itemId))
486
+ return undefined;
454
487
  const item = this.requireQueueItem(itemId);
455
488
  const run = {
456
489
  ...active,
@@ -508,6 +541,8 @@ export class NodeRuntimeState {
508
541
  }
509
542
  reorderSessionQueue(sessionId, expectedVersion, itemIds) {
510
543
  const current = this.queuedItems.get(sessionId) ?? [];
544
+ for (const id of current)
545
+ this.assertQueueItemMutable(id);
511
546
  if (this.sessionQueue(sessionId).version !== expectedVersion ||
512
547
  current.length !== itemIds.length ||
513
548
  new Set(current).size !== current.length ||
@@ -519,6 +554,7 @@ export class NodeRuntimeState {
519
554
  this.bumpQueueVersion(sessionId);
520
555
  }
521
556
  prioritizeQueueItem(itemId) {
557
+ this.assertQueueItemMutable(itemId);
522
558
  const item = this.requireQueueItem(itemId);
523
559
  const queue = this.queuedItems.get(item.sessionId) ?? [];
524
560
  if (!queue.includes(itemId))
@@ -531,6 +567,7 @@ export class NodeRuntimeState {
531
567
  return item;
532
568
  }
533
569
  updateQueueItem(itemId, content) {
570
+ this.assertQueueItemMutable(itemId);
534
571
  const item = this.requireQueueItem(itemId);
535
572
  if (!(this.queuedItems.get(item.sessionId) ?? []).includes(itemId))
536
573
  throw new Error('QUEUE_ITEM_INVALID');
@@ -540,6 +577,7 @@ export class NodeRuntimeState {
540
577
  return next;
541
578
  }
542
579
  updateQueueItemAttachments(itemId, attachments, content) {
580
+ this.assertQueueItemMutable(itemId);
543
581
  const item = this.requireQueueItem(itemId);
544
582
  const next = { ...item, attachments, ...(content === undefined ? {} : { content }) };
545
583
  this.queueItems.set(itemId, next);
@@ -548,6 +586,7 @@ export class NodeRuntimeState {
548
586
  return next;
549
587
  }
550
588
  deleteQueueItem(itemId) {
589
+ this.assertQueueItemMutable(itemId);
551
590
  const item = this.requireQueueItem(itemId);
552
591
  const queue = this.queuedItems.get(item.sessionId) ?? [];
553
592
  if (!queue.includes(itemId))
@@ -1324,13 +1363,16 @@ export class NodeRuntimeState {
1324
1363
  }
1325
1364
  const projected = conversationItemFromEvent(event);
1326
1365
  if (projected !== undefined) {
1366
+ const itemId = runConversationItemId(event.runId, projected.itemId, projected.kind);
1327
1367
  const trailingItem = turn.items.at(-1);
1328
- const orderedItems = trailingItem?.kind === 'assistant_message' && activeConversationItem(trailingItem.status)
1368
+ const orderedItems = projected.kind !== 'user_message' &&
1369
+ trailingItem?.kind === 'assistant_message' &&
1370
+ activeConversationItem(trailingItem.status)
1329
1371
  ? turn.items.map((item) => item.id === trailingItem.id
1330
1372
  ? { ...item, status: 'COMPLETED', completedAt: event.createdAt }
1331
1373
  : item)
1332
1374
  : turn.items;
1333
- const existing = orderedItems.find((item) => item.id === `${event.runId}:${projected.itemId}`) ??
1375
+ const existing = orderedItems.find((item) => item.id === itemId) ??
1334
1376
  (projected.kind === 'assistant_message' &&
1335
1377
  projected.itemId.startsWith('codex:') &&
1336
1378
  trailingItem?.kind === 'assistant_message' &&
@@ -1340,7 +1382,7 @@ export class NodeRuntimeState {
1340
1382
  : undefined);
1341
1383
  const status = projected.status;
1342
1384
  const item = {
1343
- id: `${event.runId}:${projected.itemId}`,
1385
+ id: itemId,
1344
1386
  sessionId: run.sessionId,
1345
1387
  turnId: turn.id,
1346
1388
  runId: event.runId,
@@ -1480,7 +1522,8 @@ function conversationItemFromEvent(event) {
1480
1522
  };
1481
1523
  }
1482
1524
  function projectableConversationKind(value) {
1483
- return (value === 'assistant_message' ||
1525
+ return (value === 'user_message' ||
1526
+ value === 'assistant_message' ||
1484
1527
  value === 'reasoning_summary' ||
1485
1528
  value === 'tool_call' ||
1486
1529
  value === 'command_execution' ||
@@ -2,6 +2,7 @@ import { withNonImageAttachmentPrompt } from '../util/node-operation-parsers.js'
2
2
  import { safeErrorCode } from '../util/safe-error.js';
3
3
  import { withSessionPrompt } from '../util/personal-instructions.js';
4
4
  import { prepareComposerResourceInput } from './composer-message-input.js';
5
+ import { nodeLog } from '../operational.js';
5
6
  export class ContinuousRunCoordinator {
6
7
  options;
7
8
  preparedInputs = new Map();
@@ -10,6 +11,7 @@ export class ContinuousRunCoordinator {
10
11
  startedExecutions = new Set();
11
12
  scheduledSessions = new Map();
12
13
  immediateQueueItems = new Set();
14
+ steerInFlight = new Map();
13
15
  suspended = false;
14
16
  constructor(options) {
15
17
  this.options = options;
@@ -108,6 +110,25 @@ export class ContinuousRunCoordinator {
108
110
  this.options.emitRun(run.id, 'run.rejected', { code }, 'FAILED');
109
111
  }
110
112
  settle(input) {
113
+ const item = this.options.runtime.getQueueItem(input.submissionId);
114
+ const active = item && this.options.runtime.activeSessionRun(item.sessionId);
115
+ const turn = active && this.options.runtime.conversationTurnForRun(active.id);
116
+ if (input.steerTarget &&
117
+ active &&
118
+ active.id === input.activeRunId &&
119
+ turn?.id === input.steerTarget.turnId &&
120
+ this.options.runtime.currentExecutionId(active.id) === input.steerTarget.executionId) {
121
+ const attempt = this.tryNativeSteer(input.submissionId, active, input.onNativeAccepted);
122
+ if (attempt)
123
+ return attempt.then((accepted) => accepted
124
+ ? 'RESTARTING'
125
+ : this.suspended || this.options.stopped()
126
+ ? 'QUEUED'
127
+ : this.settleFallback(input));
128
+ }
129
+ return this.settleFallback(input);
130
+ }
131
+ settleFallback(input) {
111
132
  const prepared = this.preparedInputs.get(input.submissionId);
112
133
  const item = this.options.runtime.getQueueItem(input.submissionId);
113
134
  const sessionId = prepared?.sessionId ?? item?.sessionId;
@@ -187,6 +208,100 @@ export class ContinuousRunCoordinator {
187
208
  return 'QUEUED';
188
209
  }
189
210
  executeQueueItem(itemId) {
211
+ const item = this.requirePreparedItem(itemId);
212
+ const active = this.options.runtime.activeSessionRun(item.sessionId);
213
+ if (active) {
214
+ const attempt = this.tryNativeSteer(itemId, active);
215
+ if (attempt)
216
+ return attempt.then((accepted) => accepted
217
+ ? { run: active, delivery: 'RESTARTING' }
218
+ : this.suspended || this.options.stopped()
219
+ ? { run: active, delivery: 'QUEUED' }
220
+ : this.executeQueueItemFallback(itemId));
221
+ }
222
+ return this.executeQueueItemFallback(itemId);
223
+ }
224
+ tryNativeSteer(itemId, active, onAccepted) {
225
+ const previous = this.steerInFlight.get(itemId);
226
+ if (previous)
227
+ return previous.task;
228
+ if (this.suspended || this.options.stopped() || !this.options.steer || !this.canSteer(active))
229
+ return undefined;
230
+ const executionId = this.options.runtime.currentExecutionId(active.id);
231
+ if (!executionId)
232
+ return undefined;
233
+ this.prepareSteering(itemId);
234
+ const prepared = this.preparedInputs.get(itemId);
235
+ const item = this.options.runtime.getQueueItem(itemId);
236
+ const release = this.options.runtime.reserveQueueItem(itemId);
237
+ let attempt;
238
+ try {
239
+ attempt = this.options.steer(prepared, item, executionId);
240
+ }
241
+ catch (error) {
242
+ release();
243
+ throw error;
244
+ }
245
+ if (!attempt) {
246
+ release();
247
+ return undefined;
248
+ }
249
+ const task = attempt
250
+ .then(async (result) => {
251
+ if (result.status === 'notAccepted')
252
+ return false;
253
+ onAccepted?.();
254
+ this.options.runtime.consumeSteeredQueueItem(itemId, active.id);
255
+ this.immediateQueueItems.delete(itemId);
256
+ this.options.runtime.resumeSessionQueue(item.sessionId);
257
+ this.options.clearImages(itemId);
258
+ if (prepared.goal !== undefined && this.goalCurrent(prepared)) {
259
+ const current = this.preparedInputs.get(executionId);
260
+ if (current !== undefined)
261
+ this.preparedInputs.set(executionId, { ...current, goal: prepared.goal });
262
+ }
263
+ try {
264
+ await this.options.afterExecutionSteered?.({ ...prepared, runId: executionId });
265
+ }
266
+ catch (error) {
267
+ nodeLog('queue.native_steer.continuation_failed', {
268
+ level: 'warn',
269
+ sessionId: item.sessionId,
270
+ executionId,
271
+ code: safeErrorCode(error, 'NATIVE_STEER_CONTINUATION_FAILED')
272
+ });
273
+ }
274
+ this.remove(itemId);
275
+ // Publication is best effort after ownership transfers; it cannot undo SDK acceptance.
276
+ for (const notify of [
277
+ () => {
278
+ if (result.status === 'accepted')
279
+ this.options.emitRun(executionId, 'conversation.item', result.item);
280
+ },
281
+ () => this.publish(item.sessionId)
282
+ ]) {
283
+ try {
284
+ notify();
285
+ }
286
+ catch {
287
+ nodeLog('queue.native_steer.notification_failed', {
288
+ level: 'warn',
289
+ sessionId: item.sessionId,
290
+ executionId
291
+ });
292
+ }
293
+ }
294
+ return true;
295
+ })
296
+ .finally(() => {
297
+ release();
298
+ this.steerInFlight.delete(itemId);
299
+ this.schedule(item.sessionId);
300
+ });
301
+ this.steerInFlight.set(itemId, { sessionId: item.sessionId, task });
302
+ return task;
303
+ }
304
+ executeQueueItemFallback(itemId) {
190
305
  const item = this.requirePreparedItem(itemId);
191
306
  const active = this.options.runtime.activeSessionRun(item.sessionId);
192
307
  if (active !== undefined && this.canSteer(active))
@@ -320,6 +435,8 @@ export class ContinuousRunCoordinator {
320
435
  async dispatch(sessionId) {
321
436
  if (this.suspended || this.options.stopped())
322
437
  return;
438
+ if ([...this.steerInFlight.values()].some((entry) => entry.sessionId === sessionId))
439
+ return;
323
440
  const active = this.options.runtime.activeSessionRun(sessionId);
324
441
  if (active === undefined)
325
442
  return;
@@ -490,6 +607,9 @@ export class ContinuousRunCoordinator {
490
607
  for (const [id, pending] of this.preparedInputs) {
491
608
  if (id === preservedSubmissionId)
492
609
  continue;
610
+ // Native admission owns receiving items until its receipt settles.
611
+ if (this.steerInFlight.has(id))
612
+ continue;
493
613
  if (pending.sessionId !== sessionId || pending.goal === undefined)
494
614
  continue;
495
615
  if (pending.goal.id === preservedGoal?.id && pending.goal.revision === preservedGoal.revision)
@@ -37,6 +37,7 @@ export class HostedConversationNodeService {
37
37
  pendingMessages = new Map();
38
38
  activeRuns = new Map();
39
39
  runSettlements = new Map();
40
+ nativeAdmissions = new Map();
40
41
  activities = new Map();
41
42
  expectedInterruptions = new Set();
42
43
  uploads = new Map();
@@ -448,6 +449,45 @@ export class HostedConversationNodeService {
448
449
  return this.startMessage(payload, input);
449
450
  }
450
451
  const steerPrompt = steerComposerMessage(input.prompt);
452
+ if (handle.steer) {
453
+ const barrier = Promise.withResolvers();
454
+ this.nativeAdmissions.set(handle, barrier.promise);
455
+ let result;
456
+ try {
457
+ const attachments = await Promise.all(input.attachmentIds.map((fileId) => this.requireAttachment(payload.userId, conversation.id, fileId)));
458
+ result = await handle.steer({
459
+ prompt: input.prompt,
460
+ modelText: attachmentPrompt(steerPrompt, attachments),
461
+ clientMessageId: input.clientMessageId,
462
+ ...(input.modelId === undefined ? {} : { modelId: input.modelId }),
463
+ ...(payload.runtimeScopeId === undefined
464
+ ? {}
465
+ : { runtimeScopeId: payload.runtimeScopeId })
466
+ });
467
+ if (result.status !== 'notAccepted') {
468
+ const now = this.now();
469
+ this.options.store.createMessage({
470
+ conversationId: conversation.id,
471
+ clientMessageId: input.clientMessageId,
472
+ runId: handle.run.id,
473
+ userTextPresent: result.status === 'accepted' && sessionTitleFromFirstMessage(input.prompt).length > 0,
474
+ status: 'RUNNING',
475
+ createdAt: handle.run.startedAt,
476
+ updatedAt: now
477
+ });
478
+ const accepted = await this.options.store.saveMessageAcceptance({
479
+ conversationId: conversation.id,
480
+ modelId: conversation.modelId,
481
+ activityAt: now
482
+ });
483
+ return { conversation: this.summary(accepted.conversation), run: handle.run };
484
+ }
485
+ }
486
+ finally {
487
+ barrier.resolve();
488
+ this.nativeAdmissions.delete(handle);
489
+ }
490
+ }
451
491
  if (!this.interruptActiveRun(payload.userId, conversation, handle))
452
492
  throw new Error('HOSTED_CONVERSATION_RUN_ACTIVE');
453
493
  await this.runSettlements.get(conversation.id)?.catch(() => undefined);
@@ -637,6 +677,7 @@ export class HostedConversationNodeService {
637
677
  status: 'FAILED',
638
678
  completedAt: this.now()
639
679
  }));
680
+ await this.nativeAdmissions.get(handle);
640
681
  await this.finishRun(payload.userId, conversation.id, input.clientMessageId, handle.run, completion);
641
682
  }
642
683
  catch {
@@ -736,7 +777,7 @@ export class HostedConversationNodeService {
736
777
  const expectedInterruption = this.expectedInterruptions.has(interruptionKey);
737
778
  let conversation;
738
779
  try {
739
- this.options.store.finishMessage(conversationId, clientMessageId, completion.status, completion.completedAt);
780
+ this.options.store.finishRunMessages(conversationId, run.id, completion.status, completion.completedAt);
740
781
  this.publishHostedEvent(conversationId, {
741
782
  conversationId,
742
783
  kind: 'run',
@@ -649,6 +649,11 @@ export class HostedConversationStore {
649
649
  WHERE conversation_id=? AND client_message_id=?`)
650
650
  .run(status, updatedAt, conversationId, clientMessageId);
651
651
  }
652
+ finishRunMessages(conversationId, runId, status, updatedAt) {
653
+ this.options.database.raw
654
+ .prepare(`UPDATE hosted_conversation_messages SET status=?,updated_at=? WHERE conversation_id=? AND run_id=?`)
655
+ .run(status, updatedAt, conversationId, runId);
656
+ }
652
657
  async rebuildConversationIndex() {
653
658
  const existing = this.existingFilesByConversation();
654
659
  const rebuilt = await this.scanConversationRoot(this.usersDirectory, existing);
@@ -1,4 +1,5 @@
1
1
  import { conversationSegmentIdentityForItem, conversationSegments } from './conversation-segment-service.js';
2
+ import { runConversationItemId } from '../runtime-state.js';
2
3
  import { isNodeAgentRun, isNodeConversationTurn, isPlainRecord } from '../runner/codex/conversation-parser.js';
3
4
  export function workbenchPayloadSessionId(runtime, category, payload) {
4
5
  if (!isPlainRecord(payload))
@@ -59,7 +60,8 @@ export class NodeRunEventBridge {
59
60
  ? turn.items.find((candidate) => candidate.kind === 'assistant_message' && candidate.revision === sequence)
60
61
  : nativeItemId === undefined
61
62
  ? undefined
62
- : turn.items.find((candidate) => candidate.id === `${runId}:${nativeItemId}`);
63
+ : turn.items.find((candidate) => candidate.id ===
64
+ runConversationItemId(runId, nativeItemId, typeof rawPayload?.kind === 'string' ? rawPayload.kind : undefined));
63
65
  if (text !== undefined && item?.kind === 'assistant_message') {
64
66
  const segment = conversationSegmentIdentityForItem(turn, item.id, item.kind);
65
67
  payload = {
@@ -206,6 +206,7 @@ export class SessionMessageService {
206
206
  this.options.coordinator.publishCurrentTurn(created.run.id);
207
207
  const prepare = async () => {
208
208
  let reservation;
209
+ let nativeAccepted = false;
209
210
  try {
210
211
  const currentSession = this.options.runtime.getAgentSession(session.id) ?? session;
211
212
  if (attachmentIds.length > 0)
@@ -301,7 +302,10 @@ export class SessionMessageService {
301
302
  this.options.attachments.retainSessionFiles(currentSession.id, submissionId);
302
303
  const delivery = internalRestore
303
304
  ? 'QUEUED'
304
- : this.options.coordinator.settle({
305
+ : await this.options.coordinator.settle({
306
+ onNativeAccepted: () => {
307
+ nativeAccepted = true;
308
+ },
305
309
  submissionId,
306
310
  intent,
307
311
  queueBefore: queueBeforeCreate,
@@ -313,6 +317,8 @@ export class SessionMessageService {
313
317
  return delivery;
314
318
  }
315
319
  catch (error) {
320
+ if (nativeAccepted)
321
+ throw error;
316
322
  reservation?.release();
317
323
  if (goalMessage !== undefined)
318
324
  this.options.goalPreparationFailed(session, goalMessage.goal, goalMessage.activated);
@@ -144,12 +144,12 @@ export class WorkspaceQueueWorkbenchService {
144
144
  auditReceipt: this.auditReceipt(item.sessionId, item.id, 'DELETE_QUEUE')
145
145
  };
146
146
  }
147
- execute(input) {
147
+ async execute(input) {
148
148
  if (typeof input.queueItemId !== 'string')
149
149
  throw new Error('QUEUE_INVALID');
150
150
  const item = this.requireItem(input.queueItemId, input.workspaceId);
151
151
  assertQueueItemOwner(item, input.__requestUserId);
152
- const result = this.options.coordinator.executeQueueItem(input.queueItemId);
152
+ const result = await this.options.coordinator.executeQueueItem(input.queueItemId);
153
153
  if (isPlainRecord(result))
154
154
  return {
155
155
  ...result,
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { link, lstat, mkdir, open, readFile, rename, rm, stat } from 'node:fs/promises';
2
+ import { link, lstat, mkdir, open, readFile, rename, rm } from 'node:fs/promises';
3
3
  import { dirname, join, posix, resolve } from 'node:path';
4
4
  import { parseComposerAttachmentUpload, validComposerAttachmentName } from '../util/node-operation-parsers.js';
5
5
  import { preflightWorkspaceUpload, workspaceUploadTemporaryName } from '../workspace.js';
@@ -61,9 +61,7 @@ export class WorkspaceUploadService {
61
61
  throw new Error('UPLOAD_TARGET_BUSY');
62
62
  const id = randomUUID();
63
63
  const temporaryPath = resolve(dirname(target.absolutePath), workspaceUploadTemporaryName(id));
64
- const parentIdentity = await stat(dirname(target.absolutePath));
65
64
  const handle = await open(temporaryPath, 'wx+');
66
- const stagingIdentity = await handle.stat();
67
65
  const timer = setTimeout(() => {
68
66
  const upload = this.state.uploads.get(id);
69
67
  if (upload !== undefined)
@@ -73,8 +71,6 @@ export class WorkspaceUploadService {
73
71
  id,
74
72
  workspaceId: workspace.id,
75
73
  workspacePath: workspace.path,
76
- parentIdentity,
77
- stagingIdentity,
78
74
  path: target.path,
79
75
  targetPath: target.absolutePath,
80
76
  temporaryPath,
@@ -361,15 +357,10 @@ export class WorkspaceUploadService {
361
357
  throw new Error('UPLOAD_TARGET_EXISTS');
362
358
  }
363
359
  const parent = await lstat(dirname(upload.targetPath));
364
- if (!parent.isDirectory() ||
365
- parent.isSymbolicLink() ||
366
- !sameIdentity(parent, upload.parentIdentity))
360
+ if (!parent.isDirectory() || parent.isSymbolicLink())
367
361
  throw new Error('UPLOAD_TARGET_INVALID');
368
362
  const staging = await lstat(upload.temporaryPath);
369
- if (!staging.isFile() ||
370
- staging.isSymbolicLink() ||
371
- !sameIdentity(staging, upload.stagingIdentity) ||
372
- staging.size !== upload.size)
363
+ if (!staging.isFile() || staging.isSymbolicLink() || staging.size !== upload.size)
373
364
  throw new Error('UPLOAD_STAGING_INVALID');
374
365
  }
375
366
  async remove(upload) {
@@ -382,9 +373,9 @@ export class WorkspaceUploadService {
382
373
  return undefined;
383
374
  throw error;
384
375
  });
385
- if (!sameIdentity(parent, upload.parentIdentity) ||
376
+ if (!parent.isDirectory() ||
386
377
  parent.isSymbolicLink() ||
387
- (staging !== undefined && !sameIdentity(staging, upload.stagingIdentity)))
378
+ (staging !== undefined && (!staging.isFile() || staging.isSymbolicLink())))
388
379
  throw new Error('UPLOAD_CLEANUP_FAILED');
389
380
  if (staging !== undefined)
390
381
  await rm(upload.temporaryPath, { force: true });
@@ -427,9 +418,6 @@ export class WorkspaceUploadService {
427
418
  };
428
419
  }
429
420
  }
430
- function sameIdentity(actual, expected) {
431
- return actual.dev === expected.dev && actual.ino === expected.ino;
432
- }
433
421
  async function safeDirectory(path) {
434
422
  const value = await lstat(path);
435
423
  if (!value.isDirectory() || value.isSymbolicLink())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.10.48",
3
+ "version": "0.10.50",
4
4
  "description": "MyAgentRoam Node runtime CLI.",
5
5
  "license": "Apache-2.0 AND GPL-2.0-or-later",
6
6
  "type": "module",
@@ -37,10 +37,10 @@
37
37
  "playwright": "1.62.1",
38
38
  "ws": "^8.21.3",
39
39
  "zod": "4.4.3",
40
- "@myagentroam/code-navigation": "0.10.1",
41
- "@myagentroam/agent": "0.10.48",
40
+ "@myagentroam/agent": "0.10.50",
41
+ "@myagentroam/protocol": "0.10.50",
42
42
  "@myagentroam/marctl": "0.10.10",
43
- "@myagentroam/protocol": "0.10.48"
43
+ "@myagentroam/code-navigation": "0.10.1"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/ws": "^8.18.1"