@myagentroam/node 0.10.47 → 0.10.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/connector.js CHANGED
@@ -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);
@@ -1248,6 +1284,7 @@ export class NodeConnector {
1248
1284
  respondUserInput: (runId, requestId, answers) => this.openCodeRunController.respondUserInput(runId, requestId, answers)
1249
1285
  });
1250
1286
  this.marAgentClient.bindControl({
1287
+ steer: (input) => this.marAgentRunController.steer(input),
1251
1288
  decideApproval: (input) => this.marAgentRunController.decideApproval(input),
1252
1289
  start: (envelope) => this.marAgentRunController.start(envelope),
1253
1290
  cancel: (runId, intent) => this.marAgentRunController.cancel(runId, intent),
@@ -1619,7 +1656,8 @@ export class NodeConnector {
1619
1656
  emitSession: (session) => this.emitSessionWorkbenchEvent({
1620
1657
  session: this.sessionPresentationService.present(session)
1621
1658
  }),
1622
- changed: () => this.scheduledSessions.changed()
1659
+ changed: () => this.scheduledSessions.changed(),
1660
+ proposed: (sessionId, proposalId) => this.emitScheduledProposalNotification(sessionId, proposalId)
1623
1661
  });
1624
1662
  this.operationRouter.registerAll(this.sessionScheduleService.operations());
1625
1663
  this.operationRouter.registerAll(this.sessionMessageService.operations());
@@ -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' ||
@@ -320,8 +325,10 @@ export class MarAgentManagedRunController {
320
325
  runtime.activeRunId = input.runId;
321
326
  await runtime.mcpLease.pool.refreshForExecution();
322
327
  controller.signal.throwIfAborted();
328
+ const marctlSkill = marctlHostSkill(input.secretEnvironment);
323
329
  const executionMcpServers = input.mcpServers
324
330
  .filter(isExecutionScopedMcp)
331
+ .filter((server) => marctlSkill === undefined || server.name !== NODE_MCP.schedule.name)
325
332
  .map((server) => ({ ...server, lifecycle: 'EXECUTION' }));
326
333
  computerUseBinding = await this.#computerUse?.bind({
327
334
  runId: input.runId,
@@ -371,7 +378,6 @@ export class MarAgentManagedRunController {
371
378
  ? {}
372
379
  : { webFetch: (request, signal) => this.host.webFetch(request, signal) })
373
380
  }));
374
- const marctlSkill = marctlHostSkill(input.secretEnvironment);
375
381
  executionMcpBinding = runtime.host.bindExecutionExtensions({
376
382
  ...extensions,
377
383
  environment: marctlExecutionEnvironment(input.secretEnvironment),
@@ -444,6 +450,29 @@ export class MarAgentManagedRunController {
444
450
  ? session.compact(runInput)
445
451
  : session.run(runInput);
446
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
+ });
447
476
  this.host.emit(input.runId, 'run.running', {}, 'RUNNING');
448
477
  for await (const event of handle) {
449
478
  if (event.type === 'execution.started' && metadata?.automaticTitlePending && !titleTask) {
@@ -558,6 +587,7 @@ export class MarAgentManagedRunController {
558
587
  await this.#finishRuntimeExecution(input.runId, runtime).catch(() => undefined);
559
588
  this.host.active.delete(input.runId);
560
589
  this.#executions.delete(input.runId);
590
+ this.#steerTargets.delete(input.runId);
561
591
  if (cancelledDuringPreparation)
562
592
  this.host.emit(input.runId, 'run.cancelled', { status: 'CANCELLED' }, 'CANCELLED');
563
593
  }
@@ -10,7 +10,7 @@ export function marctlHostSkill(environment = {}) {
10
10
  return undefined;
11
11
  return {
12
12
  name: 'marctl',
13
- description: 'You are in a MAR-managed Workspace with marctl on PATH. Use this skill to inspect, create, message, stop or coordinate independent Sessions, or use authorized Node terminals and file transfer. Load before cross-Session coordination.',
13
+ description: 'You are in a MAR-managed Workspace with marctl on PATH. Use this skill to inspect, create, rename, configure, message, stop or coordinate independent Sessions, manage their scheduled tasks, or use authorized Node terminals and file transfer. Load before cross-Session coordination.',
14
14
  requiredTools: ['exec'],
15
15
  load: async () => marctlInstructions
16
16
  };
@@ -27,6 +27,9 @@ const marctlInstructions = [
27
27
  'Create workers only as tasks need them, named fixer#<task-id> <description> or reviewer#<task-or-change-id> <description>. These are conventions, not product roles or a fixed pool. Put role, scope, acceptance criteria and delivery requirements in the first message. Follow the repository workflow for development and review. Session Prompt is edited in the Web UI only; marctl has no editing command, and sends to existing Sessions use normal prompt injection.',
28
28
  'Reuse recorded Session/Run IDs and receipts. Refresh volatile Run, queue and Goal state with sessions show only when the next action depends on it. Select needed JSON fields instead of dumping full Session lists or tool transcripts. Hidden reasoning, credentials and raw model requests are not collaboration artifacts.',
29
29
  'show --json fields: session.activity, session.model, session.effort, session.customTitle or session.runnerTitle, runs[] (id/status), queue.items[], and goal.status (goal may be null). Run status and Goal status are separate.',
30
+ 'Rename any authorized Workspace Session with sessions rename <session-id> --title <name> --json; parent relationships do not restrict renaming.',
31
+ 'show/config/history/graph/rename and schedule get/create/update may omit the Session ID to use the current Session. Without a current Session, specify an ID. send/goal/stop/wait always require explicit target IDs.',
32
+ 'Use sessions schedule list/get/create/update for the same tasks as the built-in schedule MCP, which is hidden while this skill is available. list is Workspace-scoped. create requires --cron and text (-m/--message-file, up to 20,000 characters). update requires --expected-revision from get and at least cron or text. Other-owner edits return a PENDING proposal, not an applied change; scheduled execution is read-only. Follow existing approval rules and do not retry conflicts or uncertain writes blindly.',
30
33
  'Read results with sessions history <session-id> --limit 10 --json (default: latest 10 SEGMENTs, not messages; --limit 1..100). In segments[].items[], select kind === "assistant_message" and read payload.text; keep segment runId and user_message.payload.clientMessageId for queued-input correlation. Reuse the returned page; follow --cursor <nextCursor> only for missing evidence.',
31
34
  'Use sessions send <session-id> --steer -m "Additional context: focus on the resume path." --json for decision-changing updates rather than routine reports: it may switch the current execution. Node decides delivery from actual state; an idle Session receives ordinary input. Choose exactly one of --steer or --queue, even when idle; missing, conflicting, repeated, or obsolete options are rejected locally before input is read or a request is sent. Use --queue to place input in a subsequent turn; with an active Goal, ordinary queued input waits until the Goal exits. start/send return clientMessageId and the accepted/queued result. A queued result may refer to the currently active Run: associate queueItemId/clientMessageId with history before deciding that input finished. There are no queue-item edit/delete/reorder commands; show includes queue state.',
32
35
  'Wait with sessions wait <session-id> --run <run-id> --timeout 5m --json through the existing Watch. Prefer --timeout 5m when blocked on task completion; use 30s for a short deadline. Continue useful independent work before waiting. If exec returns a running process or cell, reuse its process/cell handle rather than starting another wait or polling show/list. Timeout or cancelled waiting does not stop the task. Use sessions stop <session-id> --run <run-id> --json only to stop that active Run; ownership rules still apply.',
@@ -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,84 @@ 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((result) => {
251
+ if (result.status === 'notAccepted')
252
+ return false;
253
+ onAccepted?.();
254
+ this.options.runtime.consumeSteeredQueueItem(itemId, active.id);
255
+ this.remove(itemId);
256
+ this.immediateQueueItems.delete(itemId);
257
+ this.options.runtime.resumeSessionQueue(item.sessionId);
258
+ this.options.clearImages(itemId);
259
+ // Publication is best effort after ownership transfers; it cannot undo SDK acceptance.
260
+ for (const notify of [
261
+ () => {
262
+ if (result.status === 'accepted')
263
+ this.options.emitRun(executionId, 'conversation.item', result.item);
264
+ },
265
+ () => this.publish(item.sessionId)
266
+ ]) {
267
+ try {
268
+ notify();
269
+ }
270
+ catch {
271
+ nodeLog('queue.native_steer.notification_failed', {
272
+ level: 'warn',
273
+ sessionId: item.sessionId,
274
+ executionId
275
+ });
276
+ }
277
+ }
278
+ return true;
279
+ })
280
+ .finally(() => {
281
+ release();
282
+ this.steerInFlight.delete(itemId);
283
+ this.schedule(item.sessionId);
284
+ });
285
+ this.steerInFlight.set(itemId, { sessionId: item.sessionId, task });
286
+ return task;
287
+ }
288
+ executeQueueItemFallback(itemId) {
190
289
  const item = this.requirePreparedItem(itemId);
191
290
  const active = this.options.runtime.activeSessionRun(item.sessionId);
192
291
  if (active !== undefined && this.canSteer(active))
@@ -320,6 +419,8 @@ export class ContinuousRunCoordinator {
320
419
  async dispatch(sessionId) {
321
420
  if (this.suspended || this.options.stopped())
322
421
  return;
422
+ if ([...this.steerInFlight.values()].some((entry) => entry.sessionId === sessionId))
423
+ return;
323
424
  const active = this.options.runtime.activeSessionRun(sessionId);
324
425
  if (active === undefined)
325
426
  return;
@@ -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 = {
@@ -139,32 +139,18 @@ export class ScheduleMcpBridge {
139
139
  if (task === undefined)
140
140
  throw new Error('SCHEDULE_NOT_FOUND');
141
141
  const args = record(request.arguments);
142
- const objective = args.objective;
143
- const cron = args.cron;
144
- if ((objective !== undefined && typeof objective !== 'string') ||
145
- (cron !== undefined && typeof cron !== 'string') ||
146
- (objective === undefined && cron === undefined))
147
- throw new Error('SCHEDULE_UPDATE_INVALID');
148
- const patch = {
149
- ...(objective === undefined ? {} : { objective: objective }),
150
- ...(cron === undefined ? {} : { cron: cron })
151
- };
152
- if (capability.actorUserId === task.ownerUserId) {
153
- const updated = this.store().update(task.sessionId, task.revision, {
154
- ...patch,
155
- ownerUserId: capability.actorUserId
156
- });
142
+ const result = this.store().updateFromAgent(task.sessionId, task.revision, capability.actorUserId, args);
143
+ if ('proposalId' in result) {
144
+ this.proposed(task.sessionId, result.proposalId);
145
+ socket.end(`${JSON.stringify({ result })}\n`);
146
+ }
147
+ else {
157
148
  this.#capabilities.set(request.token, {
158
149
  ...capability,
159
- revision: updated.revision
150
+ revision: result.revision
160
151
  });
161
152
  this.changed(task.sessionId);
162
- socket.end(`${JSON.stringify({ result: updated })}\n`);
163
- }
164
- else {
165
- const proposalId = this.store().propose(task, capability.actorUserId, patch);
166
- this.proposed(task.sessionId, proposalId);
167
- socket.end(`${JSON.stringify({ result: { proposalId, status: 'PENDING' } })}\n`);
153
+ socket.end(`${JSON.stringify({ result })}\n`);
168
154
  }
169
155
  }
170
156
  catch (error) {
@@ -25,6 +25,62 @@ export class ScheduledSessionStore {
25
25
  .prepare('SELECT * FROM scheduled_session_tasks WHERE enabled=1 AND next_run_at<=? ORDER BY next_run_at,id')
26
26
  .all(at).map(taskFromRow);
27
27
  }
28
+ list(workspaceId, cursor, limit = 20) {
29
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)
30
+ throw new Error('SCHEDULE_INVALID');
31
+ let after = '';
32
+ if (cursor !== undefined) {
33
+ try {
34
+ if (cursor.length > 4096)
35
+ throw new Error();
36
+ const decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
37
+ if (decoded.workspaceId !== workspaceId ||
38
+ typeof decoded.after !== 'string' ||
39
+ decoded.after.length < 1 ||
40
+ decoded.after.length > 512)
41
+ throw new Error();
42
+ after = decoded.after;
43
+ }
44
+ catch {
45
+ throw new Error('SCHEDULE_INVALID');
46
+ }
47
+ }
48
+ const rows = this.database
49
+ .prepare(`SELECT t.*, COALESCE(json_extract(s.metadata,'$.customTitle'),
50
+ json_extract(s.metadata,'$.runnerTitle'),s.id) AS session_title
51
+ FROM scheduled_session_tasks t JOIN agent_sessions s ON s.id=t.session_id
52
+ WHERE t.workspace_id=? AND t.id>? ORDER BY t.id LIMIT ?`)
53
+ .all(workspaceId, after, limit + 1);
54
+ const tasks = rows
55
+ .slice(0, limit)
56
+ .map((row) => ({ ...taskFromRow(row), sessionTitle: row['session_title'] }));
57
+ return {
58
+ tasks,
59
+ nextCursor: rows.length > limit
60
+ ? Buffer.from(JSON.stringify({ workspaceId, after: tasks.at(-1).id })).toString('base64url')
61
+ : null
62
+ };
63
+ }
64
+ updateFromAgent(sessionId, expectedRevision, actorUserId, input) {
65
+ if (!Number.isSafeInteger(actorUserId) || actorUserId < 1)
66
+ throw new Error('USER_AUTHORIZATION_REQUIRED');
67
+ const task = this.get(sessionId);
68
+ if (task === undefined)
69
+ throw new Error('SCHEDULE_NOT_FOUND');
70
+ if (task.revision !== expectedRevision)
71
+ throw new Error('SCHEDULE_REVISION_CONFLICT');
72
+ if ((input.objective !== undefined && typeof input.objective !== 'string') ||
73
+ (input.cron !== undefined && typeof input.cron !== 'string') ||
74
+ (input.objective === undefined && input.cron === undefined))
75
+ throw new Error('SCHEDULE_UPDATE_INVALID');
76
+ const patch = {
77
+ ...(input.objective === undefined ? {} : { objective: input.objective }),
78
+ ...(input.cron === undefined ? {} : { cron: input.cron })
79
+ };
80
+ if (actorUserId === task.ownerUserId)
81
+ return this.update(sessionId, expectedRevision, patch);
82
+ return { proposalId: this.propose(task, actorUserId, patch), status: 'PENDING' };
83
+ }
28
84
  nextDueAt() {
29
85
  const row = this.database
30
86
  .prepare('SELECT MIN(next_run_at) AS value FROM scheduled_session_tasks WHERE enabled=1')
@@ -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);
@@ -21,11 +21,64 @@ export class SessionScheduleService {
21
21
  operations() {
22
22
  return {
23
23
  'session.schedule.get': (data) => this.get(record(data)),
24
+ 'session.schedule.list': (data) => this.list(record(data)),
25
+ 'session.schedule.agent.save': (data) => this.saveFromAgent(record(data)),
24
26
  'session.schedule.save': (data) => this.save(record(data)),
25
27
  'session.schedule.remove': (data) => this.remove(record(data)),
26
28
  'session.schedule.proposal.resolve': (data) => this.resolveProposal(record(data))
27
29
  };
28
30
  }
31
+ list(input) {
32
+ if (typeof input.workspaceId !== 'string' ||
33
+ this.options.database().getWorkspace(input.workspaceId) === undefined)
34
+ throw new Error('WORKSPACE_NOT_FOUND');
35
+ if ((input.cursor !== undefined && typeof input.cursor !== 'string') ||
36
+ (input.limit !== undefined && typeof input.limit !== 'number'))
37
+ throw new Error('SCHEDULE_INVALID');
38
+ const page = this.store().list(input.workspaceId, input.cursor, input.limit);
39
+ return {
40
+ ...page,
41
+ tasks: page.tasks.map((task) => {
42
+ const session = this.options.runtime.getAgentSession(task.sessionId);
43
+ return {
44
+ ...task,
45
+ sessionTitle: session?.customTitle ?? session?.runnerTitle ?? task.sessionTitle
46
+ };
47
+ })
48
+ };
49
+ }
50
+ async saveFromAgent(input) {
51
+ const session = await this.requireSession(input, input.mode === 'create');
52
+ const actor = input.__requestUserId;
53
+ if (!Number.isSafeInteger(actor) || actor < 1)
54
+ throw new Error('USER_AUTHORIZATION_REQUIRED');
55
+ const sessionId = session.externalSessionId ?? session.id;
56
+ if (input.mode === 'create') {
57
+ if (this.store().get(sessionId) !== undefined)
58
+ throw new Error('SCHEDULE_ALREADY_EXISTS');
59
+ if (typeof input.objective !== 'string' || typeof input.cron !== 'string')
60
+ throw new Error('SCHEDULE_INVALID');
61
+ return {
62
+ task: await this.createFromMcp({
63
+ sessionId: session.id,
64
+ workspaceId: session.workspaceId,
65
+ actorUserId: actor,
66
+ objective: input.objective,
67
+ cron: input.cron
68
+ })
69
+ };
70
+ }
71
+ if (input.mode !== 'update')
72
+ throw new Error('SCHEDULE_INVALID');
73
+ const result = this.store().updateFromAgent(sessionId, input.expectedRevision, actor, input);
74
+ if ('proposalId' in result) {
75
+ this.options.proposed?.(sessionId, result.proposalId);
76
+ return result;
77
+ }
78
+ this.options.emitSession(this.options.runtime.getAgentSession(session.id) ?? session);
79
+ this.options.changed?.();
80
+ return { task: result };
81
+ }
29
82
  async get(input) {
30
83
  const session = await this.requireSession(input);
31
84
  const store = this.store();
@@ -128,14 +181,14 @@ export class SessionScheduleService {
128
181
  this.options.changed?.();
129
182
  return { task, proposals: this.store().pendingProposals(task.id) };
130
183
  }
131
- async requireSession(input) {
184
+ async requireSession(input, allowPendingNative = false) {
132
185
  if (typeof input.sessionId !== 'string' || typeof input.workspaceId !== 'string')
133
186
  throw new Error('SCHEDULE_INVALID');
134
187
  const session = this.options.runtime.getAgentSession(input.sessionId) ??
135
188
  (await this.options.projection.createMetadataProjection(input.sessionId));
136
189
  if (session === undefined ||
137
190
  session.workspaceId !== input.workspaceId ||
138
- session.externalSessionId === null)
191
+ (session.externalSessionId === null && !allowPendingNative))
139
192
  throw new Error('SESSION_NOT_FOUND');
140
193
  return session;
141
194
  }
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.10.47",
3
+ "version": "0.10.49",
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/agent": "0.10.47",
40
+ "@myagentroam/agent": "0.10.49",
41
41
  "@myagentroam/code-navigation": "0.10.1",
42
- "@myagentroam/protocol": "0.10.47",
43
- "@myagentroam/marctl": "0.10.9"
42
+ "@myagentroam/marctl": "0.10.10",
43
+ "@myagentroam/protocol": "0.10.49"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/ws": "^8.18.1"