@myagentroam/node 0.10.2 → 0.10.4

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.
package/dist/connector.js CHANGED
@@ -316,13 +316,19 @@ export class NodeConnector {
316
316
  workspaceFileService = new WorkspaceFileService(this.workspaceState, (workspaceId) => {
317
317
  this.workspaceWorkbenchService?.invalidateWorkspaceFiles(workspaceId);
318
318
  this.workspaceCoordinator.invalidate(workspaceId);
319
- });
319
+ }, {}, (workspaceId, resourcePath) => this.workspaceUploadService.fileResource(workspaceId, resourcePath) ??
320
+ this.runAttachmentService.fileResource(workspaceId, resourcePath));
320
321
  workspaceUploadService = new WorkspaceUploadService(this.workspaceState, (workspaceId) => {
321
322
  this.workspaceFileService.dispose(workspaceId);
322
323
  this.workspaceWorkbenchService?.invalidateWorkspaceFiles(workspaceId);
323
324
  this.workspaceCoordinator.invalidate(workspaceId);
324
- });
325
+ }, () => this.attachmentTemporaryRoot());
325
326
  directTransferService;
327
+ attachmentTemporaryRoot() {
328
+ if (this.config === undefined)
329
+ throw new Error('NODE_CONFIG_UNAVAILABLE');
330
+ return resolve(nodeDataDirectory(this.config, this.configPath), 'temp-files');
331
+ }
326
332
  workspaceChangeService = new WorkspaceChangeService(this.workspaceState);
327
333
  workspaceWorkbenchService;
328
334
  database;
@@ -518,7 +524,7 @@ export class NodeConnector {
518
524
  options.fakeRunner === undefined ? undefined : new FakeTestRunner(options.fakeRunner);
519
525
  this.fakeScript = options.fakeScript;
520
526
  this.runAttachmentService = new RunAttachmentService((session) => this.runners.require(session.runner).nativeImageRoots(session), (sessionId) => this.runtime.activeSessionRun(sessionId) !== undefined ||
521
- this.runtime.sessionQueue(sessionId).itemIds.length > 0);
527
+ this.runtime.sessionQueue(sessionId).itemIds.length > 0, () => this.attachmentTemporaryRoot());
522
528
  this.codexClient = new CodexRunner(options.codexClient, options.codexProfileClientFactory, options.codexRunClientFactory);
523
529
  this.codexRunController = new CodexManagedRunController(this.codexClient, {
524
530
  available: () => this.capabilities.codex.available,
@@ -615,7 +621,15 @@ export class NodeConnector {
615
621
  intercepted: () => this.fakeRunner !== undefined,
616
622
  active: this.runState.active,
617
623
  parseAttachments: parseRunnerImageAttachments,
618
- persistAttachments: (cwd, runId, attachments) => this.runAttachmentService.persistFiles(cwd, `${runId}-opencode-images`, attachments),
624
+ persistAttachments: async (_cwd, runId, attachments) => {
625
+ const run = this.runtime.getRun(runId);
626
+ if (run === undefined)
627
+ throw new Error('RUN_NOT_FOUND');
628
+ const storageRunId = `${runId}-opencode-images`;
629
+ const paths = await this.runAttachmentService.persistFiles(run.workspaceId, storageRunId, attachments);
630
+ this.runAttachmentService.retainSessionFiles(run.sessionId, storageRunId);
631
+ return paths;
632
+ },
619
633
  emit: (runId, eventType, payload, status) => this.runEventBridge.emitRun(runId, eventType, payload, status),
620
634
  emitItem: (runId, item) => this.emitConversationItem(runId, item),
621
635
  adopt: (input) => this.runtime.adoptRun(input),
@@ -1684,7 +1698,7 @@ export class NodeConnector {
1684
1698
  nodeGeneration: () => this.nodeGeneration,
1685
1699
  send: (type, payload) => this.send(type, payload),
1686
1700
  terminalManager: this.terminalManager,
1687
- sendTerminalReady: (directSessionId) => this.send('terminal.direct.ready', { directSessionId }),
1701
+ sendTerminalReady: (directSessionId, phase) => this.send('terminal.direct.ready', { directSessionId, ...(phase ? { phase } : {}) }),
1688
1702
  udpPortRange: () => directTransferUdpPortRange(this.config),
1689
1703
  openUpload: async (descriptor) => {
1690
1704
  if (descriptor.direction !== 'UPLOAD' || descriptor.resource.kind !== 'WORKSPACE_FILE')
package/dist/main.js CHANGED
@@ -23,11 +23,6 @@ if (command === 'run') {
23
23
  for (const signal of ['SIGINT', 'SIGTERM']) {
24
24
  process.once(signal, shutdown);
25
25
  }
26
- if (args.includes('--managed-parent')) {
27
- if (process.send === undefined || !process.connected)
28
- throw new Error('NODE_MANAGED_PARENT_UNAVAILABLE');
29
- process.once('disconnect', shutdown);
30
- }
31
26
  await connector.start();
32
27
  }
33
28
  else if (command === 'supervise') {
@@ -1,3 +1,4 @@
1
+ import { parseComposerTags } from '@myagentroam/protocol';
1
2
  import { nodeExecutionEnvironment } from '../../node-execution-environment.js';
2
3
  import { parseSecretEnvironment } from '../../util/secret-environment.js';
3
4
  import { runnerErrorProjection } from '../../util/runner-error.js';
@@ -264,9 +265,18 @@ export class OpenCodeManagedRunController {
264
265
  this.host.emit(input.runId, 'run.running', {}, 'RUNNING');
265
266
  // The initial SSE snapshot can contain an idle event from before this prompt.
266
267
  this.pendingIdle.delete(input.runId);
268
+ // Replacement inputs can retain earlier tags; the latest tag describes the current images.
269
+ const currentAttachments = parseComposerTags(input.input)
270
+ .filter((part) => part.kind === 'mar_attachments')
271
+ .at(-1);
272
+ const existingImagePaths = currentAttachments?.payload.files
273
+ .filter((file) => input.attachments.some((image) => image.name === file.name && image.mime === file.mime))
274
+ .map((file) => file.path) ?? [];
267
275
  const attachmentPaths = input.attachments.length === 0
268
276
  ? []
269
- : await this.host.persistAttachments(input.cwd, input.runId, input.attachments);
277
+ : existingImagePaths.length === input.attachments.length
278
+ ? existingImagePaths
279
+ : await this.host.persistAttachments(input.cwd, input.runId, input.attachments);
270
280
  const execution = this.runner.execution.get(input.runId);
271
281
  if (execution !== undefined) {
272
282
  execution.responseLatency = new ResponseLatencyTracker();
@@ -660,7 +670,7 @@ function openCodeImageInput(input, attachmentPaths) {
660
670
  if (attachmentPaths.length === 0)
661
671
  return input;
662
672
  const paths = attachmentPaths.map((path) => `- ${JSON.stringify(path)}`).join('\n');
663
- return `${input}\n\nIf an inline image in this message is unavailable, use the read tool on the corresponding copy below. Do not read it again when the inline image is already visible:\n${paths}`;
673
+ return `${input}\n\nIf an inline image in this message is unavailable, use the read tool on the corresponding file below. Do not read it again when the inline image is already visible:\n${paths}`;
664
674
  }
665
675
  function hasMcpConfiguration(value) {
666
676
  return value !== null && typeof value === 'object' && Object.keys(value).length > 0;
@@ -47,7 +47,7 @@ export class DirectTransferService {
47
47
  this.leaseGraceMs = options.leaseGraceMs ?? LEASE_GRACE_MS;
48
48
  this.leaseCheckMs = options.leaseCheckMs ?? LEASE_CHECK_MS;
49
49
  if (options.terminalManager !== undefined)
50
- this.terminalDirect = new TerminalDirectService(options.terminalManager, (id) => options.sendTerminalReady?.(id), (command, peer) => {
50
+ this.terminalDirect = new TerminalDirectService(options.terminalManager, (id, phase) => options.sendTerminalReady?.(id, phase), (command, peer) => {
51
51
  const transport = this.transports.get(command.transportSessionId);
52
52
  return (transport?.peer === peer &&
53
53
  transport.command.workbenchConnectionId === command.workbenchConnectionId &&
@@ -1,4 +1,4 @@
1
- import { createHash } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
4
4
  import { lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
@@ -11,11 +11,16 @@ const SESSION_ATTACHMENT_IDLE_TTL_MS = 30 * 60_000;
11
11
  export class RunAttachmentService {
12
12
  nativeImageRoots;
13
13
  sessionIsBusy;
14
+ temporaryRoot;
14
15
  state = new AttachmentDomainState();
15
16
  disposed = false;
16
- constructor(nativeImageRoots, sessionIsBusy = () => false) {
17
+ fileResources = new Map();
18
+ constructor(nativeImageRoots, sessionIsBusy = () => false, temporaryRoot = () => {
19
+ throw new Error('NODE_CONFIG_UNAVAILABLE');
20
+ }) {
17
21
  this.nativeImageRoots = nativeImageRoots;
18
22
  this.sessionIsBusy = sessionIsBusy;
23
+ this.temporaryRoot = temporaryRoot;
19
24
  }
20
25
  protectSession(sessionId) {
21
26
  this.assertOpen();
@@ -51,6 +56,25 @@ export class RunAttachmentService {
51
56
  this.state.runFileSessions.set(runId, sessionId);
52
57
  this.refreshSession(sessionId);
53
58
  }
59
+ adoptSessionFiles(sessionId, runId, directories, workspaceId, paths) {
60
+ this.assertOpen();
61
+ if (directories.length === 0)
62
+ return;
63
+ this.state.runFileDirectories.set(runId, [...directories]);
64
+ this.registerFileResources(workspaceId, paths);
65
+ this.retainSessionFiles(sessionId, runId);
66
+ }
67
+ resourcePath(path) {
68
+ return `mar-attachment://${basename(dirname(path))}/${basename(path)}`;
69
+ }
70
+ fileResource(workspaceId, resourcePath) {
71
+ const resource = this.fileResources.get(resourcePath);
72
+ return resource?.workspaceId === workspaceId ? resource.path : undefined;
73
+ }
74
+ registerFileResources(workspaceId, paths) {
75
+ for (const path of paths)
76
+ this.fileResources.set(this.resourcePath(path), { workspaceId, path });
77
+ }
54
78
  refreshSession(sessionId) {
55
79
  if (this.disposed)
56
80
  return;
@@ -106,16 +130,20 @@ export class RunAttachmentService {
106
130
  this.refreshSession(nextId);
107
131
  }
108
132
  async discardFiles(runId) {
109
- const directory = this.state.runFileDirectories.get(runId);
133
+ const directories = this.state.runFileDirectories.get(runId);
110
134
  this.state.runFileDirectories.delete(runId);
135
+ if (directories !== undefined)
136
+ for (const [resourcePath, resource] of this.fileResources)
137
+ if (directories.includes(dirname(resource.path)))
138
+ this.fileResources.delete(resourcePath);
111
139
  const sessionId = this.state.runFileSessions.get(runId);
112
140
  this.state.runFileSessions.delete(runId);
113
141
  if (sessionId !== undefined) {
114
142
  this.state.sessionRetentions.get(sessionId)?.runs.delete(runId);
115
143
  this.refreshSession(sessionId);
116
144
  }
117
- if (directory !== undefined)
118
- await rm(directory, { recursive: true, force: true }).catch(() => undefined);
145
+ if (directories !== undefined)
146
+ await Promise.all(directories.map((directory) => rm(directory, { recursive: true, force: true }).catch(() => undefined)));
119
147
  }
120
148
  async releaseSession(sessionId) {
121
149
  const retained = this.state.sessionRetentions.get(sessionId);
@@ -173,32 +201,48 @@ export class RunAttachmentService {
173
201
  throw error;
174
202
  }
175
203
  }
176
- async persistFiles(workspacePath, runId, attachments) {
204
+ async persistFiles(workspaceId, runId, attachments) {
177
205
  if (attachments.length === 0)
178
206
  return [];
179
207
  this.assertOpen();
180
- const marDirectory = resolve(workspacePath, '.mar');
181
- await mkdir(marDirectory, { recursive: true, mode: 0o700 });
182
- const marMetadata = await lstat(marDirectory);
183
- if (!marMetadata.isDirectory() || marMetadata.isSymbolicLink())
184
- throw new Error('MESSAGE_ATTACHMENT_DIRECTORY_INVALID');
185
- const filesDirectory = join(marDirectory, 'temp-files');
208
+ const filesDirectory = this.temporaryRoot();
186
209
  await mkdir(filesDirectory, { recursive: true, mode: 0o700 });
187
210
  const filesMetadata = await lstat(filesDirectory);
188
211
  if (!filesMetadata.isDirectory() || filesMetadata.isSymbolicLink())
189
212
  throw new Error('MESSAGE_ATTACHMENT_DIRECTORY_INVALID');
190
- const runDirectory = join(filesDirectory, runId);
191
- await mkdir(runDirectory, { mode: 0o700 });
192
- this.state.runFileDirectories.set(runId, runDirectory);
213
+ const createdDirectories = [];
193
214
  try {
215
+ const paths = [];
216
+ for (const attachment of attachments) {
217
+ this.assertOpen();
218
+ const directory = join(filesDirectory, randomUUID());
219
+ await mkdir(directory, { mode: 0o700 });
220
+ createdDirectories.push(directory);
221
+ this.state.runFileDirectories.set(runId, [
222
+ ...(this.state.runFileDirectories.get(runId) ?? []),
223
+ directory
224
+ ]);
225
+ const path = join(directory, attachment.name);
226
+ await writeFile(path, Buffer.from(attachment.dataBase64, 'base64'), {
227
+ flag: 'wx',
228
+ mode: 0o600
229
+ });
230
+ paths.push(path);
231
+ }
194
232
  this.assertOpen();
195
- await Promise.all(attachments.map((attachment) => writeFile(join(runDirectory, attachment.name), Buffer.from(attachment.dataBase64, 'base64'), { flag: 'wx', mode: 0o600 })));
196
- this.assertOpen();
197
- return attachments.map((attachment) => `.mar/temp-files/${runId}/${attachment.name}`);
233
+ this.registerFileResources(workspaceId, paths);
234
+ return paths;
198
235
  }
199
236
  catch (error) {
200
- this.state.runFileDirectories.delete(runId);
201
- await rm(runDirectory, { recursive: true, force: true });
237
+ const directories = this.state.runFileDirectories.get(runId);
238
+ if (directories !== undefined) {
239
+ const retained = directories.filter((directory) => !createdDirectories.includes(directory));
240
+ if (retained.length === 0)
241
+ this.state.runFileDirectories.delete(runId);
242
+ else
243
+ this.state.runFileDirectories.set(runId, retained);
244
+ }
245
+ await Promise.all(createdDirectories.map((directory) => rm(directory, { recursive: true, force: true })));
202
246
  throw error;
203
247
  }
204
248
  }
@@ -5,7 +5,7 @@ import { personalInstructionsForRunner } from '../util/personal-instructions.js'
5
5
  import { safeErrorCode } from '../util/safe-error.js';
6
6
  import { publicGoalStartText } from './goal-runtime.js';
7
7
  import { serializeComposerTag, composerVisibleText } from '@myagentroam/protocol';
8
- import { relative, resolve } from 'node:path';
8
+ import { relative } from 'node:path';
9
9
  import { prepareComposerResourceInput, composerResourceInputTags, validateComposerSkills } from './composer-message-input.js';
10
10
  const INTERNAL_GOAL = Symbol('internalGoal');
11
11
  const INTERNAL_SCHEDULE = Symbol('internalSchedule');
@@ -148,24 +148,29 @@ export class SessionMessageService {
148
148
  if (created.queueItem === undefined)
149
149
  this.options.coordinator.publishCurrentTurn(created.run.id);
150
150
  const prepare = async () => {
151
+ let reservation;
151
152
  try {
152
153
  const currentSession = this.options.runtime.getAgentSession(session.id) ?? session;
153
- const uploaded = await this.options.uploads.resolve(session.workspaceId, attachmentIds);
154
- const attachments = attachmentIds.length === 0
154
+ if (attachmentIds.length > 0)
155
+ reservation = await this.options.uploads.reserve(session.workspaceId, attachmentIds);
156
+ const attachments = reservation === undefined
155
157
  ? inlineAttachments
156
- : uploaded.map((attachment) => attachment.attachment);
158
+ : reservation.attachments.map((attachment) => attachment.attachment);
157
159
  const imageAttachments = attachments.filter(isImageAttachment);
158
160
  const runnerImageAttachments = attachments.filter(isRunnerImageAttachment);
159
161
  const workspacePath = this.options.workspacePath?.(currentSession.workspaceId) ?? currentSession.cwd;
160
162
  await validateComposerSkills(runnerContent, workspacePath);
161
- const attachmentPaths = await this.persistAttachments(currentSession, submissionId, attachments);
163
+ const attachmentPaths = reservation === undefined
164
+ ? await this.persistAttachments(currentSession, submissionId, attachments)
165
+ : reservation.attachments.map((attachment) => attachment.path);
162
166
  const attachmentFiles = attachments.map((attachment, index) => ({
163
167
  name: attachment.name,
164
168
  mime: attachment.mime,
165
- path: attachmentPaths[index]
169
+ path: reservation?.attachments[index]?.resourcePath ??
170
+ this.options.attachments.resourcePath(attachmentPaths[index])
166
171
  }));
167
- const runnerFiles = attachmentFiles.map((file) => {
168
- const path = relative(currentSession.cwd, resolve(workspacePath, file.path)).replaceAll('\\', '/');
172
+ const runnerFiles = attachmentFiles.map((file, index) => {
173
+ const path = relative(currentSession.cwd, attachmentPaths[index]).replaceAll('\\', '/');
169
174
  return path === file.path ? file : { ...file, path, workspacePath: file.path };
170
175
  });
171
176
  const displayInput = withNonImageAttachmentPrompt(publicContent, attachmentPaths, attachmentFiles);
@@ -228,11 +233,12 @@ export class SessionMessageService {
228
233
  queueBefore: queueBeforeCreate,
229
234
  ...(replaceCurrentRun === undefined ? {} : { activeRunId: replaceCurrentRun.id })
230
235
  });
236
+ reservation?.commit((directories) => this.options.attachments.adoptSessionFiles(currentSession.id, submissionId, directories, currentSession.workspaceId, attachmentPaths));
231
237
  this.options.attachments.acceptSessionInput(currentSession.id);
232
- void this.options.uploads.consume(attachmentIds).catch(() => undefined);
233
238
  return delivery;
234
239
  }
235
240
  catch (error) {
241
+ reservation?.release();
236
242
  if (goalMessage !== undefined)
237
243
  this.options.goalPreparationFailed(session, goalMessage.goal, goalMessage.activated);
238
244
  this.options.coordinator.failPreparation(submissionId, safeErrorCode(error, 'MESSAGE_PREPARATION_FAILED'));
@@ -263,7 +269,7 @@ export class SessionMessageService {
263
269
  }
264
270
  }
265
271
  async persistAttachments(session, runId, attachments) {
266
- return this.options.attachments.persistFiles(this.options.workspacePath?.(session.workspaceId) ?? session.cwd, runId, attachments);
272
+ return this.options.attachments.persistFiles(session.workspaceId, runId, attachments);
267
273
  }
268
274
  async channelMessage(input) {
269
275
  if (typeof input.sessionId !== 'string' ||
@@ -114,6 +114,9 @@ export class TerminalDirectService {
114
114
  this.sendFrame(session, frame);
115
115
  channel.onmessage = (event) => this.accept(session, event.data);
116
116
  this.manager.startCommand(session.command.terminalId);
117
+ void Promise.resolve()
118
+ .then(() => this.sendReady(session.command.directSessionId, 'ATTACHED'))
119
+ .catch(() => console.warn('AUDIT_RECEIPT_UNAVAILABLE'));
117
120
  }
118
121
  catch (error) {
119
122
  this.remove(session, error instanceof Error && error.message === 'TERMINAL_ATTACH_CONFLICT'
@@ -1,5 +1,6 @@
1
- import { isAbsolute, relative, resolve, win32 } from 'node:path';
2
- import { realpath } from 'node:fs/promises';
1
+ import { basename, dirname, isAbsolute, relative, resolve, win32 } from 'node:path';
2
+ import { lstat, realpath } from 'node:fs/promises';
3
+ import { isComposerAttachmentResourcePath } from '@myagentroam/protocol';
3
4
  import { listWorkspaceFiles, mutateWorkspaceFile, normalizeFilePreviewPath, readAbsoluteFileContentRange, readWorkspaceFileContentRange, readAbsoluteTextFile, readWorkspaceTextFile, writeWorkspaceTextFile, WorkspaceFileIndex } from '../workspace.js';
4
5
  const INDEX_CACHE_LIMIT = 32;
5
6
  const INDEX_CACHE_BYTE_LIMIT = 128 * 1024 * 1024;
@@ -9,13 +10,15 @@ export class WorkspaceFileService {
9
10
  state;
10
11
  onInvalidated;
11
12
  indexLimits;
13
+ attachmentResource;
12
14
  mutationTails = new Map();
13
15
  indexQueue = [];
14
16
  indexRunning = false;
15
- constructor(state, onInvalidated, indexLimits = {}) {
17
+ constructor(state, onInvalidated, indexLimits = {}, attachmentResource = () => undefined) {
16
18
  this.state = state;
17
19
  this.onInvalidated = onInvalidated;
18
20
  this.indexLimits = indexLimits;
21
+ this.attachmentResource = attachmentResource;
19
22
  }
20
23
  async list(workspace, input) {
21
24
  if ((input.path !== undefined && typeof input.path !== 'string') ||
@@ -38,6 +41,13 @@ export class WorkspaceFileService {
38
41
  throw new Error('FILE_RANGE_INVALID');
39
42
  const offset = typeof input.offset === 'number' ? input.offset : 0;
40
43
  const limit = typeof input.limit === 'number' ? input.limit : 512 * 1024;
44
+ if (input.path.startsWith('mar-attachment:')) {
45
+ const target = await this.resolveAttachmentResource(workspace.id, input.path);
46
+ return {
47
+ ...(await readWorkspaceTextFile(dirname(target), basename(target), offset, limit)),
48
+ path: input.path
49
+ };
50
+ }
41
51
  const path = normalizeFilePreviewPath(input.path);
42
52
  if (workspacePath !== workspace.path && isFilePreviewAbsolutePath(path))
43
53
  throw new Error('FILE_PATH_ESCAPE');
@@ -54,6 +64,15 @@ export class WorkspaceFileService {
54
64
  input.limit < 1 ||
55
65
  input.limit > 512 * 1024)))
56
66
  throw new Error('FILE_RANGE_INVALID');
67
+ if (input.path.startsWith('mar-attachment:')) {
68
+ if (input.previewDirectory !== undefined)
69
+ throw new Error('FILE_PATH_ESCAPE');
70
+ const target = await this.resolveAttachmentResource(workspace.id, input.path);
71
+ return {
72
+ ...(await readWorkspaceFileContentRange(dirname(target), basename(target), typeof input.offset === 'number' ? input.offset : 0, typeof input.limit === 'number' ? input.limit : 512 * 1024)),
73
+ path: input.path
74
+ };
75
+ }
57
76
  const path = normalizeFilePreviewPath(input.path);
58
77
  if (workspacePath !== workspace.path && isFilePreviewAbsolutePath(path))
59
78
  throw new Error('FILE_PATH_ESCAPE');
@@ -76,6 +95,23 @@ export class WorkspaceFileService {
76
95
  ? readAbsoluteFileContentRange(path, offset, limit)
77
96
  : readWorkspaceFileContentRange(workspacePath, path, offset, limit);
78
97
  }
98
+ async resolveAttachmentResource(workspaceId, path) {
99
+ if (!isComposerAttachmentResourcePath(path))
100
+ throw new Error('FILE_PATH_ESCAPE');
101
+ const target = this.attachmentResource(workspaceId, path);
102
+ if (target === undefined)
103
+ throw new Error('FILE_NOT_FOUND');
104
+ const [directory, file] = await Promise.all([lstat(dirname(target)), lstat(target)]).catch((error) => {
105
+ if (error.code === 'ENOENT')
106
+ throw new Error('FILE_NOT_FOUND');
107
+ throw error;
108
+ });
109
+ if (directory.isSymbolicLink() || file.isSymbolicLink())
110
+ throw new Error('FILE_PATH_ESCAPE');
111
+ if (!directory.isDirectory() || !file.isFile())
112
+ throw new Error('FILE_NOT_REGULAR');
113
+ return target;
114
+ }
79
115
  readDirect(workspace, input, workspacePath = workspace.path) {
80
116
  const readContent = this.readContent.bind(this);
81
117
  return (async function* () {
@@ -147,6 +183,8 @@ export class WorkspaceFileService {
147
183
  const path = input.path;
148
184
  const content = input.content;
149
185
  const expectedRevision = input.expectedRevision;
186
+ if (path.startsWith('mar-attachment:'))
187
+ throw new Error('FILE_PATH_ESCAPE');
150
188
  return this.serializeMutation(workspace.id, async () => {
151
189
  const result = await writeWorkspaceTextFile(workspace.path, path, content, expectedRevision);
152
190
  this.dispose(workspace.id);
@@ -177,6 +215,8 @@ export class WorkspaceFileService {
177
215
  throw new Error('FILE_MUTATION_INVALID');
178
216
  const path = input.path;
179
217
  const destination = typeof input.destination === 'string' ? input.destination : undefined;
218
+ if (path.startsWith('mar-attachment:') || destination?.startsWith('mar-attachment:'))
219
+ throw new Error('FILE_PATH_ESCAPE');
180
220
  return this.serializeMutation(workspace.id, async () => {
181
221
  const result = await mutateWorkspaceFile(workspace.path, action === 'MOVE' ? { action, path, destination: destination } : { action, path });
182
222
  this.dispose(workspace.id);
@@ -9,11 +9,10 @@ const BLOCK_END = '# END MyAgentRoam';
9
9
  const LEGACY_BLOCK_BEGIN = '# BEGIN MyAgentRoam Skills';
10
10
  const LEGACY_BLOCK_END = '# END MyAgentRoam Skills';
11
11
  export async function ensureWorkspaceGitExclude(workspace) {
12
- await mutateGitExclude(workspace, (entries) => entries.add(MAR_TEMP_DIRECTORY));
12
+ await mutateGitExclude(workspace, () => undefined);
13
13
  }
14
14
  export async function updateWorkspaceSkillGitExclude(workspace, name, add) {
15
15
  await mutateGitExclude(workspace, (entries) => {
16
- entries.add(MAR_TEMP_DIRECTORY);
17
16
  for (const root of ['.agents', '.claude']) {
18
17
  const line = `/${root}/skills/${name}/`;
19
18
  if (add)
@@ -26,7 +25,6 @@ export async function updateWorkspaceSkillGitExclude(workspace, name, add) {
26
25
  export async function rewriteWorkspaceGitExclude(workspace, names) {
27
26
  await mutateGitExclude(workspace, (entries) => {
28
27
  entries.clear();
29
- entries.add(MAR_TEMP_DIRECTORY);
30
28
  for (const name of names)
31
29
  for (const root of ['.agents', '.claude'])
32
30
  entries.add(`/${root}/skills/${name}/`);
@@ -65,9 +63,13 @@ async function mutateGitExclude(workspace, mutate) {
65
63
  const pattern = managedBlockPattern();
66
64
  const existing = [...content.matchAll(pattern)].map((match) => match[0]).join('\n');
67
65
  const entries = new Set(existing.split(/\r?\n/u).filter((line) => line.startsWith('/.')));
66
+ entries.delete(MAR_TEMP_DIRECTORY);
68
67
  mutate(entries);
69
- const block = `${BLOCK_BEGIN}\n${[...entries].sort().join('\n')}\n${BLOCK_END}\n`;
70
- const next = content.replace(pattern, '').replace(/\s*$/u, '\n') + block;
68
+ const block = entries.size === 0 ? '' : `${BLOCK_BEGIN}\n${[...entries].sort().join('\n')}\n${BLOCK_END}\n`;
69
+ const unmanaged = content.replace(pattern, '');
70
+ const next = block === '' ? unmanaged : unmanaged.replace(/\s*$/u, '\n') + block;
71
+ if (next === content)
72
+ return;
71
73
  const staging = path.join(path.dirname(exclude), `.mar-git-exclude-${randomUUID()}.tmp`);
72
74
  try {
73
75
  await writeFile(staging, next, { encoding: 'utf8', flag: 'wx' });
@@ -3,16 +3,17 @@ import { lstat, mkdir, open, readFile, rename, rm } from 'node:fs/promises';
3
3
  import { dirname, join, posix, resolve } from 'node:path';
4
4
  import { parseComposerAttachmentUpload, validComposerAttachmentName } from '../util/node-operation-parsers.js';
5
5
  import { preflightWorkspaceUpload, workspaceUploadTemporaryName } from '../workspace.js';
6
- import { ensureWorkspaceGitExclude } from './workspace-git-exclude-service.js';
7
6
  const CHUNK_BYTES = 512 * 1024;
8
7
  const TTL_MS = 60 * 60_000;
9
8
  const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
10
9
  export class WorkspaceUploadService {
11
10
  state;
12
11
  onCompleted;
13
- constructor(state, onCompleted) {
12
+ temporaryRoot;
13
+ constructor(state, onCompleted, temporaryRoot) {
14
14
  this.state = state;
15
15
  this.onCompleted = onCompleted;
16
+ this.temporaryRoot = temporaryRoot;
16
17
  }
17
18
  async preflight(workspace, input) {
18
19
  if ((input.directory !== undefined && typeof input.directory !== 'string') ||
@@ -47,7 +48,7 @@ export class WorkspaceUploadService {
47
48
  throw new Error('COMPOSER_ATTACHMENT_INVALID');
48
49
  const target = attachment === undefined
49
50
  ? await preflightWorkspaceUpload(workspace.path, typeof input.directory === 'string' ? input.directory : '.', input.name)
50
- : await this.attachmentTarget(workspace.path, attachment.id, input.name);
51
+ : await this.attachmentTarget(attachment.id, input.name);
51
52
  if (target.status === 'INVALID_NAME')
52
53
  throw new Error('UPLOAD_NAME_INVALID');
53
54
  if (target.status === 'EXISTS_DIRECTORY')
@@ -164,14 +165,7 @@ export class WorkspaceUploadService {
164
165
  this.state.uploads.delete(upload.id);
165
166
  this.onCompleted(upload.workspaceId);
166
167
  if (upload.composerAttachment !== undefined) {
167
- const attachmentId = upload.composerAttachment.id;
168
- const timer = setTimeout(() => {
169
- const attachment = this.state.composerAttachments.get(attachmentId);
170
- if (attachment !== undefined)
171
- void this.removeCompleted(attachment);
172
- }, TTL_MS);
173
- timer.unref();
174
- this.state.composerAttachments.set(attachmentId, {
168
+ const attachment = {
175
169
  id: upload.composerAttachment.id,
176
170
  workspaceId: upload.workspaceId,
177
171
  path: upload.path,
@@ -179,8 +173,12 @@ export class WorkspaceUploadService {
179
173
  name: upload.composerAttachment.name,
180
174
  mime: upload.composerAttachment.mime,
181
175
  size: upload.size,
182
- timer
183
- });
176
+ expiresAt: Date.now() + TTL_MS,
177
+ reserved: false,
178
+ timer: undefined
179
+ };
180
+ this.state.composerAttachments.set(attachment.id, attachment);
181
+ this.scheduleAttachmentExpiry(attachment);
184
182
  }
185
183
  return {
186
184
  uploadId: upload.id,
@@ -198,36 +196,77 @@ export class WorkspaceUploadService {
198
196
  const attachment = this.state.composerAttachments.get(uploadId);
199
197
  if (attachment === undefined)
200
198
  throw new Error('UPLOAD_NOT_FOUND');
199
+ if (attachment.reserved)
200
+ throw new Error('COMPOSER_ATTACHMENT_UNAVAILABLE');
201
201
  await this.removeCompleted(attachment);
202
202
  }
203
- async resolve(workspaceId, ids) {
204
- return Promise.all(ids.map(async (id) => {
203
+ async reserve(workspaceId, ids) {
204
+ if (new Set(ids).size !== ids.length)
205
+ throw new Error('COMPOSER_ATTACHMENT_UNAVAILABLE');
206
+ const storedAttachments = ids.map((id) => {
205
207
  const stored = this.state.composerAttachments.get(id);
206
- if (stored === undefined || stored.workspaceId !== workspaceId)
208
+ if (stored === undefined || stored.workspaceId !== workspaceId || stored.reserved)
207
209
  throw new Error('COMPOSER_ATTACHMENT_UNAVAILABLE');
208
- const metadata = await lstat(stored.targetPath).catch(() => undefined);
209
- if (metadata === undefined ||
210
- metadata.isSymbolicLink() ||
211
- !metadata.isFile() ||
212
- metadata.size !== stored.size)
210
+ return stored;
211
+ });
212
+ for (const stored of storedAttachments) {
213
+ stored.reserved = true;
214
+ clearTimeout(stored.timer);
215
+ stored.timer = undefined;
216
+ }
217
+ let active = true;
218
+ const assertReserved = () => {
219
+ if (!active ||
220
+ storedAttachments.some((stored) => this.state.composerAttachments.get(stored.id) !== stored))
213
221
  throw new Error('COMPOSER_ATTACHMENT_UNAVAILABLE');
214
- const bytes = await readFile(stored.targetPath);
222
+ };
223
+ const release = () => {
224
+ if (!active)
225
+ return;
226
+ active = false;
227
+ for (const stored of storedAttachments) {
228
+ if (this.state.composerAttachments.get(stored.id) !== stored)
229
+ continue;
230
+ stored.reserved = false;
231
+ this.scheduleAttachmentExpiry(stored);
232
+ }
233
+ };
234
+ try {
235
+ const attachments = await Promise.all(storedAttachments.map(async (stored) => {
236
+ const metadata = await lstat(stored.targetPath).catch(() => undefined);
237
+ if (metadata === undefined ||
238
+ metadata.isSymbolicLink() ||
239
+ !metadata.isFile() ||
240
+ metadata.size !== stored.size)
241
+ throw new Error('COMPOSER_ATTACHMENT_UNAVAILABLE');
242
+ const bytes = await readFile(stored.targetPath);
243
+ return {
244
+ attachment: {
245
+ name: stored.name,
246
+ mime: stored.mime,
247
+ dataBase64: bytes.toString('base64')
248
+ },
249
+ path: stored.targetPath,
250
+ resourcePath: stored.path
251
+ };
252
+ }));
253
+ assertReserved();
215
254
  return {
216
- attachment: {
217
- name: stored.name,
218
- mime: stored.mime,
219
- dataBase64: bytes.toString('base64')
255
+ attachments,
256
+ commit: (retain) => {
257
+ assertReserved();
258
+ retain(storedAttachments.map((stored) => dirname(stored.targetPath)));
259
+ for (const stored of storedAttachments)
260
+ this.state.composerAttachments.delete(stored.id);
261
+ active = false;
220
262
  },
221
- path: stored.path
263
+ release
222
264
  };
223
- }));
224
- }
225
- async consume(ids) {
226
- await Promise.all(ids.map(async (id) => {
227
- const attachment = this.state.composerAttachments.get(id);
228
- if (attachment !== undefined)
229
- await this.removeCompleted(attachment);
230
- }));
265
+ }
266
+ catch (error) {
267
+ release();
268
+ throw error;
269
+ }
231
270
  }
232
271
  async shutdown() {
233
272
  await Promise.allSettled([...this.state.uploads.values()].map((upload) => this.remove(upload)));
@@ -235,6 +274,12 @@ export class WorkspaceUploadService {
235
274
  await this.removeCompleted(attachment);
236
275
  this.state.composerAttachments.clear();
237
276
  }
277
+ fileResource(workspaceId, resourcePath) {
278
+ for (const attachment of this.state.composerAttachments.values())
279
+ if (attachment.workspaceId === workspaceId && attachment.path === resourcePath)
280
+ return attachment.targetPath;
281
+ return undefined;
282
+ }
238
283
  require(id) {
239
284
  if (typeof id !== 'string')
240
285
  throw new Error('UPLOAD_NOT_FOUND');
@@ -258,12 +303,15 @@ export class WorkspaceUploadService {
258
303
  clearTimeout(attachment.timer);
259
304
  await rm(dirname(attachment.targetPath), { recursive: true, force: true }).catch(() => undefined);
260
305
  }
261
- async attachmentTarget(workspacePath, id, name) {
262
- await ensureWorkspaceGitExclude(workspacePath);
263
- const mar = resolve(workspacePath, '.mar');
264
- await mkdir(mar, { recursive: true, mode: 0o700 });
265
- await safeDirectory(mar);
266
- const temp = join(mar, 'temp-files');
306
+ scheduleAttachmentExpiry(attachment) {
307
+ attachment.timer = setTimeout(() => {
308
+ if (!attachment.reserved)
309
+ void this.removeCompleted(attachment);
310
+ }, Math.max(0, attachment.expiresAt - Date.now()));
311
+ attachment.timer.unref();
312
+ }
313
+ async attachmentTarget(id, name) {
314
+ const temp = this.temporaryRoot();
267
315
  await mkdir(temp, { recursive: true, mode: 0o700 });
268
316
  await safeDirectory(temp);
269
317
  const directory = join(temp, id);
@@ -277,7 +325,7 @@ export class WorkspaceUploadService {
277
325
  }
278
326
  await safeDirectory(directory);
279
327
  return {
280
- path: `.mar/temp-files/${id}/${name}`,
328
+ path: `mar-attachment://${id}/${name}`,
281
329
  absolutePath: join(directory, name),
282
330
  status: 'READY'
283
331
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.10.2",
3
+ "version": "0.10.4",
4
4
  "description": "MyAgentRoam Node runtime CLI.",
5
5
  "license": "Apache-2.0 AND GPL-2.0-or-later",
6
6
  "type": "module",
@@ -37,10 +37,10 @@
37
37
  "playwright": "1.62.1",
38
38
  "ws": "^8.21.3",
39
39
  "zod": "4.4.3",
40
- "@myagentroam/agent": "0.10.2",
40
+ "@myagentroam/agent": "0.10.4",
41
41
  "@myagentroam/code-navigation": "0.10.1",
42
42
  "@myagentroam/marctl": "0.10.1",
43
- "@myagentroam/protocol": "0.10.2"
43
+ "@myagentroam/protocol": "0.10.4"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/ws": "^8.18.1"