@myagentroam/node 0.9.2 → 0.9.3

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 (69) hide show
  1. package/dist/connector.js +12 -13
  2. package/dist/control-outbound-scheduler.d.ts +4 -1
  3. package/dist/control-outbound-scheduler.js +39 -6
  4. package/dist/database.d.ts +0 -19
  5. package/dist/database.js +1 -62
  6. package/dist/event-buffer.d.ts +2 -5
  7. package/dist/event-buffer.js +3 -12
  8. package/dist/native-session-history.js +33 -12
  9. package/dist/runner/claude/managed-run-controller.d.ts +0 -1
  10. package/dist/runner/claude/managed-run-controller.js +17 -12
  11. package/dist/runner/codex/conversation-parser.d.ts +1 -1
  12. package/dist/runner/codex/conversation-parser.js +3 -0
  13. package/dist/runner/codex/managed-run-controller.d.ts +2 -2
  14. package/dist/runner/codex/managed-run-controller.js +22 -7
  15. package/dist/runner/codex-runner.d.ts +2 -0
  16. package/dist/runner/codex-runner.js +30 -2
  17. package/dist/runner/opencode/conversation-parser.js +1 -1
  18. package/dist/runner/opencode/managed-run-controller.d.ts +7 -2
  19. package/dist/runner/opencode/managed-run-controller.js +72 -14
  20. package/dist/runner/opencode-runner.d.ts +3 -0
  21. package/dist/runner/opencode-runner.js +25 -0
  22. package/dist/runner-profiles.js +10 -6
  23. package/dist/runtime-state.d.ts +5 -12
  24. package/dist/runtime-state.js +32 -46
  25. package/dist/service/attachment-domain-state.d.ts +2 -0
  26. package/dist/service/attachment-domain-state.js +2 -0
  27. package/dist/service/conversation-segment-service.js +10 -9
  28. package/dist/service/external-session-resume-service.js +9 -0
  29. package/dist/service/fake-managed-run-service.d.ts +0 -1
  30. package/dist/service/fake-managed-run-service.js +0 -2
  31. package/dist/service/native-session-projection-service.js +5 -0
  32. package/dist/service/node-connection-lifecycle-service.d.ts +0 -2
  33. package/dist/service/node-connection-lifecycle-service.js +0 -1
  34. package/dist/service/node-control-channel.js +1 -1
  35. package/dist/service/node-control-message-service.d.ts +0 -1
  36. package/dist/service/node-control-message-service.js +9 -6
  37. package/dist/service/node-workspace-coordinator.d.ts +1 -4
  38. package/dist/service/node-workspace-coordinator.js +2 -12
  39. package/dist/service/run-attachment-service.js +30 -6
  40. package/dist/service/run-event-service.d.ts +1 -0
  41. package/dist/service/run-event-service.js +69 -0
  42. package/dist/service/run-workbench-service.d.ts +1 -1
  43. package/dist/service/run-workbench-service.js +1 -5
  44. package/dist/service/runner-interaction-service.d.ts +1 -2
  45. package/dist/service/runner-interaction-service.js +1 -4
  46. package/dist/service/session-context-service.d.ts +1 -0
  47. package/dist/service/session-context-service.js +14 -0
  48. package/dist/service/session-domain-state.d.ts +1 -0
  49. package/dist/service/session-domain-state.js +1 -0
  50. package/dist/service/session-message-service.js +5 -10
  51. package/dist/service/skill-directory-service.d.ts +3 -1
  52. package/dist/service/skill-directory-service.js +28 -4
  53. package/dist/service/skill-node-operation-service.d.ts +1 -1
  54. package/dist/service/skill-node-operation-service.js +4 -2
  55. package/dist/service/workbench-event-service.d.ts +1 -13
  56. package/dist/service/workbench-event-service.js +2 -36
  57. package/dist/service/workbench-manifest-service.d.ts +0 -1
  58. package/dist/service/workbench-manifest-service.js +0 -1
  59. package/dist/service/workspace-domain-state.d.ts +1 -0
  60. package/dist/service/workspace-queue-workbench-service.d.ts +1 -0
  61. package/dist/service/workspace-queue-workbench-service.js +1 -0
  62. package/dist/service/workspace-upload-service.d.ts +2 -0
  63. package/dist/service/workspace-upload-service.js +28 -6
  64. package/dist/service/workspace-workbench-service.d.ts +0 -3
  65. package/dist/service/workspace-workbench-service.js +0 -17
  66. package/dist/util/runner-error.d.ts +6 -0
  67. package/dist/util/runner-error.js +52 -0
  68. package/dist/util/runner-native-session-parsers.js +43 -6
  69. package/package.json +2 -2
@@ -9,22 +9,22 @@ export class NodeRuntimeState {
9
9
  nodeId;
10
10
  getWorkspace;
11
11
  now;
12
+ static MAX_COMPLETED_TURNS_PER_SESSION = 10;
12
13
  sessions = new Map();
13
14
  runs = new Map();
14
15
  messageRuns = new Map();
15
16
  events = new Map();
16
17
  turns = new Map();
17
18
  approvals = new Map();
18
- workspaceEvents = new Map();
19
19
  /** A Session owns at most one running turn; Workspace sessions may run concurrently. */
20
20
  leases = new Map();
21
21
  /** Session FIFO metadata is deliberately process-local with the rest of run state. */
22
22
  queuedRuns = new Map();
23
23
  queueVersions = new Map();
24
24
  pausedQueues = new Set();
25
- snapshots = new Map();
25
+ /** Bounded tombstones reject stale internal start envelopes after terminal cleanup. */
26
+ terminalRunIds = new Set();
26
27
  nextEventId = 1;
27
- nextWorkspaceEventId = 1;
28
28
  constructor(nodeId, getWorkspace, now = Date.now) {
29
29
  this.nodeId = nodeId;
30
30
  this.getWorkspace = getWorkspace;
@@ -73,6 +73,8 @@ export class NodeRuntimeState {
73
73
  }
74
74
  /** Internal envelope compatibility for a Run already accepted in this process. */
75
75
  adoptRun(input) {
76
+ if (this.terminalRunIds.has(input.runId))
77
+ throw new Error('RUN_TERMINAL');
76
78
  let session = this.sessions.get(input.sessionId);
77
79
  if (session === undefined) {
78
80
  const now = this.now();
@@ -316,11 +318,6 @@ export class NodeRuntimeState {
316
318
  this.queuedRuns.set(session.id, [...(this.queuedRuns.get(session.id) ?? []), run.id]);
317
319
  this.bumpQueueVersion(session.id);
318
320
  this.turns.set(session.id, [...(this.turns.get(session.id) ?? []), fixedTurn]);
319
- this.recordWorkspaceActivity(run.workspaceId, 'run.created', {
320
- runId: run.id,
321
- sessionId: session.id,
322
- runner: run.runner
323
- });
324
321
  this.replaceSession(session.id, { lastActivityAt: now });
325
322
  return { run, created: true };
326
323
  }
@@ -402,7 +399,6 @@ export class NodeRuntimeState {
402
399
  this.bumpQueueVersion(run.sessionId);
403
400
  this.runs.delete(runId);
404
401
  this.events.delete(runId);
405
- this.snapshots.delete(runId);
406
402
  this.turns.set(run.sessionId, (this.turns.get(run.sessionId) ?? []).filter((turn) => turn.runId !== runId));
407
403
  for (const [key, candidate] of this.messageRuns)
408
404
  if (candidate === runId)
@@ -468,12 +464,6 @@ export class NodeRuntimeState {
468
464
  if (input.status !== undefined)
469
465
  this.transitionRun(input.runId, input.status);
470
466
  this.projectEvent(value);
471
- const run = this.requireRun(input.runId);
472
- this.recordWorkspaceActivity(run.workspaceId, 'run.updated', {
473
- runId: run.id,
474
- eventType: input.eventType,
475
- status: input.status ?? null
476
- });
477
467
  return value;
478
468
  }
479
469
  listRunEvents(id, after = 0) {
@@ -559,16 +549,6 @@ export class NodeRuntimeState {
559
549
  this.replaceSession(session.id, { lastActivityAt: now });
560
550
  return completedTurn;
561
551
  }
562
- listWorkspaceActivity(input) {
563
- const all = this.workspaceEvents.get(input.workspaceId) ?? [];
564
- const start = decodeCursor(input.cursor, input.workspaceId);
565
- const limit = Math.min(Math.max(input.limit ?? 30, 1), 500);
566
- const events = all.slice(start, start + limit);
567
- return {
568
- events,
569
- nextCursor: start + limit < all.length ? encodeCursor(input.workspaceId, start + limit) : null
570
- };
571
- }
572
552
  createApproval(input) {
573
553
  this.requireRun(input.runId);
574
554
  if (this.approvals.has(input.approvalId))
@@ -631,11 +611,30 @@ export class NodeRuntimeState {
631
611
  lastRunSequence(id) {
632
612
  return this.events.get(id)?.at(-1)?.sequence ?? 0;
633
613
  }
634
- saveSnapshot(id, value) {
635
- this.snapshots.set(id, value);
636
- }
637
- getSnapshot(id) {
638
- return this.snapshots.get(id);
614
+ removeTerminalRun(runId) {
615
+ const run = this.requireRun(runId);
616
+ if (!terminal(run.status))
617
+ throw new Error('RUN_ACTIVE');
618
+ this.terminalRunIds.add(runId);
619
+ if (this.terminalRunIds.size > 1_024)
620
+ this.terminalRunIds.delete(this.terminalRunIds.values().next().value);
621
+ const queue = this.queuedRuns.get(run.sessionId) ?? [];
622
+ if (queue.includes(runId)) {
623
+ this.queuedRuns.set(run.sessionId, queue.filter((candidate) => candidate !== runId));
624
+ this.bumpQueueVersion(run.sessionId);
625
+ }
626
+ this.runs.delete(runId);
627
+ this.events.delete(runId);
628
+ const turns = this.turns.get(run.sessionId) ?? [];
629
+ const completed = turns.filter((turn) => terminalTurn(turn.status));
630
+ const retainedCompleted = new Set(completed.slice(-NodeRuntimeState.MAX_COMPLETED_TURNS_PER_SESSION).map((turn) => turn.id));
631
+ this.turns.set(run.sessionId, turns.filter((turn) => !terminalTurn(turn.status) || retainedCompleted.has(turn.id)));
632
+ for (const [key, candidate] of this.messageRuns)
633
+ if (candidate === runId)
634
+ this.messageRuns.delete(key);
635
+ for (const [approvalId, approval] of this.approvals)
636
+ if (approval.runId === runId)
637
+ this.approvals.delete(approvalId);
639
638
  }
640
639
  hasActiveWorkspaceLease(workspaceId) {
641
640
  return [...this.leases.values()]
@@ -658,7 +657,6 @@ export class NodeRuntimeState {
658
657
  for (const runId of runIds) {
659
658
  this.runs.delete(runId);
660
659
  this.events.delete(runId);
661
- this.snapshots.delete(runId);
662
660
  }
663
661
  for (const [key, runId] of this.messageRuns)
664
662
  if (runIds.has(runId))
@@ -681,7 +679,6 @@ export class NodeRuntimeState {
681
679
  for (const runId of runIds) {
682
680
  this.runs.delete(runId);
683
681
  this.events.delete(runId);
684
- this.snapshots.delete(runId);
685
682
  }
686
683
  for (const [key, runId] of this.messageRuns)
687
684
  if (runIds.has(runId))
@@ -689,7 +686,6 @@ export class NodeRuntimeState {
689
686
  for (const [approvalId, approval] of this.approvals)
690
687
  if (runIds.has(approval.runId))
691
688
  this.approvals.delete(approvalId);
692
- this.workspaceEvents.delete(workspaceId);
693
689
  for (const sessionId of sessionIds) {
694
690
  this.queuedRuns.delete(sessionId);
695
691
  this.queueVersions.delete(sessionId);
@@ -730,19 +726,6 @@ export class NodeRuntimeState {
730
726
  throw new Error('APPROVAL_NOT_FOUND');
731
727
  return value;
732
728
  }
733
- recordWorkspaceActivity(workspaceId, eventType, payload) {
734
- const event = {
735
- id: this.nextWorkspaceEventId++,
736
- workspaceId,
737
- eventType,
738
- payload,
739
- createdAt: this.now()
740
- };
741
- this.workspaceEvents.set(workspaceId, [
742
- ...(this.workspaceEvents.get(workspaceId) ?? []),
743
- event
744
- ]);
745
- }
746
729
  updateTurnForRun(runId, update) {
747
730
  const run = this.requireRun(runId);
748
731
  const current = this.turns.get(run.sessionId) ?? [];
@@ -840,6 +823,9 @@ export class NodeRuntimeState {
840
823
  function terminal(status) {
841
824
  return ['SUCCEEDED', 'FAILED', 'CANCELLED', 'INTERRUPTED', 'LOST'].includes(status);
842
825
  }
826
+ function terminalTurn(status) {
827
+ return ['SUCCEEDED', 'FAILED', 'CANCELLED', 'INTERRUPTED'].includes(status);
828
+ }
843
829
  function transitionAllowed(from, to) {
844
830
  return !terminal(from) || from === to;
845
831
  }
@@ -1,5 +1,7 @@
1
1
  export declare class AttachmentDomainState<TCachedRunImages, TNativeImageSource> {
2
2
  readonly runDirectories: Map<string, string>;
3
+ readonly runFileDirectories: Map<string, string>;
3
4
  readonly runImages: Map<string, TCachedRunImages>;
4
5
  readonly nativeHistoryImages: Map<string, TNativeImageSource[]>;
6
+ readonly nativeHistoryImageTimers: Map<string, NodeJS.Timeout>;
5
7
  }
@@ -1,5 +1,7 @@
1
1
  export class AttachmentDomainState {
2
2
  runDirectories = new Map();
3
+ runFileDirectories = new Map();
3
4
  runImages = new Map();
4
5
  nativeHistoryImages = new Map();
6
+ nativeHistoryImageTimers = new Map();
5
7
  }
@@ -3,11 +3,11 @@ export function conversationSegments(turn) {
3
3
  const groups = [];
4
4
  let current = [];
5
5
  for (const item of turn.items) {
6
+ if (current.at(-1)?.kind === 'assistant_message' && item.kind !== 'assistant_message') {
7
+ groups.push(current);
8
+ current = [];
9
+ }
6
10
  current.push(item);
7
- if (item.kind !== 'assistant_message')
8
- continue;
9
- groups.push(current);
10
- current = [];
11
11
  }
12
12
  if (current.length > 0 || groups.length === 0)
13
13
  groups.push(current);
@@ -36,14 +36,15 @@ export function conversationSegments(turn) {
36
36
  export function conversationSegmentIdentityForItem(turn, itemId, itemKind) {
37
37
  let index = 0;
38
38
  const runtimeItemId = turn.runId === null ? itemId : `${turn.runId}:${itemId}`;
39
- for (const item of turn.items) {
39
+ for (const [itemIndex, item] of turn.items.entries()) {
40
+ const previous = turn.items[itemIndex - 1];
41
+ if (previous?.kind === 'assistant_message' && item.kind !== 'assistant_message')
42
+ index += 1;
40
43
  if (item.id === itemId || item.id === runtimeItemId)
41
44
  return { id: `${turn.id}:segment:${index}`, index };
42
- if (item.kind === 'assistant_message')
43
- index += 1;
44
45
  }
45
- if (itemKind === 'assistant_message' && turn.items.at(-1)?.kind === 'assistant_message')
46
- index -= 1;
46
+ if (turn.items.at(-1)?.kind === 'assistant_message' && itemKind !== 'assistant_message')
47
+ index += 1;
47
48
  return { id: `${turn.id}:segment:${index}`, index };
48
49
  }
49
50
  export class ConversationSegmentService {
@@ -1,3 +1,4 @@
1
+ const RESUME_FAILURE_TTL_MS = 30 * 60_000;
1
2
  export class ExternalSessionResumeService {
2
3
  options;
3
4
  constructor(options) {
@@ -84,15 +85,23 @@ export class ExternalSessionResumeService {
84
85
  : 'REJECTED';
85
86
  }
86
87
  clearFailure(sessionId) {
88
+ const timer = this.options.state.failureTimers.get(sessionId);
89
+ if (timer !== undefined)
90
+ clearTimeout(timer);
91
+ this.options.state.failureTimers.delete(sessionId);
87
92
  this.options.state.resumeFailures.delete(sessionId);
88
93
  this.options.state.resumeFailureDetails.delete(sessionId);
89
94
  this.options.state.messageResumeFailures.delete(sessionId);
90
95
  }
91
96
  setFailure(sessionId, code, detail) {
97
+ this.clearFailure(sessionId);
92
98
  this.options.state.resumeFailures.set(sessionId, code);
93
99
  if (detail === undefined)
94
100
  this.options.state.resumeFailureDetails.delete(sessionId);
95
101
  else
96
102
  this.options.state.resumeFailureDetails.set(sessionId, detail);
103
+ const timer = setTimeout(() => this.clearFailure(sessionId), RESUME_FAILURE_TTL_MS);
104
+ timer.unref();
105
+ this.options.state.failureTimers.set(sessionId, timer);
97
106
  }
98
107
  }
@@ -17,7 +17,6 @@ interface FakeManagedRunServiceOptions {
17
17
  readonly payload: unknown;
18
18
  readonly expiresAt: number;
19
19
  }) => void;
20
- readonly captureSnapshot: (runId: string, cwd: string) => Promise<void>;
21
20
  readonly disconnect: () => void;
22
21
  }
23
22
  /** Test-only managed Run adapter; production Runner paths never depend on it. */
@@ -32,8 +32,6 @@ export class FakeManagedRunService {
32
32
  this.options.emitRun(runId, eventType, eventPayload, status);
33
33
  if (status !== undefined && isTerminalRunStatus(status)) {
34
34
  this.options.state.active.delete(runId);
35
- if (cwd !== undefined)
36
- void this.options.captureSnapshot(runId, cwd);
37
35
  }
38
36
  },
39
37
  approval: (approvalId, approvalPayload, expiresAt) => {
@@ -238,7 +238,12 @@ export class NativeSessionProjectionService {
238
238
  this.options.state.externalActivity.delete(directId);
239
239
  this.options.state.resumedByDirectId.delete(directId);
240
240
  this.options.state.resumeFailures.delete(directId);
241
+ this.options.state.messageResumeFailures.delete(directId);
241
242
  this.options.state.resumeFailureDetails.delete(directId);
243
+ const timer = this.options.state.failureTimers.get(directId);
244
+ if (timer !== undefined)
245
+ clearTimeout(timer);
246
+ this.options.state.failureTimers.delete(directId);
242
247
  }
243
248
  }
244
249
  for (const [directId, resumedId] of this.options.state.resumedByDirectId)
@@ -3,7 +3,6 @@ import { type NodeConfig } from '../config.js';
3
3
  import type { NodeMetrics } from '../operational.js';
4
4
  import type { NodeControlChannel } from './node-control-channel.js';
5
5
  import type { NodeTerminalChannel } from './node-terminal-channel.js';
6
- import type { WorkbenchEventService } from './workbench-event-service.js';
7
6
  interface NodeConnectionLifecycleServiceOptions {
8
7
  readonly config: () => NodeConfig | undefined;
9
8
  readonly setConfig: (config: NodeConfig) => void;
@@ -15,7 +14,6 @@ interface NodeConnectionLifecycleServiceOptions {
15
14
  readonly setCapabilities: (capabilities: NodeCapabilities) => void;
16
15
  readonly control: NodeControlChannel;
17
16
  readonly terminal: NodeTerminalChannel;
18
- readonly events: WorkbenchEventService;
19
17
  readonly metrics: NodeMetrics;
20
18
  readonly heartbeatMs: number;
21
19
  readonly refreshMs: number;
@@ -48,7 +48,6 @@ export class NodeConnectionLifecycleService {
48
48
  this.options.terminal.connect();
49
49
  this.options.control.send('heartbeat', {});
50
50
  this.options.metrics.heartbeatSent();
51
- this.options.events.replay();
52
51
  this.heartbeat = setInterval(() => {
53
52
  this.options.metrics.heartbeatSent();
54
53
  this.options.control.send('heartbeat', {});
@@ -5,7 +5,7 @@ import { nodeConnectEndpoint } from '../runner/codex/conversation-parser.js';
5
5
  export class NodeControlChannel {
6
6
  options;
7
7
  socket;
8
- outbound = new ControlOutboundScheduler(() => this.socket);
8
+ outbound = new ControlOutboundScheduler(() => this.socket, () => this.socket?.close(1013, 'NODE_CONTROL_BACKPRESSURE_TIMEOUT'));
9
9
  reconnectTimer;
10
10
  attempts = 0;
11
11
  constructor(options) {
@@ -8,7 +8,6 @@ interface NodeControlMessageServiceOptions {
8
8
  readonly close: (code: number, reason: string) => void;
9
9
  readonly inspectWorkspace: (envelope: NodeEnvelope) => Promise<void>;
10
10
  readonly handleNodeRequest: (envelope: NodeEnvelope) => Promise<void>;
11
- readonly acknowledgeWorkbenchEvent: (replyTo: string | undefined) => void;
12
11
  readonly reconcileRuns: (envelope: NodeEnvelope) => void;
13
12
  readonly runners: RunnerRegistry;
14
13
  readonly deliverSessionChannelMessage: (envelope: NodeEnvelope) => void;
@@ -33,12 +33,8 @@ export class NodeControlMessageService {
33
33
  void this.options.handleNodeRequest(envelope);
34
34
  return;
35
35
  }
36
- if (envelope.type === 'ack') {
37
- const payload = envelope.payload;
38
- if (payload.type === 'workbench.event')
39
- this.options.acknowledgeWorkbenchEvent(envelope.replyTo);
36
+ if (envelope.type === 'ack')
40
37
  return;
41
- }
42
38
  if (envelope.type === 'runs.reconcile') {
43
39
  this.options.reconcileRuns(envelope);
44
40
  return;
@@ -49,7 +45,14 @@ export class NodeControlMessageService {
49
45
  }
50
46
  if (envelope.type === 'run.start') {
51
47
  this.options.send('ack', { type: envelope.type }, envelope.id);
52
- this.options.runners.startManagedRun(envelope);
48
+ try {
49
+ this.options.runners.startManagedRun(envelope);
50
+ }
51
+ catch (error) {
52
+ // A delayed internal start may arrive after the terminal projection was removed.
53
+ if (!(error instanceof Error) || error.message !== 'RUN_TERMINAL')
54
+ throw error;
55
+ }
53
56
  return;
54
57
  }
55
58
  if (envelope.type === 'session.channel.message') {
@@ -1,7 +1,6 @@
1
1
  import type { NodeEnvelope } from '@myagentroam/protocol';
2
2
  import type { NodeConfig } from '../config.js';
3
3
  import type { NodeAgentSession, NodeDatabase, NodeWorkspace } from '../database.js';
4
- import type { NodeRuntimeState } from '../runtime-state.js';
5
4
  import type { TerminalSummary } from '../terminal.js';
6
5
  import type { WorkspaceChangeService } from './workspace-change-service.js';
7
6
  import type { WorkspaceSessionService } from './workspace-session-service.js';
@@ -9,14 +8,13 @@ import type { WorkspaceWatchService } from './workspace-watch-service.js';
9
8
  interface NodeWorkspaceCoordinatorOptions {
10
9
  readonly config: () => NodeConfig | undefined;
11
10
  readonly database: () => NodeDatabase | undefined;
12
- readonly runtime: NodeRuntimeState;
13
11
  readonly watches: WorkspaceWatchService;
14
12
  readonly sessions: WorkspaceSessionService;
15
13
  readonly changes: WorkspaceChangeService;
16
14
  readonly emit: (workspaceId: string, topic: 'files' | 'changes', revision: number) => void;
17
15
  readonly send: (type: string, payload: unknown, replyTo?: string) => void;
18
16
  }
19
- /** Coordinates Workspace invalidation, inspection, snapshots and persisted Terminal summaries. */
17
+ /** Coordinates Workspace invalidation, inspection and persisted Terminal summaries. */
20
18
  export declare class NodeWorkspaceCoordinator {
21
19
  private readonly options;
22
20
  constructor(options: NodeWorkspaceCoordinatorOptions);
@@ -27,6 +25,5 @@ export declare class NodeWorkspaceCoordinator {
27
25
  invalidateChanges(workspaceId: string): void;
28
26
  persistTerminal(summary: TerminalSummary): void;
29
27
  inspect(envelope: NodeEnvelope): Promise<void>;
30
- captureRunSnapshot(runId: string, cwd: string): Promise<void>;
31
28
  }
32
29
  export {};
@@ -1,5 +1,5 @@
1
- import { inspectWorkspace, readGitSummary } from '../workspace.js';
2
- /** Coordinates Workspace invalidation, inspection, snapshots and persisted Terminal summaries. */
1
+ import { inspectWorkspace } from '../workspace.js';
2
+ /** Coordinates Workspace invalidation, inspection and persisted Terminal summaries. */
3
3
  export class NodeWorkspaceCoordinator {
4
4
  options;
5
5
  constructor(options) {
@@ -67,14 +67,4 @@ export class NodeWorkspaceCoordinator {
67
67
  this.options.send('workspace.inspect.result', { error: 'WORKSPACE_INVALID' }, envelope.id);
68
68
  }
69
69
  }
70
- async captureRunSnapshot(runId, cwd) {
71
- try {
72
- const snapshot = await readGitSummary(cwd);
73
- this.options.runtime.saveSnapshot(runId, snapshot);
74
- this.options.send('workspace.snapshot', { runId, snapshot });
75
- }
76
- catch {
77
- // Ordinary directories have no Git snapshot and keep the existing Run result.
78
- }
79
- }
80
70
  }
@@ -4,6 +4,7 @@ import { lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:f
4
4
  import { AttachmentDomainState } from './attachment-domain-state.js';
5
5
  const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
6
6
  const IMAGE_CACHE_TTL_MS = 60 * 60_000;
7
+ const NATIVE_IMAGE_INDEX_TTL_MS = 60 * 60_000;
7
8
  export class RunAttachmentService {
8
9
  nativeImageRoots;
9
10
  state = new AttachmentDomainState();
@@ -43,21 +44,28 @@ export class RunAttachmentService {
43
44
  throw new Error('MESSAGE_ATTACHMENT_DIRECTORY_INVALID');
44
45
  const runDirectory = join(filesDirectory, runId);
45
46
  await mkdir(runDirectory, { mode: 0o700 });
47
+ this.state.runFileDirectories.set(runId, runDirectory);
46
48
  try {
47
49
  await Promise.all(attachments.map((attachment) => writeFile(join(runDirectory, attachment.name), Buffer.from(attachment.dataBase64, 'base64'), { flag: 'wx', mode: 0o600 })));
48
50
  return attachments.map((attachment) => `.mar/temp-files/${runId}/${attachment.name}`);
49
51
  }
50
52
  catch (error) {
53
+ this.state.runFileDirectories.delete(runId);
51
54
  await rm(runDirectory, { recursive: true, force: true });
52
55
  throw error;
53
56
  }
54
57
  }
55
58
  cleanup(runId) {
56
59
  const directory = this.state.runDirectories.get(runId);
57
- if (directory === undefined)
58
- return;
59
- this.state.runDirectories.delete(runId);
60
- void rm(directory, { recursive: true, force: true }).catch(() => undefined);
60
+ if (directory !== undefined) {
61
+ this.state.runDirectories.delete(runId);
62
+ void rm(directory, { recursive: true, force: true }).catch(() => undefined);
63
+ }
64
+ const fileDirectory = this.state.runFileDirectories.get(runId);
65
+ if (fileDirectory !== undefined) {
66
+ this.state.runFileDirectories.delete(runId);
67
+ void rm(fileDirectory, { recursive: true, force: true }).catch(() => undefined);
68
+ }
61
69
  }
62
70
  cache(runId, images) {
63
71
  if (images.length === 0)
@@ -122,8 +130,18 @@ export class RunAttachmentService {
122
130
  }
123
131
  attachments.push({ name: image.name, mime: image.mime, runId, imageIndex });
124
132
  }
125
- if (sources.length > 0)
133
+ if (sources.length > 0) {
126
134
  this.state.nativeHistoryImages.set(runId, sources);
135
+ const previousTimer = this.state.nativeHistoryImageTimers.get(runId);
136
+ if (previousTimer !== undefined)
137
+ clearTimeout(previousTimer);
138
+ const timer = setTimeout(() => {
139
+ this.state.nativeHistoryImageTimers.delete(runId);
140
+ this.state.nativeHistoryImages.delete(runId);
141
+ }, NATIVE_IMAGE_INDEX_TTL_MS);
142
+ timer.unref();
143
+ this.state.nativeHistoryImageTimers.set(runId, timer);
144
+ }
127
145
  return attachments;
128
146
  }
129
147
  async readNative(runId, imageIndex) {
@@ -161,10 +179,16 @@ export class RunAttachmentService {
161
179
  this.state.runImages.delete(runId);
162
180
  }
163
181
  dispose() {
164
- for (const runId of this.state.runDirectories.keys())
182
+ for (const runId of new Set([
183
+ ...this.state.runDirectories.keys(),
184
+ ...this.state.runFileDirectories.keys()
185
+ ]))
165
186
  this.cleanup(runId);
166
187
  for (const runId of this.state.runImages.keys())
167
188
  this.clear(runId);
189
+ for (const timer of this.state.nativeHistoryImageTimers.values())
190
+ clearTimeout(timer);
191
+ this.state.nativeHistoryImageTimers.clear();
168
192
  this.state.nativeHistoryImages.clear();
169
193
  }
170
194
  }
@@ -29,4 +29,5 @@ export declare class RunEventService {
29
29
  private enqueueText;
30
30
  private flushText;
31
31
  private emitNow;
32
+ private notificationPayload;
32
33
  }
@@ -50,6 +50,9 @@ export class RunEventService {
50
50
  this.emitNow(runId, 'text.delta', { text: pending.text });
51
51
  }
52
52
  emitNow(runId, eventType, payload, status) {
53
+ if (this.options.runtime.getRun(runId) === undefined)
54
+ return;
55
+ const notificationPayload = this.notificationPayload(runId, eventType, payload, status);
53
56
  const event = {
54
57
  runId,
55
58
  sequence: (this.sequences.get(runId) ?? 0) + 1,
@@ -76,6 +79,7 @@ export class RunEventService {
76
79
  /* A stale relayed run may race the storage boundary. */
77
80
  }
78
81
  if (status !== undefined && isTerminalRunStatus(status)) {
82
+ this.options.state.active.delete(runId);
79
83
  this.options.state.interruptRequested.delete(runId);
80
84
  const terminalRun = this.options.runtime.getRun(runId);
81
85
  if (terminalRun !== undefined && this.options.isBootstrapSession(terminalRun.sessionId))
@@ -91,6 +95,11 @@ export class RunEventService {
91
95
  this.options.emitWorkbench('conversation', {
92
96
  event: { ...event, createdAt: persisted?.createdAt ?? Date.now() }
93
97
  });
98
+ if (notificationPayload !== undefined)
99
+ this.emitNow(runId, 'notification', {
100
+ ...notificationPayload,
101
+ occurredAt: persisted?.createdAt ?? Date.now()
102
+ });
94
103
  if (status === undefined || !isTerminalRunStatus(status))
95
104
  return;
96
105
  const turn = this.options.runtime.conversationTurnForRun(runId);
@@ -102,7 +111,67 @@ export class RunEventService {
102
111
  const run = this.options.runtime.getRun(runId);
103
112
  if (run !== undefined) {
104
113
  this.options.scheduleContextRefresh(run.sessionId);
114
+ this.options.runtime.removeTerminalRun(runId);
115
+ this.sequences.delete(runId);
105
116
  void this.options.dispatchQueue(run.sessionId);
106
117
  }
107
118
  }
119
+ notificationPayload(runId, eventType, payload, status) {
120
+ if (eventType === 'notification')
121
+ return undefined;
122
+ const run = this.options.runtime.getRun(runId);
123
+ if (run === undefined)
124
+ return undefined;
125
+ const session = this.options.runtime.getAgentSession(run.sessionId);
126
+ if (session === undefined)
127
+ return undefined;
128
+ const base = {
129
+ workspaceId: run.workspaceId,
130
+ sessionId: run.sessionId,
131
+ sessionTitle: session.customTitle ?? session.runnerTitle ?? session.id,
132
+ runId
133
+ };
134
+ if (status !== undefined && isTerminalRunStatus(status)) {
135
+ if (this.options.state.interruptRequested.has(runId))
136
+ return undefined;
137
+ if (status === 'SUCCEEDED' &&
138
+ this.options.runtime.sessionQueue(run.sessionId).runIds.length > 0)
139
+ return undefined;
140
+ return {
141
+ ...base,
142
+ notificationId: `run:${run.workspaceId}:${runId}`,
143
+ kind: 'RUN_TERMINAL',
144
+ outcome: status
145
+ };
146
+ }
147
+ if (eventType === 'approval.request' && isPlainRecord(payload)) {
148
+ const approvalId = payload.approvalId;
149
+ if (typeof approvalId !== 'string')
150
+ return undefined;
151
+ return {
152
+ ...base,
153
+ notificationId: `approval:${run.workspaceId}:${runId}:${approvalId}`,
154
+ kind: 'APPROVAL_REQUIRED'
155
+ };
156
+ }
157
+ if (eventType !== 'conversation.item' || !isPlainRecord(payload))
158
+ return undefined;
159
+ if (payload.kind === 'plan' && typeof payload.itemId === 'string')
160
+ return {
161
+ ...base,
162
+ notificationId: `plan:${run.workspaceId}:${run.sessionId}`,
163
+ kind: 'PLAN_READY'
164
+ };
165
+ if (payload.kind === 'user_input_request' &&
166
+ typeof payload.itemId === 'string' &&
167
+ isPlainRecord(payload.payload) &&
168
+ Array.isArray(payload.payload.questions) &&
169
+ payload.payload.questions.length > 0)
170
+ return {
171
+ ...base,
172
+ notificationId: `user-input:${run.workspaceId}:${runId}:${payload.itemId}`,
173
+ kind: 'USER_INPUT_REQUIRED'
174
+ };
175
+ return undefined;
176
+ }
108
177
  }
@@ -6,6 +6,7 @@ export interface RunWorkbenchServiceOptions {
6
6
  readonly respondUserInput: (runId: string, requestId: string, answers: Record<string, unknown>) => void;
7
7
  readonly deleteQueuedStart: (runId: string) => void;
8
8
  readonly clearImages: (runId: string) => void;
9
+ readonly cleanupAttachments: (runId: string) => void;
9
10
  readonly emitQueue: (workspaceId: string, sessionId: string) => void;
10
11
  readonly emitRun: (runId: string, eventType: string, payload: unknown, status?: 'CANCELLED') => void;
11
12
  readonly control: (operation: string, payload: Record<string, unknown>) => void;
@@ -16,7 +17,6 @@ export declare class RunWorkbenchService {
16
17
  constructor(options: RunWorkbenchServiceOptions);
17
18
  operations(): Readonly<Record<string, NodeOperationHandler>>;
18
19
  private get;
19
- private snapshot;
20
20
  private image;
21
21
  private respond;
22
22
  private interruptSession;
@@ -8,7 +8,6 @@ export class RunWorkbenchService {
8
8
  operations() {
9
9
  return {
10
10
  'run.get': (data) => this.get(record(data)),
11
- 'run.snapshot': (data) => this.snapshot(record(data)),
12
11
  'run.image.read': (data) => this.image(record(data)),
13
12
  'run.user-input.respond': (data) => this.respond(record(data)),
14
13
  'session.interrupt': (data) => this.interruptSession(record(data)),
@@ -21,10 +20,6 @@ export class RunWorkbenchService {
21
20
  get(input) {
22
21
  return { run: this.requireRun(input.runId) };
23
22
  }
24
- snapshot(input) {
25
- const run = this.requireRun(input.runId);
26
- return { snapshot: this.options.runtime.getSnapshot(run.id) ?? null };
27
- }
28
23
  async image(input) {
29
24
  if (typeof input.runId !== 'string' ||
30
25
  typeof input.imageIndex !== 'number' ||
@@ -63,6 +58,7 @@ export class RunWorkbenchService {
63
58
  this.options.runtime.deleteQueuedRun(run.id);
64
59
  this.options.deleteQueuedStart(run.id);
65
60
  this.options.clearImages(run.id);
61
+ this.options.cleanupAttachments(run.id);
66
62
  this.options.emitQueue(run.workspaceId, run.sessionId);
67
63
  return { cancelled: true };
68
64
  }
@@ -13,7 +13,6 @@ interface RunnerInteractionServiceOptions {
13
13
  readonly runners: RunnerRegistry;
14
14
  readonly emitRun: (runId: string, eventType: string, payload: unknown, status?: 'FAILED' | 'CANCELLED' | 'INTERRUPTED') => void;
15
15
  readonly emitItem: (runId: string, item: PublicConversationItem) => void;
16
- readonly captureSnapshot: (runId: string, cwd: string) => Promise<void>;
17
16
  readonly approvalTimeoutMs: number;
18
17
  readonly publishApproval: (input: {
19
18
  readonly runId: string;
@@ -33,7 +32,7 @@ export declare class RunnerInteractionService {
33
32
  cancelClaudeRun(runId: string, intent: 'CANCEL' | 'INTERRUPT'): boolean;
34
33
  cancelCodexTurn(runId: string, threadId: string, turnId: string, cwd: string, terminalStatus: 'CANCELLED' | 'INTERRUPTED'): Promise<void>;
35
34
  respondUserInput(runId: string, requestId: string, rawAnswers: Record<string, unknown>): void;
36
- completeCancelledRun(runId: string, cwd: string | undefined): boolean;
35
+ completeCancelledRun(runId: string): boolean;
37
36
  private respondCodexUserInput;
38
37
  private answers;
39
38
  }