@myagentroam/node 0.9.107 → 0.9.109

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.
@@ -8,6 +8,7 @@ const nodeAppVersion = typeof nodePackage.version === 'string' ? nodePackage.ver
8
8
  const runtimeCredentialCapability = () => ({
9
9
  apiVersion: 1,
10
10
  websocket: true,
11
+ fastMode: true,
11
12
  httpVersions: ['1.1', '2']
12
13
  });
13
14
  const workspaceDirectTransferCapability = () => ({
@@ -19,6 +20,10 @@ const workspaceDirectTransferCapability = () => ({
19
20
  gitCommitBlob: true,
20
21
  maxChunkBytes: 256 * 1024
21
22
  });
23
+ const terminalDirectCapability = () => ({
24
+ apiVersion: 1,
25
+ transport: true
26
+ });
22
27
  export function reportedPlatform(platform = process.platform) {
23
28
  if (platform === 'win32')
24
29
  return 'windows';
@@ -47,7 +52,8 @@ export function unavailableCapabilities() {
47
52
  openCode: { available: false },
48
53
  marAgent: { available: true, version: nodeAppVersion },
49
54
  marAgentRuntimeCredentials: runtimeCredentialCapability(),
50
- workspaceDirectTransfer: workspaceDirectTransferCapability()
55
+ workspaceDirectTransfer: workspaceDirectTransferCapability(),
56
+ terminalDirect: terminalDirectCapability()
51
57
  };
52
58
  }
53
59
  export function detectCapabilities(commands = {}) {
@@ -80,7 +86,8 @@ export function detectCapabilities(commands = {}) {
80
86
  },
81
87
  marAgent: { available: true, version: nodeAppVersion },
82
88
  marAgentRuntimeCredentials: runtimeCredentialCapability(),
83
- workspaceDirectTransfer: workspaceDirectTransferCapability()
89
+ workspaceDirectTransfer: workspaceDirectTransferCapability(),
90
+ terminalDirect: terminalDirectCapability()
84
91
  };
85
92
  }
86
93
  export async function detectCapabilitiesAsync(commands = {}) {
@@ -117,6 +124,7 @@ export async function detectCapabilitiesAsync(commands = {}) {
117
124
  },
118
125
  marAgent: { available: true, version: nodeAppVersion },
119
126
  marAgentRuntimeCredentials: runtimeCredentialCapability(),
120
- workspaceDirectTransfer: workspaceDirectTransferCapability()
127
+ workspaceDirectTransfer: workspaceDirectTransferCapability(),
128
+ terminalDirect: terminalDirectCapability()
121
129
  };
122
130
  }
@@ -301,7 +301,8 @@ export async function probeClaudeCodeAsync(command = 'claude') {
301
301
  const authInvocation = platformCliInvocation(command, ['auth', 'status', '--json']);
302
302
  const authResult = await runRunnerProbe(authInvocation.command, authInvocation.args, {
303
303
  env: minimalRunnerEnvironment(),
304
- timeoutMs: 3_000
304
+ timeoutMs: 3_000,
305
+ logStderr: false
305
306
  });
306
307
  let authenticated = false;
307
308
  if (authResult.status === 0) {
package/dist/connector.js CHANGED
@@ -1133,6 +1133,7 @@ export class NodeConnector {
1133
1133
  handleNodeRequest: (envelope) => this.nodeRequests.handle(envelope),
1134
1134
  handleRuntimeCredentialResponse: (envelope) => this.runtimeCredentials.handle(envelope),
1135
1135
  handleDirectTransfer: (envelope) => this.handleDirectTransfer(envelope),
1136
+ handleTerminalDirect: (envelope) => this.handleTerminalDirect(envelope),
1136
1137
  reconcileRuns: (envelope) => this.runEventBridge.reconcile(envelope),
1137
1138
  runners: this.runners,
1138
1139
  deliverSessionChannelMessage: (envelope) => this.runnerChannelMessages.handle(envelope),
@@ -1456,6 +1457,9 @@ export class NodeConnector {
1456
1457
  return new DirectTransferService({
1457
1458
  nodeGeneration: () => this.nodeGeneration,
1458
1459
  send: (type, payload) => this.send(type, payload),
1460
+ terminalManager: this.terminalManager,
1461
+ sendTerminalReady: (directSessionId) => this.send('terminal.direct.ready', { directSessionId }),
1462
+ sendTerminalTakeover: (directSessionId) => this.send('terminal.direct.takeover', { directSessionId }),
1459
1463
  udpPortRange: () => directTransferUdpPortRange(this.config),
1460
1464
  openUpload: async (descriptor) => {
1461
1465
  if (descriptor.direction !== 'UPLOAD' || descriptor.resource.kind !== 'WORKSPACE_FILE')
@@ -1512,6 +1516,13 @@ export class NodeConnector {
1512
1516
  });
1513
1517
  return true;
1514
1518
  }
1519
+ handleTerminalDirect(envelope) {
1520
+ const command = {
1521
+ type: envelope.type,
1522
+ ...(typeof envelope.payload === 'object' && envelope.payload !== null ? envelope.payload : {})
1523
+ };
1524
+ return this.directTransferService?.handleTerminal(command) ?? false;
1525
+ }
1515
1526
  clearMissionRetry(sessionId) {
1516
1527
  const retry = this.missionRetryTimers.get(sessionId);
1517
1528
  if (retry !== undefined)
@@ -1,8 +1,10 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { basename } from 'node:path';
2
3
  import { writeNodeLog } from './rotating-log.js';
3
4
  const secretKey = /(?:password|credential|token|cookie|authorization|secret|api[_-]?key)/i;
4
5
  export function runRunnerProbe(command, args, options) {
5
6
  return new Promise((resolve) => {
7
+ const startedAt = performance.now();
6
8
  const child = spawn(command, args, {
7
9
  windowsHide: true,
8
10
  env: options.env,
@@ -11,22 +13,29 @@ export function runRunnerProbe(command, args, options) {
11
13
  let stdout = '';
12
14
  let stderr = '';
13
15
  let settled = false;
14
- const finish = (status) => {
16
+ const finish = (status, reason) => {
15
17
  if (settled)
16
18
  return;
17
19
  settled = true;
18
20
  clearTimeout(timer);
21
+ if (status !== 0)
22
+ logNodeCommandFailure(basename(command), {
23
+ ...(status === null ? {} : { exitCode: status }),
24
+ ...(options.logStderr === false ? {} : { stderr }),
25
+ ...(reason === undefined ? {} : { reason }),
26
+ durationMs: Math.round(performance.now() - startedAt)
27
+ });
19
28
  resolve({ status, stdout, stderr });
20
29
  };
21
30
  const timer = setTimeout(() => {
22
- finish(null);
31
+ finish(null, 'TIMEOUT');
23
32
  child.kill();
24
33
  }, options.timeoutMs);
25
34
  child.stdout.setEncoding('utf8');
26
35
  child.stderr.setEncoding('utf8');
27
36
  child.stdout.on('data', (value) => (stdout += value));
28
37
  child.stderr.on('data', (value) => (stderr += value));
29
- child.once('error', () => finish(null));
38
+ child.once('error', (error) => finish(null, error.code ?? 'START_FAILED'));
30
39
  child.once('close', (status) => finish(status));
31
40
  });
32
41
  }
@@ -120,3 +129,14 @@ export function nodeLog(event, fields = {}) {
120
129
  process.stdout.write(line);
121
130
  writeNodeLog(line);
122
131
  }
132
+ /** Logs Node-owned subprocess failures without copying argv, stdout or environment values. */
133
+ export function logNodeCommandFailure(command, result) {
134
+ const details = result.stderr?.trim().slice(0, 1_000);
135
+ nodeLog('node.command.failed', {
136
+ command,
137
+ ...(result.exitCode === undefined ? {} : { exitCode: result.exitCode }),
138
+ ...(result.reason === undefined ? {} : { reason: result.reason }),
139
+ ...(result.durationMs === undefined ? {} : { durationMs: result.durationMs }),
140
+ ...(details ? { errorDetails: details } : {})
141
+ });
142
+ }
@@ -134,8 +134,10 @@ export class MarAgentConversationNormalizer {
134
134
  if (action === 'start' && typeof input.command === 'string') {
135
135
  const command = {
136
136
  itemId: ownItemId,
137
+ turnKey: event.turnId ?? event.executionId,
137
138
  command: compactRunnerText(input.command, 10_000),
138
139
  cwd: typeof input.cwd === 'string' ? compactRunnerText(input.cwd, 4_096) : null,
140
+ status: 'IN_PROGRESS',
139
141
  outputPreview: '',
140
142
  exitCode: null,
141
143
  truncated: false
@@ -154,6 +156,29 @@ export class MarAgentConversationNormalizer {
154
156
  typeof input.processId === 'string') {
155
157
  const origin = this.#processOrigins.get(input.processId);
156
158
  if (origin !== undefined) {
159
+ const previous = this.#commands.get(origin);
160
+ const sameTurn = previous?.turnKey === (event.turnId ?? event.executionId);
161
+ if (previous !== undefined && !sameTurn) {
162
+ const command = {
163
+ itemId: ownItemId,
164
+ turnKey: event.turnId ?? event.executionId,
165
+ command: previous.command,
166
+ cwd: previous.cwd,
167
+ status: 'IN_PROGRESS',
168
+ outputPreview: '',
169
+ exitCode: null,
170
+ truncated: false
171
+ };
172
+ this.#commands.set(event.callId, command);
173
+ this.#tools.set(event.callId, {
174
+ toolName: event.toolName,
175
+ input: event.input,
176
+ itemId: ownItemId,
177
+ structured: true,
178
+ commandOrigin: event.callId
179
+ });
180
+ return [];
181
+ }
157
182
  this.#tools.set(event.callId, {
158
183
  toolName: event.toolName,
159
184
  input: event.input,
@@ -201,6 +226,7 @@ export class MarAgentConversationNormalizer {
201
226
  : result.status === 'completed' && result.exitCode === 0
202
227
  ? 'COMPLETED'
203
228
  : 'FAILED';
229
+ command.status = status;
204
230
  return [commandItem(command, status)];
205
231
  }
206
232
  }
@@ -256,8 +282,15 @@ export class MarAgentConversationNormalizer {
256
282
  const state = this.#tools.get(event.callId);
257
283
  if (event.toolName === 'exec' && state?.commandOrigin !== undefined) {
258
284
  const command = this.#commands.get(state.commandOrigin);
259
- if (command !== undefined)
285
+ const input = record(state.input);
286
+ if (command !== undefined &&
287
+ command.status === 'IN_PROGRESS' &&
288
+ (state.itemId !== command.itemId ||
289
+ input?.action === 'start' ||
290
+ (input?.action == null && typeof input?.command === 'string'))) {
291
+ command.status = 'FAILED';
260
292
  return [commandItem(command, 'FAILED')];
293
+ }
261
294
  }
262
295
  if (event.toolName === 'apply_patch' && state?.structured === true)
263
296
  return [fileChangeItem(state?.itemId ?? itemId('tool', event.callId), 'FAILED', [])];
@@ -361,7 +394,9 @@ function commandItem(command, status) {
361
394
  function applyCommandResult(command, result) {
362
395
  const output = [result.stdout, result.stderr].filter(Boolean).join('\n');
363
396
  if (output.length > 0) {
364
- const combined = [command.outputPreview, output].filter(Boolean).join('\n');
397
+ const combined = result.cached
398
+ ? output
399
+ : [command.outputPreview, output].filter(Boolean).join('\n');
365
400
  command.truncated ||= combined.length > 10_000;
366
401
  command.outputPreview = compactRunnerText(combined, 10_000);
367
402
  }
@@ -443,6 +478,7 @@ function execResult(value, summary, eventTruncated = false) {
443
478
  stdout,
444
479
  stderr,
445
480
  ...(exitCode === undefined ? {} : { exitCode }),
481
+ ...(data?.cached === true || summaryData?.cached === true ? { cached: true } : {}),
446
482
  truncated: eventTruncated ||
447
483
  data?.truncated === true ||
448
484
  summaryData?.truncated === true ||
@@ -511,6 +511,7 @@ export class MarAgentManagedRunController {
511
511
  agent,
512
512
  session,
513
513
  host: localHost,
514
+ normalizer: new MarAgentConversationNormalizer(),
514
515
  mcpLease,
515
516
  dynamicCredentials,
516
517
  models,
@@ -639,7 +640,7 @@ export class MarAgentManagedRunController {
639
640
  itemId: boundedConversationItemId('mar-agent-message', event.itemId)
640
641
  });
641
642
  else {
642
- let normalizer = this.#normalizers.get(runId);
643
+ let normalizer = this.#normalizers.get(runId) ?? this.#sessionRuntimes.get(event.sessionId)?.normalizer;
643
644
  if (normalizer === undefined) {
644
645
  normalizer = new MarAgentConversationNormalizer();
645
646
  this.#normalizers.set(runId, normalizer);
@@ -64,6 +64,7 @@ export class MarAgentRuntimeModelFactory {
64
64
  titleGeneration: configuration.titleGeneration,
65
65
  codeMode: configuration.codeMode,
66
66
  highDensityCompaction: configuration.highDensityCompaction,
67
+ ...(configuration.fastMode === true ? { fastMode: true } : {}),
67
68
  hostedWebSearch: configuration.hostedWebSearch,
68
69
  ...(imageGeneration === undefined ? {} : { imageGeneration }),
69
70
  ...(configuration.responsesTransport === undefined
@@ -4,6 +4,7 @@ import { createReadStream, createWriteStream } from 'node:fs';
4
4
  import { lstat, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
5
5
  import { relative, resolve, sep } from 'node:path';
6
6
  import { createInterface } from 'node:readline';
7
+ import { logNodeCommandFailure } from '../../operational.js';
7
8
  import { compareTagNames } from './ctags-query.js';
8
9
  import { sourceFileFingerprint } from './navigation-target-validator.js';
9
10
  const BUILD_TIMEOUT_MS = 120_000;
@@ -175,6 +176,15 @@ export async function runCtags(executable, cwd, fileList, destination) {
175
176
  windowsHide: true,
176
177
  stdio: ['ignore', 'ignore', 'pipe']
177
178
  });
179
+ const errors = [];
180
+ let errorBytes = 0;
181
+ const stderr = () => Buffer.concat(errors).toString('utf8');
182
+ const reportFailure = (reason, exitCode) => logNodeCommandFailure('ctags', {
183
+ reason,
184
+ ...(exitCode === undefined ? {} : { exitCode }),
185
+ stderr: stderr(),
186
+ durationMs: Date.now() - startedAt
187
+ });
178
188
  let settled = false;
179
189
  const settle = (callback) => {
180
190
  if (settled)
@@ -197,12 +207,32 @@ export async function runCtags(executable, cwd, fileList, destination) {
197
207
  };
198
208
  const timer = setTimeout(() => {
199
209
  terminate();
200
- settle(() => reject(new Error('SYMBOL_INDEX_BUILD_FAILED')));
210
+ settle(() => {
211
+ reportFailure('TIMEOUT');
212
+ reject(new Error('SYMBOL_INDEX_BUILD_FAILED'));
213
+ });
201
214
  }, BUILD_TIMEOUT_MS);
202
215
  timer.unref();
203
- child.stderr.on('data', () => undefined);
204
- child.once('error', () => settle(() => reject(new Error('SYMBOL_INDEX_BUILD_FAILED'))));
205
- child.once('exit', (code) => settle(() => (code === 0 ? resolvePromise() : reject(new Error('SYMBOL_INDEX_BUILD_FAILED')))));
216
+ child.stderr.on('data', (chunk) => {
217
+ const remaining = Math.max(0, 1_000 - errorBytes);
218
+ if (remaining === 0)
219
+ return;
220
+ const retained = chunk.subarray(0, remaining);
221
+ errors.push(retained);
222
+ errorBytes += retained.length;
223
+ });
224
+ child.once('error', (error) => settle(() => {
225
+ reportFailure(error.code ?? 'START_FAILED');
226
+ reject(new Error('SYMBOL_INDEX_BUILD_FAILED'));
227
+ }));
228
+ child.once('close', (code) => settle(() => {
229
+ if (code === 0)
230
+ resolvePromise();
231
+ else {
232
+ reportFailure('EXIT', code ?? undefined);
233
+ reject(new Error('SYMBOL_INDEX_BUILD_FAILED'));
234
+ }
235
+ }));
206
236
  });
207
237
  return Date.now() - startedAt;
208
238
  }
@@ -1,4 +1,5 @@
1
1
  import { directDataControlFrameSchema, directNodeCommandSchema } from '@myagentroam/protocol';
2
+ import { TerminalDirectService } from './terminal-direct-service.js';
2
3
  import { IceUdpMuxListener as NativeIceUdpMuxListener } from 'node-datachannel';
3
4
  import { RTCPeerConnection } from 'node-datachannel/polyfill';
4
5
  const MAX_HOST_CANDIDATES = 32;
@@ -18,6 +19,7 @@ export class DirectTransferService {
18
19
  peerConfiguration;
19
20
  leaseGraceMs;
20
21
  leaseCheckMs;
22
+ terminalDirect;
21
23
  disposed = false;
22
24
  constructor(options) {
23
25
  this.options = options;
@@ -37,6 +39,17 @@ export class DirectTransferService {
37
39
  ((configuration) => new RTCPeerConnection(configuration));
38
40
  this.leaseGraceMs = options.leaseGraceMs ?? LEASE_GRACE_MS;
39
41
  this.leaseCheckMs = options.leaseCheckMs ?? LEASE_CHECK_MS;
42
+ if (options.terminalManager !== undefined)
43
+ this.terminalDirect = new TerminalDirectService(options.terminalManager, (id) => options.sendTerminalReady?.(id), (id) => options.sendTerminalTakeover?.(id), (command, peer) => {
44
+ const transport = this.transports.get(command.transportSessionId);
45
+ return (transport?.peer === peer &&
46
+ transport.command.workbenchConnectionId === command.workbenchConnectionId &&
47
+ transport.command.nodeGeneration === command.nodeGeneration &&
48
+ command.nodeGeneration === options.nodeGeneration() &&
49
+ peer.connectionState === 'connected' &&
50
+ command.browserFingerprint === fingerprint(peer.remoteDescription?.sdp) &&
51
+ command.nodeFingerprint === fingerprint(peer.localDescription?.sdp));
52
+ }, this.now);
40
53
  }
41
54
  handle(value) {
42
55
  const parsed = directNodeCommandSchema.safeParse(value);
@@ -54,7 +67,11 @@ export class DirectTransferService {
54
67
  });
55
68
  return true;
56
69
  }
70
+ handleTerminal(value) {
71
+ return this.terminalDirect?.handle(value, (id) => this.transports.get(id)?.peer) ?? false;
72
+ }
57
73
  close() {
74
+ this.terminalDirect?.close();
58
75
  for (const transfer of [...this.transfers.values()])
59
76
  void this.removeTransfer(transfer, 'DIRECT_NODE_STOPPED');
60
77
  for (const transport of [...this.transports.values()])
@@ -65,6 +82,7 @@ export class DirectTransferService {
65
82
  return;
66
83
  this.disposed = true;
67
84
  this.close();
85
+ this.terminalDirect?.dispose();
68
86
  this.iceUdpMuxListener.stop();
69
87
  }
70
88
  async dispatch(command) {
@@ -236,6 +254,12 @@ export class DirectTransferService {
236
254
  if (attached)
237
255
  return;
238
256
  attached = true;
257
+ if (channel.label.startsWith('mar-terminal-')) {
258
+ this.terminalDirect?.open(transport.command.directSessionId, channel, event.data);
259
+ if (this.terminalDirect === undefined)
260
+ channel.close();
261
+ return;
262
+ }
239
263
  void this.openChannel(transport, channel, event.data).catch((error) => {
240
264
  channel.close();
241
265
  const id = openFrameSessionId(event.data);
@@ -396,6 +420,7 @@ export class DirectTransferService {
396
420
  if (!this.transports.delete(transport.command.directSessionId))
397
421
  return;
398
422
  clearInterval(transport.leaseTimer);
423
+ this.terminalDirect?.removeTransport(transport.command.directSessionId);
399
424
  for (const transfer of [...this.transfers.values()])
400
425
  if (transfer.command.transportSessionId === transport.command.directSessionId)
401
426
  void this.removeTransfer(transfer, code ?? 'DIRECT_CONNECTION_FAILED');
@@ -2004,6 +2004,7 @@ function modelSummary(model, index) {
2004
2004
  titleGeneration: model.titleGeneration,
2005
2005
  codeMode: model.codeMode,
2006
2006
  highDensityCompaction: model.highDensityCompaction,
2007
+ ...(model.fastMode === true ? { fastMode: true } : {}),
2007
2008
  hostedWebSearch: model.hostedWebSearch,
2008
2009
  ...(model.responsesTransport === undefined
2009
2010
  ? {}
@@ -38,6 +38,9 @@ export class NodeControlMessageService {
38
38
  if (envelope.type.startsWith('direct.') &&
39
39
  this.options.handleDirectTransfer?.(envelope) === true)
40
40
  return;
41
+ if (envelope.type.startsWith('terminal.direct.') &&
42
+ this.options.handleTerminalDirect?.(envelope) === true)
43
+ return;
41
44
  if (envelope.type === 'ack')
42
45
  return;
43
46
  if (envelope.type === 'runs.reconcile') {
@@ -0,0 +1,202 @@
1
+ import { terminalDataFrameSchema, terminalDirectNodeCommandSchema, terminalDirectOpenFrameSchema } from '@myagentroam/protocol';
2
+ const MAX_SESSIONS = 128;
3
+ const MAX_BUFFERED_BYTES = 1024 * 1024;
4
+ const MAX_FRAME_BYTES = 128 * 1024;
5
+ const HEARTBEAT_GRACE_MS = 30_000;
6
+ export class TerminalDirectService {
7
+ manager;
8
+ sendReady;
9
+ sendTakeover;
10
+ verifyTransport;
11
+ sessions = new Map();
12
+ timer;
13
+ stopListening;
14
+ now;
15
+ constructor(manager, sendReady, sendTakeover, verifyTransport, now = Date.now) {
16
+ this.manager = manager;
17
+ this.sendReady = sendReady;
18
+ this.sendTakeover = sendTakeover;
19
+ this.verifyTransport = verifyTransport;
20
+ this.now = now;
21
+ this.stopListening = manager.onFrame((frame) => {
22
+ for (const session of this.sessions.values())
23
+ if (session.opened && session.command.terminalId === frame.terminalId)
24
+ this.sendFrame(session, frame);
25
+ });
26
+ this.timer = setInterval(() => {
27
+ for (const session of [...this.sessions.values()])
28
+ if (session.expiresAt <= this.now() ||
29
+ this.now() - session.lastHeartbeat > HEARTBEAT_GRACE_MS)
30
+ this.remove(session);
31
+ }, 5_000);
32
+ this.timer.unref?.();
33
+ }
34
+ handle(value, getPeer) {
35
+ const parsed = terminalDirectNodeCommandSchema.safeParse(value);
36
+ if (!parsed.success)
37
+ return false;
38
+ const command = parsed.data;
39
+ if (command.type === 'terminal.direct.prepare') {
40
+ const peer = getPeer(command.transportSessionId);
41
+ if (peer === undefined ||
42
+ !this.verifyTransport(command, peer) ||
43
+ this.sessions.size >= MAX_SESSIONS ||
44
+ this.sessions.has(command.directSessionId) ||
45
+ command.openExpiresAt <= this.now() ||
46
+ command.expiresAt <= this.now())
47
+ return true;
48
+ this.sessions.set(command.directSessionId, {
49
+ command,
50
+ peer,
51
+ opened: false,
52
+ expiresAt: command.expiresAt,
53
+ lastHeartbeat: this.now()
54
+ });
55
+ this.sendReady(command.directSessionId);
56
+ return true;
57
+ }
58
+ const session = this.sessions.get(command.directSessionId);
59
+ if (session === undefined)
60
+ return true;
61
+ if (command.type === 'terminal.direct.cancel')
62
+ this.remove(session);
63
+ else if (session.expiresAt > this.now() && command.expiresAt > this.now()) {
64
+ session.expiresAt = command.expiresAt;
65
+ session.lastHeartbeat = this.now();
66
+ }
67
+ else
68
+ this.remove(session);
69
+ return true;
70
+ }
71
+ open(transportId, channel, raw) {
72
+ if (typeof raw !== 'string' || Buffer.byteLength(raw) > 4_096) {
73
+ channel.close();
74
+ return;
75
+ }
76
+ let parsed;
77
+ try {
78
+ parsed = terminalDirectOpenFrameSchema.safeParse(JSON.parse(raw));
79
+ }
80
+ catch {
81
+ channel.close();
82
+ return;
83
+ }
84
+ const session = parsed.success ? this.sessions.get(parsed.data.directSessionId) : undefined;
85
+ if (session === undefined ||
86
+ session.channel !== undefined ||
87
+ session.opened ||
88
+ session.command.transportSessionId !== transportId ||
89
+ session.command.grant !== (parsed.success ? parsed.data.grant : undefined) ||
90
+ session.command.openExpiresAt <= this.now() ||
91
+ session.expiresAt <= this.now() ||
92
+ !this.verifyTransport(session.command, session.peer)) {
93
+ channel.close();
94
+ return;
95
+ }
96
+ session.channel = channel;
97
+ channel.onclose = () => this.remove(session);
98
+ try {
99
+ const terminal = this.manager.get(session.command.terminalId);
100
+ if (terminal.state !== 'RUNNING' ||
101
+ terminal.workspaceId !== (session.command.workspaceId ?? null))
102
+ throw new Error('TERMINAL_NOT_FOUND');
103
+ const attachment = this.manager.authorizeAttach({
104
+ terminalId: session.command.terminalId,
105
+ afterSequence: session.command.afterSequence,
106
+ readonly: session.command.readonly,
107
+ takeover: session.command.takeover
108
+ });
109
+ session.attachmentId = attachment.attachmentId;
110
+ session.opened = true;
111
+ channel.send(JSON.stringify({
112
+ type: 'ATTACHED',
113
+ terminalId: session.command.terminalId,
114
+ afterSequence: session.command.afterSequence
115
+ }));
116
+ for (const frame of attachment.frames)
117
+ this.sendFrame(session, frame);
118
+ if (!session.command.readonly && session.command.takeover) {
119
+ for (const other of [...this.sessions.values()])
120
+ if (other !== session &&
121
+ other.opened &&
122
+ !other.command.readonly &&
123
+ other.command.terminalId === session.command.terminalId)
124
+ this.remove(other, 'TERMINAL_RELAY_REPLACED');
125
+ this.sendTakeover(session.command.directSessionId);
126
+ }
127
+ channel.onmessage = (event) => this.accept(session, event.data);
128
+ }
129
+ catch {
130
+ this.remove(session, 'TERMINAL_ATTACH_FAILED');
131
+ }
132
+ }
133
+ removeTransport(transportId) {
134
+ for (const session of [...this.sessions.values()])
135
+ if (session.command.transportSessionId === transportId)
136
+ this.remove(session);
137
+ }
138
+ close() {
139
+ for (const session of [...this.sessions.values()])
140
+ this.remove(session);
141
+ }
142
+ dispose() {
143
+ this.close();
144
+ clearInterval(this.timer);
145
+ this.stopListening();
146
+ }
147
+ accept(session, raw) {
148
+ if (!session.opened ||
149
+ typeof raw !== 'string' ||
150
+ Buffer.byteLength(raw) > MAX_FRAME_BYTES ||
151
+ session.expiresAt <= this.now()) {
152
+ this.remove(session, 'TERMINAL_FRAME_REJECTED');
153
+ return;
154
+ }
155
+ try {
156
+ const frame = terminalDataFrameSchema.parse(JSON.parse(raw));
157
+ if (frame.terminalId !== session.command.terminalId ||
158
+ !['input', 'resize', 'close'].includes(frame.type) ||
159
+ session.attachmentId === undefined)
160
+ throw new Error('TERMINAL_FRAME_INVALID');
161
+ void this.manager
162
+ .acceptFrame(session.attachmentId, frame)
163
+ .catch(() => this.remove(session, 'TERMINAL_FRAME_REJECTED'));
164
+ }
165
+ catch {
166
+ this.remove(session, 'TERMINAL_FRAME_REJECTED');
167
+ }
168
+ }
169
+ sendFrame(session, frame) {
170
+ const channel = session.channel;
171
+ if (channel?.readyState !== 'open') {
172
+ this.remove(session);
173
+ return;
174
+ }
175
+ const value = JSON.stringify(frame);
176
+ if (channel.bufferedAmount + Buffer.byteLength(value) > MAX_BUFFERED_BYTES) {
177
+ this.remove(session, 'TERMINAL_BACKPRESSURE');
178
+ return;
179
+ }
180
+ try {
181
+ channel.send(value);
182
+ }
183
+ catch {
184
+ this.remove(session);
185
+ }
186
+ }
187
+ remove(session, code) {
188
+ if (!this.sessions.delete(session.command.directSessionId))
189
+ return;
190
+ if (session.attachmentId !== undefined)
191
+ this.manager.detach(session.attachmentId);
192
+ if (code !== undefined && session.channel?.readyState === 'open') {
193
+ try {
194
+ session.channel.send(JSON.stringify({ type: 'error', terminalId: session.command.terminalId, code }));
195
+ }
196
+ catch {
197
+ // A closing channel must not prevent attachment cleanup.
198
+ }
199
+ }
200
+ session.channel?.close();
201
+ }
202
+ }
@@ -26,7 +26,8 @@ export class TerminalService {
26
26
  data?.__requestUserId)
27
27
  }),
28
28
  'terminal.close': (data) => this.close(data),
29
- 'terminal.attach.authorize': (data) => this.authorizeAttach(data)
29
+ 'terminal.attach.authorize': (data) => this.authorizeAttach(data),
30
+ 'terminal.direct.preflight': (data) => this.preflightDirect(data)
30
31
  };
31
32
  }
32
33
  open(data) {
@@ -82,6 +83,18 @@ export class TerminalService {
82
83
  const attachment = this.terminalManager.authorizeAttach(data);
83
84
  return { attachment, terminal: this.terminalManager.get(attachment.terminalId) };
84
85
  }
86
+ preflightDirect(data) {
87
+ if (data === null || typeof data !== 'object')
88
+ throw new Error('TERMINAL_ATTACH_INVALID');
89
+ const input = data;
90
+ this.assertOwner(input);
91
+ const terminal = this.terminalManager.get(String(input.terminalId));
92
+ if (terminal.state !== 'RUNNING')
93
+ throw new Error('TERMINAL_CLOSED');
94
+ if (terminal.workspaceId !== (input.workspaceId ?? null))
95
+ throw new Error('TERMINAL_WORKSPACE_MISMATCH');
96
+ return { terminalId: terminal.id, workspaceId: terminal.workspaceId };
97
+ }
85
98
  assertOwner(input) {
86
99
  const owner = this.gitOwners.get(String(input.terminalId));
87
100
  if (owner !== undefined && owner.userId !== input.__requestUserId)
@@ -47,6 +47,7 @@ export class WorkbenchManifestService {
47
47
  maxSearchResults: 200
48
48
  },
49
49
  directTransfer: capabilities.workspaceDirectTransfer,
50
+ terminalDirect: capabilities.terminalDirect,
50
51
  git: {
51
52
  available: true,
52
53
  reasonCode: null,
@@ -42,7 +42,7 @@ export class WorkspaceChangeService {
42
42
  ...(error instanceof GitCommandError
43
43
  ? {
44
44
  parameters: { gitExitCode: error.code },
45
- details: `Git command exited with status ${error.code}.`
45
+ details: error.publicDetails
46
46
  }
47
47
  : {})
48
48
  });
@@ -53,6 +53,7 @@ export class WorkspaceChangeService {
53
53
  groupPath: path,
54
54
  errorCode: error.code,
55
55
  ...error.parameters,
56
+ ...(error.details === undefined ? {} : { errorDetails: error.details }),
56
57
  durationMs: Math.round(performance.now() - startedAt)
57
58
  });
58
59
  };
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { createReadStream } from 'node:fs';
3
3
  import { lstat, realpath } from 'node:fs/promises';
4
4
  import { isAbsolute, join, relative, sep } from 'node:path';
5
+ import { logNodeCommandFailure } from '../operational.js';
5
6
  import { bundledRipgrepPath } from '../ripgrep-binary.js';
6
7
  const RESULT_LIMIT = 200;
7
8
  const PER_FILE_LIMIT = 20;
@@ -113,10 +114,14 @@ export class WorkspaceContentSearchService {
113
114
  async validateNativeRegex(root, search, deadline) {
114
115
  const command = commandFor('RIPGREP', [], search, this.ripgrep ?? undefined);
115
116
  const result = await runCommand(command.file, [...command.args, '-'], root, deadline);
116
- if (result.code === 2)
117
+ if (result.code === 2) {
118
+ logNodeCommandFailure('rg', { exitCode: result.code, reason: 'INVALID_QUERY' });
117
119
  throw new Error('FILE_CONTENT_SEARCH_INVALID');
118
- if (!command.validCodes.includes(result.code))
120
+ }
121
+ if (!command.validCodes.includes(result.code)) {
122
+ logNodeCommandFailure('rg', { exitCode: result.code, stderr: result.stderr });
119
123
  throw new Error('CONTENT_SEARCH_COMMAND_FAILED');
124
+ }
120
125
  }
121
126
  async candidates(workspace, deadline) {
122
127
  const root = await realpath(workspace.path);
@@ -167,8 +172,11 @@ export class WorkspaceContentSearchService {
167
172
  const command = commandFor(engine, batch, search, this.ripgrep ?? undefined);
168
173
  const result = await runCommand(command.file, command.args, root, deadline, engine === 'RIPGREP');
169
174
  if (!command.validCodes.includes(result.code)) {
170
- if (search.regex && result.code === 2)
175
+ if (search.regex && result.code === 2) {
176
+ logNodeCommandFailure('rg', { exitCode: result.code, reason: 'INVALID_QUERY' });
171
177
  throw new Error('FILE_CONTENT_SEARCH_INVALID');
178
+ }
179
+ logNodeCommandFailure('rg', { exitCode: result.code, stderr: result.stderr });
172
180
  throw new Error('CONTENT_SEARCH_COMMAND_FAILED');
173
181
  }
174
182
  const paths = new Map(batch.flatMap((file) => [
@@ -268,21 +276,42 @@ function resolveRipgrep() {
268
276
  }
269
277
  async function runCommand(file, args, cwd, deadline, stopAfterResultLimit = false) {
270
278
  return new Promise((resolve, reject) => {
279
+ const startedAt = performance.now();
271
280
  const child = spawn(file, args, {
272
281
  cwd,
273
282
  windowsHide: true,
274
- stdio: ['ignore', 'pipe', 'ignore']
283
+ stdio: ['ignore', 'pipe', 'pipe']
275
284
  });
276
285
  const chunks = [];
286
+ const errors = [];
277
287
  let pending = Buffer.alloc(0);
278
288
  let bytes = 0;
289
+ let errorBytes = 0;
279
290
  let matches = 0;
280
291
  let stopped = false;
281
- const timer = setTimeout(() => child.kill(), Math.max(1, deadline - Date.now()));
292
+ let failed = false;
293
+ let timedOut = false;
294
+ const timer = setTimeout(() => {
295
+ timedOut = true;
296
+ child.kill();
297
+ }, Math.max(1, deadline - Date.now()));
282
298
  child.once('error', (error) => {
283
299
  clearTimeout(timer);
300
+ failed = true;
301
+ logNodeCommandFailure('rg', {
302
+ reason: error.code ?? 'START_FAILED',
303
+ durationMs: Math.round(performance.now() - startedAt)
304
+ });
284
305
  reject(error);
285
306
  });
307
+ child.stderr.on('data', (chunk) => {
308
+ const remaining = Math.max(0, 1_000 - errorBytes);
309
+ if (remaining === 0)
310
+ return;
311
+ const retained = chunk.subarray(0, remaining);
312
+ errors.push(retained);
313
+ errorBytes += retained.length;
314
+ });
286
315
  child.stdout.on('data', (chunk) => {
287
316
  bytes += chunk.length;
288
317
  if (bytes > OUTPUT_BYTE_LIMIT) {
@@ -311,14 +340,23 @@ async function runCommand(file, args, cwd, deadline, stopAfterResultLimit = fals
311
340
  });
312
341
  child.once('close', (code, signal) => {
313
342
  clearTimeout(timer);
343
+ if (failed)
344
+ return;
345
+ const stderr = Buffer.concat(errors).toString('utf8');
314
346
  if (stopped)
315
- resolve({ code: 0, stdout: Buffer.concat(chunks) });
316
- else if (signal !== null || bytes > OUTPUT_BYTE_LIMIT)
347
+ resolve({ code: 0, stdout: Buffer.concat(chunks), stderr });
348
+ else if (timedOut || signal !== null || bytes > OUTPUT_BYTE_LIMIT) {
349
+ logNodeCommandFailure('rg', {
350
+ reason: timedOut ? 'TIMEOUT' : bytes > OUTPUT_BYTE_LIMIT ? 'OUTPUT_LIMIT' : 'SIGNAL',
351
+ stderr,
352
+ durationMs: Math.round(performance.now() - startedAt)
353
+ });
317
354
  reject(new Error('CONTENT_SEARCH_COMMAND_LIMIT'));
355
+ }
318
356
  else {
319
357
  if (stopAfterResultLimit && pending.length > 0)
320
358
  chunks.push(pending);
321
- resolve({ code: code ?? -1, stdout: Buffer.concat(chunks) });
359
+ resolve({ code: code ?? -1, stdout: Buffer.concat(chunks), stderr });
322
360
  }
323
361
  });
324
362
  });
package/dist/workspace.js CHANGED
@@ -4,6 +4,7 @@ import { homedir, platform as hostPlatform } from 'node:os';
4
4
  import { basename, dirname, isAbsolute, posix, relative, resolve, sep, win32 } from 'node:path';
5
5
  import { spawn } from 'node:child_process';
6
6
  import { minimatch } from 'minimatch';
7
+ import { logNodeCommandFailure } from './operational.js';
7
8
  import { terminateProcessTree } from './process-tree.js';
8
9
  import { gitOperationEnvironment } from './service/git-operation-service.js';
9
10
  const GIT_TIMEOUT_MS = 5_000;
@@ -1116,7 +1117,7 @@ export async function listGitWorktrees(workspacePath) {
1116
1117
  if (nulDelimited.code === 0)
1117
1118
  return parseGitWorktreeList(workspacePath, nulDelimited, true);
1118
1119
  if (!canRetryGitWorktreeListWithoutNullOutput(nulDelimited))
1119
- throw new GitCommandError(nulDelimited.code, nulDelimited.stderr);
1120
+ throw gitCommandFailure(nulDelimited.code, nulDelimited.stderr, 'worktree');
1120
1121
  const lineDelimited = await runReadOnlyGit(workspacePath, ['worktree', 'list', '--porcelain'], options);
1121
1122
  return parseGitWorktreeList(workspacePath, lineDelimited, false);
1122
1123
  }
@@ -1570,7 +1571,7 @@ export async function readGitHistoryFileContentRange(repository, commit, request
1570
1571
  timeoutMs: GIT_HISTORY_TIMEOUT_MS
1571
1572
  });
1572
1573
  if (result.code !== 0)
1573
- throw new GitCommandError(result.code, result.stderr);
1574
+ throw gitCommandFailure(result.code, result.stderr, 'cat-file');
1574
1575
  const content = result.output.subarray(offset, Math.min(size, offset + limit));
1575
1576
  const nextOffset = offset + content.length;
1576
1577
  return {
@@ -1633,7 +1634,7 @@ export async function openGitHistoryFileContent(repository, commit, requestedPat
1633
1634
  yield new Uint8Array(chunk);
1634
1635
  const result = await exit;
1635
1636
  if (result.code !== 0)
1636
- throw new GitCommandError(result.code, result.stderr);
1637
+ throw gitCommandFailure(result.code, result.stderr, 'cat-file');
1637
1638
  }
1638
1639
  finally {
1639
1640
  if (!closed)
@@ -1686,7 +1687,7 @@ async function readCurrentChangeGitDiff(workspacePath, path) {
1686
1687
  path
1687
1688
  ]);
1688
1689
  if (result.code !== 0 && result.code !== 1)
1689
- throw new GitCommandError(result.code, result.stderr);
1690
+ throw gitCommandFailure(result.code, result.stderr, 'diff');
1690
1691
  return result;
1691
1692
  }
1692
1693
  return runReadOnlyGit(workspacePath, [
@@ -1816,12 +1817,25 @@ function gitChange(status) {
1816
1817
  export class GitCommandError extends Error {
1817
1818
  code;
1818
1819
  stderr;
1820
+ publicDetails;
1821
+ publicParameters;
1819
1822
  constructor(code, stderr) {
1820
1823
  super('GIT_COMMAND_FAILED');
1821
1824
  this.code = code;
1822
1825
  this.stderr = stderr;
1826
+ this.publicParameters = { gitExitCode: code };
1827
+ this.publicDetails = stderr.trim().slice(0, 1_000) || `Git exited with status ${code}.`;
1823
1828
  }
1824
1829
  }
1830
+ function gitCommandFailure(code, stderr, subcommand, durationMs, reason) {
1831
+ logNodeCommandFailure(`git ${subcommand}`, {
1832
+ exitCode: code,
1833
+ stderr,
1834
+ ...(durationMs === undefined ? {} : { durationMs }),
1835
+ ...(reason === undefined ? {} : { reason })
1836
+ });
1837
+ return new GitCommandError(code, stderr);
1838
+ }
1825
1839
  async function runReadOnlyGit(workspacePath, args, options = {}) {
1826
1840
  return runGit(workspacePath, args, { ...options, readOnly: true });
1827
1841
  }
@@ -1829,14 +1843,16 @@ async function runReadOnlyGitAllowFailure(workspacePath, args, options = {}) {
1829
1843
  return runGitAllowFailure(workspacePath, args, { ...options, readOnly: true });
1830
1844
  }
1831
1845
  async function runGit(workspacePath, args, options = {}) {
1846
+ const startedAt = performance.now();
1832
1847
  const result = await runGitAllowFailure(workspacePath, args, options);
1833
1848
  if (result.code !== 0) {
1834
- throw new GitCommandError(result.code, result.stderr);
1849
+ throw gitCommandFailure(result.code, result.stderr, args[0] ?? 'unknown', Math.round(performance.now() - startedAt));
1835
1850
  }
1836
1851
  return result;
1837
1852
  }
1838
1853
  async function runGitAllowFailure(workspacePath, args, options = {}) {
1839
1854
  return new Promise((resolve, reject) => {
1855
+ const startedAt = performance.now();
1840
1856
  const outputLimit = options.outputLimit ?? GIT_OUTPUT_LIMIT;
1841
1857
  const child = spawn('git', [...args], {
1842
1858
  cwd: workspacePath,
@@ -1854,7 +1870,9 @@ async function runGitAllowFailure(workspacePath, args, options = {}) {
1854
1870
  let size = 0;
1855
1871
  let errorSize = 0;
1856
1872
  let truncated = false;
1873
+ let timedOut = false;
1857
1874
  const timer = setTimeout(() => {
1875
+ timedOut = true;
1858
1876
  if (process.platform === 'win32') {
1859
1877
  const pid = child.pid;
1860
1878
  if (pid === undefined) {
@@ -1895,16 +1913,24 @@ async function runGitAllowFailure(workspacePath, args, options = {}) {
1895
1913
  });
1896
1914
  child.once('error', (error) => {
1897
1915
  clearTimeout(timer);
1916
+ logNodeCommandFailure(`git ${args[0] ?? 'unknown'}`, {
1917
+ reason: error.code ?? 'START_FAILED'
1918
+ });
1898
1919
  reject(error);
1899
1920
  });
1900
1921
  child.once('close', (code) => {
1901
1922
  clearTimeout(timer);
1923
+ const stderr = Buffer.concat(errors).toString('utf8');
1924
+ if (timedOut) {
1925
+ reject(gitCommandFailure(code === null || code === 0 ? 1 : code, `Git command timed out after ${options.timeoutMs ?? GIT_TIMEOUT_MS} ms.${stderr ? `\n${stderr}` : ''}`, args[0] ?? 'unknown', Math.round(performance.now() - startedAt), 'TIMEOUT'));
1926
+ return;
1927
+ }
1902
1928
  const output = Buffer.concat(chunks);
1903
1929
  resolve({
1904
1930
  code: code ?? 1,
1905
1931
  output,
1906
1932
  text: output.toString('utf8'),
1907
- stderr: Buffer.concat(errors).toString('utf8'),
1933
+ stderr,
1908
1934
  truncated
1909
1935
  });
1910
1936
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.9.107",
3
+ "version": "0.9.109",
4
4
  "description": "MyAgentRoam Node runtime CLI.",
5
5
  "license": "Apache-2.0 AND GPL-2.0-or-later",
6
6
  "type": "module",
@@ -30,9 +30,9 @@
30
30
  "npm": "11.12.1",
31
31
  "ws": "^8.21.3",
32
32
  "zod": "4.4.3",
33
- "@myagentroam/agent": "0.9.107",
34
- "@myagentroam/code-navigation": "0.9.107",
35
- "@myagentroam/protocol": "0.9.107"
33
+ "@myagentroam/agent": "0.9.109",
34
+ "@myagentroam/code-navigation": "0.9.109",
35
+ "@myagentroam/protocol": "0.9.109"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/ws": "^8.18.1"