@myagentroam/node 0.9.67 → 0.9.68

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,6 +10,14 @@ const runtimeCredentialCapability = () => ({
10
10
  websocket: true,
11
11
  httpVersions: ['1.1', '2']
12
12
  });
13
+ const workspaceDirectTransferCapability = () => ({
14
+ apiVersion: 1,
15
+ probe: true,
16
+ upload: true,
17
+ download: true,
18
+ gitCommitBlob: true,
19
+ maxChunkBytes: 256 * 1024
20
+ });
13
21
  export function reportedPlatform(platform = process.platform) {
14
22
  if (platform === 'win32')
15
23
  return 'windows';
@@ -37,7 +45,8 @@ export function unavailableCapabilities() {
37
45
  claudeCode: { available: false },
38
46
  openCode: { available: false },
39
47
  marAgent: { available: true, version: nodeAppVersion },
40
- marAgentRuntimeCredentials: runtimeCredentialCapability()
48
+ marAgentRuntimeCredentials: runtimeCredentialCapability(),
49
+ workspaceDirectTransfer: workspaceDirectTransferCapability()
41
50
  };
42
51
  }
43
52
  export function detectCapabilities(commands = {}) {
@@ -69,7 +78,8 @@ export function detectCapabilities(commands = {}) {
69
78
  ...(openCode.version === undefined ? {} : { version: openCode.version })
70
79
  },
71
80
  marAgent: { available: true, version: nodeAppVersion },
72
- marAgentRuntimeCredentials: runtimeCredentialCapability()
81
+ marAgentRuntimeCredentials: runtimeCredentialCapability(),
82
+ workspaceDirectTransfer: workspaceDirectTransferCapability()
73
83
  };
74
84
  }
75
85
  export async function detectCapabilitiesAsync(commands = {}) {
@@ -105,6 +115,7 @@ export async function detectCapabilitiesAsync(commands = {}) {
105
115
  ...(openCode.version === undefined ? {} : { version: openCode.version })
106
116
  },
107
117
  marAgent: { available: true, version: nodeAppVersion },
108
- marAgentRuntimeCredentials: runtimeCredentialCapability()
118
+ marAgentRuntimeCredentials: runtimeCredentialCapability(),
119
+ workspaceDirectTransfer: workspaceDirectTransferCapability()
109
120
  };
110
121
  }
package/dist/config.js CHANGED
@@ -26,6 +26,7 @@ export async function loadNodeConfig(path = nodeConfigPath()) {
26
26
  ? input['allowedRoots']
27
27
  : defaultAllowedRoots();
28
28
  const conversationHosting = parseConversationHosting(input['conversationHosting']);
29
+ const directTransfer = parseDirectTransfer(input['directTransfer']);
29
30
  return {
30
31
  serverUrl: parsed.serverUrl,
31
32
  allowedRoots,
@@ -33,7 +34,15 @@ export async function loadNodeConfig(path = nodeConfigPath()) {
33
34
  ...(credential === undefined ? {} : { credential }),
34
35
  ...(registrationToken === undefined ? {} : { registrationToken }),
35
36
  ...(dataDirectory === undefined ? {} : { dataDirectory }),
36
- ...(conversationHosting === undefined ? {} : { conversationHosting })
37
+ ...(conversationHosting === undefined ? {} : { conversationHosting }),
38
+ ...(directTransfer === undefined ? {} : { directTransfer })
39
+ };
40
+ }
41
+ export function directTransferUdpPortRange(config) {
42
+ const directTransfer = config?.directTransfer;
43
+ return {
44
+ begin: directTransfer?.udpPortRangeBegin ?? 49_152,
45
+ end: directTransfer?.udpPortRangeEnd ?? 49_215
37
46
  };
38
47
  }
39
48
  /** Uses the Node service account's scope when no Workspace roots are configured. */
@@ -97,6 +106,29 @@ function parseConversationHosting(value) {
97
106
  inactiveRetentionDays: inactiveRetentionDays
98
107
  };
99
108
  }
109
+ function parseDirectTransfer(value) {
110
+ if (value === undefined)
111
+ return undefined;
112
+ if (typeof value !== 'object' ||
113
+ value === null ||
114
+ Array.isArray(value) ||
115
+ Object.keys(value).some((key) => key !== 'udpPortRangeBegin' && key !== 'udpPortRangeEnd'))
116
+ throw new Error('NODE_CONFIG_INVALID');
117
+ const input = value;
118
+ const begin = input['udpPortRangeBegin'];
119
+ const end = input['udpPortRangeEnd'];
120
+ if (!Number.isInteger(begin) ||
121
+ !Number.isInteger(end) ||
122
+ begin < 1_024 ||
123
+ end > 65_535 ||
124
+ begin > end ||
125
+ end - begin + 1 > 256)
126
+ throw new Error('NODE_CONFIG_INVALID');
127
+ return {
128
+ udpPortRangeBegin: begin,
129
+ udpPortRangeEnd: end
130
+ };
131
+ }
100
132
  function validStoreDirectory(value) {
101
133
  if (typeof value !== 'string' || value.trim() !== value || value.length === 0)
102
134
  return false;
package/dist/connector.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { dirname, resolve } from 'node:path';
2
2
  import { createEnvelope } from '@myagentroam/protocol';
3
- import { hostedConversationStorePath, loadNodeConfig, nodeDataDirectory, nodeDatabasePath } from './config.js';
3
+ import { hostedConversationStorePath, loadNodeConfig, directTransferUdpPortRange, nodeDataDirectory, nodeDatabasePath } from './config.js';
4
4
  import { NodeMetrics, nodeLog } from './operational.js';
5
5
  import { assertLocalSqlitePath } from './storage.js';
6
6
  import { detectCapabilitiesAsync, unavailableCapabilities } from './capabilities.js';
@@ -32,6 +32,7 @@ import { WorkspaceService } from './service/workspace-service.js';
32
32
  import { WorkspaceWatchService } from './service/workspace-watch-service.js';
33
33
  import { WorkspaceFileService } from './service/workspace-file-service.js';
34
34
  import { WorkspaceUploadService } from './service/workspace-upload-service.js';
35
+ import { DirectTransferService } from './service/direct-transfer-service.js';
35
36
  import { WorkspaceChangeService } from './service/workspace-change-service.js';
36
37
  import { WorkspaceSessionService } from './service/workspace-session-service.js';
37
38
  import { WorkbenchEventService } from './service/workbench-event-service.js';
@@ -271,6 +272,7 @@ export class NodeConnector {
271
272
  this.workspaceFileService.dispose(workspaceId);
272
273
  this.workspaceCoordinator.invalidate(workspaceId);
273
274
  });
275
+ directTransferService;
274
276
  workspaceChangeService = new WorkspaceChangeService(this.workspaceState);
275
277
  workspaceWorkbenchService;
276
278
  database;
@@ -668,11 +670,13 @@ export class NodeConnector {
668
670
  return;
669
671
  const updated = this.missionRuntime.recordTurn(input.sessionId);
670
672
  const session = this.runtime.getAgentSession(input.sessionId);
671
- if (updated !== undefined && session !== undefined)
672
- this.publishMissionState(session, updated);
673
+ const run = this.runtime.getRun(input.runId);
673
674
  await this.enqueueMissionContinuation({
674
675
  sessionId: input.sessionId,
675
676
  mission: { id: mission.id, revision: mission.revision },
677
+ ...(run?.initiatedByUserId === undefined
678
+ ? {}
679
+ : { initiatedByUserId: run.initiatedByUserId }),
676
680
  secretEnvironment: input.secretEnvironment,
677
681
  ...(input.personalInstructions === undefined
678
682
  ? {}
@@ -683,6 +687,17 @@ export class NodeConnector {
683
687
  ? {}
684
688
  : { marAgentRuntimeScopeId: input.marAgentRuntimeScopeId })
685
689
  });
690
+ if (updated !== undefined && session !== undefined)
691
+ this.publishMissionState(session, updated);
692
+ },
693
+ afterExecutionStartFailed: (input, error) => {
694
+ nodeLog('mission.execution-start.failed', {
695
+ sessionId: input.sessionId,
696
+ runId: input.runId,
697
+ missionId: input.mission?.id,
698
+ revision: input.mission?.revision,
699
+ code: safeErrorCode(error, 'MISSION_EXECUTION_START_FAILED')
700
+ });
686
701
  },
687
702
  missionMcp: async (input) => {
688
703
  if (input.mission === undefined)
@@ -1049,6 +1064,7 @@ export class NodeConnector {
1049
1064
  onReconnectScheduled: () => this.metrics.reconnectScheduled(),
1050
1065
  onMessage: (raw) => this.handleMessage(raw),
1051
1066
  onClose: (code, reason) => {
1067
+ this.directTransferService?.close();
1052
1068
  this.runtimeCredentials.disconnected();
1053
1069
  this.terminalChannel.disconnect();
1054
1070
  if (code === 4000 && reason === 'NODE_CONNECTION_REPLACED')
@@ -1059,6 +1075,36 @@ export class NodeConnector {
1059
1075
  void this.terminalManager.shutdown();
1060
1076
  }
1061
1077
  });
1078
+ this.directTransferService = new DirectTransferService({
1079
+ nodeGeneration: () => this.nodeGeneration,
1080
+ send: (type, payload) => this.send(type, payload),
1081
+ udpPortRange: () => directTransferUdpPortRange(this.config),
1082
+ openUpload: async (descriptor) => {
1083
+ if (descriptor.resource.kind !== 'WORKSPACE_FILE')
1084
+ throw new Error('DIRECT_RESOURCE_UNSUPPORTED');
1085
+ const workspace = this.workspaceCoordinator.require(descriptor.resource.workspaceId);
1086
+ const upload = await this.workspaceUploadService.createDirect(workspace, {
1087
+ path: descriptor.resource.path,
1088
+ size: descriptor.size,
1089
+ sha256: descriptor.sha256,
1090
+ overwrite: descriptor.overwrite === true,
1091
+ ...(descriptor.composerAttachment === undefined
1092
+ ? {}
1093
+ : { composerAttachment: descriptor.composerAttachment })
1094
+ });
1095
+ return {
1096
+ offset: upload.receivedBytes,
1097
+ write: (bytes) => this.workspaceUploadService.writeDirect(upload.uploadId, bytes),
1098
+ complete: (size, sha256) => this.workspaceUploadService.completeDirect(upload.uploadId, size, sha256),
1099
+ cancel: () => this.workspaceUploadService.cancel(upload.uploadId)
1100
+ };
1101
+ },
1102
+ openDownload: async (descriptor) => {
1103
+ if (this.workspaceWorkbenchService === undefined)
1104
+ throw new Error('DIRECT_RESOURCE_UNAVAILABLE');
1105
+ return this.workspaceWorkbenchService.directDownload(descriptor);
1106
+ }
1107
+ });
1062
1108
  this.controlMessages = new NodeControlMessageService({
1063
1109
  config: () => this.config,
1064
1110
  register: (nodeId, credential) => this.connectionLifecycle.register(nodeId, credential),
@@ -1067,6 +1113,12 @@ export class NodeConnector {
1067
1113
  inspectWorkspace: (envelope) => this.workspaceCoordinator.inspect(envelope),
1068
1114
  handleNodeRequest: (envelope) => this.nodeRequests.handle(envelope),
1069
1115
  handleRuntimeCredentialResponse: (envelope) => this.runtimeCredentials.handle(envelope),
1116
+ handleDirectTransfer: (envelope) => this.directTransferService.handle({
1117
+ type: envelope.type,
1118
+ ...(typeof envelope.payload === 'object' && envelope.payload !== null
1119
+ ? envelope.payload
1120
+ : {})
1121
+ }),
1070
1122
  reconcileRuns: (envelope) => this.runEventBridge.reconcile(envelope),
1071
1123
  runners: this.runners,
1072
1124
  deliverSessionChannelMessage: (envelope) => this.runnerChannelMessages.handle(envelope),
@@ -1134,6 +1186,7 @@ export class NodeConnector {
1134
1186
  this.runnerService = new RunnerService(requireDatabase, () => this.capabilities, this.runners, this.runnerUsageReader);
1135
1187
  this.workbenchManifestService = new WorkbenchManifestService({
1136
1188
  capabilities: () => this.capabilities,
1189
+ nodeGeneration: () => this.nodeGeneration,
1137
1190
  runners: this.runnerService,
1138
1191
  terminals: this.terminalManager
1139
1192
  });
@@ -1386,6 +1439,9 @@ export class NodeConnector {
1386
1439
  content: this.missionRuntime.prompt(input.sessionId, 'continue'),
1387
1440
  deliveryIntent: 'QUEUE',
1388
1441
  mission: input.mission,
1442
+ ...(input.initiatedByUserId === undefined
1443
+ ? {}
1444
+ : { initiatedByUserId: input.initiatedByUserId }),
1389
1445
  secretEnvironment: input.secretEnvironment,
1390
1446
  ...(input.personalInstructions === undefined
1391
1447
  ? {}
@@ -1407,8 +1463,16 @@ export class NodeConnector {
1407
1463
  }
1408
1464
  this.clearMissionRetry(input.sessionId);
1409
1465
  }
1410
- catch {
1466
+ catch (error) {
1411
1467
  const current = this.missionRuntime.get(input.sessionId);
1468
+ const failedAttempts = (input.failedAttempts ?? 0) + 1;
1469
+ nodeLog('mission.continuation.enqueue.failed', {
1470
+ sessionId: input.sessionId,
1471
+ missionId: input.mission.id,
1472
+ revision: input.mission.revision,
1473
+ attempt: failedAttempts,
1474
+ code: safeErrorCode(error, 'MISSION_CONTINUATION_ENQUEUE_FAILED')
1475
+ });
1412
1476
  if (current?.status !== 'active' ||
1413
1477
  current.id !== input.mission.id ||
1414
1478
  current.revision !== input.mission.revision) {
@@ -1416,7 +1480,6 @@ export class NodeConnector {
1416
1480
  this.missionContinuationPending.delete(input.sessionId);
1417
1481
  return;
1418
1482
  }
1419
- const failedAttempts = (input.failedAttempts ?? 0) + 1;
1420
1483
  const session = this.runtime.getAgentSession(input.sessionId);
1421
1484
  if (failedAttempts > 3 || session === undefined) {
1422
1485
  this.clearMissionRetry(input.sessionId);
@@ -1537,6 +1600,7 @@ export class NodeConnector {
1537
1600
  const terminalDrain = this.terminalManager.shutdown();
1538
1601
  this.terminalChannel.stop();
1539
1602
  this.runtimeCredentials.close();
1603
+ this.directTransferService.close();
1540
1604
  this.controlChannel.stop();
1541
1605
  const database = this.database;
1542
1606
  this.database = undefined;
@@ -1,4 +1,5 @@
1
1
  import { MarAgentConversationNormalizer } from './conversation-normalizer.js';
2
+ import { publicMissionHistoryText } from '../../service/mission-runtime.js';
2
3
  export function projectMarAgentHistory(session, entries, options = {}) {
3
4
  const turns = new Map();
4
5
  const normalizer = new MarAgentConversationNormalizer();
@@ -42,7 +43,7 @@ export function projectMarAgentHistory(session, entries, options = {}) {
42
43
  const registeredImages = images === undefined ? [] : (options.registerInputImages?.(entry) ?? []);
43
44
  replace(turn, historyItem(session, turn, id, 'user_message', 'COMPLETED', {
44
45
  clientMessageId: entry.clientMessageId ?? null,
45
- text: entry.text,
46
+ text: publicMissionHistoryText(entry.text),
46
47
  contexts: [],
47
48
  delivery: 'ACCEPTED',
48
49
  ...(images?.length
@@ -321,7 +321,7 @@ export class ContinuousRunCoordinator {
321
321
  this.startedExecutions.add(executionId);
322
322
  void this.options
323
323
  .afterExecutionStarted?.(pending)
324
- .catch(() => undefined);
324
+ .catch((error) => this.options.afterExecutionStartFailed?.(pending, error));
325
325
  }
326
326
  hasExecutionStarted(executionId) {
327
327
  return this.startedExecutions.has(executionId);
@@ -0,0 +1,453 @@
1
+ import { directDataControlFrameSchema, directNodeCommandSchema } from '@myagentroam/protocol';
2
+ import { RTCPeerConnection } from 'node-datachannel/polyfill';
3
+ const MAX_HOST_CANDIDATES = 32;
4
+ const MAX_ACTIVE_SESSIONS = 32;
5
+ const DATA_CHANNEL_HIGH_WATER_BYTES = 4 * 1024 * 1024;
6
+ const DATA_CHANNEL_LOW_WATER_BYTES = 1024 * 1024;
7
+ const LEASE_GRACE_MS = 30_000;
8
+ const LEASE_CHECK_MS = 5_000;
9
+ export class DirectTransferService {
10
+ options;
11
+ sessions = new Map();
12
+ now;
13
+ createPeerConnection;
14
+ leaseGraceMs;
15
+ leaseCheckMs;
16
+ constructor(options) {
17
+ this.options = options;
18
+ this.now = options.now ?? Date.now;
19
+ this.createPeerConnection =
20
+ options.createPeerConnection ??
21
+ (() => {
22
+ const range = options.udpPortRange?.() ?? { begin: 49_152, end: 49_215 };
23
+ return new RTCPeerConnection({
24
+ iceServers: [],
25
+ portRangeBegin: range.begin,
26
+ portRangeEnd: range.end
27
+ });
28
+ });
29
+ this.leaseGraceMs = options.leaseGraceMs ?? LEASE_GRACE_MS;
30
+ this.leaseCheckMs = options.leaseCheckMs ?? LEASE_CHECK_MS;
31
+ }
32
+ handle(value) {
33
+ const parsed = directNodeCommandSchema.safeParse(value);
34
+ if (!parsed.success)
35
+ return false;
36
+ void this.dispatch(parsed.data).catch((error) => {
37
+ const directSessionId = typeof value === 'object' &&
38
+ value !== null &&
39
+ 'directSessionId' in value &&
40
+ typeof value.directSessionId === 'string'
41
+ ? value.directSessionId
42
+ : undefined;
43
+ if (directSessionId !== undefined)
44
+ this.fail(directSessionId, errorCode(error), errorMessage(error));
45
+ });
46
+ return true;
47
+ }
48
+ close() {
49
+ for (const session of [...this.sessions.values()])
50
+ void this.remove(session, 'DIRECT_NODE_STOPPED');
51
+ }
52
+ async dispatch(command) {
53
+ if (command.type === 'direct.prepare') {
54
+ this.prepare(command);
55
+ return;
56
+ }
57
+ const session = this.sessions.get(command.directSessionId);
58
+ if (session === undefined)
59
+ throw new Error('DIRECT_SESSION_INVALID');
60
+ if (command.type === 'direct.signal') {
61
+ await this.signal(session, command.signal);
62
+ return;
63
+ }
64
+ if (command.type === 'direct.grant') {
65
+ if (command.browserFingerprint !== fingerprint(session.peer.remoteDescription?.sdp) ||
66
+ command.nodeFingerprint !== fingerprint(session.peer.localDescription?.sdp) ||
67
+ command.openExpiresAt <= this.now() ||
68
+ command.expiresAt > session.command.expiresAt)
69
+ throw new Error('DIRECT_GRANT_INVALID');
70
+ session.grant = command;
71
+ return;
72
+ }
73
+ if (command.type === 'direct.heartbeat') {
74
+ if (session.command.expiresAt <= this.now())
75
+ await this.remove(session, 'DIRECT_SESSION_EXPIRED');
76
+ else
77
+ session.lastLeaseAliveAt = this.now();
78
+ return;
79
+ }
80
+ await this.remove(session, command.code);
81
+ }
82
+ prepare(command) {
83
+ if (command.nodeGeneration !== this.options.nodeGeneration())
84
+ throw new Error('DIRECT_NODE_GENERATION_MISMATCH');
85
+ if (this.sessions.has(command.directSessionId))
86
+ throw new Error('DIRECT_SESSION_CONFLICT');
87
+ if (this.sessions.size >= MAX_ACTIVE_SESSIONS)
88
+ throw new Error('DIRECT_SESSION_LIMIT_REACHED');
89
+ const peer = this.createPeerConnection();
90
+ const session = {
91
+ command,
92
+ peer,
93
+ opened: false,
94
+ uploadCommitted: false,
95
+ processing: Promise.resolve(),
96
+ sentCandidateKeys: new Set()
97
+ };
98
+ this.sessions.set(command.directSessionId, session);
99
+ peer.onicecandidate = (event) => {
100
+ const candidate = event.candidate;
101
+ if (candidate === null) {
102
+ this.emit(command.directSessionId, {
103
+ type: 'direct.signal',
104
+ directSessionId: command.directSessionId,
105
+ signal: { kind: 'END_OF_CANDIDATES' }
106
+ });
107
+ return;
108
+ }
109
+ const json = candidate.toJSON();
110
+ if (typeof json.candidate !== 'string' ||
111
+ !acceptHostCandidate(json.candidate, session.sentCandidateKeys))
112
+ return;
113
+ this.emit(command.directSessionId, {
114
+ type: 'direct.signal',
115
+ directSessionId: command.directSessionId,
116
+ signal: {
117
+ kind: 'CANDIDATE',
118
+ candidate: {
119
+ candidate: json.candidate,
120
+ sdpMid: json.sdpMid ?? null,
121
+ sdpMLineIndex: json.sdpMLineIndex ?? null
122
+ }
123
+ }
124
+ });
125
+ };
126
+ peer.ondatachannel = (event) => this.attachChannel(session, event.channel);
127
+ peer.onconnectionstatechange = () => {
128
+ if (peer.connectionState === 'connected')
129
+ this.emit(session.command.directSessionId, {
130
+ type: 'direct.connected',
131
+ directSessionId: session.command.directSessionId
132
+ });
133
+ if (peer.connectionState === 'failed' || peer.connectionState === 'closed')
134
+ void this.remove(session, 'DIRECT_CONNECTION_FAILED');
135
+ };
136
+ }
137
+ async signal(session, signal) {
138
+ if (signal.kind === 'CANDIDATE') {
139
+ if (!safeHostCandidate(signal.candidate.candidate))
140
+ throw new Error('DIRECT_CANDIDATE_REJECTED');
141
+ await session.peer.addIceCandidate(signal.candidate);
142
+ return;
143
+ }
144
+ if (signal.kind === 'END_OF_CANDIDATES') {
145
+ await session.peer.addIceCandidate(null);
146
+ return;
147
+ }
148
+ if (!hostOnlySdp(signal.sdp))
149
+ throw new Error('DIRECT_CANDIDATE_REJECTED');
150
+ await session.peer.setRemoteDescription({
151
+ type: signal.kind === 'OFFER' ? 'offer' : 'answer',
152
+ sdp: signal.sdp
153
+ });
154
+ if (signal.kind !== 'OFFER')
155
+ return;
156
+ const answer = await session.peer.createAnswer();
157
+ await session.peer.setLocalDescription(answer);
158
+ const localSdp = session.peer.localDescription?.sdp;
159
+ const filtered = filterIceCandidates(localSdp, session.sentCandidateKeys);
160
+ const sdp = filtered.sdp;
161
+ const localFingerprint = fingerprint(localSdp);
162
+ if (sdp === undefined || localFingerprint === undefined)
163
+ throw new Error('DIRECT_FINGERPRINT_MISSING');
164
+ this.emit(session.command.directSessionId, {
165
+ type: 'direct.signal',
166
+ directSessionId: session.command.directSessionId,
167
+ signal: { kind: 'ANSWER', sdp, fingerprint: localFingerprint }
168
+ });
169
+ }
170
+ attachChannel(session, channel) {
171
+ session.channel = channel;
172
+ channel.binaryType = 'arraybuffer';
173
+ channel.onmessage = (event) => {
174
+ session.processing = session.processing
175
+ .then(() => this.channelMessage(session, event.data))
176
+ .catch((error) => this.fail(session.command.directSessionId, errorCode(error), errorMessage(error)));
177
+ };
178
+ channel.onclose = () => {
179
+ if (this.sessions.get(session.command.directSessionId) === session)
180
+ void this.remove(session, 'DIRECT_CHANNEL_CLOSED');
181
+ };
182
+ }
183
+ async channelMessage(session, data) {
184
+ if (!session.opened) {
185
+ if (typeof data !== 'string')
186
+ throw new Error('DIRECT_OPEN_REQUIRED');
187
+ const parsed = directDataControlFrameSchema.parse(JSON.parse(data));
188
+ if (parsed.type !== 'OPEN')
189
+ throw new Error('DIRECT_OPEN_REQUIRED');
190
+ const grant = session.grant;
191
+ if (grant === undefined ||
192
+ parsed.directSessionId !== session.command.directSessionId ||
193
+ parsed.grant !== grant.grant ||
194
+ grant.openExpiresAt <= this.now() ||
195
+ grant.expiresAt <= this.now())
196
+ throw new Error('DIRECT_GRANT_INVALID');
197
+ session.opened = true;
198
+ if (session.command.purpose === 'TRANSFER')
199
+ this.startLeaseWatch(session);
200
+ await this.open(session);
201
+ return;
202
+ }
203
+ if (session.command.purpose !== 'TRANSFER' || session.command.transfer === undefined)
204
+ throw new Error('DIRECT_FRAME_INVALID');
205
+ if (session.command.transfer.direction === 'DOWNLOAD') {
206
+ if (typeof data !== 'string')
207
+ throw new Error('DIRECT_FRAME_INVALID');
208
+ const control = directDataControlFrameSchema.parse(JSON.parse(data));
209
+ if (control.type !== 'ACK' || control.offset !== session.command.transfer.size)
210
+ throw new Error('DIRECT_FRAME_INVALID');
211
+ this.emit(session.command.directSessionId, {
212
+ type: 'direct.complete',
213
+ directSessionId: session.command.directSessionId,
214
+ size: session.command.transfer.size,
215
+ sha256: session.command.transfer.sha256
216
+ });
217
+ await this.remove(session);
218
+ return;
219
+ }
220
+ if (typeof data === 'string') {
221
+ const control = directDataControlFrameSchema.parse(JSON.parse(data));
222
+ if (control.type !== 'COMPLETE' || session.upload === undefined)
223
+ throw new Error('DIRECT_FRAME_INVALID');
224
+ if (control.size !== session.command.transfer.size ||
225
+ control.sha256 !== session.command.transfer.sha256)
226
+ throw new Error('DIRECT_INTEGRITY_FAILED');
227
+ await session.upload.complete(control.size, control.sha256);
228
+ session.uploadCommitted = true;
229
+ this.emit(session.command.directSessionId, {
230
+ type: 'direct.complete',
231
+ directSessionId: session.command.directSessionId,
232
+ size: control.size,
233
+ sha256: control.sha256
234
+ });
235
+ await this.remove(session);
236
+ return;
237
+ }
238
+ const bytes = toBytes(data);
239
+ if (bytes.byteLength === 0 || bytes.byteLength > 256 * 1024 || session.upload === undefined)
240
+ throw new Error('DIRECT_CHUNK_INVALID');
241
+ const offset = await session.upload.write(bytes);
242
+ session.channel?.send(JSON.stringify({ type: 'ACK', offset }));
243
+ }
244
+ async open(session) {
245
+ if (session.command.purpose === 'PROBE') {
246
+ session.channel?.send(JSON.stringify({ type: 'ACK', offset: 0 }));
247
+ this.emit(session.command.directSessionId, {
248
+ type: 'direct.probe-result',
249
+ directSessionId: session.command.directSessionId,
250
+ result: 'DIRECT_AVAILABLE'
251
+ });
252
+ await this.remove(session);
253
+ return;
254
+ }
255
+ const descriptor = session.command.transfer;
256
+ if (descriptor === undefined)
257
+ throw new Error('DIRECT_TRANSFER_INVALID');
258
+ if (descriptor.direction === 'UPLOAD') {
259
+ if (this.options.openUpload === undefined)
260
+ throw new Error('DIRECT_UPLOAD_UNAVAILABLE');
261
+ session.upload = await this.options.openUpload(descriptor);
262
+ session.channel?.send(JSON.stringify({ type: 'ACK', offset: session.upload.offset }));
263
+ this.emit(session.command.directSessionId, {
264
+ type: 'direct.opened',
265
+ directSessionId: session.command.directSessionId,
266
+ offset: session.upload.offset
267
+ });
268
+ return;
269
+ }
270
+ if (this.options.openDownload === undefined)
271
+ throw new Error('DIRECT_DOWNLOAD_UNAVAILABLE');
272
+ const source = await this.options.openDownload(descriptor);
273
+ session.channel?.send(JSON.stringify({ type: 'ACK', offset: source.offset }));
274
+ this.emit(session.command.directSessionId, {
275
+ type: 'direct.opened',
276
+ directSessionId: session.command.directSessionId,
277
+ offset: source.offset
278
+ });
279
+ for await (const chunk of source.chunks) {
280
+ for (let offset = 0; offset < chunk.byteLength; offset += 256 * 1024) {
281
+ if (this.sessions.get(session.command.directSessionId) !== session)
282
+ return;
283
+ const channel = session.channel;
284
+ if (channel === undefined)
285
+ throw new Error('DIRECT_CHANNEL_CLOSED');
286
+ await this.waitForSendCapacity(session, channel);
287
+ const end = Math.min(chunk.byteLength, offset + 256 * 1024);
288
+ channel.send(chunk.buffer.slice(chunk.byteOffset + offset, chunk.byteOffset + end));
289
+ }
290
+ }
291
+ session.channel?.send(JSON.stringify({
292
+ type: 'COMPLETE',
293
+ size: descriptor.size,
294
+ sha256: descriptor.sha256
295
+ }));
296
+ }
297
+ async waitForSendCapacity(session, channel) {
298
+ if (channel.readyState !== 'open')
299
+ throw new Error('DIRECT_CHANNEL_CLOSED');
300
+ if (channel.bufferedAmount <= DATA_CHANNEL_HIGH_WATER_BYTES)
301
+ return;
302
+ channel.bufferedAmountLowThreshold = DATA_CHANNEL_LOW_WATER_BYTES;
303
+ await new Promise((resolve, reject) => {
304
+ const complete = (error) => {
305
+ channel.removeEventListener('bufferedamountlow', available);
306
+ channel.removeEventListener('close', closed);
307
+ if (error === undefined)
308
+ resolve();
309
+ else
310
+ reject(error);
311
+ };
312
+ const available = () => complete();
313
+ const closed = () => complete(new Error('DIRECT_CHANNEL_CLOSED'));
314
+ channel.addEventListener('bufferedamountlow', available, { once: true });
315
+ channel.addEventListener('close', closed, { once: true });
316
+ if (this.sessions.get(session.command.directSessionId) !== session ||
317
+ channel.readyState !== 'open')
318
+ closed();
319
+ else if (channel.bufferedAmount <= DATA_CHANNEL_LOW_WATER_BYTES)
320
+ available();
321
+ });
322
+ }
323
+ startLeaseWatch(session) {
324
+ session.lastLeaseAliveAt = this.now();
325
+ session.leaseTimer = setInterval(() => {
326
+ const now = this.now();
327
+ if (session.command.expiresAt <= now) {
328
+ void this.remove(session, 'DIRECT_SESSION_EXPIRED');
329
+ return;
330
+ }
331
+ if (now - (session.lastLeaseAliveAt ?? now) > this.leaseGraceMs)
332
+ void this.remove(session, 'DIRECT_LEASE_EXPIRED');
333
+ }, this.leaseCheckMs);
334
+ }
335
+ emit(directSessionId, event) {
336
+ if (this.sessions.has(directSessionId))
337
+ this.options.send(event.type, event);
338
+ }
339
+ fail(directSessionId, code, message) {
340
+ const session = this.sessions.get(directSessionId);
341
+ this.options.send('direct.error', { type: 'direct.error', directSessionId, code, message });
342
+ if (session !== undefined)
343
+ void this.remove(session);
344
+ }
345
+ async remove(session, code) {
346
+ if (!this.sessions.delete(session.command.directSessionId))
347
+ return;
348
+ if (session.leaseTimer !== undefined)
349
+ clearInterval(session.leaseTimer);
350
+ if (!session.uploadCommitted)
351
+ await session.upload?.cancel().catch(() => undefined);
352
+ session.channel?.close();
353
+ session.peer.close();
354
+ if (code !== undefined)
355
+ this.options.send('direct.error', {
356
+ type: 'direct.error',
357
+ directSessionId: session.command.directSessionId,
358
+ code,
359
+ message: directErrorMessage(code)
360
+ });
361
+ }
362
+ }
363
+ export function fingerprint(sdp) {
364
+ const match = sdp?.match(/^a=fingerprint:(sha-256 [A-F0-9:]+)\r?$/imu);
365
+ return match?.[1];
366
+ }
367
+ function safeHostCandidate(candidate) {
368
+ if (!/\styp host(?:\s|$)/u.test(candidate))
369
+ return false;
370
+ const address = candidate.trim().split(/\s+/u)[4];
371
+ if (address === undefined)
372
+ return false;
373
+ return address.length > 0;
374
+ }
375
+ function hostOnlySdp(sdp) {
376
+ return sdp
377
+ .split(/\r?\n/u)
378
+ .filter((line) => line.startsWith('a=candidate:'))
379
+ .every((line) => safeHostCandidate(line.slice(2)));
380
+ }
381
+ function candidateKey(candidate) {
382
+ const fields = candidate.trim().split(/\s+/u);
383
+ const component = fields[1];
384
+ const protocol = fields[2]?.toLowerCase();
385
+ const address = fields[4]?.toLowerCase();
386
+ if (component === undefined || protocol === undefined || address === undefined)
387
+ return undefined;
388
+ return `${component}:${protocol}:${address}`;
389
+ }
390
+ function acceptHostCandidate(candidate, accepted) {
391
+ if (!safeHostCandidate(candidate))
392
+ return false;
393
+ const key = candidateKey(candidate);
394
+ if (key === undefined || accepted.has(key) || accepted.size >= MAX_HOST_CANDIDATES)
395
+ return false;
396
+ accepted.add(key);
397
+ return true;
398
+ }
399
+ function filterIceCandidates(sdp, accepted) {
400
+ if (sdp === undefined)
401
+ return { sdp: undefined };
402
+ const included = new Set();
403
+ const lines = sdp.split(/\r?\n/u).filter((line) => {
404
+ if (!line.startsWith('a=candidate:'))
405
+ return true;
406
+ const candidate = line.slice(2);
407
+ if (!safeHostCandidate(candidate))
408
+ return false;
409
+ const key = candidateKey(candidate);
410
+ if (key === undefined || included.has(key))
411
+ return false;
412
+ if (!accepted.has(key) && accepted.size >= MAX_HOST_CANDIDATES)
413
+ return false;
414
+ accepted.add(key);
415
+ included.add(key);
416
+ return true;
417
+ });
418
+ return { sdp: lines.join('\r\n') };
419
+ }
420
+ function toBytes(value) {
421
+ if (value instanceof ArrayBuffer)
422
+ return new Uint8Array(value);
423
+ if (ArrayBuffer.isView(value))
424
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
425
+ throw new Error('DIRECT_CHUNK_INVALID');
426
+ }
427
+ function errorCode(error) {
428
+ return error instanceof Error && /^[A-Z][A-Z0-9_]{0,127}$/u.test(error.message)
429
+ ? error.message
430
+ : 'DIRECT_INTERNAL_ERROR';
431
+ }
432
+ function errorMessage(error) {
433
+ const code = errorCode(error);
434
+ return code === 'DIRECT_INTERNAL_ERROR'
435
+ ? 'The Node could not complete the direct transfer operation.'
436
+ : directErrorMessage(code);
437
+ }
438
+ function directErrorMessage(code) {
439
+ const messages = {
440
+ DIRECT_NODE_GENERATION_MISMATCH: 'The Node connection generation changed.',
441
+ DIRECT_SESSION_INVALID: 'The direct transfer session is unavailable.',
442
+ DIRECT_SESSION_CONFLICT: 'The direct transfer session already exists.',
443
+ DIRECT_SESSION_LIMIT_REACHED: 'The direct transfer session limit has been reached.',
444
+ DIRECT_CANDIDATE_REJECTED: 'The direct transfer candidate is not a host candidate.',
445
+ DIRECT_GRANT_INVALID: 'The direct transfer grant is invalid or expired.',
446
+ DIRECT_CONNECTION_FAILED: 'The direct connection failed.',
447
+ DIRECT_CHANNEL_CLOSED: 'The direct data channel closed.',
448
+ DIRECT_SESSION_EXPIRED: 'The direct transfer session expired.',
449
+ DIRECT_LEASE_EXPIRED: 'The Server authorization lease expired.',
450
+ DIRECT_NODE_STOPPED: 'The Node stopped the direct transfer service.'
451
+ };
452
+ return messages[code] ?? 'The direct transfer operation failed.';
453
+ }
@@ -14,6 +14,16 @@ export function stripMissionStartPrompt(text) {
14
14
  const close = text.indexOf(START_CLOSE);
15
15
  return close === -1 ? '' : text.slice(close + START_CLOSE.length).trimStart();
16
16
  }
17
+ export function publicMissionHistoryText(text) {
18
+ if (!text.startsWith(START_OPEN))
19
+ return text;
20
+ const stripped = stripMissionStartPrompt(text);
21
+ if (stripped.length > 0)
22
+ return stripped;
23
+ const close = text.indexOf(START_CLOSE);
24
+ const prompt = close === -1 ? text : text.slice(0, close);
25
+ return prompt.includes('Continue the active Mission.') ? 'Mission Continue' : 'Mission Start';
26
+ }
17
27
  export class MissionRuntime {
18
28
  changed;
19
29
  missions = new Map();
@@ -37,6 +37,7 @@ export class NodeConnectionLifecycleService {
37
37
  ...(config.conversationHosting === undefined
38
38
  ? {}
39
39
  : { conversationHosting: config.conversationHosting }),
40
+ ...(config.directTransfer === undefined ? {} : { directTransfer: config.directTransfer }),
40
41
  nodeId,
41
42
  credential
42
43
  };
@@ -35,6 +35,9 @@ export class NodeControlMessageService {
35
35
  }
36
36
  if (this.options.handleRuntimeCredentialResponse(envelope))
37
37
  return;
38
+ if (envelope.type.startsWith('direct.') &&
39
+ this.options.handleDirectTransfer?.(envelope) === true)
40
+ return;
38
41
  if (envelope.type === 'ack')
39
42
  return;
40
43
  if (envelope.type === 'runs.reconcile') {
@@ -25,6 +25,9 @@ export class SessionMessageService {
25
25
  content: input.kind === 'start' ? 'Mission Start' : 'Mission Continue',
26
26
  __runnerContent: input.content,
27
27
  __missionRef: input.mission,
28
+ ...(input.initiatedByUserId === undefined
29
+ ? {}
30
+ : { __requestUserId: input.initiatedByUserId }),
28
31
  deliveryIntent: input.deliveryIntent,
29
32
  secretEnvironment: input.secretEnvironment,
30
33
  personalInstructions: input.personalInstructions,
@@ -23,6 +23,7 @@ export class WorkbenchManifestService {
23
23
  manifestVersion: 1,
24
24
  revision: revisionFor(capabilities),
25
25
  observedAt: Date.now(),
26
+ nodeGeneration: this.options.nodeGeneration(),
26
27
  platform: capabilities.platform,
27
28
  architecture: capabilities.architecture,
28
29
  conversation: {
@@ -45,6 +46,7 @@ export class WorkbenchManifestService {
45
46
  maxReadBytes: 512 * 1024,
46
47
  maxSearchResults: 200
47
48
  },
49
+ directTransfer: capabilities.workspaceDirectTransfer,
48
50
  git: {
49
51
  available: true,
50
52
  reasonCode: null,
@@ -1,4 +1,5 @@
1
1
  import { tmpdir } from 'node:os';
2
+ import { createHash } from 'node:crypto';
2
3
  import { isAbsolute, win32 } from 'node:path';
3
4
  import { listWorkspaceFiles, mutateWorkspaceFile, normalizeFilePreviewPath, readAllowedFileContentRange, readWorkspaceFileContentRange, readAllowedTextFile, readWorkspaceTextFile, writeWorkspaceTextFile, WorkspaceFileIndex } from '../workspace.js';
4
5
  const INDEX_CACHE_LIMIT = 8;
@@ -58,6 +59,54 @@ export class WorkspaceFileService {
58
59
  ? readAllowedFileContentRange(path, this.previewRoots(), offset, limit)
59
60
  : readWorkspaceFileContentRange(workspacePath, path, offset, limit);
60
61
  }
62
+ readDirect(workspace, input, workspacePath = workspace.path) {
63
+ const service = this;
64
+ return (async function* () {
65
+ const hash = createHash('sha256');
66
+ let offset = 0;
67
+ while (true) {
68
+ const range = (await service.readContent(workspace, {
69
+ path: input.path,
70
+ offset,
71
+ limit: 512 * 1024
72
+ }, workspacePath));
73
+ if (range.size !== input.size)
74
+ throw new Error('DIRECT_FILE_REVISION_CHANGED');
75
+ const bytes = Buffer.from(range.contentBase64, 'base64');
76
+ hash.update(bytes);
77
+ offset += bytes.length;
78
+ if (bytes.length > 0)
79
+ yield bytes;
80
+ if (range.nextOffset === null)
81
+ break;
82
+ if (range.nextOffset !== offset)
83
+ throw new Error('DIRECT_FILE_REVISION_CHANGED');
84
+ }
85
+ if (offset !== input.size || hash.digest('hex') !== input.sha256)
86
+ throw new Error('DIRECT_FILE_REVISION_CHANGED');
87
+ })();
88
+ }
89
+ async directMetadata(workspace, input, workspacePath = workspace.path) {
90
+ const hash = createHash('sha256');
91
+ let offset = 0;
92
+ let size;
93
+ while (true) {
94
+ const range = (await this.readContent(workspace, { ...input, offset, limit: 512 * 1024 }, workspacePath));
95
+ size ??= range.size;
96
+ if (range.size !== size)
97
+ throw new Error('DIRECT_FILE_REVISION_CHANGED');
98
+ const bytes = Buffer.from(range.contentBase64, 'base64');
99
+ hash.update(bytes);
100
+ offset += bytes.length;
101
+ if (range.nextOffset === null)
102
+ break;
103
+ if (range.nextOffset !== offset)
104
+ throw new Error('DIRECT_FILE_REVISION_CHANGED');
105
+ }
106
+ if (size === undefined || offset !== size)
107
+ throw new Error('DIRECT_FILE_REVISION_CHANGED');
108
+ return { size, sha256: hash.digest('hex') };
109
+ }
61
110
  async write(workspace, input) {
62
111
  if (typeof input.path !== 'string' ||
63
112
  typeof input.content !== 'string' ||
@@ -1,6 +1,7 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
2
3
  import { lstat, mkdir, open, readFile, rename, rm } from 'node:fs/promises';
3
- import { dirname, join, resolve } from 'node:path';
4
+ import { dirname, join, posix, resolve } from 'node:path';
4
5
  import { parseComposerAttachmentUpload, validComposerAttachmentName } from '../util/node-operation-parsers.js';
5
6
  import { preflightWorkspaceUpload, workspaceUploadTemporaryName } from '../workspace.js';
6
7
  const CHUNK_BYTES = 512 * 1024;
@@ -9,6 +10,7 @@ const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
9
10
  export class WorkspaceUploadService {
10
11
  state;
11
12
  onCompleted;
13
+ directHashes = new Map();
12
14
  constructor(state, onCompleted) {
13
15
  this.state = state;
14
16
  this.onCompleted = onCompleted;
@@ -99,6 +101,44 @@ export class WorkspaceUploadService {
99
101
  upload.receivedBytes += bytes.length;
100
102
  return this.status(upload.id);
101
103
  }
104
+ async createDirect(workspace, input) {
105
+ const normalized = input.path.replaceAll('\\', '/');
106
+ const status = await this.create(workspace, {
107
+ name: posix.basename(normalized),
108
+ size: input.size,
109
+ overwrite: input.overwrite,
110
+ ...(input.composerAttachment === undefined
111
+ ? { directory: posix.dirname(normalized) }
112
+ : {
113
+ composerAttachmentId: input.composerAttachment.id,
114
+ mime: input.composerAttachment.mime
115
+ })
116
+ });
117
+ this.directHashes.set(status.uploadId, input.sha256);
118
+ return status;
119
+ }
120
+ async writeDirect(uploadId, bytes) {
121
+ const status = await this.write({
122
+ uploadId,
123
+ offset: this.status(uploadId).receivedBytes,
124
+ dataBase64: Buffer.from(bytes).toString('base64')
125
+ });
126
+ return status.receivedBytes;
127
+ }
128
+ async completeDirect(uploadId, size, sha256) {
129
+ const upload = this.require(uploadId);
130
+ const expected = this.directHashes.get(uploadId);
131
+ if (size !== upload.size || sha256 !== expected)
132
+ throw new Error('UPLOAD_HASH_INVALID');
133
+ await upload.handle.sync();
134
+ const actual = await fileSha256(upload.temporaryPath);
135
+ if (actual !== expected) {
136
+ await this.cancel(uploadId);
137
+ throw new Error('UPLOAD_HASH_INVALID');
138
+ }
139
+ await this.complete(uploadId);
140
+ this.directHashes.delete(uploadId);
141
+ }
102
142
  status(uploadId) {
103
143
  const upload = this.require(uploadId);
104
144
  return {
@@ -163,8 +203,10 @@ export class WorkspaceUploadService {
163
203
  if (typeof uploadId !== 'string')
164
204
  throw new Error('UPLOAD_NOT_FOUND');
165
205
  const active = this.state.uploads.get(uploadId);
166
- if (active !== undefined)
206
+ if (active !== undefined) {
207
+ this.directHashes.delete(active.id);
167
208
  return this.remove(active);
209
+ }
168
210
  const attachment = this.state.composerAttachments.get(uploadId);
169
211
  if (attachment === undefined)
170
212
  throw new Error('UPLOAD_NOT_FOUND');
@@ -204,6 +246,7 @@ export class WorkspaceUploadService {
204
246
  for (const attachment of this.state.composerAttachments.values())
205
247
  await this.removeCompleted(attachment);
206
248
  this.state.composerAttachments.clear();
249
+ this.directHashes.clear();
207
250
  }
208
251
  require(id) {
209
252
  if (typeof id !== 'string')
@@ -215,6 +258,7 @@ export class WorkspaceUploadService {
215
258
  }
216
259
  async remove(upload) {
217
260
  this.state.uploads.delete(upload.id);
261
+ this.directHashes.delete(upload.id);
218
262
  clearTimeout(upload.timer);
219
263
  await upload.handle.close().catch(() => undefined);
220
264
  await rm(upload.temporaryPath, { force: true }).catch(() => undefined);
@@ -252,6 +296,12 @@ export class WorkspaceUploadService {
252
296
  };
253
297
  }
254
298
  }
299
+ async function fileSha256(path) {
300
+ const hash = createHash('sha256');
301
+ for await (const chunk of createReadStream(path))
302
+ hash.update(chunk);
303
+ return hash.digest('hex');
304
+ }
255
305
  async function safeDirectory(path) {
256
306
  const value = await lstat(path);
257
307
  if (!value.isDirectory() || value.isSymbolicLink())
@@ -1,4 +1,5 @@
1
- import { listGitHistoryRefs, readGitHistory, readGitHistoryFileDiff, readGitHistoryFileContentRange, readGitHistoryFiles, readCurrentChangeDiff, readRestrictedDiff, resolveGitRepository, resolveGitRepositoryTarget, restoreCurrentChange } from '../workspace.js';
1
+ import { createHash } from 'node:crypto';
2
+ import { listGitHistoryRefs, openGitHistoryFileContent, readGitHistory, readGitHistoryFileDiff, readGitHistoryFileContentRange, readGitHistoryFiles, readCurrentChangeDiff, readRestrictedDiff, resolveGitRepository, resolveGitRepositoryTarget, restoreCurrentChange } from '../workspace.js';
2
3
  import { WorkspaceContentSearchService } from './workspace-content-search-service.js';
3
4
  import { WorkspaceWatchController } from './workspace-watch-controller.js';
4
5
  import { WorkspaceWatchSnapshotService } from './workspace-watch-snapshot-service.js';
@@ -25,6 +26,7 @@ export class WorkspaceWorkbenchService {
25
26
  'workspace.file.read': (data) => this.readFile(record(data)),
26
27
  'workspace.file.write': (data) => this.writeFile(record(data)),
27
28
  'workspace.file.content.read': (data) => this.readFileContent(record(data)),
29
+ 'workspace.file.direct.metadata': (data) => this.directFileMetadata(record(data)),
28
30
  'workspace.files.search': (data) => this.searchFiles(record(data)),
29
31
  'workspace.files.mutate': (data) => this.mutateFile(record(data)),
30
32
  'workspace.content.search': (data) => this.searchContent(record(data)),
@@ -44,6 +46,7 @@ export class WorkspaceWorkbenchService {
44
46
  'workspace.git.commit.files': (data) => this.gitCommitFiles(record(data)),
45
47
  'workspace.git.commit.file.diff': (data) => this.gitCommitFileDiff(record(data)),
46
48
  'workspace.git.commit.file.content.read': (data) => this.gitCommitFileContent(record(data)),
49
+ 'workspace.git.commit.file.direct.metadata': (data) => this.gitCommitFileDirectMetadata(record(data)),
47
50
  'workspace.diff': (data) => this.diff(record(data))
48
51
  };
49
52
  }
@@ -123,6 +126,15 @@ export class WorkspaceWorkbenchService {
123
126
  file: await this.options.files.readContent(workspace, input, repository)
124
127
  };
125
128
  }
129
+ async directFileMetadata(input) {
130
+ if (typeof input.workspaceId !== 'string')
131
+ throw new Error('WORKSPACE_NOT_FOUND');
132
+ const workspace = this.options.requireWorkspace(input.workspaceId);
133
+ const repository = await this.fileRepository(workspace, input);
134
+ return {
135
+ file: await this.options.files.directMetadata(workspace, input, repository)
136
+ };
137
+ }
126
138
  async changeDiff(input) {
127
139
  if (typeof input.workspaceId !== 'string' ||
128
140
  typeof input.path !== 'string' ||
@@ -217,6 +229,76 @@ export class WorkspaceWorkbenchService {
217
229
  file: await readGitHistoryFileContentRange(await this.gitRepository(input), input.commit, input.path, input.offset, input.limit, this.refs(input))
218
230
  };
219
231
  }
232
+ async gitCommitFileDirectMetadata(input) {
233
+ return {
234
+ file: await this.gitDirectMetadata(input)
235
+ };
236
+ }
237
+ async directDownload(descriptor) {
238
+ const workspace = this.options.requireWorkspace(descriptor.resource.workspaceId);
239
+ if (descriptor.resource.kind === 'WORKSPACE_FILE') {
240
+ const input = {
241
+ workspaceId: workspace.id,
242
+ path: descriptor.resource.path,
243
+ ...(descriptor.resource.worktreePath === undefined
244
+ ? {}
245
+ : { worktreePath: descriptor.resource.worktreePath })
246
+ };
247
+ return {
248
+ offset: descriptor.resumeOffset ?? 0,
249
+ chunks: this.options.files.readDirect(workspace, {
250
+ path: descriptor.resource.path,
251
+ size: descriptor.size,
252
+ sha256: descriptor.sha256
253
+ }, await this.fileRepository(workspace, input))
254
+ };
255
+ }
256
+ const input = {
257
+ workspaceId: workspace.id,
258
+ path: descriptor.resource.path,
259
+ commit: descriptor.resource.commit,
260
+ ...(descriptor.resource.repositoryPath === undefined
261
+ ? {}
262
+ : { repositoryPath: descriptor.resource.repositoryPath }),
263
+ ...(descriptor.resource.worktreePath === undefined
264
+ ? {}
265
+ : { worktreePath: descriptor.resource.worktreePath })
266
+ };
267
+ const repository = await this.gitRepository(input);
268
+ const content = await openGitHistoryFileContent(repository, descriptor.resource.commit, descriptor.resource.path, this.refs(input));
269
+ if (content.size !== descriptor.size)
270
+ throw new Error('DIRECT_FILE_REVISION_CHANGED');
271
+ return {
272
+ offset: descriptor.resumeOffset ?? 0,
273
+ chunks: (async function* () {
274
+ const hash = createHash('sha256');
275
+ let offset = 0;
276
+ for await (const bytes of content.chunks) {
277
+ hash.update(bytes);
278
+ offset += bytes.length;
279
+ if (bytes.length > 0)
280
+ yield bytes;
281
+ }
282
+ if (offset !== descriptor.size || hash.digest('hex') !== descriptor.sha256)
283
+ throw new Error('DIRECT_FILE_REVISION_CHANGED');
284
+ })()
285
+ };
286
+ }
287
+ async gitDirectMetadata(input) {
288
+ const repository = await this.gitRepository(input);
289
+ if (typeof input.commit !== 'string' || typeof input.path !== 'string')
290
+ throw new Error('GIT_HISTORY_COMMIT_INVALID');
291
+ const content = await openGitHistoryFileContent(repository, input.commit, input.path, this.refs(input));
292
+ const hash = createHash('sha256');
293
+ let offset = 0;
294
+ for await (const bytes of content.chunks) {
295
+ hash.update(bytes);
296
+ offset += bytes.length;
297
+ }
298
+ if (offset !== content.size)
299
+ throw new Error('DIRECT_FILE_REVISION_CHANGED');
300
+ return { size: content.size, sha256: hash.digest('hex') };
301
+ }
220
302
  async diff(input) {
221
303
  if (typeof input.workspaceId !== 'string' ||
222
304
  typeof input.fromRef !== 'string' ||
package/dist/workspace.js CHANGED
@@ -1510,6 +1510,67 @@ export async function readGitHistoryFileContentRange(repository, commit, request
1510
1510
  nextOffset: nextOffset < size ? nextOffset : null
1511
1511
  };
1512
1512
  }
1513
+ export async function openGitHistoryFileContent(repository, commit, requestedPath, refs) {
1514
+ await assertHistoryCommitReachable(repository, commit, refs);
1515
+ const path = safeGitWorkspacePath(requestedPath);
1516
+ const object = `${commit}:${path}`;
1517
+ const sizeResult = await runReadOnlyGit(repository, ['cat-file', '-s', object]);
1518
+ const size = Number(sizeResult.text.trim());
1519
+ if (!Number.isSafeInteger(size) || size < 0)
1520
+ throw new Error('FILE_RANGE_INVALID');
1521
+ return {
1522
+ path,
1523
+ size,
1524
+ chunks: (async function* () {
1525
+ const child = spawn('git', ['cat-file', 'blob', object], {
1526
+ cwd: repository,
1527
+ shell: false,
1528
+ windowsHide: true,
1529
+ stdio: ['ignore', 'pipe', 'pipe'],
1530
+ env: {
1531
+ ...process.env,
1532
+ ...gitOperationEnvironment.getStore(),
1533
+ GIT_OPTIONAL_LOCKS: '0'
1534
+ }
1535
+ });
1536
+ const errors = [];
1537
+ let errorSize = 0;
1538
+ let closed = false;
1539
+ const exit = new Promise((resolveExit) => {
1540
+ child.stderr.on('data', (chunk) => {
1541
+ const remaining = Math.max(0, 4096 - errorSize);
1542
+ if (remaining === 0)
1543
+ return;
1544
+ const retained = chunk.subarray(0, remaining);
1545
+ errors.push(retained);
1546
+ errorSize += retained.length;
1547
+ });
1548
+ child.once('error', (error) => {
1549
+ closed = true;
1550
+ resolveExit({ code: 1, stderr: error.message });
1551
+ });
1552
+ child.once('close', (code) => {
1553
+ closed = true;
1554
+ resolveExit({
1555
+ code: code ?? 1,
1556
+ stderr: Buffer.concat(errors).toString('utf8')
1557
+ });
1558
+ });
1559
+ });
1560
+ try {
1561
+ for await (const chunk of child.stdout)
1562
+ yield new Uint8Array(chunk);
1563
+ const result = await exit;
1564
+ if (result.code !== 0)
1565
+ throw new GitCommandError(result.code, result.stderr);
1566
+ }
1567
+ finally {
1568
+ if (!closed)
1569
+ child.kill('SIGKILL');
1570
+ }
1571
+ })()
1572
+ };
1573
+ }
1513
1574
  export async function readCurrentChanges(workspacePath) {
1514
1575
  return (await readCurrentChangesSummary(workspacePath)).changes;
1515
1576
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.9.67",
3
+ "version": "0.9.68",
4
4
  "description": "MyAgentRoam Node runtime CLI.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -24,11 +24,12 @@
24
24
  "@modelcontextprotocol/sdk": "1.30.0",
25
25
  "@opencode-ai/sdk": "1.18.20",
26
26
  "minimatch": "^10.2.6",
27
+ "node-datachannel": "0.33.4",
27
28
  "node-pty": "1.1.0",
28
29
  "ws": "^8.21.3",
29
30
  "zod": "4.4.3",
30
- "@myagentroam/agent": "0.9.67",
31
- "@myagentroam/protocol": "0.9.67"
31
+ "@myagentroam/agent": "0.9.68",
32
+ "@myagentroam/protocol": "0.9.68"
32
33
  },
33
34
  "devDependencies": {
34
35
  "@types/ws": "^8.18.1"