@myagentroam/node 0.10.34 → 0.10.36

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.
@@ -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 }),
@@ -186,6 +203,14 @@ export class ContinuousRunCoordinator {
186
203
  const resources = prepareComposerResourceInput(input, pending.cwd, pending.workspacePath ?? pending.cwd);
187
204
  this.preparedInputs.set(itemId, {
188
205
  ...pending,
206
+ ...(pending.restartMessage === undefined
207
+ ? {}
208
+ : {
209
+ restartMessage: {
210
+ ...pending.restartMessage,
211
+ content: this.options.runtime.getQueueItem(itemId)?.content ?? input
212
+ }
213
+ }),
189
214
  input: withNonImageAttachmentPrompt(resources, pending.attachmentPaths, pending.attachmentFiles)
190
215
  });
191
216
  }
@@ -238,6 +263,8 @@ export class ContinuousRunCoordinator {
238
263
  this.startIdleQueue(sessionId);
239
264
  }
240
265
  schedule(sessionId, delayMs = 0) {
266
+ if (this.suspended)
267
+ return;
241
268
  if (this.scheduledSessions.has(sessionId))
242
269
  return;
243
270
  const timer = setTimeout(() => {
@@ -256,6 +283,8 @@ export class ContinuousRunCoordinator {
256
283
  this.schedule(sessionId);
257
284
  }
258
285
  async dispatch(sessionId) {
286
+ if (this.suspended || this.options.stopped())
287
+ return;
259
288
  const active = this.options.runtime.activeSessionRun(sessionId);
260
289
  if (active === undefined)
261
290
  return;
@@ -323,6 +352,8 @@ export class ContinuousRunCoordinator {
323
352
  const goalMcp = pending.goal === undefined ? undefined : await this.options.goalMcp?.(executionInput);
324
353
  const scheduledMcp = await this.options.scheduledMcp?.(executionInput);
325
354
  const mailMcp = await this.options.mailMcp?.(executionInput);
355
+ if (this.suspended || this.options.stopped())
356
+ return;
326
357
  if (!this.goalCurrent(pending)) {
327
358
  this.dispatchedExecutions.delete(executionId);
328
359
  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;
@@ -50,20 +50,16 @@ export class NativeSessionProjectionService {
50
50
  this.remember(sessions);
51
51
  return sessions;
52
52
  }
53
- async resolve(sessionId) {
53
+ async resolve(sessionId, registeredOnly = false) {
54
54
  const cached = this.options.state.directNative.get(sessionId);
55
55
  if (cached !== undefined)
56
56
  return cached;
57
- const discovered = (await this.list()).find((session) => session.id === sessionId);
58
- if (discovered !== undefined)
59
- return discovered;
60
57
  const database = this.options.database();
61
- const record = database
62
- ?.listWorkspaces()
63
- .flatMap((workspace) => database.listSessionRecords(workspace.id))
64
- .find((candidate) => candidate.id === sessionId);
58
+ const record = database?.getSessionRecord(sessionId);
65
59
  if (record === undefined)
66
- return undefined;
60
+ return registeredOnly
61
+ ? undefined
62
+ : (await this.list()).find((session) => session.id === sessionId);
67
63
  const workspace = database?.getWorkspace(record.workspaceId);
68
64
  if (workspace === undefined)
69
65
  return undefined;
@@ -80,6 +76,7 @@ export class NativeSessionProjectionService {
80
76
  effort: record.metadata.effort,
81
77
  access: record.metadata.access,
82
78
  customTitle: record.metadata.customTitle,
79
+ parentSessionId: record.metadata.parentSessionId ?? null,
83
80
  sessionPrompt: record.metadata.sessionPrompt ?? null,
84
81
  runnerTitle: record.metadata.runnerTitle,
85
82
  pinnedAt: record.pinOrder === null ? null : 1,
@@ -114,7 +111,8 @@ export class NativeSessionProjectionService {
114
111
  ...(direct.access === null ? {} : { access: direct.access }),
115
112
  ...(direct.runnerTitle === null ? {} : { title: direct.runnerTitle }),
116
113
  ...(direct.customTitle === null ? {} : { customTitle: direct.customTitle }),
117
- sessionPrompt: direct.sessionPrompt ?? null
114
+ sessionPrompt: direct.sessionPrompt ?? null,
115
+ parentSessionId: direct.parentSessionId ?? null
118
116
  });
119
117
  }
120
118
  resumedConfiguration(session) {
@@ -186,6 +184,7 @@ export class NativeSessionProjectionService {
186
184
  access: metadata?.access ?? null,
187
185
  customTitle: metadata?.customTitle ?? null,
188
186
  sessionPrompt: metadata?.sessionPrompt ?? null,
187
+ parentSessionId: metadata?.parentSessionId ?? null,
189
188
  runnerTitle,
190
189
  pinnedAt: metadata?.pinnedAt ?? null,
191
190
  pinOrder: metadata?.pinOrder ?? null,
@@ -202,8 +201,61 @@ export class NativeSessionProjectionService {
202
201
  };
203
202
  }
204
203
  remember(sessions) {
205
- for (const session of sessions)
204
+ const database = this.options.database();
205
+ for (const session of sessions) {
206
206
  this.options.state.directNative.set(session.id, session);
207
+ if (database === undefined || session.externalSessionId === null)
208
+ continue;
209
+ const metadata = database.getNativeSessionMetadata(session.runner, session.externalSessionId, session.workspaceId);
210
+ const title = session.runnerTitle?.trim().slice(0, 160) || undefined;
211
+ if (metadata === undefined ||
212
+ (title !== undefined && title !== metadata.runnerTitle && !metadata.runnerTitlePending))
213
+ database.saveNativeSessionMetadata({
214
+ runner: session.runner,
215
+ externalSessionId: session.externalSessionId,
216
+ workspaceId: session.workspaceId,
217
+ ...(title === undefined || metadata?.runnerTitlePending ? {} : { runnerTitle: title })
218
+ });
219
+ }
220
+ }
221
+ similarSessionDetails(sessionId, workspaceId) {
222
+ if (sessionId.length < 8 || sessionId.length > 128)
223
+ return undefined;
224
+ const records = this.options.database()?.listSessionRecords(workspaceId) ?? [];
225
+ const recordById = new Map(records.map((record) => [record.id, record]));
226
+ const known = new Map(records.map((record) => [
227
+ record.id,
228
+ record.metadata.customTitle ?? record.metadata.runnerTitle ?? 'Untitled session'
229
+ ]));
230
+ for (const session of [
231
+ ...this.options.state.directNative.values(),
232
+ ...this.options.runtime.listAgentSessions()
233
+ ]) {
234
+ if (session.workspaceId !== workspaceId)
235
+ continue;
236
+ const metadata = recordById.get(session.id)?.metadata;
237
+ known.set(session.id, metadata?.customTitle ??
238
+ session.customTitle ??
239
+ session.runnerTitle ??
240
+ metadata?.runnerTitle ??
241
+ 'Untitled session');
242
+ }
243
+ const maximum = Math.min(3, Math.floor(sessionId.length / 8));
244
+ const candidates = [...known]
245
+ .map(([id, title]) => ({
246
+ id,
247
+ title,
248
+ distance: sessionIdDistance(sessionId.toLowerCase(), id.toLowerCase(), maximum)
249
+ }))
250
+ .filter((candidate) => candidate.distance <= maximum)
251
+ .sort((left, right) => left.distance - right.distance || left.id.localeCompare(right.id))
252
+ .slice(0, 3);
253
+ return candidates.length === 0
254
+ ? undefined
255
+ : [
256
+ 'Similar Sessions (suggestions only; no action was redirected):',
257
+ ...candidates.map(({ id, title }) => id + ' ' + title.replace(/[\u0000-\u001f\u007f]/gu, ' ').slice(0, 160))
258
+ ].join('\n');
207
259
  }
208
260
  projectionForNative(native) {
209
261
  if (native.externalSessionId === null)
@@ -297,3 +349,17 @@ export class NativeSessionProjectionService {
297
349
  return database;
298
350
  }
299
351
  }
352
+ function sessionIdDistance(left, right, maximum) {
353
+ if (Math.abs(left.length - right.length) > maximum)
354
+ return maximum + 1;
355
+ let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
356
+ for (let row = 1; row <= left.length; row += 1) {
357
+ const current = [row];
358
+ for (let column = 1; column <= right.length; column += 1)
359
+ current[column] = Math.min(current[column - 1] + 1, previous[column] + 1, previous[column - 1] + (left[row - 1] === right[column - 1] ? 0 : 1));
360
+ if (Math.min(...current) > maximum)
361
+ return maximum + 1;
362
+ previous = current;
363
+ }
364
+ return previous[right.length];
365
+ }
@@ -0,0 +1,279 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { resolve } from 'node:path';
4
+ import { z } from 'zod';
5
+ import { parseComposerTags } from '@myagentroam/protocol';
6
+ const identifier = z.string().min(1).max(512);
7
+ const count = z.number().int().nonnegative();
8
+ const attachmentSchema = z
9
+ .object({
10
+ name: z.string().min(1).max(512),
11
+ mime: z.string().min(1).max(128),
12
+ dataBase64: z.string().min(1).max(7_000_000)
13
+ })
14
+ .strict();
15
+ const messageSchema = z
16
+ .object({
17
+ id: identifier,
18
+ clientMessageId: identifier,
19
+ content: z.string().max(1_000_000),
20
+ attachments: z.array(attachmentSchema).max(5),
21
+ initiatedByUserId: z.number().int().positive(),
22
+ immediate: z.boolean()
23
+ })
24
+ .strict();
25
+ const goalSchema = z
26
+ .object({
27
+ goal: z
28
+ .object({
29
+ id: identifier,
30
+ sessionId: identifier,
31
+ objective: z.string().min(1).max(20_000),
32
+ inputTags: z.string().default(''),
33
+ startedAt: count,
34
+ revisionStartedAt: count,
35
+ revision: z.number().int().positive(),
36
+ status: z.enum(['active', 'completed', 'blocked']),
37
+ turns: count,
38
+ retryRequired: z.boolean(),
39
+ retryStartedAtTurn: count.nullable(),
40
+ consecutiveNoProgressTurns: count
41
+ })
42
+ .strict(),
43
+ executionFailures: count
44
+ })
45
+ .strict();
46
+ const sessionSchema = z
47
+ .object({
48
+ session: z
49
+ .object({
50
+ id: identifier,
51
+ workspaceId: identifier,
52
+ cwd: z.string().min(1),
53
+ model: z.string().nullable(),
54
+ effort: z.string().nullable(),
55
+ access: z.string().nullable()
56
+ })
57
+ .strict(),
58
+ ownerUserId: z.number().int().positive(),
59
+ paused: z.boolean(),
60
+ plan: z.boolean(),
61
+ awaitingGoal: z.boolean(),
62
+ goal: goalSchema.optional(),
63
+ current: messageSchema.optional(),
64
+ queue: z.array(messageSchema)
65
+ })
66
+ .strict();
67
+ export const plannedRestartSchema = z
68
+ .object({
69
+ schemaVersion: z.literal(1),
70
+ id: identifier,
71
+ nodeId: identifier,
72
+ serverUrl: z.string().min(1),
73
+ createdAt: count,
74
+ sessions: z.array(sessionSchema)
75
+ })
76
+ .strict();
77
+ export const RESTART_NOTICE = 'The previous execution was interrupted by a system restart. Check the session history and current workspace, then continue unfinished work. Verify completed or uncertain actions before proceeding; do not blindly repeat actions with external side effects.';
78
+ /** A one-shot orderly-shutdown handoff, not a crash-recovery journal. */
79
+ export class PlannedRestartHandoff {
80
+ options;
81
+ pending;
82
+ blocked = new Set();
83
+ recovery;
84
+ owners = new Map();
85
+ constructor(options) {
86
+ this.options = options;
87
+ }
88
+ path(processing = false) {
89
+ return resolve(this.options.directory(), processing ? 'planned-restart-handoff.processing.json' : 'planned-restart-handoff.json');
90
+ }
91
+ blocks(sessionId) {
92
+ return this.blocked.has(sessionId);
93
+ }
94
+ blocksOperation(operation, input) {
95
+ const sessionId = typeof input.sessionId === 'string'
96
+ ? input.sessionId
97
+ : typeof input.queueItemId === 'string'
98
+ ? this.options.runtime.getQueueItem(input.queueItemId)?.sessionId
99
+ : undefined;
100
+ if (sessionId === undefined || !this.blocks(sessionId))
101
+ return false;
102
+ return (operation === 'session.message' ||
103
+ operation.startsWith('session.command.') ||
104
+ (operation.startsWith('workspace.queue.') && operation !== 'workspace.queue.get') ||
105
+ [
106
+ 'session.remove',
107
+ 'session.rewind',
108
+ 'session.rollback',
109
+ 'session.config.update',
110
+ 'session.metadata.update'
111
+ ].includes(operation));
112
+ }
113
+ rememberUser(sessionId, userId) {
114
+ this.owners.set(sessionId, userId);
115
+ }
116
+ async load() {
117
+ this.recovery = undefined;
118
+ this.pending = undefined;
119
+ this.blocked.clear();
120
+ let text;
121
+ try {
122
+ text = await readFile(this.path(), 'utf8');
123
+ }
124
+ catch (error) {
125
+ if (error.code !== 'ENOENT')
126
+ this.options.diagnose('RESTART_HANDOFF_READ_FAILED');
127
+ return;
128
+ }
129
+ try {
130
+ const value = plannedRestartSchema.parse(JSON.parse(text));
131
+ const identity = this.options.identity();
132
+ if (value.nodeId !== identity.nodeId || value.serverUrl !== identity.serverUrl)
133
+ throw new Error('RESTART_HANDOFF_IDENTITY_MISMATCH');
134
+ this.pending = value;
135
+ for (const entry of value.sessions)
136
+ this.blocked.add(entry.session.id);
137
+ }
138
+ catch {
139
+ this.options.diagnose('RESTART_HANDOFF_INVALID');
140
+ }
141
+ }
142
+ capture() {
143
+ const { runtime, coordinator, goals } = this.options;
144
+ const sessions = [...(this.pending?.sessions ?? [])];
145
+ for (const session of runtime.listAgentSessions()) {
146
+ if (session.runner !== 'mar-agent' || this.blocks(session.id))
147
+ continue;
148
+ const queue = runtime.sessionQueue(session.id);
149
+ const active = runtime.activeSessionRun(session.id);
150
+ const goal = goals.restartState(session.id);
151
+ const awaitingGoal = goal === undefined && this.options.commandActive(session.id, 'goal');
152
+ const currentId = active && (runtime.currentExecutionId(active.id) ?? active.id);
153
+ const pending = currentId === undefined ? undefined : coordinator.pending(currentId);
154
+ const toMessage = (id, clientMessageId, content, attachments, owner) => ({
155
+ id,
156
+ clientMessageId,
157
+ content: withoutAttachmentTags(content),
158
+ attachments,
159
+ initiatedByUserId: owner,
160
+ immediate: coordinator.isImmediate(id)
161
+ });
162
+ const items = queue.itemIds.flatMap((id) => {
163
+ if (coordinator.pending(id)?.goal !== undefined)
164
+ return [];
165
+ const item = runtime.getQueueItem(id);
166
+ const original = coordinator.pending(id)?.restartMessage;
167
+ return [
168
+ toMessage(id, item.clientMessageId, original?.content ?? item.content, [...(original?.attachments ?? item.attachments)], item.initiatedByUserId)
169
+ ];
170
+ });
171
+ const current = pending?.restartMessage === undefined || pending.goal !== undefined
172
+ ? undefined
173
+ : toMessage(currentId, pending.clientMessageId ?? active.clientMessageId, pending.restartMessage.content, [...pending.restartMessage.attachments], pending.restartMessage.initiatedByUserId ?? active?.initiatedByUserId);
174
+ if (current === undefined && items.length === 0 && goal === undefined && !awaitingGoal)
175
+ continue;
176
+ if (session.externalSessionId === null)
177
+ throw new Error('RESTART_SESSION_NOT_READY');
178
+ const ownerUserId = active?.initiatedByUserId ??
179
+ items[0]?.initiatedByUserId ??
180
+ this.owners.get(session.id) ??
181
+ runtime.listRunsForSession(session.id).find((run) => run.initiatedByUserId !== undefined)
182
+ ?.initiatedByUserId;
183
+ sessions.push({
184
+ session: {
185
+ id: session.externalSessionId,
186
+ workspaceId: session.workspaceId,
187
+ cwd: session.cwd,
188
+ model: session.model,
189
+ effort: session.effort,
190
+ access: session.access
191
+ },
192
+ ownerUserId: ownerUserId,
193
+ paused: queue.paused,
194
+ plan: this.options.commandActive(session.id, 'plan'),
195
+ awaitingGoal,
196
+ ...(goal === undefined ? {} : { goal: goalSchema.parse(goal) }),
197
+ ...(current === undefined ? {} : { current }),
198
+ queue: items
199
+ });
200
+ }
201
+ return plannedRestartSchema.parse({
202
+ schemaVersion: 1,
203
+ id: randomUUID(),
204
+ ...this.options.identity(),
205
+ createdAt: Date.now(),
206
+ sessions
207
+ });
208
+ }
209
+ async publish(snapshot) {
210
+ for (const entry of snapshot.sessions) {
211
+ const goal = this.options.goals.restartState(entry.session.id);
212
+ if (goal !== undefined)
213
+ entry.goal = goalSchema.parse(goal);
214
+ if (entry.current !== undefined) {
215
+ const state = await this.options.inputState(entry, entry.current);
216
+ if (state === 'completed')
217
+ delete entry.current;
218
+ if (state === 'paused')
219
+ entry.paused = true;
220
+ }
221
+ }
222
+ if (snapshot.sessions.length === 0)
223
+ return;
224
+ const temporary = `${this.path()}.${process.pid}.tmp`;
225
+ await mkdir(this.options.directory(), { recursive: true, mode: 0o700 });
226
+ try {
227
+ await writeFile(temporary, JSON.stringify(plannedRestartSchema.parse(snapshot)), {
228
+ mode: 0o600
229
+ });
230
+ await rename(temporary, this.path());
231
+ }
232
+ finally {
233
+ await rm(temporary, { force: true });
234
+ }
235
+ }
236
+ recover() {
237
+ if (this.pending === undefined)
238
+ return this.recovery ?? Promise.resolve();
239
+ return (this.recovery ??= this.consume());
240
+ }
241
+ async wait() {
242
+ await this.recovery;
243
+ }
244
+ async consume() {
245
+ const value = this.pending;
246
+ this.pending = undefined;
247
+ let failed = false;
248
+ try {
249
+ await rename(this.path(), this.path(true));
250
+ for (const entry of value.sessions) {
251
+ try {
252
+ this.rememberUser(entry.session.id, entry.ownerUserId);
253
+ await this.options.restore(entry, value.id);
254
+ }
255
+ catch {
256
+ failed = true;
257
+ this.options.diagnose('RESTART_SESSION_RESTORE_FAILED', entry.session.id);
258
+ }
259
+ finally {
260
+ this.blocked.delete(entry.session.id);
261
+ }
262
+ }
263
+ if (!failed)
264
+ await rm(this.path(true), { force: true });
265
+ }
266
+ catch {
267
+ this.options.diagnose('RESTART_HANDOFF_CONSUME_FAILED');
268
+ }
269
+ finally {
270
+ this.blocked.clear();
271
+ }
272
+ }
273
+ }
274
+ function withoutAttachmentTags(content) {
275
+ return parseComposerTags(content)
276
+ .map((part) => part.kind === 'text' ? part.text : part.kind === 'mar_attachments' ? '' : part.raw)
277
+ .join('')
278
+ .trim();
279
+ }