@myagentroam/node 0.11.38 → 0.11.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/computer-use/contracts.js +8 -0
  2. package/dist/{runner/mar-agent/computer-use-indicator.js → computer-use/indicator.js} +61 -38
  3. package/dist/computer-use/probe.js +95 -0
  4. package/dist/computer-use/provider.js +159 -0
  5. package/dist/computer-use/session.js +334 -0
  6. package/dist/computer-use/worker-source.js +135 -0
  7. package/dist/computer-use/worker.js +321 -0
  8. package/dist/connector.js +181 -22
  9. package/dist/database.js +35 -11
  10. package/dist/main.js +12 -1
  11. package/dist/marctl-guidance.js +18 -27
  12. package/dist/mcp/naming.js +6 -1
  13. package/dist/migrations/v022.js +58 -0
  14. package/dist/runner/abstract-runner.js +14 -0
  15. package/dist/runner/claude/tool-normalizer.js +6 -1
  16. package/dist/runner/codex/conversation-parser.js +23 -4
  17. package/dist/runner/codex/managed-run-controller.js +4 -2
  18. package/dist/runner/codex/tool-normalizer.js +9 -0
  19. package/dist/runner/execution-presentation.js +56 -0
  20. package/dist/runner/mar-agent/agent-network-extension.js +168 -0
  21. package/dist/runner/mar-agent/agent-network-wait-output.js +72 -0
  22. package/dist/runner/mar-agent/computer-use-skill.js +11 -2
  23. package/dist/runner/mar-agent/computer-use.js +104 -728
  24. package/dist/runner/mar-agent/conversation-normalizer.js +241 -121
  25. package/dist/runner/mar-agent/managed-run-controller.js +13 -5
  26. package/dist/runner/mar-agent/marctl-skill.js +3 -3
  27. package/dist/runner/mar-agent/runtime-model-factory.js +1 -0
  28. package/dist/runner/mar-agent/session-runtime.js +34 -1
  29. package/dist/runner/mar-agent-runner.js +36 -0
  30. package/dist/runner/opencode/tool-normalizer.js +6 -1
  31. package/dist/runtime-state.js +57 -0
  32. package/dist/service/agent-network-bindings.js +483 -0
  33. package/dist/service/agent-network-goal.js +19 -0
  34. package/dist/service/agent-network-invoke.js +179 -0
  35. package/dist/service/agent-network-observations.js +377 -0
  36. package/dist/service/agent-network-service.js +315 -0
  37. package/dist/service/agent-network-wait.js +130 -0
  38. package/dist/service/composer-message-input.js +9 -2
  39. package/dist/service/computer-use-enablement-service.js +13 -7
  40. package/dist/service/continuous-run-coordinator.js +24 -1
  41. package/dist/service/conversation-hosting-enablement-service.js +2 -2
  42. package/dist/service/goal-continuation-coordinator.js +12 -2
  43. package/dist/service/marctl-caller.js +80 -0
  44. package/dist/service/marctl-execution-credential-control.js +65 -0
  45. package/dist/service/model-usage-service.js +9 -0
  46. package/dist/service/node-config-mutations.js +27 -0
  47. package/dist/service/node-control-message-service.js +10 -0
  48. package/dist/service/node-request-service.js +52 -7
  49. package/dist/service/node-upgrade-coordinator.js +79 -8
  50. package/dist/service/node-upgrade-credential-control.js +57 -0
  51. package/dist/service/node-upgrade-reporter.js +108 -0
  52. package/dist/service/run-event-service.js +8 -0
  53. package/dist/service/run-workbench-service.js +11 -0
  54. package/dist/service/supervised-node-child.js +153 -0
  55. package/dist/service/supervisor-upgrade-handoff.js +104 -0
  56. package/dist/session/native-session-projection-service.js +69 -6
  57. package/dist/session/operation-admission.js +7 -0
  58. package/dist/session/runtime/conversation-projection.js +25 -3
  59. package/dist/session/session-catalog-service.js +156 -32
  60. package/dist/session/session-lifecycle-service.js +68 -42
  61. package/dist/session/session-message-service.js +16 -6
  62. package/dist/supervisor.js +209 -20
  63. package/dist/terminal.js +41 -21
  64. package/package.json +6 -5
  65. /package/dist/{runner/mar-agent/computer-use-probe.js → computer-use/probe-source.js} +0 -0
@@ -0,0 +1,153 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { terminateProcessTree } from '../process-tree.js';
3
+ /** Wait for the child process itself, not inherited output handles held by descendants. */
4
+ export function runSupervisedNodeChild(entrypoint, configPath, accept, accepted, upgradeAttemptId) {
5
+ return new Promise((resolve) => {
6
+ const child = spawn(process.execPath, [entrypoint, 'run', '--config', configPath], {
7
+ windowsHide: true,
8
+ stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
9
+ env: {
10
+ ...process.env,
11
+ MAR_NODE_SUPERVISED: '1',
12
+ ...(upgradeAttemptId === undefined ? {} : { MAR_NODE_UPGRADE_ATTEMPT_ID: upgradeAttemptId })
13
+ },
14
+ detached: process.platform !== 'win32'
15
+ });
16
+ let done = false;
17
+ let signalled = false;
18
+ let channelId;
19
+ let accepting = false;
20
+ let handoff;
21
+ let forced = false;
22
+ let reclaiming = false;
23
+ let reclaimed = false;
24
+ let exited;
25
+ let deadline;
26
+ let reapDeadline;
27
+ const finish = (code, reclaimFailed = false) => {
28
+ if (done)
29
+ return;
30
+ done = true;
31
+ if (deadline !== undefined)
32
+ clearTimeout(deadline);
33
+ if (reapDeadline !== undefined)
34
+ clearTimeout(reapDeadline);
35
+ process.off('SIGINT', onSigint);
36
+ process.off('SIGTERM', onSigterm);
37
+ if (child.connected)
38
+ child.disconnect();
39
+ if (reclaimFailed)
40
+ child.unref();
41
+ resolve({
42
+ code,
43
+ signalled,
44
+ ...(handoff === undefined ? {} : { handoff }),
45
+ ...(forced ? { forced: true } : {}),
46
+ ...(reclaimFailed ? { reclaimFailed: true } : {})
47
+ });
48
+ };
49
+ const forward = (signal) => {
50
+ if (signalled)
51
+ return;
52
+ signalled = true;
53
+ child.kill(signal);
54
+ };
55
+ const onSigint = () => forward('SIGINT');
56
+ const onSigterm = () => forward('SIGTERM');
57
+ process.on('SIGINT', onSigint);
58
+ process.on('SIGTERM', onSigterm);
59
+ child.on('error', () => {
60
+ if (child.pid === undefined)
61
+ finish(null);
62
+ });
63
+ child.once('exit', (code) => {
64
+ exited = { code };
65
+ if (!reclaiming || reclaimed)
66
+ finish(code);
67
+ });
68
+ child.on('message', (message) => {
69
+ if (done || signalled || typeof message !== 'object' || message === null)
70
+ return;
71
+ const value = message;
72
+ if (value['type'] === 'node-upgrade-hello' &&
73
+ channelId === undefined &&
74
+ typeof value['channelId'] === 'string') {
75
+ channelId = value['channelId'];
76
+ send(child, { type: 'node-upgrade-ready', channelId });
77
+ return;
78
+ }
79
+ if (value['type'] !== 'node-upgrade-handoff' ||
80
+ value['channelId'] !== channelId ||
81
+ channelId === undefined ||
82
+ accepting ||
83
+ handoff !== undefined ||
84
+ typeof value['id'] !== 'string' ||
85
+ typeof value['attemptId'] !== 'string' ||
86
+ typeof value['targetVersion'] !== 'string' ||
87
+ typeof value['credential'] !== 'string')
88
+ return;
89
+ const id = value['id'];
90
+ const request = {
91
+ attemptId: value['attemptId'],
92
+ targetVersion: value['targetVersion'],
93
+ credential: value['credential']
94
+ };
95
+ accepting = true;
96
+ void accept(request)
97
+ .then((ok) => {
98
+ if (done || signalled)
99
+ return;
100
+ if (!ok) {
101
+ accepting = false;
102
+ send(child, { type: 'node-upgrade-ack', channelId, id, accepted: false });
103
+ return;
104
+ }
105
+ accepted(request);
106
+ handoff = request;
107
+ deadline = setTimeout(() => {
108
+ if (done || signalled)
109
+ return;
110
+ forced = true;
111
+ reclaiming = true;
112
+ reapDeadline = setTimeout(() => finish(null, true), 10_000);
113
+ void terminateProcessTree(child)
114
+ .then(() => {
115
+ reclaimed = true;
116
+ if (exited !== undefined)
117
+ finish(exited.code);
118
+ })
119
+ .catch(() => finish(null, true));
120
+ if (process.platform !== 'win32' && child.pid !== undefined) {
121
+ try {
122
+ process.kill(-child.pid, 'SIGKILL');
123
+ }
124
+ catch {
125
+ try {
126
+ child.kill('SIGKILL');
127
+ }
128
+ catch {
129
+ /* Wait for exit or timeout. */
130
+ }
131
+ }
132
+ }
133
+ }, 25_000);
134
+ send(child, { type: 'node-upgrade-ack', channelId, id, accepted: true });
135
+ })
136
+ .catch(() => {
137
+ accepting = false;
138
+ if (!done && !signalled)
139
+ send(child, { type: 'node-upgrade-ack', channelId, id, accepted: false });
140
+ });
141
+ });
142
+ });
143
+ }
144
+ function send(child, message) {
145
+ if (!child.connected)
146
+ return;
147
+ try {
148
+ child.send(message, () => undefined);
149
+ }
150
+ catch {
151
+ /* An absent ACK leaves the child blocked. */
152
+ }
153
+ }
@@ -0,0 +1,104 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ /** Parent capability is established once at startup; a handoff is never retried. */
3
+ export class SupervisorUpgradeHandoff {
4
+ parent;
5
+ channelId = randomUUID();
6
+ supported = false;
7
+ uncertain = false;
8
+ handshakeResolve;
9
+ pending;
10
+ constructor(parent = process) {
11
+ this.parent = parent;
12
+ parent.on('message', this.onMessage);
13
+ }
14
+ async handshake() {
15
+ if (!this.parent.connected || this.parent.send === undefined)
16
+ return false;
17
+ return new Promise((resolve) => {
18
+ const timer = setTimeout(() => {
19
+ this.handshakeResolve = undefined;
20
+ resolve(false);
21
+ }, 1_000);
22
+ timer.unref();
23
+ this.handshakeResolve = (supported) => {
24
+ clearTimeout(timer);
25
+ this.handshakeResolve = undefined;
26
+ resolve(supported);
27
+ };
28
+ try {
29
+ this.parent.send({ type: 'node-upgrade-hello', channelId: this.channelId }, (error) => {
30
+ if (error !== null)
31
+ this.handshakeResolve?.(false);
32
+ });
33
+ }
34
+ catch {
35
+ this.handshakeResolve?.(false);
36
+ }
37
+ });
38
+ }
39
+ async handoff(input) {
40
+ if (!this.supported || !this.parent.connected || this.parent.send === undefined)
41
+ throw new Error('NODE_UPGRADE_HANDOFF_UNAVAILABLE');
42
+ if (this.uncertain)
43
+ throw new Error('NODE_UPGRADE_HANDOFF_UNCERTAIN');
44
+ if (this.pending !== undefined)
45
+ throw new Error('NODE_UPGRADE_HANDOFF_ALREADY_PENDING');
46
+ const id = randomUUID();
47
+ return new Promise((resolve, reject) => {
48
+ const timer = setTimeout(() => {
49
+ this.pending = undefined;
50
+ this.uncertain = true;
51
+ reject(new Error('NODE_UPGRADE_HANDOFF_UNCERTAIN'));
52
+ }, 5_000);
53
+ timer.unref();
54
+ this.pending = {
55
+ id,
56
+ resolve: () => {
57
+ clearTimeout(timer);
58
+ this.pending = undefined;
59
+ resolve();
60
+ },
61
+ reject: (error) => {
62
+ clearTimeout(timer);
63
+ this.pending = undefined;
64
+ reject(error);
65
+ }
66
+ };
67
+ try {
68
+ this.parent.send({ type: 'node-upgrade-handoff', channelId: this.channelId, id, ...input }, (error) => {
69
+ if (error !== null && this.pending?.id === id)
70
+ this.pending.reject(new Error('NODE_UPGRADE_HANDOFF_UNAVAILABLE'));
71
+ });
72
+ }
73
+ catch {
74
+ this.pending?.reject(new Error('NODE_UPGRADE_HANDOFF_UNAVAILABLE'));
75
+ }
76
+ });
77
+ }
78
+ close() {
79
+ this.parent.off('message', this.onMessage);
80
+ this.handshakeResolve?.(false);
81
+ this.pending?.reject(new Error('NODE_UPGRADE_HANDOFF_UNCERTAIN'));
82
+ }
83
+ onMessage = (message) => {
84
+ if (typeof message !== 'object' || message === null)
85
+ return;
86
+ const value = message;
87
+ if (value['channelId'] !== this.channelId)
88
+ return;
89
+ if (value['type'] === 'node-upgrade-ready' && this.handshakeResolve !== undefined) {
90
+ this.supported = true;
91
+ this.handshakeResolve?.(true);
92
+ this.handshakeResolve = undefined;
93
+ }
94
+ else if (value['type'] === 'node-upgrade-ack') {
95
+ const pending = this.pending;
96
+ if (pending === undefined || value['id'] !== pending.id)
97
+ return;
98
+ if (value['accepted'] === true)
99
+ pending.resolve();
100
+ else
101
+ pending.reject(new Error('NODE_UPGRADE_HANDOFF_REJECTED'));
102
+ }
103
+ };
104
+ }
@@ -22,9 +22,19 @@ export class NativeSessionProjectionService {
22
22
  createProjection: (input) => this.project(input)
23
23
  }, workspace);
24
24
  const ordered = deduplicateDirectSessions(sessions);
25
- if (!cleanup)
25
+ if (!cleanup) {
26
26
  this.remember(ordered);
27
- return ordered;
27
+ return ordered.flatMap((session) => {
28
+ const accepted = this.directNative.get(session.id);
29
+ return accepted === undefined ? [] : [accepted];
30
+ });
31
+ }
32
+ return ordered.flatMap((session) => {
33
+ const record = database.getSessionRecord(session.id);
34
+ if (record === undefined && session.parentSessionId != null)
35
+ return [];
36
+ return [{ ...session, parentSessionId: record?.metadata.parentSessionId ?? null }];
37
+ });
28
38
  }
29
39
  async discoverCodex(workspaceId) {
30
40
  const sessions = await this.discover('codex', workspaceId);
@@ -209,7 +219,9 @@ export class NativeSessionProjectionService {
209
219
  access: metadata?.access ?? null,
210
220
  customTitle: metadata?.customTitle ?? null,
211
221
  sessionPrompt: metadata?.sessionPrompt ?? null,
212
- parentSessionId: metadata?.parentSessionId ?? null,
222
+ parentSessionId: metadata === undefined
223
+ ? (input.parentSessionId ?? null)
224
+ : (metadata.parentSessionId ?? null),
213
225
  runnerTitle,
214
226
  pinnedAt: metadata?.pinnedAt ?? null,
215
227
  pinOrder: metadata?.pinOrder ?? null,
@@ -227,7 +239,57 @@ export class NativeSessionProjectionService {
227
239
  }
228
240
  remember(sessions) {
229
241
  const database = this.options.database();
230
- for (const session of sessions) {
242
+ const candidates = new Map(sessions.map((session) => [session.id, session]));
243
+ const validParent = (session) => {
244
+ const visited = new Set([session.id]);
245
+ let parentId = session.parentSessionId ?? null;
246
+ let depth = 1;
247
+ while (parentId !== null) {
248
+ if (visited.has(parentId) || ++depth > 3)
249
+ return false;
250
+ visited.add(parentId);
251
+ const stored = database?.getSessionRecord(parentId);
252
+ const candidate = stored === undefined ? candidates.get(parentId) : undefined;
253
+ if ((stored?.workspaceId ?? candidate?.workspaceId) !== session.workspaceId)
254
+ return false;
255
+ parentId =
256
+ stored === undefined
257
+ ? (candidate?.parentSessionId ?? null)
258
+ : (stored.metadata.parentSessionId ?? null);
259
+ }
260
+ return true;
261
+ };
262
+ const valid = new Map(sessions.map((session) => [session.id, validParent(session)]));
263
+ const accepted = sessions.flatMap((proposed) => {
264
+ const metadata = database === undefined || proposed.externalSessionId === null
265
+ ? undefined
266
+ : database.getNativeSessionMetadata(proposed.runner, proposed.externalSessionId, proposed.workspaceId);
267
+ if (metadata === undefined &&
268
+ proposed.parentSessionId != null &&
269
+ (database === undefined || !valid.get(proposed.id)))
270
+ return [];
271
+ const parentSessionId = metadata !== undefined
272
+ ? (metadata.parentSessionId ?? null)
273
+ : database !== undefined && valid.get(proposed.id)
274
+ ? (proposed.parentSessionId ?? null)
275
+ : null;
276
+ return [{ ...proposed, parentSessionId }];
277
+ });
278
+ const acceptedById = new Map(accepted.map((session) => [session.id, session]));
279
+ const ordered = [];
280
+ const visited = new Set();
281
+ const visit = (session) => {
282
+ if (visited.has(session.id))
283
+ return;
284
+ visited.add(session.id);
285
+ const parent = session.parentSessionId == null ? undefined : acceptedById.get(session.parentSessionId);
286
+ if (parent !== undefined)
287
+ visit(parent);
288
+ ordered.push(session);
289
+ };
290
+ for (const session of accepted)
291
+ visit(session);
292
+ for (const session of ordered) {
231
293
  this.directNative.set(session.id, session);
232
294
  if (database === undefined || session.externalSessionId === null)
233
295
  continue;
@@ -239,6 +301,9 @@ export class NativeSessionProjectionService {
239
301
  runner: session.runner,
240
302
  externalSessionId: session.externalSessionId,
241
303
  workspaceId: session.workspaceId,
304
+ ...(metadata === undefined && session.parentSessionId != null
305
+ ? { parentSessionId: session.parentSessionId }
306
+ : {}),
242
307
  ...(title === undefined || metadata?.runnerTitlePending ? {} : { runnerTitle: title })
243
308
  });
244
309
  }
@@ -327,8 +392,6 @@ export class NativeSessionProjectionService {
327
392
  return ids;
328
393
  }
329
394
  forget(session, knownNativeIds) {
330
- if (session.externalSessionId === null)
331
- return;
332
395
  const projectionIds = new Set([session.id]);
333
396
  const nativeIds = new Set(knownNativeIds);
334
397
  const runner = this.options.runners.require(session.runner);
@@ -0,0 +1,7 @@
1
+ /** Internal-only signal: caller cancellation is checked until the Session owner accepts work. */
2
+ export const SESSION_ADMISSION_SIGNAL = Symbol('session-admission-signal');
3
+ export function checkSessionAdmission(input) {
4
+ const signal = input[SESSION_ADMISSION_SIGNAL];
5
+ if (signal instanceof AbortSignal)
6
+ signal.throwIfAborted();
7
+ }
@@ -218,7 +218,10 @@ export function conversationItemFromEvent(event) {
218
218
  : {}),
219
219
  ...(permissionStop.success ? { permissionStop: permissionStop.data } : {}),
220
220
  merge: event.payload.merge === true,
221
- ...(append === 'sections' || append === 'outputPreview' || append === 'outputSummary'
221
+ ...(append === 'sections' ||
222
+ append === 'outputPreview' ||
223
+ append === 'outputSummary' ||
224
+ append === 'output'
222
225
  ? { append }
223
226
  : {})
224
227
  };
@@ -257,7 +260,7 @@ function projectableConversationStatus(value) {
257
260
  'INTERRUPTED'
258
261
  ].includes(value);
259
262
  }
260
- function mergeConversationItemPayload(existing, next) {
263
+ export function mergeConversationItemPayload(existing, next) {
261
264
  const base = next.merge && isRecord(existing) ? existing : {};
262
265
  const value = { ...base, ...next.payload };
263
266
  if (next.append === 'sections') {
@@ -287,11 +290,30 @@ function mergeConversationItemPayload(existing, next) {
287
290
  const incoming = typeof next.payload.outputSummary === 'string' ? next.payload.outputSummary : '';
288
291
  value.outputSummary = appendProjectionText(prior, incoming);
289
292
  }
293
+ if (next.append === 'output' &&
294
+ isRecord(next.payload.output) &&
295
+ typeof next.payload.output.text === 'string') {
296
+ const prior = isRecord(base.output) && typeof base.output.text === 'string' ? base.output : undefined;
297
+ const text = `${prior?.text ?? ''}${next.payload.output.text}`;
298
+ value.output = {
299
+ text: boundedOutputTail(text),
300
+ truncated: prior?.truncated === true || next.payload.output.truncated === true || text.length > 10_000
301
+ };
302
+ if (typeof next.payload.outputPreview === 'string') {
303
+ value.outputPreview = boundedOutputTail(`${typeof base.outputPreview === 'string' ? base.outputPreview : ''}${next.payload.outputPreview}`);
304
+ }
305
+ }
290
306
  return value;
291
307
  }
308
+ function boundedOutputTail(text) {
309
+ if (text.length <= 10_000)
310
+ return text;
311
+ const tail = text.slice(-10_000);
312
+ return /^[\uDC00-\uDFFF]/u.test(tail) ? tail.slice(1) : tail;
313
+ }
292
314
  function appendProjectionText(previous, next) {
293
315
  const text = `${previous}${next}`;
294
- return text.length <= 10_000 ? text : `${text.slice(0, 9_999)}…`;
316
+ return text.length <= 10_000 ? text : `${text.slice(0, 9_999).replace(/[\uD800-\uDBFF]$/, '')}…`;
295
317
  }
296
318
  export function activeConversationItem(status) {
297
319
  return status === 'PENDING' || status === 'IN_PROGRESS';
@@ -57,34 +57,47 @@ export class SessionCatalogService {
57
57
  throw new Error('WORKSPACE_NOT_FOUND');
58
58
  if (typeof input.sessionId !== 'string')
59
59
  throw new Error('SESSION_INVALID');
60
+ if (input.includeAncestors !== undefined && typeof input.includeAncestors !== 'boolean')
61
+ throw new Error('SESSION_INVALID');
60
62
  const limit = input.limit === undefined ? 100 : input.limit;
61
63
  if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > 100)
62
64
  throw new Error('SESSION_INVALID');
63
- const sessions = await this.collect({ workspaceId: input.workspaceId, registeredOnly: true });
64
- const root = sessions.find((session) => session.id === input.sessionId);
65
- if (root === undefined)
65
+ const database = this.options.database();
66
+ let root = database.getSessionRecord(input.sessionId);
67
+ if (root === undefined || root.workspaceId !== input.workspaceId)
66
68
  throw new Error('SESSION_NOT_FOUND');
67
- const children = new Map();
68
- for (const session of sessions) {
69
- if (session.parentSessionId == null)
70
- continue;
71
- const siblings = children.get(session.parentSessionId) ?? [];
72
- siblings.push(session);
73
- children.set(session.parentSessionId, siblings);
69
+ const ancestors = new Set([root.id]);
70
+ while (input.includeAncestors === true && typeof root.metadata.parentSessionId === 'string') {
71
+ const parent = database.getSessionRecord(root.metadata.parentSessionId);
72
+ if (parent === undefined ||
73
+ parent.workspaceId !== input.workspaceId ||
74
+ ancestors.has(parent.id))
75
+ throw new Error('SESSION_PARENT_INVALID');
76
+ ancestors.add(parent.id);
77
+ root = parent;
74
78
  }
75
79
  const queue = [root];
76
80
  const visited = new Set([root.id]);
77
81
  const nodes = [];
78
82
  for (let index = 0; index < queue.length && index < limit; index += 1) {
79
83
  const session = queue[index];
84
+ const active = this.options.runtime.getAgentSession(session.id);
85
+ const live = active === undefined ? undefined : this.options.present(active);
80
86
  nodes.push({
81
87
  id: session.id,
82
- parentSessionId: session.parentSessionId ?? null,
83
- title: session.customTitle ?? session.runnerTitle ?? 'Untitled session',
88
+ parentSessionId: session.metadata.parentSessionId ?? null,
89
+ title: live?.customTitle ??
90
+ session.metadata.customTitle ??
91
+ live?.runnerTitle ??
92
+ session.metadata.runnerTitle ??
93
+ 'Untitled session',
84
94
  runner: session.runner,
85
- ...('activity' in session ? { activity: session.activity } : {})
95
+ activity: live && 'activity' in live ? live.activity : 'IDLE'
86
96
  });
87
- for (const child of children.get(session.id) ?? []) {
97
+ const children = [...database.listChildSessionRecords(input.workspaceId, session.id)];
98
+ if (input.includeAncestors === true)
99
+ children.sort((left, right) => Number(ancestors.has(right.id)) - Number(ancestors.has(left.id)));
100
+ for (const child of children) {
88
101
  if (visited.has(child.id))
89
102
  continue;
90
103
  visited.add(child.id);
@@ -302,13 +315,98 @@ export class SessionCatalogService {
302
315
  if (session === undefined)
303
316
  throw new Error('SESSION_NOT_FOUND');
304
317
  try {
305
- await this.removeResolved(session);
318
+ await this.removeTree(session);
306
319
  return { deleted: true };
307
320
  }
308
321
  finally {
309
322
  this.options.invalidateWorkspaceSessions(session.workspaceId);
310
323
  }
311
324
  }
325
+ async removeTree(root) {
326
+ if (root.externalSessionId === null)
327
+ throw new Error('NATIVE_TRANSCRIPT_UNAVAILABLE');
328
+ const database = this.options.database();
329
+ const live = this.options.runtime
330
+ .listAgentSessions()
331
+ .filter((session) => session.workspaceId === root.workspaceId);
332
+ const liveChildren = new Map();
333
+ for (const session of live) {
334
+ if (session.parentSessionId == null)
335
+ continue;
336
+ const children = liveChildren.get(session.parentSessionId) ?? [];
337
+ children.push(session.externalSessionId ?? session.id);
338
+ liveChildren.set(session.parentSessionId, children);
339
+ }
340
+ const ids = [root.externalSessionId];
341
+ const seen = new Set(ids);
342
+ const parents = new Map();
343
+ const releases = [];
344
+ const locked = new Set();
345
+ const lock = (id) => {
346
+ const actual = this.options.runtime.getAgentSession(id)?.id ?? id;
347
+ if (locked.has(actual))
348
+ return;
349
+ releases.push(this.options.runtime.beginSessionHistoryMutation(actual, { allowQueued: true }));
350
+ locked.add(actual);
351
+ };
352
+ try {
353
+ lock(root.id);
354
+ for (let index = 0; index < ids.length; index += 1) {
355
+ const parent = ids[index];
356
+ const children = [
357
+ ...database.listChildSessionRecords(root.workspaceId, parent).map((record) => record.id),
358
+ ...(liveChildren.get(parent) ?? [])
359
+ ];
360
+ for (const child of children) {
361
+ if (seen.has(child)) {
362
+ if (parents.get(child) !== parent)
363
+ throw new Error('SESSION_RELATION_INVALID');
364
+ continue;
365
+ }
366
+ seen.add(child);
367
+ parents.set(child, parent);
368
+ lock(child);
369
+ ids.push(child);
370
+ }
371
+ }
372
+ const sessions = [];
373
+ for (const id of ids) {
374
+ const session = this.options.runtime.getAgentSession(id) ??
375
+ (await this.options.projection.resolve(id, true));
376
+ if (session === undefined || session.workspaceId !== root.workspaceId)
377
+ throw new Error('SESSION_NOT_FOUND');
378
+ sessions.push(session);
379
+ }
380
+ // Validate every member before performing any irreversible native deletion.
381
+ for (const session of sessions)
382
+ await this.assertRemovable(session);
383
+ const batches = new Map();
384
+ for (const session of [...sessions].reverse()) {
385
+ if (session.externalSessionId === null)
386
+ continue;
387
+ const runner = this.options.runners.require(session.runner);
388
+ if (runner.supportsNativeBatchRemoval?.() === true) {
389
+ const members = batches.get(session.runner) ?? [];
390
+ members.push(session);
391
+ batches.set(session.runner, members);
392
+ }
393
+ else
394
+ await this.removeNativeHistories(session, this.options.nativeIds(session));
395
+ }
396
+ for (const [runner, members] of batches)
397
+ await this.options.runners.require(runner).removeNativeMembers(members);
398
+ // Preserve every relationship if any native owner fails, so retries cannot
399
+ // lose sight of descendants whose native cleanup was already attempted.
400
+ for (const session of sessions.reverse()) {
401
+ this.options.database().deleteSessionRecord(session.id);
402
+ this.options.forget(session, this.options.nativeIds(session));
403
+ }
404
+ }
405
+ finally {
406
+ for (const release of releases.reverse())
407
+ release();
408
+ }
409
+ }
312
410
  async cleanupWorkspaceSessions(input) {
313
411
  const inactiveDays = input.inactiveDays;
314
412
  if (typeof input.workspaceId !== 'string' ||
@@ -360,6 +458,7 @@ export class SessionCatalogService {
360
458
  candidates.set(session.id, session);
361
459
  }
362
460
  this.mergePersisted(candidates, workspaceId, false);
461
+ const candidateParents = new Set([...candidates.values()].flatMap((candidate) => candidate.parentSessionId === null ? [] : [candidate.parentSessionId]));
363
462
  candidateCount = candidates.size;
364
463
  for (const session of candidates.values()) {
365
464
  budget.check();
@@ -373,6 +472,14 @@ export class SessionCatalogService {
373
472
  session.lastActivityAt > 0 &&
374
473
  session.lastActivityAt >= cutoff) ||
375
474
  (session.pinOrder !== null && session.pinOrder !== undefined) ||
475
+ (session.externalSessionId !== null &&
476
+ (this.options.database().listChildSessionRecords(workspaceId, session.externalSessionId)
477
+ .length > 0 ||
478
+ candidateParents.has(session.externalSessionId) ||
479
+ this.options.runtime
480
+ .listAgentSessions()
481
+ .some((child) => child.workspaceId === workspaceId &&
482
+ child.parentSessionId === session.externalSessionId))) ||
376
483
  this.options.isWatching(session.id) ||
377
484
  this.options.isRecovering?.(session.id) ||
378
485
  this.options.runtime.sessionQueue(session.id).itemIds.length > 0 ||
@@ -452,6 +559,39 @@ export class SessionCatalogService {
452
559
  lastActivityAt: latestSessionActivityAt(projected.lastActivityAt, native.lastActivityAt)
453
560
  };
454
561
  }
562
+ async assertRemovable(session) {
563
+ if (this.options.isRecovering?.(session.id))
564
+ throw new Error('SESSION_RESTART_PENDING');
565
+ if (this.options.runtime
566
+ .listRunsForSession(session.id)
567
+ .some((run) => !isTerminalRunStatus(run.status)))
568
+ throw new Error('SESSION_ACTIVE');
569
+ if (session.externalSessionId !== null && session.nativeControl === 'EXTERNAL') {
570
+ await this.options.refreshActivity(session);
571
+ if (this.options.externalActivity(session.id) === 'BUSY')
572
+ throw new Error('SESSION_ACTIVE');
573
+ }
574
+ }
575
+ async removeNativeHistories(session, nativeIds) {
576
+ const runner = this.options.runners.require(session.runner);
577
+ let deleted = false;
578
+ for (const externalSessionId of nativeIds) {
579
+ try {
580
+ const result = await runner.removeNative(session, externalSessionId);
581
+ if (result === true)
582
+ deleted = true;
583
+ else if (result === false) {
584
+ await removeNativeSession(session.runner, session.cwd, externalSessionId);
585
+ deleted = true;
586
+ }
587
+ }
588
+ catch (error) {
589
+ if (!(error instanceof Error) || error.message !== 'NATIVE_TRANSCRIPT_UNAVAILABLE')
590
+ throw error;
591
+ }
592
+ }
593
+ return deleted;
594
+ }
455
595
  async removeResolved(session, requireCleanupSafety = false, budget) {
456
596
  if (session.externalSessionId === null)
457
597
  throw new Error('NATIVE_TRANSCRIPT_UNAVAILABLE');
@@ -493,23 +633,7 @@ export class SessionCatalogService {
493
633
  this.options.forget(session, nativeIds);
494
634
  return 'deleted';
495
635
  }
496
- const runner = this.options.runners.require(session.runner);
497
- let deleted = false;
498
- for (const externalSessionId of nativeIds) {
499
- try {
500
- const result = await runner.removeNative(session, externalSessionId);
501
- if (result === true)
502
- deleted = true;
503
- else if (result === false) {
504
- await removeNativeSession(session.runner, session.cwd, externalSessionId);
505
- deleted = true;
506
- }
507
- }
508
- catch (error) {
509
- if (!(error instanceof Error) || error.message !== 'NATIVE_TRANSCRIPT_UNAVAILABLE')
510
- throw error;
511
- }
512
- }
636
+ const deleted = await this.removeNativeHistories(session, nativeIds);
513
637
  this.options.database().deleteSessionRecord(session.id);
514
638
  this.options.forget(session, nativeIds);
515
639
  return deleted ? 'deleted' : 'skipped';