@myagentroam/node 0.10.36 → 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.
package/dist/connector.js CHANGED
@@ -1476,6 +1476,7 @@ export class NodeConnector {
1476
1476
  onConnected: () => {
1477
1477
  this.runtimeCredentials.connected();
1478
1478
  this.scheduledRunAuthorization.connected();
1479
+ this.scheduledSessions.connected();
1479
1480
  this.restartAuthorization.connected();
1480
1481
  if (!this.stopped)
1481
1482
  void this.restartHandoff.recover();
@@ -1866,6 +1867,7 @@ export class NodeConnector {
1866
1867
  this.usageOutbox.recoverInterrupted();
1867
1868
  this.database.closeRunningTerminalSessions();
1868
1869
  await this.restartHandoff.load();
1870
+ this.scheduledSessions.recoverInterrupted();
1869
1871
  this.scheduledSessions.start();
1870
1872
  await this.initializeConversationHosting();
1871
1873
  this.computerUseEnablement.initialize();
package/dist/database.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { NODE_SCHEMA_MIGRATION_V17 } from './migrations/v017.js';
2
2
  import { NODE_SCHEMA_MIGRATION_V18 } from './migrations/v018.js';
3
3
  import { NODE_SCHEMA_MIGRATION_V19 } from './migrations/v019.js';
4
+ import { NODE_SCHEMA_MIGRATION_V20 } from './migrations/v020.js';
4
5
  import { normalizeSessionPrompt } from './util/personal-instructions.js';
5
6
  import { randomUUID } from 'node:crypto';
6
7
  import { closeSync, existsSync, mkdirSync, openSync, rmSync } from 'node:fs';
@@ -21,7 +22,8 @@ export const NODE_SCHEMA_MIGRATIONS = [
21
22
  NODE_SCHEMA_MIGRATION_V16,
22
23
  NODE_SCHEMA_MIGRATION_V17,
23
24
  NODE_SCHEMA_MIGRATION_V18,
24
- NODE_SCHEMA_MIGRATION_V19
25
+ NODE_SCHEMA_MIGRATION_V19,
26
+ NODE_SCHEMA_MIGRATION_V20
25
27
  ];
26
28
  const LEGACY_NODE_SCHEMA_V12 = NODE_SCHEMA_VERSION_V13 - 1;
27
29
  const OBSOLETE_NODE_SCHEMA_V16 = 16;
@@ -0,0 +1,46 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { NODE_SCHEMA_V13 } from './v013.js';
3
+ import { NODE_SCHEMA_MIGRATION_V14 } from './v014.js';
4
+ import { NODE_SCHEMA_MIGRATION_V15 } from './v015.js';
5
+ import { NODE_SCHEMA_MIGRATION_V16 } from './v016.js';
6
+ import { NODE_SCHEMA_MIGRATION_V17 } from './v017.js';
7
+ import { NODE_SCHEMA_MIGRATION_V18 } from './v018.js';
8
+ import { NODE_SCHEMA_MIGRATION_V19 } from './v019.js';
9
+ const sql = `CREATE INDEX scheduled_session_occurrences_unfinished
10
+ ON scheduled_session_occurrences(status) WHERE status IN ('CLAIMED','SUBMITTED');`;
11
+ export const NODE_SCHEMA_MIGRATION_V20 = {
12
+ version: 20,
13
+ sql,
14
+ preflight: (database) => NODE_SCHEMA_MIGRATION_V19.assert(database),
15
+ assert(database) {
16
+ const reference = new DatabaseSync(':memory:');
17
+ try {
18
+ reference.exec(NODE_SCHEMA_V13);
19
+ for (const migration of [
20
+ NODE_SCHEMA_MIGRATION_V14,
21
+ NODE_SCHEMA_MIGRATION_V15,
22
+ NODE_SCHEMA_MIGRATION_V16,
23
+ NODE_SCHEMA_MIGRATION_V17,
24
+ NODE_SCHEMA_MIGRATION_V18,
25
+ NODE_SCHEMA_MIGRATION_V19
26
+ ])
27
+ reference.exec(migration.sql);
28
+ reference.exec(sql);
29
+ const schema = (db) => db
30
+ .prepare("SELECT type,name,sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY type,name")
31
+ .all()
32
+ .map((row) => [
33
+ row.type,
34
+ row.name,
35
+ row.name === 'node_schema_migrations'
36
+ ? String(row.sql).replace(/\s+/g, '')
37
+ : String(row.sql).replace(/\s+/g, ' ').trim()
38
+ ]);
39
+ if (JSON.stringify(schema(reference)) !== JSON.stringify(schema(database)))
40
+ throw new Error('NODE_DATABASE_SCHEMA_INVALID:scheduled_session_occurrences_unfinished');
41
+ }
42
+ finally {
43
+ reference.close();
44
+ }
45
+ }
46
+ };
@@ -0,0 +1,126 @@
1
+ /** Read-only platform evidence; never inject input or replace clipboard contents. */
2
+ export const PYTHON_PROBE_SOURCE = String.raw `
3
+ import ctypes
4
+ import json
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+
10
+ result = dict(supportsScreenshot=False, supportsPrimaryDisplay=False,
11
+ supportsInput=False, supportsUnicodePaste=False,
12
+ reasonCode="COMPUTER_RUNTIME_UNAVAILABLE")
13
+
14
+ def signature(library, name, arguments, returns):
15
+ function = getattr(library, name)
16
+ function.argtypes = arguments
17
+ function.restype = returns
18
+ return function
19
+
20
+ def windows_desktop():
21
+ user = ctypes.WinDLL("user32", use_last_error=True)
22
+ kernel = ctypes.WinDLL("kernel32", use_last_error=True)
23
+ handle, integer = ctypes.c_void_p, ctypes.c_uint32
24
+ open_desktop = signature(user, "OpenInputDesktop", [integer, ctypes.c_int, integer], handle)
25
+ close_desktop = signature(user, "CloseDesktop", [handle], ctypes.c_int)
26
+ thread_id = signature(kernel, "GetCurrentThreadId", [], integer)
27
+ thread_desktop = signature(user, "GetThreadDesktop", [integer], handle)
28
+ station = signature(user, "GetProcessWindowStation", [], handle)
29
+ information = signature(user, "GetUserObjectInformationW",
30
+ [handle, ctypes.c_int, handle, integer, handle], ctypes.c_int)
31
+ def name(object_handle):
32
+ buffer = ctypes.create_unicode_buffer(256)
33
+ needed = integer()
34
+ if not information(object_handle, 2, buffer, ctypes.sizeof(buffer), ctypes.byref(needed)):
35
+ return None
36
+ return buffer.value
37
+ # DESKTOP_READOBJECTS | DESKTOP_WRITEOBJECTS. Do not switch or attach desktops.
38
+ desktop = open_desktop(0, False, 0x0001 | 0x0080)
39
+ if not desktop:
40
+ return False
41
+ try:
42
+ desktop_name = name(desktop)
43
+ return (name(station()) == "WinSta0" and desktop_name is not None
44
+ and desktop_name == name(thread_desktop(thread_id())))
45
+ finally:
46
+ close_desktop(desktop)
47
+
48
+ def input_supported(gui):
49
+ if sys.platform == "win32":
50
+ return windows_desktop()
51
+ if sys.platform == "darwin":
52
+ services = ctypes.CDLL("/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices")
53
+ graphics = ctypes.CDLL("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")
54
+ trusted = signature(services, "AXIsProcessTrusted", [], ctypes.c_bool)
55
+ post_access = signature(graphics, "CGPreflightPostEventAccess", [], ctypes.c_bool)
56
+ return bool(trusted() and post_access())
57
+ if sys.platform == "linux":
58
+ display = gui._pyautogui_x11._display
59
+ if not display.has_extension("XTEST"):
60
+ return False
61
+ display.xtest_get_version(2, 2)
62
+ return True
63
+ return False
64
+
65
+ def clipboard_supported():
66
+ import pyperclip
67
+ copy, paste = pyperclip.determine_clipboard()
68
+ if not callable(copy) or not callable(paste) or not copy or not paste:
69
+ return False
70
+ names = (getattr(copy, "__name__", ""), getattr(paste, "__name__", ""))
71
+ if sys.platform == "win32" and names == ("copy_windows", "paste_windows"):
72
+ user = ctypes.WinDLL("user32", use_last_error=True)
73
+ open_clipboard = signature(user, "OpenClipboard", [ctypes.c_void_p], ctypes.c_int)
74
+ close_clipboard = signature(user, "CloseClipboard", [], ctypes.c_int)
75
+ if not open_clipboard(None):
76
+ return False
77
+ # Opening and closing does not empty, read, or take ownership of the clipboard.
78
+ return bool(close_clipboard())
79
+ if sys.platform == "darwin":
80
+ import AppKit
81
+ board = AppKit.NSPasteboard.generalPasteboard()
82
+ if board is None:
83
+ return False
84
+ # Newer systems expose pasteboard policy. Do not provoke an access prompt.
85
+ if hasattr(board, "accessBehavior"):
86
+ allowed = getattr(AppKit, "NSPasteboardAccessBehaviorAlwaysAllow", None)
87
+ if allowed is None or board.accessBehavior() != allowed:
88
+ return False
89
+ if names == ("copy_osx_pyobjc", "paste_osx_pyobjc"):
90
+ return True
91
+ return (names == ("copy_osx_pbcopy", "paste_osx_pbcopy")
92
+ and shutil.which("pbcopy") is not None and shutil.which("pbpaste") is not None)
93
+ if sys.platform == "linux":
94
+ commands = {("copy_xclip", "paste_xclip"): ["xclip", "-version"],
95
+ ("copy_xsel", "paste_xsel"): ["xsel", "--version"]}
96
+ command = commands.get(names)
97
+ executable = shutil.which(command[0]) if command is not None else None
98
+ if executable is None:
99
+ return False
100
+ # X11 access was established above. Verify the selected backend can run,
101
+ # without consuming a one-shot selection or requesting clipboard contents.
102
+ subprocess.run([executable, *command[1:]], stdin=subprocess.DEVNULL,
103
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True, timeout=2)
104
+ return True
105
+ return False
106
+
107
+ try:
108
+ if sys.platform not in ("win32", "darwin", "linux") or (sys.platform == "linux" and
109
+ (not os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))):
110
+ result["reasonCode"] = "COMPUTER_DESKTOP_UNSUPPORTED"
111
+ else:
112
+ import pyautogui
113
+ image = pyautogui.screenshot()
114
+ if image.width > 0 and image.height > 0:
115
+ result["supportsScreenshot"] = result["supportsPrimaryDisplay"] = True
116
+ result["reasonCode"] = "COMPUTER_INPUT_PERMISSION_DENIED"
117
+ if input_supported(pyautogui):
118
+ result["supportsInput"] = True
119
+ result["reasonCode"] = "COMPUTER_RUNTIME_UNAVAILABLE"
120
+ if clipboard_supported():
121
+ result["supportsUnicodePaste"] = True
122
+ result["reasonCode"] = None
123
+ except Exception:
124
+ pass
125
+ print(json.dumps(result))
126
+ `;
@@ -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
  }
@@ -27,7 +27,7 @@ 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
  '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.',
30
- 'Send urgent corrections with sessions send <session-id> --forced -m "Use the corrected acceptance criteria" --json. Always choose exactly one of --forced or --queue, even when idle; missing or conflicting modes are rejected locally. --forced inserts: send immediately when idle; if busy, interrupt the current execution and prioritize the new input after release, not live steering. Use --queue to wait for an eligible FIFO turn without interrupting or resuming a paused queue; eligible input can start immediately when idle. For non-urgent follow-ups, use sessions send <session-id> --queue -m "Include test evidence" --json. 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.',
31
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.',
32
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.',
33
33
  '## Verify, recover and respect authorization',
@@ -115,6 +115,23 @@ export class ContinuousRunCoordinator {
115
115
  throw new Error('SESSION_NOT_FOUND');
116
116
  const active = this.options.runtime.activeSessionRun(sessionId);
117
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
+ });
118
135
  const executionEnded = active !== undefined &&
119
136
  this.options.runtime.currentExecutionId(active.id) === undefined &&
120
137
  activeTurn !== undefined &&
@@ -123,9 +140,11 @@ export class ContinuousRunCoordinator {
123
140
  activeTurn.status === 'INTERRUPTED');
124
141
  const queueVersionBeforeSettle = this.options.runtime.sessionQueue(sessionId).version;
125
142
  const startsAcceptedRun = active !== undefined && input.submissionId === active.id;
126
- if (startsAcceptedRun || !input.queueBefore.paused || input.intent !== 'QUEUE')
143
+ if (startsAcceptedRun || !input.queueBefore.paused || intent !== 'QUEUE')
127
144
  this.options.runtime.resumeSessionQueue(sessionId);
128
- if (input.activeRunId !== undefined && active?.id === input.activeRunId) {
145
+ if (input.activeRunId !== undefined &&
146
+ active?.id === input.activeRunId &&
147
+ (input.steerTarget === undefined || steering)) {
129
148
  if (item !== undefined)
130
149
  this.immediateQueueItems.add(item.id);
131
150
  if (executionEnded) {
@@ -157,13 +176,13 @@ export class ContinuousRunCoordinator {
157
176
  return 'QUEUED';
158
177
  }
159
178
  if (active === undefined && item !== undefined) {
160
- if (input.queueBefore.paused && input.intent === 'SEND')
179
+ if (input.queueBefore.paused && intent === 'SEND')
161
180
  this.options.runtime.prioritizeQueueItem(item.id);
162
181
  this.startIdleQueue(sessionId);
163
182
  return 'STARTED';
164
183
  }
165
184
  if (executionEnded && item !== undefined) {
166
- if (input.queueBefore.paused && input.intent === 'SEND') {
185
+ if (input.queueBefore.paused && intent === 'SEND') {
167
186
  this.options.runtime.prioritizeQueueItem(item.id);
168
187
  this.publish(sessionId);
169
188
  }
@@ -211,7 +230,9 @@ export class ContinuousRunCoordinator {
211
230
  content: this.options.runtime.getQueueItem(itemId)?.content ?? input
212
231
  }
213
232
  }),
214
- input: withNonImageAttachmentPrompt(resources, pending.attachmentPaths, pending.attachmentFiles)
233
+ input: withNonImageAttachmentPrompt(pending.precedingInput === undefined
234
+ ? resources
235
+ : `${pending.precedingInput}\n\n${resources}`, pending.attachmentPaths, pending.attachmentFiles)
215
236
  });
216
237
  }
217
238
  deleteQueueItem(itemId) {
@@ -252,6 +273,7 @@ export class ContinuousRunCoordinator {
252
273
  if (current !== undefined && replacement !== undefined)
253
274
  this.preparedInputs.set(item.id, {
254
275
  ...replacement,
276
+ precedingInput: current.input,
255
277
  input: `${current.input}\n\n${replacement.input}`
256
278
  });
257
279
  }
@@ -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)
@@ -1,13 +1,26 @@
1
+ import { SCHEDULE_RESTART_OUTCOME_UNKNOWN } from './scheduled-session-store.js';
1
2
  export class ScheduledSessionCoordinator {
2
3
  options;
3
4
  #now;
4
5
  #timer;
5
6
  #running = false;
6
7
  #closed = true;
8
+ #recovered = [];
7
9
  constructor(options) {
8
10
  this.options = options;
9
11
  this.#now = options.now ?? Date.now;
10
12
  }
13
+ /** Called only at Node startup, before admitting any new scheduled occurrence. */
14
+ recoverInterrupted() {
15
+ for (const recovered of this.options.store().recoverInterrupted())
16
+ this.#recovered.push(recovered);
17
+ }
18
+ connected() {
19
+ // Startup has no control connection yet. Send these best-effort alerts once it is ready.
20
+ for (const { task, occurrenceId } of this.#recovered.splice(0)) {
21
+ this.options.notify(task, occurrenceId, SCHEDULE_RESTART_OUTCOME_UNKNOWN, 'FAILED');
22
+ }
23
+ }
11
24
  start() {
12
25
  if (!this.#closed)
13
26
  return;
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { assertScheduleTimezone, nextScheduledAt, parseScheduledCron } from './scheduled-session-recurrence.js';
3
+ export const SCHEDULE_RESTART_OUTCOME_UNKNOWN = 'SCHEDULE_RESTART_OUTCOME_UNKNOWN';
3
4
  export class ScheduledSessionStore {
4
5
  database;
5
6
  now;
@@ -176,6 +177,26 @@ export class ScheduledSessionStore {
176
177
  throw error;
177
178
  }
178
179
  }
180
+ recoverInterrupted() {
181
+ return this.write(() => {
182
+ // FAILED closes scheduling bookkeeping, not the unknown execution or its side effects.
183
+ const rows = this.database
184
+ .prepare(`UPDATE scheduled_session_occurrences SET status='FAILED',code=?,updated_at=?
185
+ WHERE status IN ('CLAIMED','SUBMITTED') RETURNING id,task_id`)
186
+ .all(SCHEDULE_RESTART_OUTCOME_UNKNOWN, this.now());
187
+ const tasks = new Map();
188
+ for (const row of rows) {
189
+ if (tasks.has(row.task_id))
190
+ continue;
191
+ const task = this.getById(row.task_id);
192
+ if (task === undefined)
193
+ throw new Error('SCHEDULE_TASK_NOT_FOUND');
194
+ tasks.set(task.id, task);
195
+ this.pruneTerminal(row.id);
196
+ }
197
+ return rows.map((row) => ({ task: tasks.get(row.task_id), occurrenceId: row.id }));
198
+ });
199
+ }
179
200
  finish(occurrenceId, status, code, runId) {
180
201
  const result = this.database
181
202
  .prepare(`UPDATE scheduled_session_occurrences SET status=?,code=?,run_id=?,updated_at=?
@@ -183,6 +204,9 @@ export class ScheduledSessionStore {
183
204
  .run(status, code ?? null, runId ?? null, this.now(), occurrenceId);
184
205
  if (result.changes === 0 || status === 'SUBMITTED')
185
206
  return;
207
+ this.pruneTerminal(occurrenceId);
208
+ }
209
+ pruneTerminal(occurrenceId) {
186
210
  this.database
187
211
  .prepare(`DELETE FROM scheduled_session_occurrences
188
212
  WHERE task_id=(SELECT task_id FROM scheduled_session_occurrences WHERE id=?)
@@ -5,7 +5,7 @@ import { parseSecretEnvironment } from '../util/secret-environment.js';
5
5
  import { personalInstructionsForRunner } from '../util/personal-instructions.js';
6
6
  import { safeErrorCode } from '../util/safe-error.js';
7
7
  import { publicGoalStartText } from './goal-runtime.js';
8
- import { serializeComposerTag, composerVisibleText } from '@myagentroam/protocol';
8
+ import { serializeComposerTag, composerVisibleText, steerComposerMessage } from '@myagentroam/protocol';
9
9
  import { relative } from 'node:path';
10
10
  import { prepareComposerResourceInput, composerResourceInputTags, validateComposerSkills } from './composer-message-input.js';
11
11
  const INTERNAL_GOAL = Symbol('internalGoal');
@@ -161,6 +161,19 @@ export class SessionMessageService {
161
161
  if (internalSchedule && (active !== undefined || queueBeforeCreate.itemIds.length > 0))
162
162
  throw new Error('SCHEDULE_SESSION_BUSY');
163
163
  const replaceCurrentRun = intent === 'REPLACE_CURRENT' ? active : undefined;
164
+ const userSteer = requestedIntent === 'REPLACE_CURRENT' &&
165
+ goalMessage?.activated !== true &&
166
+ !internalGoal &&
167
+ !internalSchedule &&
168
+ !internalRestore;
169
+ const steerTarget = userSteer
170
+ ? {
171
+ turnId: active === undefined
172
+ ? undefined
173
+ : this.options.runtime.conversationTurnForRun(active.id)?.id,
174
+ executionId: active === undefined ? undefined : this.options.runtime.currentExecutionId(active.id)
175
+ }
176
+ : undefined;
164
177
  const acceptedMessage = {
165
178
  runId,
166
179
  sessionId: session.id,
@@ -245,6 +258,11 @@ export class SessionMessageService {
245
258
  workspaceId: currentSession.workspaceId,
246
259
  runner: currentSession.runner,
247
260
  input: runner.prepareMessageInput(runnerInput, planActive ? 'plan' : 'default'),
261
+ ...(userSteer
262
+ ? {
263
+ prepareSteerInput: () => runner.prepareMessageInput(steerComposerMessage(runnerInput), planActive ? 'plan' : 'default')
264
+ }
265
+ : {}),
248
266
  clientMessageId,
249
267
  attachments: runnerImageAttachments,
250
268
  attachmentPaths,
@@ -285,6 +303,7 @@ export class SessionMessageService {
285
303
  submissionId,
286
304
  intent,
287
305
  queueBefore: queueBeforeCreate,
306
+ ...(steerTarget === undefined ? {} : { steerTarget }),
288
307
  ...(replaceCurrentRun === undefined ? {} : { activeRunId: replaceCurrentRun.id })
289
308
  });
290
309
  reservation?.commit((directories) => this.options.attachments.adoptSessionFiles(currentSession.id, submissionId, directories, currentSession.workspaceId, attachmentPaths));
@@ -1,3 +1,4 @@
1
+ import { steerComposerMessage } from '@myagentroam/protocol';
1
2
  import { isPlainRecord, withNonImageAttachmentPrompt } from '../util/node-operation-parsers.js';
2
3
  import { assertQueueItemOwner, assertSessionControlOwner } from './run-ownership-policy.js';
3
4
  export class WorkspaceQueueWorkbenchService {
@@ -118,12 +119,13 @@ export class WorkspaceQueueWorkbenchService {
118
119
  const item = this.requireItem(input.queueItemId, input.workspaceId);
119
120
  assertQueueItemOwner(item, input.__requestUserId);
120
121
  const content = input.content.trim();
121
- this.options.runtime.updateQueueItem(item.id, content);
122
122
  const prepared = this.options.coordinator.pending(item.id);
123
- if (prepared !== undefined) {
124
- const runnerInput = withNonImageAttachmentPrompt(content, prepared.attachmentPaths, prepared.attachmentFiles);
125
- this.options.coordinator.updatePreparedInput(item.id, this.options.prepareInput(prepared.runner, runnerInput, prepared.collaborationMode));
126
- }
123
+ const runnerInput = prepared === undefined
124
+ ? undefined
125
+ : this.options.prepareInput(prepared.runner, withNonImageAttachmentPrompt(prepared.steering ? steerComposerMessage(content) : content, prepared.attachmentPaths, prepared.attachmentFiles), prepared.collaborationMode);
126
+ this.options.runtime.updateQueueItem(item.id, content);
127
+ if (runnerInput !== undefined)
128
+ this.options.coordinator.updatePreparedInput(item.id, runnerInput);
127
129
  this.options.emit(item.workspaceId, item.sessionId);
128
130
  return {
129
131
  queue: this.present(item.workspaceId, item.sessionId),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.10.36",
3
+ "version": "0.10.37",
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.36",
40
+ "@myagentroam/marctl": "0.10.7",
41
+ "@myagentroam/agent": "0.10.37",
41
42
  "@myagentroam/code-navigation": "0.10.1",
42
- "@myagentroam/marctl": "0.10.6",
43
- "@myagentroam/protocol": "0.10.36"
43
+ "@myagentroam/protocol": "0.10.37"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/ws": "^8.18.1"