@myagentroam/node 0.11.25 → 0.11.26

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.
@@ -235,8 +235,8 @@ export class CodexAppServerClient {
235
235
  ...codexThreadConfiguration(cwd, configuration)
236
236
  });
237
237
  }
238
- listThreads() {
239
- return this.request('thread/list', {});
238
+ listThreads(params = {}) {
239
+ return this.request('thread/list', params);
240
240
  }
241
241
  readThread(threadId) {
242
242
  return this.request('thread/read', { threadId, includeTurns: false });
package/dist/connector.js CHANGED
@@ -1079,7 +1079,7 @@ export class NodeConnector {
1079
1079
  cachedNative: () => [...this.sessionState.directNative.values()],
1080
1080
  cachedWorkspace: (workspaceId) => this.workspaceCoordinator.cachedSessions(workspaceId),
1081
1081
  discoveryPending: (workspaceId) => this.workspaceSessionService.discoveryPending(workspaceId),
1082
- discoverWorkspace: (workspaceId) => this.nativeSessionProjectionService.discoverWorkspace(workspaceId),
1082
+ discoverWorkspace: (workspaceId, cleanup) => this.nativeSessionProjectionService.discoverWorkspace(workspaceId, undefined, cleanup),
1083
1083
  present: (session) => this.sessionPresentationService.present(session),
1084
1084
  nativeIds: (session) => this.nativeSessionProjectionService.nativeIds(session),
1085
1085
  forget: (session, nativeIds) => {
@@ -76,10 +76,6 @@ export class AbstractRunner {
76
76
  ...(input.pinned === undefined ? {} : { pinned: input.pinned })
77
77
  });
78
78
  }
79
- async isNativeSessionMissing(session) {
80
- void session;
81
- return false;
82
- }
83
79
  async removeNative(session, nativeSessionId) {
84
80
  void [session, nativeSessionId];
85
81
  return false;
@@ -92,8 +88,8 @@ export class AbstractRunner {
92
88
  void session;
93
89
  return false;
94
90
  }
95
- async rollbackNative(session, boundary, targetTurnBoundary) {
96
- void [session, boundary, targetTurnBoundary];
91
+ async rollbackNative(session, boundary, targetTurnBoundary, targetClientMessageId) {
92
+ void [session, boundary, targetTurnBoundary, targetClientMessageId];
97
93
  throw new RunnerCapabilityError(this.name, 'session.rollback');
98
94
  }
99
95
  async clearNative(session) {
@@ -128,7 +128,9 @@ export class ClaudeCodeRunner extends AbstractRunner {
128
128
  try {
129
129
  official = await this.listSessions(target.path);
130
130
  }
131
- catch {
131
+ catch (error) {
132
+ if (context.cleanup)
133
+ throw error;
132
134
  // Native transcripts remain authoritative when SDK listing is unavailable.
133
135
  }
134
136
  const native = await discoverNativeSessions(this.name, target.path);
@@ -1376,7 +1376,17 @@ export function extractCodexThreads(result) {
1376
1376
  const preview = typeof value.preview === 'string' ? value.preview.trim() : '';
1377
1377
  const title = name || preview || undefined;
1378
1378
  const titleOrigin = name ? 'OFFICIAL' : preview ? 'PREVIEW' : undefined;
1379
- return [{ id: value.id, source, derived, cwd: value.cwd, title, titleOrigin }];
1379
+ return [
1380
+ {
1381
+ id: value.id,
1382
+ source,
1383
+ derived,
1384
+ cwd: value.cwd,
1385
+ title,
1386
+ titleOrigin,
1387
+ lastActivityAt: codexTimestamp(value.updatedAt) ?? 0
1388
+ }
1389
+ ];
1380
1390
  });
1381
1391
  }
1382
1392
  export function codexThreadCwd(value) {
@@ -1,5 +1,5 @@
1
1
  import { withMarctlGuide } from '../mcp/marctl-guide.js';
2
- import { CodexAppServerClient, CodexAppServerRpcError, isCodexRolloutMissingError } from '../codex-app-server.js';
2
+ import { CodexAppServerClient, isCodexRolloutMissingError } from '../codex-app-server.js';
3
3
  import { AbstractRunner } from './abstract-runner.js';
4
4
  import { declaredRunnerProfiles } from '../runner-profiles.js';
5
5
  import { homedir } from 'node:os';
@@ -280,22 +280,6 @@ export class CodexRunner extends AbstractRunner {
280
280
  }
281
281
  return input.title;
282
282
  }
283
- async isNativeSessionMissing(session) {
284
- if (session.externalSessionId === null)
285
- return false;
286
- await this.start();
287
- try {
288
- await this.client.readThread(session.externalSessionId);
289
- return false;
290
- }
291
- catch (error) {
292
- if (error instanceof CodexAppServerRpcError &&
293
- error.code === -32600 &&
294
- /^no rollout found for thread id\b/i.test(error.rpcMessage))
295
- return true;
296
- throw error;
297
- }
298
- }
299
283
  async readConversationHistory(readers) {
300
284
  const page = await readers.official();
301
285
  return page === undefined ? undefined : { ...page, source: 'official' };
@@ -422,16 +406,50 @@ export class CodexRunner extends AbstractRunner {
422
406
  return { failureCode: 'RUNNER_RESUME_FAILED' };
423
407
  }
424
408
  }
409
+ async cleanupThreads() {
410
+ await this.start();
411
+ const threads = [];
412
+ const cursors = new Set();
413
+ let cursor;
414
+ do {
415
+ const result = await this.client.listThreads({
416
+ limit: 200,
417
+ ...(cursor === undefined ? {} : { cursor })
418
+ });
419
+ if (!isPlainRecord(result) ||
420
+ !Array.isArray(result.data) ||
421
+ result.data.some((entry) => !isPlainRecord(entry) ||
422
+ typeof entry.id !== 'string' ||
423
+ entry.id.length === 0 ||
424
+ typeof entry.cwd !== 'string' ||
425
+ entry.cwd.length === 0))
426
+ throw new Error('CODEX_THREAD_LIST_INVALID');
427
+ threads.push(...extractCodexThreads(result));
428
+ const next = result.nextCursor;
429
+ if (next === null || next === undefined)
430
+ return threads;
431
+ if (typeof next !== 'string' || next.length === 0 || cursors.has(next))
432
+ throw new Error('CODEX_THREAD_LIST_INVALID');
433
+ cursors.add(next);
434
+ cursor = next;
435
+ } while (true);
436
+ }
425
437
  async discoverSessions(context, workspace) {
426
- let threads = [];
427
- try {
428
- await this.start();
429
- const result = await this.listThreads();
430
- threads = extractCodexThreads(result);
431
- }
432
- catch {
433
- // Native transcripts remain a valid read-only discovery source.
434
- }
438
+ const [threads, nativeSessions] = context.cleanup
439
+ ? [await this.cleanupThreads(), []]
440
+ : await Promise.all([
441
+ (async () => {
442
+ try {
443
+ await this.start();
444
+ return extractCodexThreads(await this.listThreads());
445
+ }
446
+ catch {
447
+ // Native transcripts remain a valid read-only discovery source.
448
+ return [];
449
+ }
450
+ })(),
451
+ discoverAllNativeSessions(this.name)
452
+ ]);
435
453
  const sessions = [];
436
454
  for (const thread of threads) {
437
455
  if (thread.derived || (thread.source !== 'cli' && thread.source !== 'vscode'))
@@ -440,7 +458,9 @@ export class CodexRunner extends AbstractRunner {
440
458
  try {
441
459
  cwd = codexThreadCwd(thread.cwd);
442
460
  }
443
- catch {
461
+ catch (error) {
462
+ if (context.cleanup)
463
+ throw error;
444
464
  continue;
445
465
  }
446
466
  const target = workspace ?? context.workspaces.find((candidate) => isWithinWorkspace(cwd, candidate.path));
@@ -451,11 +471,12 @@ export class CodexRunner extends AbstractRunner {
451
471
  runner: this.name,
452
472
  externalSessionId: thread.id,
453
473
  cwd,
474
+ ...(context.cleanup ? { lastActivityAt: thread.lastActivityAt } : {}),
454
475
  ...(thread.title === undefined ? {} : { title: thread.title }),
455
476
  ...(thread.titleOrigin === undefined ? {} : { titleOrigin: thread.titleOrigin })
456
477
  }));
457
478
  }
458
- for (const entry of await discoverAllNativeSessions(this.name)) {
479
+ for (const entry of nativeSessions) {
459
480
  const target = workspace ??
460
481
  context.workspaces.find((candidate) => isWithinWorkspace(entry.cwd, candidate.path));
461
482
  if (target === undefined || !isWithinWorkspace(entry.cwd, target.path))
@@ -707,8 +728,10 @@ export class CodexRunner extends AbstractRunner {
707
728
  return (session.externalSessionId !== null &&
708
729
  this.threadHistoryModes.get(session.externalSessionId) === true);
709
730
  }
710
- async rollbackNative(session, boundary, targetTurnBoundary) {
731
+ async rollbackNative(session, boundary, targetTurnBoundary, targetClientMessageId) {
711
732
  void boundary;
733
+ if (targetClientMessageId !== undefined)
734
+ throw new Error('SESSION_ROLLBACK_TARGET_INVALID');
712
735
  if (!this.supportsRollback(session) || targetTurnBoundary === undefined)
713
736
  throw new Error('SESSION_ROLLBACK_UNAVAILABLE');
714
737
  const threadId = session.externalSessionId;
@@ -114,9 +114,9 @@ export class MarAgentHostedConversationRuntime {
114
114
  await this.releaseSession(input);
115
115
  const catalog = await this.#openCatalog({ homeDirectory: input.homeDirectory });
116
116
  try {
117
- const rolledBack = await catalog.rollback(input.sessionId, {
118
- boundaryTurnId: input.boundaryTurnId
119
- });
117
+ const rolledBack = await catalog.rollback(input.sessionId, input.beforeInput === undefined
118
+ ? { boundaryTurnId: input.boundaryTurnId }
119
+ : { beforeInput: input.beforeInput });
120
120
  return { lastActivityAt: Date.parse(rolledBack.lastActivityAt) };
121
121
  }
122
122
  catch (error) {
@@ -271,6 +271,30 @@ export class MarAgentRunner extends AbstractRunner {
271
271
  catalog = await openMarAgentSessionCatalog({ homeDirectory: this.homeDirectory });
272
272
  const activeCatalog = catalog;
273
273
  const listed = await activeCatalog.list({ limit: 200 });
274
+ if (context.cleanup) {
275
+ const sessions = [...listed.sessions];
276
+ let cursor = listed.nextCursor;
277
+ while (cursor !== undefined) {
278
+ const next = await activeCatalog.list({ limit: 200, cursor });
279
+ sessions.push(...next.sessions);
280
+ cursor = next.nextCursor;
281
+ }
282
+ const workspaces = workspace ? [workspace] : context.workspaces;
283
+ return sessions.flatMap((session) => {
284
+ const target = workspaces.find((candidate) => resolve(candidate.path) === resolve(session.workspace));
285
+ return target
286
+ ? [
287
+ context.createProjection({
288
+ workspace: target,
289
+ runner: this.name,
290
+ externalSessionId: session.id,
291
+ cwd: session.workspace,
292
+ lastActivityAt: Date.parse(session.lastActivityAt)
293
+ })
294
+ ]
295
+ : [];
296
+ });
297
+ }
274
298
  const workspaces = workspace ? [workspace] : context.workspaces;
275
299
  return (await Promise.all(listed.sessions.map(async (session) => {
276
300
  const target = workspaces.find((candidate) => resolve(candidate.path) === resolve(session.workspace));
@@ -300,7 +324,9 @@ export class MarAgentRunner extends AbstractRunner {
300
324
  ];
301
325
  }))).flat();
302
326
  }
303
- catch {
327
+ catch (error) {
328
+ if (context.cleanup)
329
+ throw error;
304
330
  return [];
305
331
  }
306
332
  finally {
@@ -324,15 +350,19 @@ export class MarAgentRunner extends AbstractRunner {
324
350
  await catalog.close().catch(() => undefined);
325
351
  }
326
352
  }
327
- async rollbackNative(session, boundary) {
353
+ async rollbackNative(session, boundary, targetTurnBoundary, targetClientMessageId) {
328
354
  if (session.externalSessionId === null)
329
355
  throw new Error('SESSION_ROLLBACK_UNAVAILABLE');
330
356
  await this.#releaseSessionResources?.(session.externalSessionId);
331
357
  const catalog = await openMarAgentSessionCatalog({ homeDirectory: this.homeDirectory });
332
358
  try {
333
- const rolledBack = await catalog.rollback(session.externalSessionId, {
334
- boundaryTurnId: boundary
335
- });
359
+ if (targetClientMessageId !== undefined && targetTurnBoundary === undefined)
360
+ throw new Error('SESSION_ROLLBACK_TARGET_INVALID');
361
+ const rolledBack = await catalog.rollback(session.externalSessionId, targetClientMessageId === undefined
362
+ ? { boundaryTurnId: boundary }
363
+ : {
364
+ beforeInput: { turnId: targetTurnBoundary, clientMessageId: targetClientMessageId }
365
+ });
336
366
  return Date.parse(rolledBack.lastActivityAt);
337
367
  }
338
368
  catch (error) {
@@ -200,7 +200,9 @@ export class OpenCodeRunner extends AbstractRunner {
200
200
  try {
201
201
  sessions = await this.client.sessions(target.path);
202
202
  }
203
- catch {
203
+ catch (error) {
204
+ if (context.cleanup)
205
+ throw error;
204
206
  continue;
205
207
  }
206
208
  for (const value of sessions) {
@@ -209,7 +211,9 @@ export class OpenCodeRunner extends AbstractRunner {
209
211
  session.parentId !== undefined ||
210
212
  !isWithinWorkspace(session.directory, target.path))
211
213
  continue;
212
- const title = await this.sessionTitle(session);
214
+ const title = context.cleanup
215
+ ? { title: session.title, titleOrigin: 'OFFICIAL' }
216
+ : await this.sessionTitle(session);
213
217
  result.push(context.createProjection({
214
218
  workspace: target,
215
219
  runner: this.name,
@@ -765,6 +765,9 @@ export class NodeRuntimeState {
765
765
  const value = {
766
766
  ...current,
767
767
  status: next,
768
+ ...(next === 'INTERRUPTED' && current.status === 'CANCELLING'
769
+ ? { interruptionReason: 'USER_STOP' }
770
+ : {}),
768
771
  version: current.version + 1,
769
772
  completedAt: terminal(next) ? now : current.completedAt,
770
773
  updatedAt: now
@@ -795,7 +798,7 @@ export class NodeRuntimeState {
795
798
  appendRunEvent(input) {
796
799
  const execution = this.executions.get(input.runId);
797
800
  const runId = execution?.runId ?? input.runId;
798
- this.requireRun(runId);
801
+ const run = this.requireRun(runId);
799
802
  const priorSequence = this.runSequences.get(runId) ?? 0;
800
803
  if (input.sequence <= priorSequence)
801
804
  return undefined;
@@ -818,7 +821,8 @@ export class NodeRuntimeState {
818
821
  completedAt: terminal(input.status) ? value.createdAt : execution.completedAt
819
822
  });
820
823
  }
821
- if (input.status !== undefined && !terminal(input.status))
824
+ // Runner startup can arrive after a user stop; keep the Run cancelling.
825
+ if (input.status !== undefined && !terminal(input.status) && run.status !== 'CANCELLING')
822
826
  this.transitionRun(runId, input.status);
823
827
  this.projectEvent(value, execution?.turnId);
824
828
  if (input.status !== undefined && terminal(input.status) && execution !== undefined)
@@ -1,4 +1,6 @@
1
+ import { resolveSessionRollbackTarget } from './session-rollback-target.js';
1
2
  import { assertMcpServerNames } from '../mcp/naming.js';
3
+ import { imagePreviews, MAX_IMAGE_PREVIEW_SOURCE_BYTES } from './image-preview-service.js';
2
4
  import { modelUsageGrantSchema, steerComposerMessage } from '@myagentroam/protocol';
3
5
  import { randomUUID } from 'node:crypto';
4
6
  import { constants } from 'node:fs';
@@ -792,10 +794,10 @@ export class HostedConversationNodeService {
792
794
  const turns = await this.readAllHistory(conversation, paths);
793
795
  const targetIndex = turns.findIndex((turn) => turn.id === input.turnId);
794
796
  const target = turns[targetIndex];
795
- const userMessage = target?.items.find((item) => item.kind === 'user_message' && item.status === 'COMPLETED');
797
+ const selectedInput = target === undefined ? undefined : resolveSessionRollbackTarget(target, input.itemId);
796
798
  if (target === undefined ||
797
799
  !terminalHostedTurnStatus(target.status) ||
798
- userMessage === undefined ||
800
+ selectedInput === undefined ||
799
801
  target.rewind?.before !== input.boundary)
800
802
  throw new Error('HOSTED_CONVERSATION_ROLLBACK_TARGET_INVALID');
801
803
  let rolledBack;
@@ -805,7 +807,15 @@ export class HostedConversationNodeService {
805
807
  sessionId: conversation.sessionId,
806
808
  homeDirectory: paths.homeDirectory,
807
809
  modelId: conversation.modelId,
808
- boundaryTurnId: input.boundary
810
+ boundaryTurnId: input.boundary,
811
+ ...(selectedInput.clientMessageId === undefined
812
+ ? {}
813
+ : {
814
+ beforeInput: {
815
+ turnId: target.rewind.after,
816
+ clientMessageId: selectedInput.clientMessageId
817
+ }
818
+ })
809
819
  });
810
820
  }
811
821
  catch (error) {
@@ -1016,14 +1026,16 @@ export class HostedConversationNodeService {
1016
1026
  const image = await this.options.readImage(input.runId, input.imageRef);
1017
1027
  if (image === undefined)
1018
1028
  throw new Error('RUN_IMAGE_NOT_FOUND');
1019
- return { image };
1029
+ return {
1030
+ image: input.preview === undefined ? image : await imagePreviews.fromImage(image, input.preview)
1031
+ };
1020
1032
  }
1021
1033
  async readFile(raw) {
1022
1034
  const payload = hostedRequest(raw);
1023
1035
  const input = hostedConversationFileReadInputSchema.parse(payload.data);
1024
1036
  const file = this.options.store.requireFile(payload.userId, input.conversationId, input.fileId);
1025
1037
  const path = this.options.store.filePath(payload.userId, file);
1026
- const offset = input.offset ?? 0;
1038
+ const offset = input.preview === undefined ? (input.offset ?? 0) : 0;
1027
1039
  const limit = Math.min(input.limit ?? MAX_FILE_READ_BYTES, MAX_FILE_READ_BYTES);
1028
1040
  const handle = await openRegularFile(path);
1029
1041
  try {
@@ -1034,12 +1046,26 @@ export class HostedConversationNodeService {
1034
1046
  throw new Error('HOSTED_CONVERSATION_FILE_INVALID');
1035
1047
  if (offset > before.size)
1036
1048
  throw new Error('HOSTED_CONVERSATION_FILE_RANGE_INVALID');
1037
- const buffer = Buffer.alloc(Math.min(limit, Math.max(0, before.size - offset)));
1049
+ if (input.preview !== undefined && before.size > MAX_IMAGE_PREVIEW_SOURCE_BYTES)
1050
+ throw new Error('IMAGE_PREVIEW_TOO_LARGE');
1051
+ const buffer = Buffer.alloc(input.preview === undefined
1052
+ ? Math.min(limit, Math.max(0, before.size - offset))
1053
+ : before.size);
1038
1054
  const read = await handle.read(buffer, 0, buffer.length, offset);
1055
+ if (input.preview !== undefined) {
1056
+ while (read.bytesRead < buffer.length) {
1057
+ const next = await handle.read(buffer, read.bytesRead, buffer.length - read.bytesRead, read.bytesRead);
1058
+ if (next.bytesRead === 0)
1059
+ throw new Error('HOSTED_CONVERSATION_FILE_INVALID');
1060
+ read.bytesRead += next.bytesRead;
1061
+ }
1062
+ }
1039
1063
  const after = await handle.stat();
1040
1064
  const currentPathMetadata = await regularFileMetadata(path);
1041
1065
  if (!sameFileSnapshot(before, after) || !sameFileSnapshot(after, currentPathMetadata))
1042
1066
  throw new Error('HOSTED_CONVERSATION_FILE_INVALID');
1067
+ if (input.preview !== undefined)
1068
+ return { image: await imagePreviews.create(buffer, input.preview) };
1043
1069
  const next = offset + read.bytesRead;
1044
1070
  const eof = next >= after.size;
1045
1071
  const currentFile = {
@@ -0,0 +1,100 @@
1
+ import { createHash } from 'node:crypto';
2
+ import sharp from 'sharp';
3
+ export const MAX_IMAGE_PREVIEW_SOURCE_BYTES = 32 * 1024 * 1024;
4
+ const MAX_CACHE_BYTES = 8 * 1024 * 1024;
5
+ const MAX_CACHE_ENTRIES = 64;
6
+ const MAX_PENDING = 16;
7
+ const MAX_PENDING_BYTES = 64 * 1024 * 1024;
8
+ /** Sources must be read through their original authorization boundary before every call. */
9
+ export class ImagePreviewService {
10
+ cache = new Map();
11
+ pending = new Map();
12
+ cacheBytes = 0;
13
+ pendingBytes = 0;
14
+ tail = Promise.resolve();
15
+ async create(bytes, options) {
16
+ if (bytes.length === 0)
17
+ throw new Error('IMAGE_PREVIEW_INVALID');
18
+ if (bytes.length > MAX_IMAGE_PREVIEW_SOURCE_BYTES)
19
+ throw new Error('IMAGE_PREVIEW_TOO_LARGE');
20
+ const key = createHash('sha256')
21
+ .update(`${options.maxWidth}x${options.maxHeight}\0`)
22
+ .update(bytes)
23
+ .digest('hex');
24
+ const cached = this.cache.get(key);
25
+ if (cached !== undefined) {
26
+ this.cache.delete(key);
27
+ this.cache.set(key, cached);
28
+ return {
29
+ mime: 'image/webp',
30
+ dataBase64: cached.bytes.toString('base64'),
31
+ resized: cached.resized
32
+ };
33
+ }
34
+ const current = this.pending.get(key);
35
+ if (current !== undefined)
36
+ return current;
37
+ if (this.pending.size >= MAX_PENDING || this.pendingBytes + bytes.length > MAX_PENDING_BYTES)
38
+ throw new Error('IMAGE_PREVIEW_BUSY');
39
+ this.pendingBytes += bytes.length;
40
+ const result = this.tail
41
+ .catch(() => undefined)
42
+ .then(async () => {
43
+ let output;
44
+ let resized;
45
+ try {
46
+ const pipeline = sharp(bytes, { limitInputPixels: 64 * 1024 * 1024, pages: 1 });
47
+ const metadata = await pipeline.metadata();
48
+ const rotated = [5, 6, 7, 8].includes(metadata.orientation ?? 1);
49
+ const width = rotated ? metadata.height : metadata.width;
50
+ const height = rotated ? metadata.width : metadata.height;
51
+ if (width === undefined || height === undefined)
52
+ throw new Error('IMAGE_PREVIEW_INVALID');
53
+ resized = width > options.maxWidth || height > options.maxHeight;
54
+ output = await pipeline
55
+ .rotate()
56
+ .resize({
57
+ width: options.maxWidth,
58
+ height: options.maxHeight,
59
+ fit: 'inside',
60
+ withoutEnlargement: true
61
+ })
62
+ .webp({ quality: 85, effort: 3 })
63
+ .toBuffer();
64
+ }
65
+ catch {
66
+ throw new Error('IMAGE_PREVIEW_INVALID');
67
+ }
68
+ this.cache.set(key, { bytes: output, resized });
69
+ this.cacheBytes += output.length;
70
+ while (this.cache.size > MAX_CACHE_ENTRIES || this.cacheBytes > MAX_CACHE_BYTES) {
71
+ const oldest = this.cache.keys().next().value;
72
+ if (oldest === undefined)
73
+ break;
74
+ this.cacheBytes -= this.cache.get(oldest).bytes.length;
75
+ this.cache.delete(oldest);
76
+ }
77
+ return { mime: 'image/webp', dataBase64: output.toString('base64'), resized };
78
+ });
79
+ this.pending.set(key, result);
80
+ this.tail = result;
81
+ try {
82
+ return await result;
83
+ }
84
+ finally {
85
+ this.pending.delete(key);
86
+ this.pendingBytes -= bytes.length;
87
+ }
88
+ }
89
+ fromImage(image, options) {
90
+ const source = image;
91
+ if (typeof source?.dataBase64 !== 'string')
92
+ throw new Error('IMAGE_PREVIEW_INVALID');
93
+ if (source.dataBase64.length > Math.ceil(MAX_IMAGE_PREVIEW_SOURCE_BYTES / 3) * 4)
94
+ throw new Error('IMAGE_PREVIEW_TOO_LARGE');
95
+ if (this.pendingBytes + Math.floor((source.dataBase64.length * 3) / 4) > MAX_PENDING_BYTES)
96
+ throw new Error('IMAGE_PREVIEW_BUSY');
97
+ return this.create(Buffer.from(source.dataBase64, 'base64'), options);
98
+ }
99
+ }
100
+ export const imagePreviews = new ImagePreviewService();
@@ -4,7 +4,7 @@ export class NativeSessionProjectionService {
4
4
  constructor(options) {
5
5
  this.options = options;
6
6
  }
7
- async discover(runnerName, workspaceId) {
7
+ async discover(runnerName, workspaceId, cleanup = false) {
8
8
  const database = this.requireDatabase();
9
9
  const workspace = workspaceId === undefined ? undefined : database.getWorkspace(workspaceId);
10
10
  if (workspaceId !== undefined && workspace === undefined)
@@ -13,11 +13,13 @@ export class NativeSessionProjectionService {
13
13
  if (runner === undefined || !runner.available(this.options.capabilities()))
14
14
  throw new Error('RUNNER_UNAVAILABLE');
15
15
  const sessions = await runner.discoverSessions({
16
+ ...(cleanup ? { cleanup: true } : {}),
16
17
  workspaces: database.listWorkspaces(),
17
18
  createProjection: (input) => this.project(input)
18
19
  }, workspace);
19
20
  const ordered = deduplicateDirectSessions(sessions);
20
- this.remember(ordered);
21
+ if (!cleanup)
22
+ this.remember(ordered);
21
23
  return ordered;
22
24
  }
23
25
  async discoverCodex(workspaceId) {
@@ -33,13 +35,13 @@ export class NativeSessionProjectionService {
33
35
  this.remember(sessions);
34
36
  return sessions.map(this.options.present);
35
37
  }
36
- async discoverWorkspace(workspaceId, onRunnerDiscovered) {
38
+ async discoverWorkspace(workspaceId, onRunnerDiscovered, cleanup = false) {
37
39
  const discovered = await Promise.allSettled(this.options.runners
38
40
  .advertised()
39
41
  .filter((runner) => runner.available(this.options.capabilities()))
40
42
  .map(async (runner) => {
41
43
  const name = runner.name;
42
- const sessions = await this.discover(name, workspaceId);
44
+ const sessions = await this.discover(name, workspaceId, cleanup);
43
45
  onRunnerDiscovered?.(name, sessions);
44
46
  return sessions;
45
47
  }));
@@ -47,7 +49,8 @@ export class NativeSessionProjectionService {
47
49
  if (failed?.status === 'rejected')
48
50
  throw failed.reason;
49
51
  const sessions = deduplicateDirectSessions(discovered.flatMap((result) => (result.status === 'fulfilled' ? result.value : [])));
50
- this.remember(sessions);
52
+ if (!cleanup)
53
+ this.remember(sessions);
51
54
  return sessions;
52
55
  }
53
56
  async resolve(sessionId, registeredOnly = false) {
@@ -41,6 +41,9 @@ export class RecentRunProjectionService {
41
41
  ...(latestUserMessage === undefined ? {} : { userMessage: latestUserMessage }),
42
42
  runner: run.runner,
43
43
  status: run.status,
44
+ ...(run.interruptionReason === undefined
45
+ ? {}
46
+ : { interruptionReason: run.interruptionReason }),
44
47
  createdAt: run.createdAt,
45
48
  startedAt,
46
49
  completedAt,
@@ -4,6 +4,7 @@ import { isTerminalRunStatus } from '../util/node-operation-parsers.js';
4
4
  import { isPlainRecord } from '../util/node-operation-parsers.js';
5
5
  import { isHostedConversationImageRunId } from '../runner/mar-agent/generated-images.js';
6
6
  import { assertRunOwner } from './run-ownership-policy.js';
7
+ import { imagePreviews } from './image-preview-service.js';
7
8
  export class RunWorkbenchService {
8
9
  options;
9
10
  constructor(options) {
@@ -47,7 +48,11 @@ export class RunWorkbenchService {
47
48
  const image = await this.options.readImage(reference.runId, selector);
48
49
  if (image === undefined)
49
50
  throw new Error('RUN_IMAGE_NOT_FOUND');
50
- return { image };
51
+ return {
52
+ image: reference.preview === undefined
53
+ ? image
54
+ : await imagePreviews.fromImage(image, reference.preview)
55
+ };
51
56
  }
52
57
  respond(input) {
53
58
  if (typeof input.runId !== 'string' ||
@@ -1,9 +1,11 @@
1
+ import { resolveSessionRollbackTarget } from './session-rollback-target.js';
1
2
  import { sessionGroup } from './session-presentation-service.js';
2
3
  import { removeNativeSession } from '../native-session-history.js';
3
4
  import { nodeLog } from '../operational.js';
4
5
  import { isTerminalRunStatus, sessionPage } from '../util/node-operation-parsers.js';
5
6
  import { latestSessionActivityAt } from './session-presentation-service.js';
6
7
  import { marctlSessionSummary } from './session-recent-activity.js';
8
+ import { WorkspaceSessionCleanupBudget, WorkspaceSessionCleanupDeadline } from './workspace-session-cleanup-budget.js';
7
9
  export class SessionCatalogService {
8
10
  options;
9
11
  rewindQueue = Promise.resolve();
@@ -15,7 +17,7 @@ export class SessionCatalogService {
15
17
  'session.list': (data) => this.list(record(data)),
16
18
  'session.graph': (data) => this.graph(record(data)),
17
19
  'session.remove': (data) => this.enqueueMutation(() => this.remove(record(data))),
18
- 'workspace.sessions.cleanup': (data) => this.enqueueMutation(() => this.cleanupWorkspaceSessions(record(data))),
20
+ 'workspace.sessions.cleanup': (data) => this.cleanupWorkspaceSessions(record(data)),
19
21
  'session.rewind': (data) => this.enqueueMutation(() => this.rewind(record(data))),
20
22
  'session.rollback': (data) => this.enqueueMutation(() => this.rollback(record(data)))
21
23
  };
@@ -195,6 +197,7 @@ export class SessionCatalogService {
195
197
  async rollback(input) {
196
198
  if (!validRewindId(input.sessionId, 128) ||
197
199
  !validRewindId(input.turnId, 128) ||
200
+ (input.itemId !== undefined && !validRewindId(input.itemId, 128)) ||
198
201
  (input.boundary !== null && !validRewindId(input.boundary, 256)))
199
202
  throw new Error('SESSION_ROLLBACK_INVALID');
200
203
  const source = this.options.runtime.getAgentSession(input.sessionId) ??
@@ -211,15 +214,17 @@ export class SessionCatalogService {
211
214
  if (this.options.runtime.sessionQueue(source.id).itemIds.length > 0)
212
215
  throw new Error('SESSION_QUEUE_NOT_EMPTY');
213
216
  const turn = await this.findTurn(source, input.turnId);
214
- const userMessage = turn?.items.find((item) => item.kind === 'user_message' && item.status === 'COMPLETED');
217
+ const target = turn === undefined
218
+ ? undefined
219
+ : resolveSessionRollbackTarget(turn, input.itemId);
215
220
  if (turn === undefined ||
216
221
  !terminalConversationStatus(turn.status) ||
217
- userMessage === undefined ||
222
+ target === undefined ||
218
223
  turn.rewind?.before !== input.boundary)
219
224
  throw new Error('SESSION_ROLLBACK_TARGET_INVALID');
220
225
  const releaseMutation = this.options.runtime.beginSessionHistoryMutation(source.id);
221
226
  try {
222
- const lastActivityAt = await runner.rollbackNative(source, input.boundary, turn.rewind?.after);
227
+ const lastActivityAt = await runner.rollbackNative(source, input.boundary, turn.rewind?.after, target.clientMessageId);
223
228
  const runtimeSession = this.options.runtime.getAgentSession(source.id);
224
229
  const session = runtimeSession === undefined
225
230
  ? { ...source, lastActivityAt }
@@ -313,22 +318,50 @@ export class SessionCatalogService {
313
318
  throw new Error('WORKSPACE_SESSION_CLEANUP_INVALID');
314
319
  if (this.options.database().getWorkspace(input.workspaceId) === undefined)
315
320
  throw new Error('WORKSPACE_NOT_FOUND');
316
- const cutoff = Date.now() - inactiveDays * 24 * 60 * 60 * 1000;
317
- const nativeSessions = await this.options.discoverWorkspace(input.workspaceId);
318
- const candidates = new Map();
319
- for (const native of deduplicateNativeSessions(nativeSessions)) {
320
- if (native.workspaceId !== input.workspaceId)
321
- continue;
322
- const session = this.cleanupCandidate(native);
323
- candidates.set(session.id, session);
321
+ const workspaceId = input.workspaceId;
322
+ const budget = new WorkspaceSessionCleanupBudget();
323
+ const started = Promise.withResolvers();
324
+ const work = this.enqueueMutation(() => {
325
+ started.resolve();
326
+ return this.performWorkspaceSessionCleanup(workspaceId, inactiveDays, budget);
327
+ });
328
+ // A queued cleanup may expire before acquiring the mutation queue. Its late
329
+ // callback still checks the budget and cannot start discovery or deletion.
330
+ void work.catch(() => undefined);
331
+ try {
332
+ await budget.read(() => started.promise);
333
+ }
334
+ catch (error) {
335
+ if (!(error instanceof WorkspaceSessionCleanupDeadline))
336
+ throw error;
337
+ return { deleted: 0, skipped: 0, failed: 0, deletedByRunner: {}, timedOut: true };
324
338
  }
325
- this.mergePersisted(candidates, input.workspaceId, false);
339
+ return work;
340
+ }
341
+ async performWorkspaceSessionCleanup(workspaceId, inactiveDays, budget) {
342
+ const startedAt = performance.now();
343
+ const cutoff = Date.now() - inactiveDays * 24 * 60 * 60 * 1000;
326
344
  let deleted = 0;
327
345
  let skipped = 0;
328
346
  let failed = 0;
329
347
  const deletedByRunner = {};
348
+ let candidateCount;
349
+ let timedOut = false;
350
+ let discoveryMs = 0;
330
351
  try {
352
+ const nativeSessions = await budget.read(() => this.options.discoverWorkspace(workspaceId, true));
353
+ discoveryMs = Math.round(performance.now() - startedAt);
354
+ const candidates = new Map();
355
+ for (const native of deduplicateNativeSessions(nativeSessions)) {
356
+ if (native.workspaceId !== workspaceId)
357
+ continue;
358
+ const session = this.cleanupCandidate(native);
359
+ candidates.set(session.id, session);
360
+ }
361
+ this.mergePersisted(candidates, workspaceId, false);
362
+ candidateCount = candidates.size;
331
363
  for (const session of candidates.values()) {
364
+ budget.check();
332
365
  const missingCandidate = session.externalDiscovered === false;
333
366
  if (session.externalSessionId === null ||
334
367
  (missingCandidate &&
@@ -350,7 +383,7 @@ export class SessionCatalogService {
350
383
  continue;
351
384
  }
352
385
  try {
353
- if ((await this.removeResolved(session, true)) === 'deleted') {
386
+ if ((await this.removeResolved(session, true, budget)) === 'deleted') {
354
387
  deleted += 1;
355
388
  deletedByRunner[session.runner] = (deletedByRunner[session.runner] ?? 0) + 1;
356
389
  }
@@ -358,12 +391,14 @@ export class SessionCatalogService {
358
391
  skipped += 1;
359
392
  }
360
393
  catch (error) {
394
+ if (error instanceof WorkspaceSessionCleanupDeadline)
395
+ throw error;
361
396
  if (isCleanupSkip(error))
362
397
  skipped += 1;
363
398
  else {
364
399
  failed += 1;
365
400
  nodeLog('workspace.session.cleanup.failed', {
366
- workspaceId: input.workspaceId,
401
+ workspaceId,
367
402
  sessionId: session.id,
368
403
  runner: session.runner,
369
404
  ...cleanupFailureDiagnostic(error)
@@ -371,11 +406,38 @@ export class SessionCatalogService {
371
406
  }
372
407
  }
373
408
  }
374
- return { deleted, skipped, failed, deletedByRunner };
409
+ }
410
+ catch (error) {
411
+ if (!(error instanceof WorkspaceSessionCleanupDeadline))
412
+ throw error;
413
+ if (candidateCount === undefined)
414
+ discoveryMs = Math.round(performance.now() - startedAt);
415
+ timedOut = true;
375
416
  }
376
417
  finally {
377
- this.options.invalidateWorkspaceSessions(input.workspaceId);
418
+ this.options.invalidateWorkspaceSessions(workspaceId);
378
419
  }
420
+ const result = {
421
+ deleted,
422
+ skipped,
423
+ failed,
424
+ deletedByRunner,
425
+ ...(timedOut
426
+ ? {
427
+ timedOut: true,
428
+ ...(candidateCount === undefined
429
+ ? {}
430
+ : { remaining: candidateCount - deleted - skipped - failed })
431
+ }
432
+ : {})
433
+ };
434
+ nodeLog('workspace.sessions.cleanup.completed', {
435
+ workspaceId,
436
+ ...result,
437
+ discoveryMs,
438
+ durationMs: Math.round(performance.now() - startedAt)
439
+ });
440
+ return result;
379
441
  }
380
442
  cleanupCandidate(native) {
381
443
  const projected = this.options.projection.projectionForNative(native);
@@ -389,23 +451,21 @@ export class SessionCatalogService {
389
451
  lastActivityAt: latestSessionActivityAt(projected.lastActivityAt, native.lastActivityAt)
390
452
  };
391
453
  }
392
- async removeResolved(session, requireCleanupSafety = false) {
454
+ async removeResolved(session, requireCleanupSafety = false, budget) {
393
455
  if (session.externalSessionId === null)
394
456
  throw new Error('NATIVE_TRANSCRIPT_UNAVAILABLE');
395
457
  const releaseMutation = this.options.runtime.beginSessionHistoryMutation(session.id, requireCleanupSafety ? undefined : { allowQueued: true });
396
458
  try {
397
459
  if (this.options.isRecovering?.(session.id))
398
460
  throw new Error('SESSION_RESTART_PENDING');
399
- const runner = this.options.runners.require(session.runner);
400
461
  const missingCandidate = requireCleanupSafety && session.externalDiscovered === false;
401
- if (missingCandidate && !(await runner.isNativeSessionMissing(session)))
402
- return 'skipped';
403
462
  if (this.options.runtime
404
463
  .listRunsForSession(session.id)
405
464
  .some((run) => !isTerminalRunStatus(run.status)))
406
465
  throw new Error('SESSION_ACTIVE');
407
- if (session.nativeControl === 'EXTERNAL') {
408
- await this.options.refreshActivity(session);
466
+ if (session.nativeControl === 'EXTERNAL' && !missingCandidate) {
467
+ const refresh = () => this.options.refreshActivity(session);
468
+ await (budget ? budget.read(refresh) : refresh());
409
469
  if (this.options.externalActivity(session.id) === 'BUSY')
410
470
  throw new Error('SESSION_ACTIVE');
411
471
  }
@@ -414,17 +474,25 @@ export class SessionCatalogService {
414
474
  throw new Error('SESSION_WATCHING');
415
475
  if (this.options.runtime.sessionQueue(session.id).itemIds.length > 0)
416
476
  throw new Error('SESSION_QUEUE_NOT_EMPTY');
477
+ // Pin updates do not share the cleanup mutation queue.
478
+ const pinOrder = this.options
479
+ .database()
480
+ .getNativeSessionMetadata(session.runner, session.externalSessionId, session.workspaceId)?.pinOrder;
481
+ if (pinOrder !== undefined && pinOrder !== null)
482
+ return 'skipped';
417
483
  if (this.options.runtime
418
484
  .listRunsForSession(session.id)
419
485
  .some((run) => !isTerminalRunStatus(run.status)))
420
486
  throw new Error('SESSION_ACTIVE');
421
487
  }
488
+ budget?.check();
422
489
  const nativeIds = this.options.nativeIds(session);
423
490
  if (missingCandidate) {
424
491
  this.options.database().deleteSessionRecord(session.id);
425
492
  this.options.forget(session, nativeIds);
426
493
  return 'deleted';
427
494
  }
495
+ const runner = this.options.runners.require(session.runner);
428
496
  let deleted = false;
429
497
  for (const externalSessionId of nativeIds) {
430
498
  try {
@@ -0,0 +1,22 @@
1
+ /** A missing message selector is unambiguous only when the Turn has one user input. */
2
+ export function resolveSessionRollbackTarget(turn, itemId) {
3
+ const inputs = turn.items.filter((item) => item.kind === 'user_message');
4
+ const selected = itemId === undefined
5
+ ? inputs.length === 1
6
+ ? inputs[0]
7
+ : undefined
8
+ : inputs.find((item) => item.id === itemId);
9
+ if (selected?.status !== 'COMPLETED')
10
+ return undefined;
11
+ if (selected === inputs[0])
12
+ return {};
13
+ const payload = selected.payload;
14
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload))
15
+ return undefined;
16
+ const clientMessageId = payload['clientMessageId'];
17
+ return typeof clientMessageId === 'string' &&
18
+ clientMessageId.trim() &&
19
+ clientMessageId.length <= 512
20
+ ? { clientMessageId }
21
+ : undefined;
22
+ }
@@ -1,8 +1,9 @@
1
1
  import { basename, dirname, isAbsolute, relative, resolve, win32 } from 'node:path';
2
2
  import { lstat, realpath } from 'node:fs/promises';
3
- import { isComposerAttachmentResourcePath } from '@myagentroam/protocol';
3
+ import { imagePreviewOptionsSchema, isComposerAttachmentResourcePath } from '@myagentroam/protocol';
4
4
  import { listWorkspaceFiles, mutateWorkspaceFile, normalizeFilePreviewPath, readAbsoluteFileContentRange, readWorkspaceFileContentRange, readAbsoluteTextFile, readWorkspaceTextFile, writeWorkspaceTextFile, WorkspaceFileIndex } from '../workspace.js';
5
5
  import { revealWorkspaceFileInFileManager } from './workspace-file-manager-service.js';
6
+ import { imagePreviews, MAX_IMAGE_PREVIEW_SOURCE_BYTES } from './image-preview-service.js';
6
7
  const INDEX_CACHE_LIMIT = 32;
7
8
  const INDEX_CACHE_BYTE_LIMIT = 128 * 1024 * 1024;
8
9
  const INDEX_QUEUE_LIMIT = 16;
@@ -96,6 +97,27 @@ export class WorkspaceFileService {
96
97
  ? readAbsoluteFileContentRange(path, offset, limit)
97
98
  : readWorkspaceFileContentRange(workspacePath, path, offset, limit);
98
99
  }
100
+ async previewImage(workspace, input, workspacePath = workspace.path) {
101
+ const preview = imagePreviewOptionsSchema.parse(input.preview);
102
+ const chunks = [];
103
+ let offset = 0;
104
+ let revision;
105
+ for (;;) {
106
+ const chunk = (await this.readContent(workspace, { ...input, offset, limit: 512 * 1024 }, workspacePath));
107
+ if (chunk.size > MAX_IMAGE_PREVIEW_SOURCE_BYTES)
108
+ throw new Error('IMAGE_PREVIEW_TOO_LARGE');
109
+ if (revision !== undefined && revision !== chunk.sourceRevision)
110
+ throw new Error('FILE_CHANGED');
111
+ revision = chunk.sourceRevision;
112
+ chunks.push(Buffer.from(chunk.contentBase64, 'base64'));
113
+ if (chunk.nextOffset === null)
114
+ break;
115
+ if (chunk.nextOffset <= offset)
116
+ throw new Error('FILE_RANGE_INVALID');
117
+ offset = chunk.nextOffset;
118
+ }
119
+ return imagePreviews.create(Buffer.concat(chunks), preview);
120
+ }
99
121
  async resolveAttachmentResource(workspaceId, path) {
100
122
  if (!isComposerAttachmentResourcePath(path))
101
123
  throw new Error('FILE_PATH_ESCAPE');
@@ -0,0 +1,32 @@
1
+ const CLEANUP_EXECUTION_BUDGET_MS = 25_000;
2
+ export class WorkspaceSessionCleanupDeadline extends Error {
3
+ constructor() {
4
+ super('Workspace session cleanup reached its execution budget.');
5
+ }
6
+ }
7
+ /** Read-only work can expire; an admitted native write must settle before returning counts. */
8
+ export class WorkspaceSessionCleanupBudget {
9
+ deadline = performance.now() + CLEANUP_EXECUTION_BUDGET_MS;
10
+ get expired() {
11
+ return performance.now() >= this.deadline;
12
+ }
13
+ check() {
14
+ if (this.expired)
15
+ throw new WorkspaceSessionCleanupDeadline();
16
+ }
17
+ async read(operation) {
18
+ this.check();
19
+ let timer;
20
+ try {
21
+ return await Promise.race([
22
+ operation(),
23
+ new Promise((_resolve, reject) => {
24
+ timer = setTimeout(() => reject(new WorkspaceSessionCleanupDeadline()), Math.max(0, this.deadline - performance.now()));
25
+ })
26
+ ]);
27
+ }
28
+ finally {
29
+ clearTimeout(timer);
30
+ }
31
+ }
32
+ }
@@ -164,6 +164,9 @@ export class WorkspaceWorkbenchService {
164
164
  throw new Error('WORKSPACE_NOT_FOUND');
165
165
  const workspace = this.options.requireWorkspace(input.workspaceId);
166
166
  const repository = await this.fileRepository(workspace, input);
167
+ if (input.preview !== undefined) {
168
+ return { image: await this.options.files.previewImage(workspace, input, repository) };
169
+ }
167
170
  return {
168
171
  file: await this.options.files.readContent(workspace, input, repository),
169
172
  ...(input.previewDirectory === undefined ? {} : { previewBoundaryVerified: true })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.11.25",
3
+ "version": "0.11.26",
4
4
  "description": "MyAgentRoam Node runtime CLI.",
5
5
  "license": "Apache-2.0 AND GPL-2.0-or-later",
6
6
  "type": "module",
@@ -35,12 +35,13 @@
35
35
  "node-pty": "1.1.0",
36
36
  "npm": "11.12.1",
37
37
  "playwright": "1.62.1",
38
+ "sharp": "0.35.5",
38
39
  "ws": "^8.21.3",
39
40
  "zod": "4.4.3",
40
- "@myagentroam/agent": "0.11.25",
41
- "@myagentroam/protocol": "0.11.25",
41
+ "@myagentroam/agent": "0.11.26",
42
+ "@myagentroam/code-navigation": "0.11.0",
42
43
  "@myagentroam/marctl": "0.11.1",
43
- "@myagentroam/code-navigation": "0.11.0"
44
+ "@myagentroam/protocol": "0.11.26"
44
45
  },
45
46
  "devDependencies": {
46
47
  "@types/ws": "^8.18.1"