@myagentroam/node 0.11.23 → 0.11.25

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
@@ -372,6 +372,7 @@ export class NodeConnector {
372
372
  onTerminalRun: (run, status) => {
373
373
  if (this.database !== undefined)
374
374
  this.scheduledSessions.executionTerminated(run, status);
375
+ this.continuousRunCoordinator.runTerminated(run.sessionId);
375
376
  },
376
377
  dispatchQueue: async (sessionId, delayMs = 0) => {
377
378
  if (this.upgradeCoordinator.pending())
@@ -543,7 +544,7 @@ export class NodeConnector {
543
544
  emit: (runId, eventType, payload, status) => this.runEventBridge.emitRun(runId, eventType, payload, status),
544
545
  nativeGoalUpdated: (runId, goal) => this.goalCoordinator.handleNativeGoalUpdate(runId, goal),
545
546
  cancel: (runId, threadId, turnId, cwd, status) => this.runnerInteractionService.cancelCodexTurn(runId, threadId, turnId, cwd, status),
546
- createApproval: (input) => this.runtime.createApproval(input),
547
+ createApproval: (input) => this.runnerInteractionService.createCodexApproval(input),
547
548
  publishApproval: (input) => this.runnerInteractionService.publishApproval(input),
548
549
  emitItem: (runId, item) => this.emitConversationItem(runId, item),
549
550
  appendImages: (runId, images) => this.runAttachmentService.append(runId, images.filter(isRunnerImageAttachment)),
@@ -5,7 +5,7 @@ export function hasMarctlContext(environment) {
5
5
  !!environment['MAR_CTL_NODE_ID'] &&
6
6
  !!environment['MAR_CTL_WORKSPACE_ID']);
7
7
  }
8
- export const marctlDescription = 'You are in a MAR-managed Workspace with marctl on PATH. Load this guide to create, coordinate, message or stop independent Sessions; inspect Session status/history/results; change titles or models; manage Goals or scheduled tasks for the current or other Sessions; or run commands/transfer files on other authorized MAR Nodes, even when marctl is not named. Ordinary local shell work and built-in subagents alone do not require it.';
8
+ export const marctlDescription = 'You are in a MAR-managed Workspace with marctl on PATH. Load this guide to create, coordinate, message or stop independent Sessions; inspect status/history/results; change titles, prompts or models; observe and decide approvals; manage Goals or scheduled tasks; or run commands/transfer files on other authorized MAR Nodes, even when marctl is not named. Ordinary local shell work and built-in subagents alone do not require it.';
9
9
  export const marctlInstructions = [
10
10
  '## MAR Workspace CLI',
11
11
  'Use your native shell tool to run marctl. Reuse loaded guidance while it remains in context. Consult local marctl help sessions for unclear options (shared by all Session subcommands) and marctl --version when version matters; neither needs a network request. Capabilities and permissions still apply.',
@@ -15,15 +15,16 @@ export const marctlInstructions = [
15
15
  'Change an existing Session with sessions config <session-id> --model <id> --effort high --json; either option can be set alone. Use show for current settings; active execution is not restarted.',
16
16
  'Prefer -m / --message for short text; use --message-file for longer UTF-8 text or an existing file (- reads stdin). These options are mutually exclusive. Example: marctl sessions start --runner codex --title "fixer#42 Handle empty totals" -m "Implement issue #42 and report test evidence" --json. Other Runner names are mar-agent, claude-code and opencode. An omitted model uses the target Runner configuration, not the Coordinator model.',
17
17
  'For start/send, repeat --image <local-path> to attach PNG, JPEG, GIF or WebP images, with or without text. Images are read on this CLI host, not by remote path. Limits: 5 images, 5 MiB each, 10 MiB total. Actual Runner/model image capabilities apply; goal set accepts text only.',
18
- '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.',
18
+ '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. Use sessions prompt get/set/clear [session-id] to read, set or clear persistent instructions; set uses -m or --message-file. Changes apply to future executions, including queued work, without interrupting current execution.',
19
19
  'Reuse recorded Session/Run IDs and receipts. Refresh execution state with show only when the next action depends on it; use goal get or schedule get for those details. Select needed JSON fields instead of dumping full Session lists or tool transcripts. Hidden reasoning, credentials and raw model requests are not collaboration artifacts.',
20
- 'list returns id/title/runner/status/runId and one latestActivity per Session. show --json fields: session.id, session.title, session.runner, session.status, session.runId, session.model, session.effort, session.queuedCount, session.latestMessage, session.recentTools (at most three). Status is RUNNING/IDLE/SLEEPING; tools keep their original states. Activity is bounded public evidence, not proof of task completion; use history for full results.',
20
+ 'list returns id/title/runner/status/runId and one latestActivity per Session. show --json fields: session.id, session.title, session.runner, session.status, session.runId, session.model, session.effort, session.queuedCount, session.latestMessage, session.recentTools (at most three), session.pendingApprovals and pendingApprovalsTruncated. Approvals use public Run IDs, without raw tool input. Status is RUNNING/IDLE/SLEEPING; tools keep their original states. Activity is bounded public evidence, not proof of task completion; use history for full results.',
21
21
  'Rename any authorized Workspace Session with sessions rename <session-id> --title <name> --json; parent relationships do not restrict renaming.',
22
- '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.',
22
+ 'show/config/history/graph/rename, prompt 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/approval always require explicit target IDs.',
23
23
  'Use sessions schedule list/get/create/update for the same tasks as the built-in schedule MCP, which remains available in other Runners and is hidden in MAR Agent while its marctl 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. get returns task (null if absent), with id, revision, objective, cron, timezone and enabled; applied updates also return task. Reuse that task instead of probing its shape or refetching unchanged state. Schedule text is trimmed at both ends; compare after the same trimming; a conclusive update response needs no extra get. Other-owner edits return a PENDING proposal, not an applied change. Scheduled execution uses normal user permissions. Follow existing approval rules and do not retry conflicts or uncertain writes blindly.',
24
24
  '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.',
25
- '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 returns only queuedCount.',
25
+ '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 without interrupting active execution. New input resumes a paused queue using ordinary send admission after cancellation settles; later explicit stops still apply; 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 returns only queuedCount.',
26
26
  '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 your shell tool returns a running process or cell, reuse its process/cell handle rather than starting another wait or polling show/list. A timeout may include progress, the last observed snapshot, not a fresh query. 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.',
27
+ 'Use wait --until approval to return early when a Run needs a decision. Inspect show/history, then use sessions approval decide <session-id> --run <public-run-id> --approval <approval-id> --decision approve|reject --json for one explicitly authorized request. This does not grant authority or auto-approve tools. Acknowledgment is not completion; observe status/history and wait again as needed.',
27
28
  'Goal get/set/clear use the existing Session Goal semantics; see marctl help sessions. A Coordinator can work from ordinary messages or its existing schedule without a Goal. Worker completion never automatically wakes it. Actively wait/read results, or finish and reconcile on the next user message or scheduled input.',
28
29
  '## Verify, recover and respect authorization',
29
30
  'An accepted message or SUCCEEDED Run is not proof of business completion. Inspect artifacts and test evidence.',
@@ -1,6 +1,7 @@
1
1
  import { NODE_MCP } from '../../mcp/naming.js';
2
2
  import { nodeExecutionEnvironment } from '../../node-execution-environment.js';
3
3
  import { extractId } from './conversation-parser.js';
4
+ import { normalizeCodexApproval } from './tool-normalizer.js';
4
5
  import { parseSecretEnvironment } from '../../util/secret-environment.js';
5
6
  import { safeErrorCode } from '../../util/safe-error.js';
6
7
  import { runnerErrorProjection } from '../../util/runner-error.js';
@@ -499,6 +500,11 @@ export class CodexManagedRunController {
499
500
  requestApproval(runId, notification) {
500
501
  const approvalId = `codex:${runId}:${notification.requestId}`;
501
502
  const expiresAt = Date.now() + 10 * 60_000;
503
+ const payload = {
504
+ method: notification.method,
505
+ params: notification.params,
506
+ ...normalizeCodexApproval(notification.method)
507
+ };
502
508
  this.runner.execution.approvals.set(approvalId, {
503
509
  runId,
504
510
  requestId: notification.requestId
@@ -507,7 +513,7 @@ export class CodexManagedRunController {
507
513
  this.host.createApproval({
508
514
  runId,
509
515
  approvalId,
510
- payload: { method: notification.method, params: notification.params },
516
+ payload,
511
517
  expiresAt
512
518
  });
513
519
  }
@@ -518,7 +524,7 @@ export class CodexManagedRunController {
518
524
  this.host.publishApproval({
519
525
  runId,
520
526
  approvalId,
521
- payload: { method: notification.method, params: notification.params },
527
+ payload,
522
528
  expiresAt
523
529
  });
524
530
  }
@@ -1,4 +1,9 @@
1
1
  import { compactRunnerText } from '../../util/node-operation-parsers.js';
2
+ export function normalizeCodexApproval(method) {
3
+ return method === 'item/commandExecution/requestApproval'
4
+ ? { toolName: 'command_execution', reason: 'Command execution requires approval.' }
5
+ : { toolName: 'file_change', reason: 'File changes require approval.' };
6
+ }
2
7
  export function normalizeCodexTool(input) {
3
8
  const name = compactRunnerText(input.name, 128) || 'tool';
4
9
  const rawInput = input.rawInput;
@@ -564,6 +564,12 @@ export class CodexRunner extends AbstractRunner {
564
564
  this.runClients.set(runId, client);
565
565
  }
566
566
  releaseEnvironment(runId) {
567
+ for (const [id, approval] of this.execution.approvals) {
568
+ if (approval.runId !== runId)
569
+ continue;
570
+ clearTimeout(approval.timer);
571
+ this.execution.approvals.delete(id);
572
+ }
567
573
  const client = this.runClients.get(runId);
568
574
  if (client === undefined)
569
575
  return;
@@ -575,6 +581,9 @@ export class CodexRunner extends AbstractRunner {
575
581
  }
576
582
  }
577
583
  stop() {
584
+ for (const approval of this.execution.approvals.values())
585
+ clearTimeout(approval.timer);
586
+ this.execution.approvals.clear();
578
587
  this.profileGeneration += 1;
579
588
  for (const profileClient of this.profileClients)
580
589
  profileClient.stop();
@@ -356,7 +356,8 @@ export class MarAgentConversationNormalizer {
356
356
  if (observation.id !== undefined)
357
357
  this.#codeModeObservationIds.add(observation.id);
358
358
  this.#codeModeSourceCallIds.add(observation.source.callId);
359
- items.push(...projected.map((item) => 'callId' in event ? { ...item, parentItemId: itemId('tool', event.callId) } : item));
359
+ // An execution/observation container does not own these public tool records.
360
+ items.push(...projected);
360
361
  }
361
362
  return items;
362
363
  }
@@ -102,7 +102,7 @@ export function projectMarAgentHistory(session, entries, options = {}) {
102
102
  const attachments = projected.generatedImages === undefined
103
103
  ? []
104
104
  : (options.registerGeneratedImages?.(event.sessionId, projected.generatedImages) ?? []);
105
- replace(projectionTurn, historyItem(session, projectionTurn, projected.itemId, projected.kind, projected.status, attachments.length === 0 ? projected.payload : { ...projected.payload, attachments }, at, projected.permissionStop, projected.parentItemId));
105
+ replace(projectionTurn, historyItem(session, projectionTurn, projected.itemId, projected.kind, projected.status, attachments.length === 0 ? projected.payload : { ...projected.payload, attachments }, at, projected.permissionStop));
106
106
  }
107
107
  }
108
108
  return [...turns.values()];
@@ -129,13 +129,13 @@ function record(value) {
129
129
  ? value
130
130
  : undefined;
131
131
  }
132
- function historyItem(session, turn, id, kind, status, payload, at, permissionStop, parentItemId) {
132
+ function historyItem(session, turn, id, kind, status, payload, at, permissionStop) {
133
133
  return {
134
134
  id,
135
135
  sessionId: session.id,
136
136
  turnId: turn.id,
137
137
  runId: turn.runId,
138
- parentItemId: parentItemId ?? null,
138
+ parentItemId: null,
139
139
  sourceSequence: null,
140
140
  revision: 0,
141
141
  kind,
@@ -701,7 +701,6 @@ export class MarAgentManagedRunController {
701
701
  []);
702
702
  this.host.emitItem(runId, {
703
703
  itemId: item.itemId,
704
- ...(item.parentItemId === undefined ? {} : { parentItemId: item.parentItemId }),
705
704
  kind: item.kind,
706
705
  status: item.status,
707
706
  payload: attachments.length === 0 ? item.payload : { ...item.payload, attachments },
@@ -13,6 +13,7 @@ export class ContinuousRunCoordinator {
13
13
  immediateQueueItems = new Set();
14
14
  queueExecutionAdmissions = new Map();
15
15
  steerInFlight = new Map();
16
+ deferredQueueResumes = new Map();
16
17
  suspended = false;
17
18
  constructor(options) {
18
19
  this.options = options;
@@ -164,9 +165,22 @@ export class ContinuousRunCoordinator {
164
165
  this.canSteer(active) &&
165
166
  (input.steerTarget.executionId === undefined ||
166
167
  this.options.runtime.currentExecutionId(active.id) === input.steerTarget.executionId);
167
- const intent = input.steerTarget !== undefined && !steering ? 'SEND' : input.intent;
168
- if (active?.status === 'CANCELLING' && intent !== 'REPLACE_CURRENT')
168
+ const pauseVersion = this.options.runtime.sessionQueuePauseVersion(sessionId);
169
+ // An explicit pause after this input's admission takes precedence over automatic recovery.
170
+ if (input.externalInput &&
171
+ pauseVersion !== undefined &&
172
+ pauseVersion !== input.queuePauseVersion)
169
173
  return 'QUEUED';
174
+ const intent = input.steerTarget !== undefined && !steering
175
+ ? 'SEND'
176
+ : input.externalInput && input.queueBefore.paused && input.intent === 'QUEUE'
177
+ ? 'SEND'
178
+ : input.intent;
179
+ if (active?.status === 'CANCELLING' && intent !== 'REPLACE_CURRENT') {
180
+ if (input.externalInput && item && pauseVersion !== undefined)
181
+ this.deferredQueueResumes.set(item.id, pauseVersion);
182
+ return 'QUEUED';
183
+ }
170
184
  if (steering)
171
185
  this.prepareSteering(input.submissionId);
172
186
  const executionEnded = active !== undefined &&
@@ -517,6 +531,9 @@ export class ContinuousRunCoordinator {
517
531
  if ([...this.steerInFlight.values()].some((entry) => entry.sessionId === sessionId))
518
532
  return;
519
533
  const active = this.options.runtime.activeSessionRun(sessionId);
534
+ if (active?.status === 'CANCELLING')
535
+ return;
536
+ this.resumeDeferredQueue(sessionId);
520
537
  if (active === undefined) {
521
538
  await this.startIdleQueue(sessionId);
522
539
  return;
@@ -694,6 +711,7 @@ export class ContinuousRunCoordinator {
694
711
  }
695
712
  }
696
713
  remove(id) {
714
+ this.deferredQueueResumes.delete(id);
697
715
  this.immediateQueueItems.delete(id);
698
716
  this.environmentLeases.get(id)?.();
699
717
  this.environmentLeases.delete(id);
@@ -739,10 +757,31 @@ export class ContinuousRunCoordinator {
739
757
  this.startedExecutions.clear();
740
758
  this.immediateQueueItems.clear();
741
759
  this.queueExecutionAdmissions.clear();
760
+ this.deferredQueueResumes.clear();
742
761
  for (const timer of this.scheduledSessions.values())
743
762
  clearTimeout(timer);
744
763
  this.scheduledSessions.clear();
745
764
  }
765
+ runTerminated(sessionId) {
766
+ const queue = this.options.runtime.sessionQueue(sessionId);
767
+ if ((!queue.paused && queue.itemIds.length > 0) ||
768
+ [...this.deferredQueueResumes.keys()].some((id) => this.options.runtime.getQueueItem(id)?.sessionId === sessionId))
769
+ this.schedule(sessionId);
770
+ }
771
+ resumeDeferredQueue(sessionId) {
772
+ for (const [id, pauseVersion] of this.deferredQueueResumes) {
773
+ const item = this.options.runtime.getQueueItem(id);
774
+ if (item?.sessionId !== sessionId)
775
+ continue;
776
+ this.deferredQueueResumes.delete(id);
777
+ if (this.options.runtime.sessionQueuePauseVersion(sessionId) !== pauseVersion)
778
+ continue;
779
+ this.options.runtime.resumeSessionQueue(sessionId);
780
+ if (!this.options.goalActive?.(sessionId))
781
+ this.options.runtime.prioritizeQueueItem(id);
782
+ this.publish(sessionId);
783
+ }
784
+ }
746
785
  async startIdleQueue(sessionId, throwOnError = false) {
747
786
  if (this.options.runtime.activeSessionRun(sessionId) !== undefined) {
748
787
  this.schedule(sessionId);
@@ -1,9 +1,11 @@
1
1
  import { createSocket } from 'node:dgram';
2
+ import { isIP } from 'node:net';
2
3
  import { directDataControlFrameSchema, directNodeCommandSchema } from '@myagentroam/protocol';
3
4
  import { TerminalDirectService } from './terminal-direct-service.js';
4
5
  import { IceUdpMuxListener as NativeIceUdpMuxListener } from 'node-datachannel';
5
6
  import { RTCPeerConnection } from 'node-datachannel/polyfill';
6
7
  const MAX_HOST_CANDIDATES = 32;
8
+ const LOOPBACK_CANDIDATE_PRIORITY = '2130706431';
7
9
  const MAX_ACTIVE_TRANSPORTS = 128;
8
10
  const MAX_ACTIVE_TRANSFERS = 128;
9
11
  const DATA_CHANNEL_HIGH_WATER_BYTES = 4 * 1024 * 1024;
@@ -159,6 +161,7 @@ export class DirectTransferService {
159
161
  };
160
162
  transport.leaseTimer = this.startLeaseWatch(() => transport.leaseExpiresAt, () => transport.lastLeaseAliveAt, () => this.removeTransport(transport, 'DIRECT_TRANSFER_CONTROL_UNAVAILABLE'));
161
163
  this.transports.set(command.directSessionId, transport);
164
+ const sentLoopbackAddresses = new Set();
162
165
  peer.onicecandidate = (event) => {
163
166
  const candidate = event.candidate;
164
167
  if (candidate === null) {
@@ -170,8 +173,29 @@ export class DirectTransferService {
170
173
  return;
171
174
  }
172
175
  const json = candidate.toJSON();
173
- if (typeof json.candidate !== 'string' ||
174
- !acceptHostCandidate(json.candidate, transport.sentCandidateKeys))
176
+ if (typeof json.candidate !== 'string')
177
+ return;
178
+ const loopback = loopbackHostCandidate(json.candidate);
179
+ const loopbackAddress = loopback?.trim().split(/\s+/u)[4];
180
+ if (loopback !== undefined &&
181
+ loopbackAddress !== undefined &&
182
+ !sentLoopbackAddresses.has(loopbackAddress)) {
183
+ sentLoopbackAddresses.add(loopbackAddress);
184
+ if (acceptHostCandidate(loopback, transport.sentCandidateKeys))
185
+ this.emit(command.directSessionId, {
186
+ type: 'direct.signal',
187
+ directSessionId: command.directSessionId,
188
+ signal: {
189
+ kind: 'CANDIDATE',
190
+ candidate: {
191
+ candidate: loopback,
192
+ sdpMid: json.sdpMid ?? null,
193
+ sdpMLineIndex: json.sdpMLineIndex ?? null
194
+ }
195
+ }
196
+ });
197
+ }
198
+ if (!acceptHostCandidate(json.candidate, transport.sentCandidateKeys))
175
199
  return;
176
200
  this.emit(command.directSessionId, {
177
201
  type: 'direct.signal',
@@ -543,6 +567,19 @@ export function probeUdp4Port(port) {
543
567
  socket.bind({ port, address: '0.0.0.0', exclusive: true }, () => socket.close(() => resolve()));
544
568
  });
545
569
  }
570
+ export function loopbackHostCandidate(candidate) {
571
+ if (!safeHostCandidate(candidate))
572
+ return undefined;
573
+ const address = candidate.trim().split(/\s+/u)[4];
574
+ const family = isIP(address ?? '');
575
+ const loopback = family === 4 ? '127.0.0.1' : family === 6 ? '::1' : undefined;
576
+ if (loopback === undefined || address === loopback)
577
+ return undefined;
578
+ const fields = candidate.trim().split(/\s+/u);
579
+ fields[3] = LOOPBACK_CANDIDATE_PRIORITY;
580
+ fields[4] = loopback;
581
+ return fields.join(' ');
582
+ }
546
583
  export function fingerprint(sdp) {
547
584
  const match = sdp?.match(/^a=fingerprint:(sha-256 [A-F0-9:]+)\r?$/imu);
548
585
  return match?.[1];
@@ -583,20 +620,26 @@ function filterIceCandidates(sdp, accepted) {
583
620
  if (sdp === undefined)
584
621
  return { sdp: undefined };
585
622
  const included = new Set();
586
- const lines = sdp.split(/\r?\n/u).filter((line) => {
623
+ const lines = sdp.split(/\r?\n/u).flatMap((line) => {
587
624
  if (!line.startsWith('a=candidate:'))
588
- return true;
625
+ return [line];
589
626
  const candidate = line.slice(2);
590
627
  if (!safeHostCandidate(candidate))
591
- return false;
592
- const key = candidateKey(candidate);
593
- if (key === undefined || included.has(key))
594
- return false;
595
- if (!accepted.has(key) && accepted.size >= MAX_HOST_CANDIDATES)
596
- return false;
597
- accepted.add(key);
598
- included.add(key);
599
- return true;
628
+ return [];
629
+ const loopback = loopbackHostCandidate(candidate);
630
+ const candidates = loopback === undefined ? [candidate] : [loopback, candidate];
631
+ const retained = [];
632
+ for (const value of candidates) {
633
+ const key = candidateKey(value);
634
+ if (key === undefined || included.has(key))
635
+ continue;
636
+ if (!accepted.has(key) && accepted.size >= MAX_HOST_CANDIDATES)
637
+ continue;
638
+ accepted.add(key);
639
+ included.add(key);
640
+ retained.push(`a=${value}`);
641
+ }
642
+ return retained;
600
643
  });
601
644
  return { sdp: lines.join('\r\n') };
602
645
  }
@@ -134,7 +134,8 @@ export class RunEventService {
134
134
  const interrupted = this.options.state.interruptRequested.delete(executionId);
135
135
  manuallyInterrupted = interrupted;
136
136
  const queue = this.options.runtime.sessionQueue(run.sessionId);
137
- const continues = permissionStop === undefined &&
137
+ const continues = run.status !== 'CANCELLING' &&
138
+ permissionStop === undefined &&
138
139
  ((!queue.paused && queue.itemIds.length > 0) ||
139
140
  this.options.keepRunActive?.(run.sessionId) === true);
140
141
  if (!continues) {
@@ -109,6 +109,11 @@ export class RunWorkbenchService {
109
109
  const current = this.options.runtime.getApproval(input.approvalId);
110
110
  if (current === undefined || this.options.runtime.canonicalRunId(current.runId) !== input.runId)
111
111
  throw new Error('APPROVAL_NOT_FOUND');
112
+ const run = this.options.runtime.activeSessionRun(this.requireRun(input.runId).sessionId);
113
+ if (run?.id !== input.runId ||
114
+ (current.runId !== run.id &&
115
+ current.runId !== this.options.runtime.currentExecutionId(run.id)))
116
+ throw new Error('APPROVAL_NOT_FOUND');
112
117
  this.assertRunOwner(current.runId, input.__requestUserId);
113
118
  const approval = this.options.runtime.decideApproval(input.approvalId, input.decision);
114
119
  this.options.emitRun(approval.runId, 'approval.decided', {
@@ -130,11 +130,40 @@ export class RunnerInteractionService {
130
130
  delivery
131
131
  }));
132
132
  }
133
+ createCodexApproval(input) {
134
+ this.options.runtime.createApproval(input);
135
+ const approvals = this.options.codex().execution.approvals;
136
+ const pending = approvals.get(input.approvalId);
137
+ const expire = () => {
138
+ const current = approvals.get(input.approvalId);
139
+ if (current === undefined)
140
+ return;
141
+ try {
142
+ this.options.runtime.expireApproval(input.approvalId);
143
+ }
144
+ catch (error) {
145
+ if (error instanceof Error && error.message === 'APPROVAL_NOT_EXPIRED') {
146
+ const timer = setTimeout(expire, Math.max(1, input.expiresAt - Date.now()));
147
+ timer.unref();
148
+ approvals.set(input.approvalId, { ...current, timer });
149
+ return;
150
+ }
151
+ approvals.delete(input.approvalId);
152
+ return;
153
+ }
154
+ this.options.emitRun(input.runId, 'approval.expired', { approvalId: input.approvalId });
155
+ this.decideCodexApproval(input.approvalId, 'REJECTED');
156
+ };
157
+ const timer = setTimeout(expire, Math.max(1, input.expiresAt - Date.now()));
158
+ timer.unref();
159
+ approvals.set(input.approvalId, { ...pending, timer });
160
+ }
133
161
  decideCodexApproval(approvalId, decision) {
134
162
  const codex = this.options.codex();
135
163
  const approval = codex.execution.approvals.get(approvalId);
136
164
  if (approval === undefined)
137
165
  return false;
166
+ clearTimeout(approval.timer);
138
167
  try {
139
168
  codex.respond(approval.requestId, { decision: decision === 'APPROVED' ? 'accept' : 'decline' }, approval.runId);
140
169
  }
@@ -158,6 +158,7 @@ export class SessionMessageService {
158
158
  ? input.__queueItemId
159
159
  : randomUUID();
160
160
  const queueBeforeCreate = this.options.runtime.sessionQueue(session.id);
161
+ const queuePauseVersion = this.options.runtime.sessionQueuePauseVersion(session.id);
161
162
  const active = this.options.runtime.activeSessionRun(session.id);
162
163
  if (internalSchedule && (active !== undefined || queueBeforeCreate.itemIds.length > 0))
163
164
  throw new Error('SCHEDULE_SESSION_BUSY');
@@ -309,6 +310,8 @@ export class SessionMessageService {
309
310
  },
310
311
  submissionId,
311
312
  intent,
313
+ externalInput: userMessage,
314
+ ...(queuePauseVersion === undefined ? {} : { queuePauseVersion }),
312
315
  queueBefore: queueBeforeCreate,
313
316
  ...(steerTarget === undefined ? {} : { steerTarget }),
314
317
  ...(replaceCurrentRun === undefined ? {} : { activeRunId: replaceCurrentRun.id })
@@ -78,7 +78,33 @@ function activity(runtime, sessionId) {
78
78
  }
79
79
  export function marctlSessionProgress(runtime, sessionId) {
80
80
  const { latestActivity: _latest, ...progress } = activity(runtime, sessionId);
81
- return progress;
81
+ return { ...progress, ...pendingApprovals(runtime, sessionId) };
82
+ }
83
+ /** Summarizes current approval facts without exposing tool input or native payloads. */
84
+ function pendingApprovals(runtime, sessionId) {
85
+ const run = runtime.activeSessionRun(sessionId);
86
+ const executionId = run && runtime.currentExecutionId(run.id);
87
+ const approvals = run && !isTerminalRunStatus(run.status)
88
+ ? runtime
89
+ .listApprovals(run.id)
90
+ .filter((approval) => approval.status === 'PENDING' &&
91
+ (approval.runId === run.id || approval.runId === executionId))
92
+ : [];
93
+ return {
94
+ pendingApprovals: approvals.slice(0, 20).map((approval) => {
95
+ const payload = isPlainRecord(approval.payload) ? approval.payload : {};
96
+ const name = clip(typeof payload.toolName === 'string' ? payload.toolName : 'Approval', 128);
97
+ const summary = clip(typeof payload.reason === 'string' ? payload.reason : 'Approval required', 100);
98
+ return {
99
+ runId: run.id,
100
+ approvalId: approval.id,
101
+ name: name.text,
102
+ summary: summary.text,
103
+ truncated: name.truncated || summary.truncated
104
+ };
105
+ }),
106
+ pendingApprovalsTruncated: approvals.length > 20
107
+ };
82
108
  }
83
109
  function identity(session) {
84
110
  return {
@@ -104,7 +130,8 @@ export function marctlSessionDetail(runtime, session, queuedCount) {
104
130
  effort: session.effort,
105
131
  queuedCount,
106
132
  latestMessage,
107
- recentTools
133
+ recentTools,
134
+ ...pendingApprovals(runtime, session.id)
108
135
  };
109
136
  }
110
137
  function time(item) {
@@ -78,7 +78,13 @@ export function projectWorkbenchPayload(queries, category, payload) {
78
78
  payload = {
79
79
  sessionId: turn.sessionId,
80
80
  runId: turn.runId,
81
- ...(['assistant_message', 'tool_call', 'command_execution', 'file_change'].includes(item.kind) &&
81
+ ...([
82
+ 'assistant_message',
83
+ 'tool_call',
84
+ 'command_execution',
85
+ 'file_change',
86
+ 'approval_request'
87
+ ].includes(item.kind) &&
82
88
  (rawPayload?.append === undefined || item.status !== 'IN_PROGRESS')
83
89
  ? { progress: queries.progress(turn.sessionId) }
84
90
  : {}),
@@ -0,0 +1,49 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { dirname } from 'node:path';
3
+ import { resolveWorkspaceFileManagerTarget } from '../workspace.js';
4
+ export function fileManagerInvocation(platform, target) {
5
+ if (platform === 'win32')
6
+ return {
7
+ command: 'explorer.exe',
8
+ args: target.kind === 'FILE' ? ['/select,', target.path] : [target.path]
9
+ };
10
+ if (platform === 'darwin')
11
+ return { command: 'open', args: target.kind === 'FILE' ? ['-R', target.path] : [target.path] };
12
+ if (platform === 'linux')
13
+ return {
14
+ command: 'xdg-open',
15
+ args: [target.kind === 'FILE' ? dirname(target.path) : target.path]
16
+ };
17
+ throw new Error('FILE_MANAGER_UNAVAILABLE');
18
+ }
19
+ export async function revealWorkspaceFileInFileManager(workspaceRoot, requestedPath, options = {}) {
20
+ const target = await resolveWorkspaceFileManagerTarget(workspaceRoot, requestedPath);
21
+ const platform = options.platform ?? process.platform;
22
+ const invocation = fileManagerInvocation(platform, target);
23
+ const spawnProcess = options.spawnProcess ?? spawn;
24
+ let child;
25
+ try {
26
+ child = spawnProcess(invocation.command, invocation.args, {
27
+ detached: true,
28
+ stdio: 'ignore',
29
+ windowsHide: true
30
+ });
31
+ await new Promise((resolve, reject) => {
32
+ child.once('error', reject);
33
+ if (platform === 'win32')
34
+ child.once('spawn', () => resolve());
35
+ else
36
+ child.once('exit', (code) => {
37
+ if (code === 0)
38
+ resolve();
39
+ else
40
+ reject(new Error('FILE_MANAGER_UNAVAILABLE'));
41
+ });
42
+ });
43
+ }
44
+ catch (error) {
45
+ throw new Error('FILE_MANAGER_UNAVAILABLE', { cause: error });
46
+ }
47
+ child.unref();
48
+ return { revealed: true };
49
+ }
@@ -2,6 +2,7 @@ import { basename, dirname, isAbsolute, relative, resolve, win32 } from 'node:pa
2
2
  import { lstat, realpath } from 'node:fs/promises';
3
3
  import { isComposerAttachmentResourcePath } from '@myagentroam/protocol';
4
4
  import { listWorkspaceFiles, mutateWorkspaceFile, normalizeFilePreviewPath, readAbsoluteFileContentRange, readWorkspaceFileContentRange, readAbsoluteTextFile, readWorkspaceTextFile, writeWorkspaceTextFile, WorkspaceFileIndex } from '../workspace.js';
5
+ import { revealWorkspaceFileInFileManager } from './workspace-file-manager-service.js';
5
6
  const INDEX_CACHE_LIMIT = 32;
6
7
  const INDEX_CACHE_BYTE_LIMIT = 128 * 1024 * 1024;
7
8
  const INDEX_QUEUE_LIMIT = 16;
@@ -224,6 +225,11 @@ export class WorkspaceFileService {
224
225
  return result;
225
226
  });
226
227
  }
228
+ async reveal(workspace, input) {
229
+ if (typeof input.path !== 'string')
230
+ throw new Error('FILE_REVEAL_INVALID');
231
+ return revealWorkspaceFileInFileManager(workspace.path, input.path);
232
+ }
227
233
  async serializeMutation(workspaceId, operation) {
228
234
  const previous = this.mutationTails.get(workspaceId) ?? Promise.resolve();
229
235
  const result = previous.then(operation, operation);
@@ -41,6 +41,10 @@ export class WorkspaceWorkbenchService {
41
41
  'workspace.file.direct.metadata': (data) => this.directFileMetadata(record(data)),
42
42
  'workspace.files.search': (data) => this.searchFiles(record(data)),
43
43
  'workspace.files.mutate': (data) => this.mutateFile(record(data)),
44
+ 'workspace.files.reveal': (data) => {
45
+ const input = record(data);
46
+ return this.options.files.reveal(this.options.requireWorkspace(input.workspaceId), input);
47
+ },
44
48
  'workspace.content.search': (data) => this.searchContent(record(data)),
45
49
  'workspace.symbol.prepare': (data) => this.prepareSymbolIndex(record(data)),
46
50
  'workspace.symbol.definition': (data) => this.symbolDefinition(record(data)),
package/dist/workspace.js CHANGED
@@ -1084,6 +1084,15 @@ async function resolveWorkspaceEntryMetadata(root, requestedPath) {
1084
1084
  const metadata = await lstat(candidate);
1085
1085
  return { path: candidate, metadata };
1086
1086
  }
1087
+ export async function resolveWorkspaceFileManagerTarget(workspaceRoot, requestedPath) {
1088
+ const root = await resolveRegisteredWorkspaceRoot(workspaceRoot);
1089
+ const entry = await resolveWorkspaceEntryMetadata(root, requestedPath);
1090
+ if (entry.metadata.isDirectory())
1091
+ return { path: entry.path, kind: 'DIRECTORY' };
1092
+ if (entry.metadata.isFile())
1093
+ return { path: entry.path, kind: 'FILE' };
1094
+ throw new Error('FILE_NOT_REGULAR');
1095
+ }
1087
1096
  async function inspectWorkspaceIndexEntry(root, requestedPath, name, excludes) {
1088
1097
  try {
1089
1098
  const entry = await resolveWorkspaceEntryMetadata(root, requestedPath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.11.23",
3
+ "version": "0.11.25",
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.11.23",
41
- "@myagentroam/code-navigation": "0.11.0",
42
- "@myagentroam/protocol": "0.11.23",
43
- "@myagentroam/marctl": "0.11.0"
40
+ "@myagentroam/agent": "0.11.25",
41
+ "@myagentroam/protocol": "0.11.25",
42
+ "@myagentroam/marctl": "0.11.1",
43
+ "@myagentroam/code-navigation": "0.11.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/ws": "^8.18.1"