@myagentroam/node 0.10.35 → 0.10.37

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.
Files changed (33) hide show
  1. package/dist/connector.js +115 -18
  2. package/dist/database.js +29 -12
  3. package/dist/main.js +18 -2
  4. package/dist/migrations/v019.js +9 -0
  5. package/dist/migrations/v020.js +46 -0
  6. package/dist/runner/mar-agent/computer-use-probe.js +126 -0
  7. package/dist/runner/mar-agent/computer-use.js +50 -20
  8. package/dist/runner/mar-agent/conversation-normalizer.js +28 -0
  9. package/dist/runner/mar-agent/marctl-skill.js +3 -2
  10. package/dist/runner/mar-agent-runner.js +32 -0
  11. package/dist/runtime-state.js +15 -10
  12. package/dist/service/continuous-run-coordinator.js +58 -5
  13. package/dist/service/direct-transfer-service.js +58 -11
  14. package/dist/service/external-session-resume-service.js +2 -1
  15. package/dist/service/goal-runtime.js +13 -0
  16. package/dist/service/hosted-conversation-node-service.js +5 -4
  17. package/dist/service/native-session-projection-service.js +77 -11
  18. package/dist/service/planned-restart-handoff.js +279 -0
  19. package/dist/service/restart-session-restorer.js +141 -0
  20. package/dist/service/{scheduled-run-authorization-control.js → run-authorization-control.js} +17 -10
  21. package/dist/service/run-event-service.js +2 -0
  22. package/dist/service/scheduled-session-coordinator.js +13 -0
  23. package/dist/service/scheduled-session-store.js +24 -0
  24. package/dist/service/session-catalog-service.js +57 -4
  25. package/dist/service/session-command-service.js +1 -0
  26. package/dist/service/session-lifecycle-service.js +25 -8
  27. package/dist/service/session-message-service.js +80 -15
  28. package/dist/service/session-presentation-service.js +2 -0
  29. package/dist/service/session-query-service.js +14 -9
  30. package/dist/service/workspace-queue-workbench-service.js +15 -8
  31. package/dist/service/workspace-upload-service.js +128 -29
  32. package/dist/supervisor.js +7 -3
  33. package/package.json +4 -4
@@ -6,6 +6,7 @@ import { resolve } from 'node:path';
6
6
  import { createInterface } from 'node:readline';
7
7
  import { terminateProcessTree } from '../../process-tree.js';
8
8
  import { ComputerUseControlIndicator } from './computer-use-indicator.js';
9
+ import { PYTHON_PROBE_SOURCE } from './computer-use-probe.js';
9
10
  const TOOL_NAME = 'mcp__computer__exec';
10
11
  const DEFAULT_TIMEOUT_MS = 30_000;
11
12
  const MAX_TIMEOUT_MS = 120_000;
@@ -545,40 +546,69 @@ export async function probeComputerUse(configuration) {
545
546
  try {
546
547
  await access(configuration.runtimeExecutable);
547
548
  const result = await runProbe(configuration.runtimeExecutable);
548
- return result
549
- ? {
550
- ...base,
551
- available: true,
552
- reasonCode: null,
553
- supportsScreenshot: true,
554
- supportsInput: true,
555
- supportsUnicodePaste: true,
556
- supportsPrimaryDisplay: true
557
- }
558
- : { ...base, available: false, reasonCode: 'COMPUTER_RUNTIME_UNAVAILABLE' };
549
+ if (result === undefined)
550
+ return { ...base, available: false, reasonCode: 'COMPUTER_RUNTIME_UNAVAILABLE' };
551
+ return { ...base, ...result, available: result.reasonCode === null };
559
552
  }
560
553
  catch {
561
554
  return { ...base, available: false, reasonCode: 'COMPUTER_RUNTIME_UNAVAILABLE' };
562
555
  }
563
556
  }
564
557
  async function runProbe(executable) {
565
- const child = spawn(executable, [
566
- '-c',
567
- 'import pyautogui, pyperclip; im=pyautogui.screenshot(); assert im.width > 0 and im.height > 0; print("ok")'
568
- ], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
558
+ const child = spawn(executable, ['-c', PYTHON_PROBE_SOURCE], {
559
+ stdio: ['ignore', 'pipe', 'pipe'],
560
+ windowsHide: true
561
+ });
569
562
  let stdout = '';
570
- child.stdout.on('data', (chunk) => (stdout += chunk.toString('utf8')));
563
+ let overflow = false;
564
+ child.stdout.on('data', (chunk) => {
565
+ if (stdout.length + chunk.length > 4096)
566
+ overflow = true;
567
+ if (!overflow)
568
+ stdout += chunk.toString('utf8');
569
+ });
570
+ child.stderr.resume();
571
571
  return await new Promise((resolveProbe) => {
572
572
  const timeout = setTimeout(() => {
573
- void terminateProcessTree(child).finally(() => resolveProbe(false));
573
+ void terminateProcessTree(child).finally(() => resolveProbe(undefined));
574
574
  }, 10_000);
575
- child.once('exit', (code) => {
575
+ child.once('close', (code) => {
576
576
  clearTimeout(timeout);
577
- resolveProbe(code === 0 && stdout.trim() === 'ok');
577
+ if (code !== 0 || overflow)
578
+ return resolveProbe(undefined);
579
+ try {
580
+ const value = JSON.parse(stdout);
581
+ if (value === null ||
582
+ typeof value !== 'object' ||
583
+ typeof value.supportsScreenshot !== 'boolean' ||
584
+ typeof value.supportsInput !== 'boolean' ||
585
+ typeof value.supportsUnicodePaste !== 'boolean' ||
586
+ typeof value.supportsPrimaryDisplay !== 'boolean')
587
+ return resolveProbe(undefined);
588
+ const reasonCode = value.reasonCode === 'COMPUTER_DESKTOP_UNSUPPORTED'
589
+ ? value.reasonCode
590
+ : !value.supportsScreenshot || !value.supportsPrimaryDisplay
591
+ ? 'COMPUTER_RUNTIME_UNAVAILABLE'
592
+ : !value.supportsInput
593
+ ? 'COMPUTER_INPUT_PERMISSION_DENIED'
594
+ : !value.supportsUnicodePaste
595
+ ? 'COMPUTER_RUNTIME_UNAVAILABLE'
596
+ : null;
597
+ resolveProbe({
598
+ supportsScreenshot: value.supportsScreenshot,
599
+ supportsInput: value.supportsInput,
600
+ supportsUnicodePaste: value.supportsUnicodePaste,
601
+ supportsPrimaryDisplay: value.supportsPrimaryDisplay,
602
+ reasonCode
603
+ });
604
+ }
605
+ catch {
606
+ resolveProbe(undefined);
607
+ }
578
608
  });
579
609
  child.once('error', () => {
580
610
  clearTimeout(timeout);
581
- resolveProbe(false);
611
+ resolveProbe(undefined);
582
612
  });
583
613
  });
584
614
  }
@@ -579,6 +579,34 @@ function genericToolItem(id, toolName, status, input, outputSummary = null, erro
579
579
  truncated: truncated || source.length > 10_000 || output.length > 10_000
580
580
  }, status);
581
581
  }
582
+ if (toolName === 'code_exec_wait') {
583
+ const inputRecord = record(input);
584
+ const action = typeof inputRecord?.action === 'string' ? inputRecord.action : 'poll';
585
+ const inputSummary = compactRunnerValue(input, 10_000);
586
+ const result = output === undefined ? null : compactRunnerValue(output, 10_000);
587
+ const resultSummary = [outputSummary, result?.text]
588
+ .filter((value, index, values) => typeof value === 'string' && value.length > 0 && values.indexOf(value) === index)
589
+ .join('\n');
590
+ const details = [
591
+ `Action: ${compactRunnerText(action, 128)}`,
592
+ `Input: ${inputSummary.text || '{}'}`,
593
+ resultSummary.length === 0 ? '' : `Result: ${compactRunnerText(resultSummary, 10_000)}`,
594
+ errorCode === null ? '' : `Error: ${compactRunnerText(errorCode, 80)}`
595
+ ]
596
+ .filter(Boolean)
597
+ .join('\n');
598
+ return commandItem({
599
+ itemId: id,
600
+ command: toolName,
601
+ cwd: null,
602
+ outputPreview: details,
603
+ exitCode: null,
604
+ truncated: truncated ||
605
+ inputSummary.truncated ||
606
+ result?.truncated === true ||
607
+ resultSummary.length > 10_000
608
+ }, status);
609
+ }
582
610
  const subagent = marAgentSubagentProjection(toolName, input, output, subagentTitle, knownSubagentDetails);
583
611
  const toolCategory = isImageGenerationTool(toolName) ? 'IMAGE_GENERATION' : undefined;
584
612
  const inputSummary = compactRunnerValue(input, 10_000);
@@ -18,15 +18,16 @@ export function marctlHostSkill(environment = {}) {
18
18
  const marctlInstructions = [
19
19
  '## MAR Workspace CLI',
20
20
  'Use exec to run marctl. Reuse loaded guidance while it remains in context. Consult local marctl help sessions for unclear options and marctl --version when version matters; neither needs a network request. Capabilities and permissions still apply.',
21
- 'Use sessions current --json only when the current identity or scope is unknown. For a known target ID, use sessions show <session-id> --json only when fresh state is needed, without current/list first. Use sessions list --limit 10 --json to discover an unknown target; list has no name-search option. Stop once found and follow nextCursor only if needed. Do not scan the Workspace to prove capacity: check assigned work, recorded worker IDs and overlapping write scopes.',
21
+ 'Use sessions current --json only when the current identity or scope is unknown. For a known target ID, use sessions show <session-id> --json only when fresh state is needed, without current/list first. Use sessions list --limit 10 --json to discover an unknown target; narrow with --query <title> or --parent <session-id>. These query Node-known Sessions without scanning Runners. Use sessions graph <session-id> --json only for a creation tree (bounded to 100 nodes with truncated); start automatically records the current Session as parent, not task ownership. Stop once found and follow nextCursor only if needed. Do not scan the Workspace to prove capacity: check assigned work, recorded worker IDs and overlapping write scopes.',
22
22
  'MAR binds sessions to the current Node and Workspace. No --node or --workspace override exists. Use returned native Session IDs, not titles, for later operations. A Workspace terminal may have no current Session. Missing scope, an old Node, expired authorization or an unavailable Runner is an explicit error, not permission to bypass the API.',
23
23
  '## Create and coordinate independent Sessions',
24
+ '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.',
24
25
  '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.',
25
26
  '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.',
26
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.',
27
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.',
28
29
  'Read results with sessions history <session-id> --limit 10 --json (default: latest 10 SEGMENTs, not messages; --limit 1..100). Extract needed assistant_message payload.text from segments[].items[]; keep segment runId and user_message.payload.clientMessageId for queued-input correlation. Reuse the returned page; follow --cursor <nextCursor> only for missing evidence.',
29
- 'Send with sessions send <session-id> -m "Review the updated patch" --json. Default send does not interrupt and can resume a paused queue; --delivery queue preserves a paused queue. Otherwise eligible queued input can start when idle. --delivery replace-current cancels the current Run and prioritizes the new input after release; it is not live steering. There are no queue-item edit/delete/reorder commands; show includes queue state. 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.',
30
+ 'When useful, add gentle context to an in-progress task with sessions send <session-id> --steer -m "Additional context: focus on the resume path." --json. 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. 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.',
30
31
  '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.',
31
32
  '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.',
32
33
  '## Verify, recover and respect authorization',
@@ -369,6 +369,38 @@ export class MarAgentRunner extends AbstractRunner {
369
369
  await catalog.close().catch(() => undefined);
370
370
  }
371
371
  }
372
+ async restartInputState(sessionId, clientMessageId) {
373
+ const catalog = await openMarAgentSessionCatalog({ homeDirectory: this.homeDirectory });
374
+ try {
375
+ let cursor;
376
+ const states = new Map();
377
+ do {
378
+ const page = await catalog.history(sessionId, {
379
+ ...(cursor === undefined ? {} : { cursor }),
380
+ limit: 1000
381
+ });
382
+ for (const entry of page.entries) {
383
+ if (entry.kind !== 'event' || entry.event.turnId === undefined)
384
+ continue;
385
+ const event = entry.event;
386
+ if (event.type === 'execution.completed')
387
+ states.set(event.turnId, event.permissionStop === undefined ? 'completed' : 'paused');
388
+ else if (event.type === 'execution.failed')
389
+ states.set(event.turnId, 'completed');
390
+ else if (event.type === 'execution.cancelled')
391
+ states.set(event.turnId, 'interrupted');
392
+ }
393
+ const user = page.entries.find((entry) => entry.kind === 'user' && entry.clientMessageId === clientMessageId);
394
+ if (user?.kind === 'user')
395
+ return states.get(user.turnId) ?? 'interrupted';
396
+ cursor = page.nextCursor;
397
+ } while (cursor !== undefined);
398
+ return 'missing';
399
+ }
400
+ finally {
401
+ await catalog.close();
402
+ }
403
+ }
372
404
  async readOfficialConversation(session, input) {
373
405
  if (session.externalSessionId === null)
374
406
  return undefined;
@@ -84,6 +84,7 @@ export class NodeRuntimeState {
84
84
  // A first user prompt is only a process-local temporary title. A
85
85
  // Runner/native title can replace it on the next discovery refresh.
86
86
  titleSource: customTitle === null ? 'AUTO' : 'CUSTOM',
87
+ parentSessionId: input.parentSessionId ?? null,
87
88
  lastActivityAt: input.lastActivityAt ?? now,
88
89
  createdAt: input.createdAt ?? now
89
90
  };
@@ -163,9 +164,9 @@ export class NodeRuntimeState {
163
164
  }
164
165
  updateAgentSessionConfiguration(input) {
165
166
  return this.replaceSession(input.sessionId, {
166
- model: input.model,
167
- effort: input.effort,
168
- access: input.access
167
+ ...(input.model === undefined ? {} : { model: input.model }),
168
+ ...(input.effort === undefined ? {} : { effort: input.effort }),
169
+ ...(input.access === undefined ? {} : { access: input.access })
169
170
  });
170
171
  }
171
172
  updateAgentSessionAccess(sessionId, access) {
@@ -338,6 +339,7 @@ export class NodeRuntimeState {
338
339
  version: 0,
339
340
  clientMessageId: input.clientMessageId,
340
341
  ...(input.source === undefined ? {} : { source: input.source }),
342
+ ...(input.suppressNotifications === true ? { suppressNotifications: true } : {}),
341
343
  ...(input.initiatedByUserId === undefined
342
344
  ? {}
343
345
  : { initiatedByUserId: input.initiatedByUserId }),
@@ -416,6 +418,7 @@ export class NodeRuntimeState {
416
418
  runner: session.runner,
417
419
  clientMessageId: input.clientMessageId,
418
420
  content: input.content,
421
+ ...(input.suppressNotifications === true ? { suppressNotifications: true } : {}),
419
422
  attachments: input.attachments ?? [],
420
423
  ...(input.initiatedByUserId === undefined
421
424
  ? {}
@@ -449,11 +452,14 @@ export class NodeRuntimeState {
449
452
  if (itemId === undefined)
450
453
  return undefined;
451
454
  const item = this.requireQueueItem(itemId);
452
- const run = active.initiatedByUserId === undefined && item.initiatedByUserId !== undefined
453
- ? { ...active, initiatedByUserId: item.initiatedByUserId }
454
- : active;
455
- if (run !== active)
456
- this.runs.set(run.id, run);
455
+ const run = {
456
+ ...active,
457
+ ...(active.initiatedByUserId === undefined && item.initiatedByUserId !== undefined
458
+ ? { initiatedByUserId: item.initiatedByUserId }
459
+ : {}),
460
+ suppressNotifications: item.suppressNotifications === true
461
+ };
462
+ this.runs.set(run.id, run);
457
463
  this.queuedItems.set(sessionId, queue.slice(1));
458
464
  this.bumpQueueVersion(sessionId);
459
465
  this.queueItems.delete(itemId);
@@ -482,6 +488,7 @@ export class NodeRuntimeState {
482
488
  clientMessageId: item.clientMessageId,
483
489
  content: content ?? item.content,
484
490
  recentUserMessage: item.content,
491
+ ...(item.suppressNotifications === true ? { suppressNotifications: true } : {}),
485
492
  ...(item.attachments.length === 0 ? {} : { attachments: item.attachments }),
486
493
  ...(item.initiatedByUserId === undefined ? {} : { initiatedByUserId: item.initiatedByUserId })
487
494
  });
@@ -607,8 +614,6 @@ export class NodeRuntimeState {
607
614
  return { kind: 'run', run: this.requireRun(submission.id) };
608
615
  const item = this.requireQueueItem(submission.id);
609
616
  const run = this.activeSessionRun(sessionId);
610
- if (run === undefined)
611
- throw new Error('RUN_NOT_FOUND');
612
617
  return { kind: 'queue', item, run };
613
618
  }
614
619
  messagePreparationFailure(sessionId, clientMessageId) {
@@ -10,9 +10,25 @@ export class ContinuousRunCoordinator {
10
10
  startedExecutions = new Set();
11
11
  scheduledSessions = new Map();
12
12
  immediateQueueItems = new Set();
13
+ suspended = false;
13
14
  constructor(options) {
14
15
  this.options = options;
15
16
  }
17
+ suspend() {
18
+ this.suspended = true;
19
+ for (const timer of this.scheduledSessions.values())
20
+ clearTimeout(timer);
21
+ this.scheduledSessions.clear();
22
+ }
23
+ resumeScheduling() {
24
+ this.suspended = false;
25
+ }
26
+ isImmediate(itemId) {
27
+ return this.immediateQueueItems.has(itemId);
28
+ }
29
+ restoreImmediate(itemId) {
30
+ this.immediateQueueItems.add(itemId);
31
+ }
16
32
  accept(message, environment = {}) {
17
33
  const release = this.options.retainEnvironment?.(environment);
18
34
  let retained = false;
@@ -31,6 +47,7 @@ export class ContinuousRunCoordinator {
31
47
  sessionId: message.sessionId,
32
48
  clientMessageId: message.clientMessageId,
33
49
  content: message.content,
50
+ ...(message.suppressNotifications === true ? { suppressNotifications: true } : {}),
34
51
  ...(message.initiatedByUserId === undefined
35
52
  ? {}
36
53
  : { initiatedByUserId: message.initiatedByUserId }),
@@ -98,6 +115,23 @@ export class ContinuousRunCoordinator {
98
115
  throw new Error('SESSION_NOT_FOUND');
99
116
  const active = this.options.runtime.activeSessionRun(sessionId);
100
117
  const activeTurn = active && this.options.runtime.conversationTurnForRun(active.id);
118
+ const steering = input.steerTarget !== undefined &&
119
+ active !== undefined &&
120
+ active.id === input.activeRunId &&
121
+ activeTurn?.id === input.steerTarget.turnId &&
122
+ (activeTurn === undefined
123
+ ? !this.isMessageRun(active)
124
+ : this.options.runtime.currentExecutionId(active.id) !== undefined &&
125
+ !['SUCCEEDED', 'FAILED', 'INTERRUPTED'].includes(activeTurn.status)) &&
126
+ (input.steerTarget.executionId === undefined ||
127
+ this.options.runtime.currentExecutionId(active.id) === input.steerTarget.executionId);
128
+ const intent = input.steerTarget !== undefined && !steering ? 'SEND' : input.intent;
129
+ if (steering && prepared?.prepareSteerInput !== undefined && !prepared.steering)
130
+ this.preparedInputs.set(input.submissionId, {
131
+ ...prepared,
132
+ input: prepared.prepareSteerInput(),
133
+ steering: true
134
+ });
101
135
  const executionEnded = active !== undefined &&
102
136
  this.options.runtime.currentExecutionId(active.id) === undefined &&
103
137
  activeTurn !== undefined &&
@@ -106,9 +140,11 @@ export class ContinuousRunCoordinator {
106
140
  activeTurn.status === 'INTERRUPTED');
107
141
  const queueVersionBeforeSettle = this.options.runtime.sessionQueue(sessionId).version;
108
142
  const startsAcceptedRun = active !== undefined && input.submissionId === active.id;
109
- if (startsAcceptedRun || !input.queueBefore.paused || input.intent !== 'QUEUE')
143
+ if (startsAcceptedRun || !input.queueBefore.paused || intent !== 'QUEUE')
110
144
  this.options.runtime.resumeSessionQueue(sessionId);
111
- if (input.activeRunId !== undefined && active?.id === input.activeRunId) {
145
+ if (input.activeRunId !== undefined &&
146
+ active?.id === input.activeRunId &&
147
+ (input.steerTarget === undefined || steering)) {
112
148
  if (item !== undefined)
113
149
  this.immediateQueueItems.add(item.id);
114
150
  if (executionEnded) {
@@ -140,13 +176,13 @@ export class ContinuousRunCoordinator {
140
176
  return 'QUEUED';
141
177
  }
142
178
  if (active === undefined && item !== undefined) {
143
- if (input.queueBefore.paused && input.intent === 'SEND')
179
+ if (input.queueBefore.paused && intent === 'SEND')
144
180
  this.options.runtime.prioritizeQueueItem(item.id);
145
181
  this.startIdleQueue(sessionId);
146
182
  return 'STARTED';
147
183
  }
148
184
  if (executionEnded && item !== undefined) {
149
- if (input.queueBefore.paused && input.intent === 'SEND') {
185
+ if (input.queueBefore.paused && intent === 'SEND') {
150
186
  this.options.runtime.prioritizeQueueItem(item.id);
151
187
  this.publish(sessionId);
152
188
  }
@@ -186,7 +222,17 @@ export class ContinuousRunCoordinator {
186
222
  const resources = prepareComposerResourceInput(input, pending.cwd, pending.workspacePath ?? pending.cwd);
187
223
  this.preparedInputs.set(itemId, {
188
224
  ...pending,
189
- input: withNonImageAttachmentPrompt(resources, pending.attachmentPaths, pending.attachmentFiles)
225
+ ...(pending.restartMessage === undefined
226
+ ? {}
227
+ : {
228
+ restartMessage: {
229
+ ...pending.restartMessage,
230
+ content: this.options.runtime.getQueueItem(itemId)?.content ?? input
231
+ }
232
+ }),
233
+ input: withNonImageAttachmentPrompt(pending.precedingInput === undefined
234
+ ? resources
235
+ : `${pending.precedingInput}\n\n${resources}`, pending.attachmentPaths, pending.attachmentFiles)
190
236
  });
191
237
  }
192
238
  deleteQueueItem(itemId) {
@@ -227,6 +273,7 @@ export class ContinuousRunCoordinator {
227
273
  if (current !== undefined && replacement !== undefined)
228
274
  this.preparedInputs.set(item.id, {
229
275
  ...replacement,
276
+ precedingInput: current.input,
230
277
  input: `${current.input}\n\n${replacement.input}`
231
278
  });
232
279
  }
@@ -238,6 +285,8 @@ export class ContinuousRunCoordinator {
238
285
  this.startIdleQueue(sessionId);
239
286
  }
240
287
  schedule(sessionId, delayMs = 0) {
288
+ if (this.suspended)
289
+ return;
241
290
  if (this.scheduledSessions.has(sessionId))
242
291
  return;
243
292
  const timer = setTimeout(() => {
@@ -256,6 +305,8 @@ export class ContinuousRunCoordinator {
256
305
  this.schedule(sessionId);
257
306
  }
258
307
  async dispatch(sessionId) {
308
+ if (this.suspended || this.options.stopped())
309
+ return;
259
310
  const active = this.options.runtime.activeSessionRun(sessionId);
260
311
  if (active === undefined)
261
312
  return;
@@ -323,6 +374,8 @@ export class ContinuousRunCoordinator {
323
374
  const goalMcp = pending.goal === undefined ? undefined : await this.options.goalMcp?.(executionInput);
324
375
  const scheduledMcp = await this.options.scheduledMcp?.(executionInput);
325
376
  const mailMcp = await this.options.mailMcp?.(executionInput);
377
+ if (this.suspended || this.options.stopped())
378
+ return;
326
379
  if (!this.goalCurrent(pending)) {
327
380
  this.dispatchedExecutions.delete(executionId);
328
381
  this.rejectStaleGoal(executionId);
@@ -77,12 +77,12 @@ export class DirectTransferService {
77
77
  handleTerminal(value) {
78
78
  return this.terminalDirect?.handle(value, (id) => this.transports.get(id)?.peer) ?? false;
79
79
  }
80
- close() {
80
+ close(code = 'DIRECT_NODE_STOPPED') {
81
81
  this.terminalDirect?.close();
82
82
  for (const transfer of [...this.transfers.values()])
83
- void this.removeTransfer(transfer, 'DIRECT_NODE_STOPPED');
83
+ void this.removeTransfer(transfer, code);
84
84
  for (const transport of [...this.transports.values()])
85
- this.removeTransport(transport, 'DIRECT_NODE_STOPPED');
85
+ this.removeTransport(transport, code);
86
86
  }
87
87
  dispose() {
88
88
  if (this.disposed)
@@ -215,6 +215,7 @@ export class DirectTransferService {
215
215
  uploadCommitted: false,
216
216
  opened: false,
217
217
  processing: Promise.resolve(),
218
+ controller: new AbortController(),
218
219
  lastLeaseAliveAt: this.now(),
219
220
  leaseExpiresAt: command.expiresAt,
220
221
  leaseTimer: undefined
@@ -267,10 +268,11 @@ export class DirectTransferService {
267
268
  channel.close();
268
269
  return;
269
270
  }
271
+ const pendingTransfer = this.transfers.get(openFrameSessionId(event.data) ?? '');
270
272
  void this.openChannel(transport, channel, event.data).catch((error) => {
271
273
  channel.close();
272
274
  const id = openFrameSessionId(event.data);
273
- if (id !== undefined)
275
+ if (id !== undefined && this.transfers.get(id) === pendingTransfer)
274
276
  this.fail(id, errorCode(error), errorMessage(error));
275
277
  });
276
278
  };
@@ -294,16 +296,25 @@ export class DirectTransferService {
294
296
  transfer.opened = true;
295
297
  channel.onmessage = (event) => {
296
298
  transfer.processing = transfer.processing
297
- .then(() => this.channelMessage(transfer, event.data))
298
- .catch((error) => this.fail(transfer.command.directSessionId, errorCode(error), errorMessage(error)));
299
+ .then(() => {
300
+ if (this.current(transfer))
301
+ return this.channelMessage(transfer, event.data);
302
+ })
303
+ .catch((error) => {
304
+ if (this.current(transfer))
305
+ this.fail(transfer.command.directSessionId, errorCode(error), errorMessage(error));
306
+ });
299
307
  };
300
308
  channel.onclose = () => {
301
309
  if (this.transfers.get(transfer.command.directSessionId) === transfer)
302
310
  void this.removeTransfer(transfer, 'DIRECT_CHANNEL_CLOSED');
303
311
  };
304
- await this.open(transfer);
312
+ transfer.processing = this.open(transfer);
313
+ await transfer.processing;
305
314
  }
306
315
  async channelMessage(transfer, data) {
316
+ if (!this.current(transfer))
317
+ return;
307
318
  const descriptor = transfer.command.transfer;
308
319
  const expectedSize = transferredSize(descriptor);
309
320
  if (descriptor.direction === 'DOWNLOAD') {
@@ -326,8 +337,10 @@ export class DirectTransferService {
326
337
  throw new Error('DIRECT_FRAME_INVALID');
327
338
  if (control.size !== descriptor.size)
328
339
  throw new Error('DIRECT_INTEGRITY_FAILED');
329
- await transfer.upload.complete(control.size);
340
+ await transfer.upload.complete(control.size, transfer.controller.signal);
330
341
  transfer.uploadCommitted = true;
342
+ if (!this.current(transfer))
343
+ return;
331
344
  this.emit(transfer.command.directSessionId, {
332
345
  type: 'direct.complete',
333
346
  directSessionId: transfer.command.directSessionId,
@@ -340,6 +353,8 @@ export class DirectTransferService {
340
353
  if (bytes.byteLength === 0 || bytes.byteLength > 256 * 1024 || transfer.upload === undefined)
341
354
  throw new Error('DIRECT_CHUNK_INVALID');
342
355
  const offset = await transfer.upload.write(bytes);
356
+ if (!this.current(transfer))
357
+ return;
343
358
  transfer.channel?.send(JSON.stringify({ type: 'ACK', offset }));
344
359
  }
345
360
  async open(transfer) {
@@ -348,6 +363,11 @@ export class DirectTransferService {
348
363
  if (this.options.openUpload === undefined)
349
364
  throw new Error('DIRECT_UPLOAD_UNAVAILABLE');
350
365
  transfer.upload = await this.options.openUpload(descriptor);
366
+ if (!this.current(transfer)) {
367
+ if (!transfer.preserveUpload)
368
+ await this.cancelUpload(transfer);
369
+ return;
370
+ }
351
371
  transfer.channel?.send(JSON.stringify({ type: 'ACK', offset: transfer.upload.offset }));
352
372
  this.emit(transfer.command.directSessionId, {
353
373
  type: 'direct.opened',
@@ -359,6 +379,8 @@ export class DirectTransferService {
359
379
  if (this.options.openDownload === undefined)
360
380
  throw new Error('DIRECT_DOWNLOAD_UNAVAILABLE');
361
381
  const source = await this.options.openDownload(descriptor);
382
+ if (!this.current(transfer))
383
+ return;
362
384
  const expectedSize = transferredSize(descriptor);
363
385
  transfer.channel?.send(JSON.stringify({ type: 'ACK', offset: source.offset }));
364
386
  this.emit(transfer.command.directSessionId, {
@@ -444,12 +466,20 @@ export class DirectTransferService {
444
466
  });
445
467
  }
446
468
  async removeTransfer(transfer, code) {
447
- if (!this.transfers.delete(transfer.command.directSessionId))
469
+ if (!this.current(transfer))
448
470
  return;
471
+ this.transfers.delete(transfer.command.directSessionId);
449
472
  clearInterval(transfer.leaseTimer);
450
- if (!transfer.uploadCommitted)
451
- await transfer.upload?.cancel().catch(() => undefined);
473
+ const preserveWorkspaceUpload = transfer.command.transfer.direction === 'UPLOAD' &&
474
+ transfer.command.transfer.composerAttachment === undefined &&
475
+ (code === 'DIRECT_CHANNEL_CLOSED' ||
476
+ code === 'DIRECT_CONNECTION_FAILED' ||
477
+ code === 'DIRECT_TRANSFER_CONTROL_UNAVAILABLE');
478
+ transfer.preserveUpload = preserveWorkspaceUpload;
479
+ transfer.controller.abort();
452
480
  transfer.channel?.close();
481
+ if (!transfer.uploadCommitted && !preserveWorkspaceUpload)
482
+ await this.cancelUpload(transfer);
453
483
  if (code !== undefined)
454
484
  this.options.send('direct.error', {
455
485
  type: 'direct.error',
@@ -458,6 +488,23 @@ export class DirectTransferService {
458
488
  message: directErrorMessage(code)
459
489
  });
460
490
  }
491
+ current(transfer) {
492
+ return (this.transfers.get(transfer.command.directSessionId) === transfer &&
493
+ !transfer.controller.signal.aborted);
494
+ }
495
+ async cancelUpload(transfer) {
496
+ if (transfer.upload === undefined)
497
+ return;
498
+ transfer.cleanup ??= transfer.upload.cancel().catch(() => {
499
+ this.options.send('direct.error', {
500
+ type: 'direct.error',
501
+ directSessionId: transfer.command.directSessionId,
502
+ code: 'DIRECT_UPLOAD_CLEANUP_FAILED',
503
+ message: 'Temporary upload cleanup could not be completed.'
504
+ });
505
+ });
506
+ await transfer.cleanup;
507
+ }
461
508
  }
462
509
  function transferredSize(descriptor) {
463
510
  return descriptor.direction === 'DOWNLOAD' && descriptor.range !== undefined
@@ -70,7 +70,8 @@ export class ExternalSessionResumeService {
70
70
  access: configuration.access,
71
71
  ...(session.runnerTitle === null ? {} : { title: session.runnerTitle }),
72
72
  ...(session.customTitle === null ? {} : { customTitle: session.customTitle }),
73
- sessionPrompt: session.sessionPrompt ?? null
73
+ sessionPrompt: session.sessionPrompt ?? null,
74
+ parentSessionId: session.parentSessionId ?? null
74
75
  });
75
76
  },
76
77
  workspacePath: this.options.workspacePath,
@@ -31,6 +31,8 @@ export function stripGoalStartPrompt(text) {
31
31
  return close === -1 ? '' : text.slice(close + tags.close.length).trimStart();
32
32
  }
33
33
  export function publicGoalHistoryText(text) {
34
+ if (parseComposerTags(text)[0]?.kind === 'mar_restart')
35
+ return serializeComposerTag('mar_restart', {});
34
36
  const tags = goalPromptTags(text);
35
37
  if (tags === undefined)
36
38
  return text;
@@ -61,6 +63,17 @@ export class GoalRuntime {
61
63
  get(sessionId) {
62
64
  return this.goals.get(sessionId);
63
65
  }
66
+ restartState(sessionId) {
67
+ const goal = this.goals.get(sessionId);
68
+ return goal === undefined
69
+ ? undefined
70
+ : { goal: { ...goal }, executionFailures: this.executionFailures.get(sessionId) ?? 0 };
71
+ }
72
+ restoreRestartState(state) {
73
+ this.goals.set(state.goal.sessionId, { ...state.goal });
74
+ this.executionFailures.set(state.goal.sessionId, state.executionFailures);
75
+ this.changed(state.goal);
76
+ }
64
77
  migrateSession(previousId, nextId) {
65
78
  if (previousId === nextId)
66
79
  return;
@@ -1,4 +1,4 @@
1
- import { modelUsageGrantSchema } from '@myagentroam/protocol';
1
+ import { modelUsageGrantSchema, steerComposerMessage } from '@myagentroam/protocol';
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
3
  import { constants } from 'node:fs';
4
4
  import { lstat, mkdir, open, readFile, rename, rm, utimes } from 'node:fs/promises';
@@ -446,12 +446,13 @@ export class HostedConversationNodeService {
446
446
  throw new Error('HOSTED_CONVERSATION_RUN_ACTIVE');
447
447
  return this.startMessage(payload, input);
448
448
  }
449
+ const steerPrompt = steerComposerMessage(input.prompt);
449
450
  if (!this.interruptActiveRun(payload.userId, conversation, handle))
450
451
  throw new Error('HOSTED_CONVERSATION_RUN_ACTIVE');
451
452
  await this.runSettlements.get(conversation.id)?.catch(() => undefined);
452
- return this.startMessage(payload, input);
453
+ return this.startMessage(payload, input, steerPrompt);
453
454
  }
454
- async startMessage(payload, input) {
455
+ async startMessage(payload, input, steerPrompt) {
455
456
  let conversation = this.options.store.requireConversation(payload.userId, input.conversationId);
456
457
  if (this.cleanupLocks.has(conversation.id))
457
458
  throw new Error('HOSTED_CONVERSATION_STATE_INVALID');
@@ -494,7 +495,7 @@ export class HostedConversationNodeService {
494
495
  selected = selectModel(version, models, input.modelId ?? conversation.modelId);
495
496
  }
496
497
  const attachments = await Promise.all(input.attachmentIds.map((fileId) => this.requireAttachment(payload.userId, conversation.id, fileId)));
497
- const prompt = attachmentPrompt(input.prompt, attachments);
498
+ const prompt = attachmentPrompt(steerPrompt ?? input.prompt, attachments);
498
499
  const generatedImagesDirectory = marAgentGeneratedImagesDirectory(paths.homeDirectory, conversation.sessionId);
499
500
  const generatedImagesLogicalPath = hostedConversationGeneratedImagesDisplayDirectory(conversation.sessionId);
500
501
  if (generatedImagesDirectory === undefined || generatedImagesLogicalPath === undefined)