@myagentroam/node 0.9.4 → 0.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/claude-agent-sdk.d.ts +1 -0
  2. package/dist/claude-agent-sdk.js +9 -0
  3. package/dist/codex-app-server.d.ts +1 -0
  4. package/dist/codex-app-server.js +4 -2
  5. package/dist/connector.d.ts +1 -0
  6. package/dist/connector.js +16 -11
  7. package/dist/native-session-history.d.ts +3 -2
  8. package/dist/native-session-history.js +199 -30
  9. package/dist/opencode-server.d.ts +1 -0
  10. package/dist/opencode-server.js +1 -0
  11. package/dist/runner/abstract-runner.d.ts +11 -2
  12. package/dist/runner/abstract-runner.js +4 -0
  13. package/dist/runner/claude/managed-run-controller.js +3 -0
  14. package/dist/runner/claude-code-runner.d.ts +2 -1
  15. package/dist/runner/claude-code-runner.js +4 -0
  16. package/dist/runner/codex/managed-run-controller.js +6 -0
  17. package/dist/runner/codex-runner.d.ts +2 -1
  18. package/dist/runner/codex-runner.js +11 -1
  19. package/dist/runner/opencode/managed-run-controller.js +4 -0
  20. package/dist/runner/opencode-runner.d.ts +10 -3
  21. package/dist/runner/opencode-runner.js +87 -14
  22. package/dist/runner/runner-registry.js +16 -1
  23. package/dist/runtime-command-detector.js +1 -6
  24. package/dist/runtime-state.js +1 -0
  25. package/dist/service/conversation-history-service.d.ts +5 -4
  26. package/dist/service/conversation-history-service.js +110 -52
  27. package/dist/service/external-session-resume-service.d.ts +2 -0
  28. package/dist/service/external-session-resume-service.js +45 -34
  29. package/dist/service/native-session-projection-service.d.ts +1 -0
  30. package/dist/service/native-session-projection-service.js +4 -0
  31. package/dist/service/native-session-watch-service.d.ts +18 -0
  32. package/dist/service/native-session-watch-service.js +232 -34
  33. package/dist/service/run-workbench-service.d.ts +2 -4
  34. package/dist/service/run-workbench-service.js +2 -7
  35. package/dist/service/session-command-service.js +1 -1
  36. package/dist/service/session-identity-service.d.ts +1 -1
  37. package/dist/service/session-identity-service.js +1 -1
  38. package/dist/service/session-lifecycle-service.js +3 -0
  39. package/dist/service/session-message-service.d.ts +0 -1
  40. package/dist/service/session-message-service.js +15 -23
  41. package/dist/service/skill-directory-service.d.ts +1 -0
  42. package/dist/service/skill-directory-service.js +40 -6
  43. package/dist/service/workspace-domain-state.d.ts +3 -0
  44. package/dist/service/workspace-domain-state.js +1 -0
  45. package/dist/service/workspace-file-service.d.ts +1 -0
  46. package/dist/service/workspace-file-service.js +14 -1
  47. package/dist/service/workspace-queue-workbench-service.d.ts +17 -0
  48. package/dist/service/workspace-queue-workbench-service.js +66 -6
  49. package/dist/service/workspace-watch-service.d.ts +7 -2
  50. package/dist/service/workspace-watch-service.js +32 -6
  51. package/dist/service/workspace-workbench-service.d.ts +14 -0
  52. package/dist/service/workspace-workbench-service.js +215 -16
  53. package/dist/util/personal-instructions.d.ts +2 -0
  54. package/dist/util/personal-instructions.js +31 -0
  55. package/dist/util/runner-native-session-parsers.d.ts +1 -0
  56. package/dist/util/runner-native-session-parsers.js +8 -1
  57. package/dist/workspace.d.ts +14 -8
  58. package/dist/workspace.js +65 -42
  59. package/package.json +2 -2
@@ -1,8 +1,10 @@
1
- import { listGitHistoryRefs, readGitHistory, readGitHistoryFileDiff, readGitHistoryFiles, readCurrentChangeDiff, readRestrictedDiff, resolveGitRepository, restoreCurrentChange } from '../workspace.js';
1
+ import { listGitHistoryRefs, readGitHistory, readGitHistoryFileDiff, readGitHistoryFileContentRange, readGitHistoryFiles, readCurrentChangeDiff, readRestrictedDiff, resolveGitRepository, restoreCurrentChange } from '../workspace.js';
2
2
  import { WorkspaceContentSearchService } from './workspace-content-search-service.js';
3
3
  export class WorkspaceWorkbenchService {
4
4
  options;
5
5
  contentSearch;
6
+ topicRefreshes = new Map();
7
+ dirtyTopics = new Set();
6
8
  constructor(options) {
7
9
  this.options = options;
8
10
  this.contentSearch = new WorkspaceContentSearchService(options.files);
@@ -13,6 +15,7 @@ export class WorkspaceWorkbenchService {
13
15
  'workspace.unwatch': (data) => this.unwatch(record(data)),
14
16
  'workspace.files.list': (data) => this.listFiles(record(data)),
15
17
  'workspace.file.read': (data) => this.readFile(record(data)),
18
+ 'workspace.file.content.read': (data) => this.readFileContent(record(data)),
16
19
  'workspace.files.search': (data) => this.searchFiles(record(data)),
17
20
  'workspace.files.mutate': (data) => this.mutateFile(record(data)),
18
21
  'workspace.content.search': (data) => this.searchContent(record(data)),
@@ -31,44 +34,220 @@ export class WorkspaceWorkbenchService {
31
34
  'workspace.git.history.list': (data) => this.gitHistory(record(data)),
32
35
  'workspace.git.commit.files': (data) => this.gitCommitFiles(record(data)),
33
36
  'workspace.git.commit.file.diff': (data) => this.gitCommitFileDiff(record(data)),
37
+ 'workspace.git.commit.file.content.read': (data) => this.gitCommitFileContent(record(data)),
34
38
  'workspace.diff': (data) => this.diff(record(data))
35
39
  };
36
40
  }
41
+ async refreshTopic(workspaceId, topic) {
42
+ const key = `${workspaceId}:${topic}`;
43
+ const active = this.topicRefreshes.get(key);
44
+ if (active !== undefined) {
45
+ this.dirtyTopics.add(key);
46
+ return active;
47
+ }
48
+ const refresh = this.performTopicRefresh(workspaceId, topic).catch(() => {
49
+ this.emitTopicFailure(workspaceId, topic);
50
+ });
51
+ this.topicRefreshes.set(key, refresh);
52
+ try {
53
+ await refresh;
54
+ }
55
+ finally {
56
+ if (this.topicRefreshes.get(key) === refresh)
57
+ this.topicRefreshes.delete(key);
58
+ if (this.dirtyTopics.delete(key))
59
+ void this.refreshTopic(workspaceId, topic);
60
+ }
61
+ }
62
+ emitTopicFailure(workspaceId, topic) {
63
+ for (const watch of this.options.watch.activeWatches(workspaceId)) {
64
+ const subscribed = topic === 'files' ? watch.topics.files !== undefined : watch.topics[topic] === true;
65
+ if (!subscribed)
66
+ continue;
67
+ this.options.emitWatchEvent({
68
+ type: 'watch.failed',
69
+ workbenchConnectionId: watch.workbenchConnectionId,
70
+ watchType: 'workspace',
71
+ watchId: watch.watchId,
72
+ nodeGeneration: watch.nodeGeneration,
73
+ nodeId: this.options.nodeId(),
74
+ workspaceId,
75
+ topic,
76
+ code: 'WORKSPACE_WATCH_SNAPSHOT_FAILED',
77
+ message: 'Workspace watch snapshot failed.'
78
+ });
79
+ }
80
+ }
81
+ async performTopicRefresh(workspaceId, topic) {
82
+ const workspace = this.options.requireWorkspace(workspaceId);
83
+ const watches = this.options.watch.activeWatches(workspaceId);
84
+ if (topic === 'sessions') {
85
+ const subscribers = watches.filter((watch) => watch.topics.sessions);
86
+ if (subscribers.length === 0)
87
+ return;
88
+ const payload = await this.options.listSessions(workspace.id);
89
+ for (const watch of subscribers)
90
+ this.deliverSnapshot(workspace, watch, topic, payload);
91
+ return;
92
+ }
93
+ if (topic === 'changes') {
94
+ const subscribers = watches.filter((watch) => watch.topics.changes);
95
+ if (subscribers.length === 0)
96
+ return;
97
+ const payload = workspace.kind === 'GIT_WORKSPACE' ? await this.options.changes.current(workspace) : null;
98
+ for (const watch of subscribers)
99
+ this.deliverSnapshot(workspace, watch, topic, payload);
100
+ return;
101
+ }
102
+ const views = new Map();
103
+ for (const watch of watches) {
104
+ if (watch.topics.files === undefined)
105
+ continue;
106
+ const key = JSON.stringify(watch.topics.files);
107
+ views.set(key, [...(views.get(key) ?? []), watch]);
108
+ }
109
+ await Promise.all([...views.values()].map(async (subscribers) => {
110
+ const view = subscribers[0]?.topics.files;
111
+ if (view === undefined)
112
+ return;
113
+ const payload = { files: await this.options.files.list(workspace, view) };
114
+ for (const watch of subscribers)
115
+ this.deliverSnapshot(workspace, watch, topic, payload);
116
+ }));
117
+ }
37
118
  async watch(input) {
38
119
  if (typeof input.workspaceId !== 'string' ||
39
120
  typeof input.watchId !== 'string' ||
121
+ typeof input.workbenchConnectionId !== 'string' ||
40
122
  !/^[A-Za-z0-9_-]{8,128}$/.test(input.watchId) ||
41
123
  (input.mode !== 'initial' && input.mode !== 'renew'))
42
124
  throw new Error('WORKSPACE_WATCH_INVALID');
43
125
  const workspace = this.options.requireWorkspace(input.workspaceId);
44
126
  if (input.mode === 'renew') {
45
- const watch = this.options.watch.renew(workspace.id, this.options.watch.key(input));
127
+ const watch = this.options.watch.renew(workspace.id, this.options.watch.key(input), input.watchId);
46
128
  if (watch === undefined)
47
129
  throw new Error('WORKSPACE_WATCH_NOT_FOUND');
48
- return { renewed: true };
130
+ return this.accepted(watch);
49
131
  }
50
132
  const topics = this.options.watch.parseTopics(input);
51
- const snapshot = {};
52
- if (topics.sessions)
53
- snapshot.sessions = await this.options.listSessions(workspace.id);
54
- if (topics.files !== undefined)
55
- snapshot.files = { files: await this.options.files.list(workspace, topics.files) };
56
- if (topics.changes)
57
- snapshot.changes =
58
- workspace.kind === 'GIT_WORKSPACE' ? await this.options.changes.current(workspace) : null;
59
- if (topics.files !== undefined || topics.changes)
60
- await this.options.files.index(workspace).observe();
61
- const watch = this.options.watch.create(workspace.id, input.watchId, this.options.watch.sessionHash(input), topics);
62
- return { watch: { expiresAt: watch.expiresAt }, snapshot };
133
+ const existing = this.options.watch.existing(workspace.id, this.options.watch.key(input));
134
+ if (existing?.watchId === input.watchId &&
135
+ JSON.stringify(existing.topics) === JSON.stringify(topics)) {
136
+ this.options.watch.renew(workspace.id, this.options.watch.key(input), input.watchId);
137
+ return this.accepted(existing);
138
+ }
139
+ const watch = this.options.watch.create(workspace.id, input.watchId, this.options.watch.sessionHash(input), input.workbenchConnectionId, this.options.nodeGeneration(), topics);
140
+ void this.createInitialSnapshots(workspace, watch);
141
+ return this.accepted(watch);
63
142
  }
64
143
  unwatch(input) {
65
144
  if (typeof input.workspaceId !== 'string' ||
66
145
  typeof input.watchId !== 'string' ||
146
+ typeof input.workbenchConnectionId !== 'string' ||
67
147
  !/^[A-Za-z0-9_-]{8,128}$/.test(input.watchId))
68
148
  throw new Error('WORKSPACE_WATCH_INVALID');
69
- this.options.watch.remove(input.workspaceId, this.options.watch.key(input));
149
+ const key = this.options.watch.key(input);
150
+ const watch = this.options.watch.renew(input.workspaceId, key, input.watchId);
151
+ if (watch !== undefined)
152
+ this.options.watch.remove(input.workspaceId, key);
70
153
  return { stopped: true };
71
154
  }
155
+ accepted(watch) {
156
+ return {
157
+ accepted: true,
158
+ watchId: watch.watchId,
159
+ nodeGeneration: watch.nodeGeneration,
160
+ expiresAt: watch.expiresAt
161
+ };
162
+ }
163
+ async createInitialSnapshots(workspace, watch) {
164
+ const tasks = [];
165
+ const emit = async (topic, read) => {
166
+ try {
167
+ const payload = await read();
168
+ if (!this.options.watch.matches(workspace.id, `${watch.sessionHash}:${watch.workbenchConnectionId}`, watch.watchId))
169
+ return;
170
+ this.options.emitWatchEvent({
171
+ type: 'watch.workspace.snapshot',
172
+ workbenchConnectionId: watch.workbenchConnectionId,
173
+ watchId: watch.watchId,
174
+ nodeGeneration: watch.nodeGeneration,
175
+ nodeId: this.options.nodeId(),
176
+ workspaceId: workspace.id,
177
+ topic,
178
+ revision: this.options.watch.nextRevision(workspace.id),
179
+ snapshotSequence: this.options.watch.nextSnapshotSequence(),
180
+ expiresAt: watch.expiresAt,
181
+ payload
182
+ });
183
+ }
184
+ catch {
185
+ this.options.emitWatchEvent({
186
+ type: 'watch.failed',
187
+ workbenchConnectionId: watch.workbenchConnectionId,
188
+ watchType: 'workspace',
189
+ watchId: watch.watchId,
190
+ nodeGeneration: watch.nodeGeneration,
191
+ nodeId: this.options.nodeId(),
192
+ workspaceId: workspace.id,
193
+ topic,
194
+ code: 'WORKSPACE_WATCH_SNAPSHOT_FAILED',
195
+ message: 'Workspace watch snapshot failed.'
196
+ });
197
+ }
198
+ };
199
+ if (watch.topics.sessions)
200
+ tasks.push(emit('sessions', () => this.options.listSessions(workspace.id)));
201
+ if (watch.topics.files !== undefined)
202
+ tasks.push(emit('files', async () => ({
203
+ files: await this.options.files.list(workspace, watch.topics.files)
204
+ })));
205
+ if (watch.topics.changes)
206
+ tasks.push(emit('changes', async () => workspace.kind === 'GIT_WORKSPACE' ? this.options.changes.current(workspace) : null));
207
+ if (watch.topics.files !== undefined || watch.topics.changes)
208
+ void this.options.files
209
+ .index(workspace)
210
+ .observe()
211
+ .catch(() => undefined);
212
+ await Promise.all(tasks);
213
+ }
214
+ async emitSnapshot(workspace, watch, topic, read) {
215
+ try {
216
+ const payload = await read();
217
+ this.deliverSnapshot(workspace, watch, topic, payload);
218
+ }
219
+ catch {
220
+ this.options.emitWatchEvent({
221
+ type: 'watch.failed',
222
+ workbenchConnectionId: watch.workbenchConnectionId,
223
+ watchType: 'workspace',
224
+ watchId: watch.watchId,
225
+ nodeGeneration: watch.nodeGeneration,
226
+ nodeId: this.options.nodeId(),
227
+ workspaceId: workspace.id,
228
+ topic,
229
+ code: 'WORKSPACE_WATCH_SNAPSHOT_FAILED',
230
+ message: 'Workspace watch snapshot failed.'
231
+ });
232
+ }
233
+ }
234
+ deliverSnapshot(workspace, watch, topic, payload) {
235
+ if (!this.options.watch.matches(workspace.id, `${watch.sessionHash}:${watch.workbenchConnectionId}`, watch.watchId))
236
+ return;
237
+ this.options.emitWatchEvent({
238
+ type: 'watch.workspace.snapshot',
239
+ workbenchConnectionId: watch.workbenchConnectionId,
240
+ watchId: watch.watchId,
241
+ nodeGeneration: watch.nodeGeneration,
242
+ nodeId: this.options.nodeId(),
243
+ workspaceId: workspace.id,
244
+ topic,
245
+ revision: this.options.watch.nextRevision(workspace.id),
246
+ snapshotSequence: this.options.watch.nextSnapshotSequence(),
247
+ expiresAt: watch.expiresAt,
248
+ payload
249
+ });
250
+ }
72
251
  async listFiles(input) {
73
252
  const workspace = this.options.requireWorkspace(input.workspaceId);
74
253
  return { files: await this.options.files.list(workspace, input) };
@@ -112,6 +291,13 @@ export class WorkspaceWorkbenchService {
112
291
  async currentChanges(input) {
113
292
  return this.options.changes.current(this.options.requireWorkspace(input.workspaceId));
114
293
  }
294
+ async readFileContent(input) {
295
+ if (typeof input.workspaceId !== 'string')
296
+ throw new Error('WORKSPACE_NOT_FOUND');
297
+ return {
298
+ file: await this.options.files.readContent(this.options.requireWorkspace(input.workspaceId), input)
299
+ };
300
+ }
115
301
  async changeDiff(input) {
116
302
  if (typeof input.workspaceId !== 'string' ||
117
303
  typeof input.path !== 'string' ||
@@ -184,6 +370,19 @@ export class WorkspaceWorkbenchService {
184
370
  diff: await readGitHistoryFileDiff(await this.gitRepository(input), input.commit, input.path, this.refs(input), input.previousPath)
185
371
  };
186
372
  }
373
+ async gitCommitFileContent(input) {
374
+ if (typeof input.commit !== 'string' ||
375
+ typeof input.path !== 'string' ||
376
+ !Number.isSafeInteger(input.offset) ||
377
+ input.offset < 0 ||
378
+ !Number.isSafeInteger(input.limit) ||
379
+ input.limit < 1 ||
380
+ input.limit > 20 * 1024 * 1024)
381
+ throw new Error('FILE_RANGE_INVALID');
382
+ return {
383
+ file: await readGitHistoryFileContentRange(await this.gitRepository(input), input.commit, input.path, input.offset, input.limit, this.refs(input))
384
+ };
385
+ }
187
386
  async diff(input) {
188
387
  if (typeof input.workspaceId !== 'string' ||
189
388
  typeof input.fromRef !== 'string' ||
@@ -0,0 +1,2 @@
1
+ import type { RunnerName } from '@myagentroam/protocol';
2
+ export declare function personalInstructionsForRunner(value: unknown, runner: RunnerName): string | undefined;
@@ -0,0 +1,31 @@
1
+ const MAX_CONTENT_LENGTH = 20_000;
2
+ export function personalInstructionsForRunner(value, runner) {
3
+ if (value === undefined)
4
+ return undefined;
5
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
6
+ throw new Error('PERSONAL_INSTRUCTION_INVALID');
7
+ const record = value;
8
+ const instructions = {
9
+ common: content(record['common']),
10
+ codex: content(record['codex']),
11
+ claudeCode: content(record['claudeCode']),
12
+ openCode: content(record['openCode'])
13
+ };
14
+ const specialized = runner === 'codex'
15
+ ? instructions.codex
16
+ : runner === 'claude-code'
17
+ ? instructions.claudeCode
18
+ : instructions.openCode;
19
+ const merged = [instructions.common, specialized].filter((item) => item.length > 0).join('\n\n');
20
+ return merged.length === 0 ? undefined : merged;
21
+ }
22
+ function content(value) {
23
+ if (value === undefined)
24
+ return '';
25
+ if (typeof value !== 'string')
26
+ throw new Error('PERSONAL_INSTRUCTION_INVALID');
27
+ const normalized = value.trim();
28
+ if (normalized.length > MAX_CONTENT_LENGTH)
29
+ throw new Error('PERSONAL_INSTRUCTION_INVALID');
30
+ return normalized;
31
+ }
@@ -22,6 +22,7 @@ export declare function settleWithin<T>(promise: Promise<T>, milliseconds: numbe
22
22
  export declare function extractCodexThreads(result: unknown): readonly {
23
23
  readonly id: string;
24
24
  readonly source: string | undefined;
25
+ readonly derived: boolean;
25
26
  readonly cwd: unknown;
26
27
  readonly title: string | undefined;
27
28
  }[];
@@ -49,12 +49,17 @@ export function extractCodexThreads(result) {
49
49
  typeof value.source.kind === 'string'
50
50
  ? value.source.kind
51
51
  : undefined;
52
+ const sourceRecord = isPlainRecord(value.source) ? value.source : undefined;
53
+ const derived = source === 'subagent' ||
54
+ sourceRecord?.kind === 'subagent' ||
55
+ isPlainRecord(sourceRecord?.subagent) ||
56
+ isPlainRecord(sourceRecord?.subAgent);
52
57
  const title = typeof value.name === 'string' && value.name.trim().length > 0
53
58
  ? value.name.trim()
54
59
  : typeof value.preview === 'string' && value.preview.trim().length > 0
55
60
  ? value.preview.trim()
56
61
  : undefined;
57
- return [{ id: value.id, source, cwd: value.cwd, title }];
62
+ return [{ id: value.id, source, derived, cwd: value.cwd, title }];
58
63
  });
59
64
  }
60
65
  export function codexThreadActivity(result) {
@@ -106,6 +111,8 @@ export function claudeDiscoveredSession(value, fallbackCwd) {
106
111
  if (typeof value !== 'object' || value === null)
107
112
  return undefined;
108
113
  const record = value;
114
+ if (record.isSidechain === true)
115
+ return undefined;
109
116
  const externalSessionId = typeof record.sessionId === 'string'
110
117
  ? record.sessionId
111
118
  : typeof record.id === 'string'
@@ -111,6 +111,13 @@ export declare function readWorkspaceTextFile(workspaceRoot: string, requestedPa
111
111
  readonly truncated: boolean;
112
112
  readonly nextOffset: number | null;
113
113
  }>;
114
+ export declare function readWorkspaceFileContentRange(workspaceRoot: string, requestedPath: string, offset?: number, limit?: number): Promise<{
115
+ readonly path: string;
116
+ readonly size: number;
117
+ readonly offset: number;
118
+ readonly contentBase64: string;
119
+ readonly nextOffset: number | null;
120
+ }>;
114
121
  /** Reads one absolute file only when the resolved target remains within a Node allowed root. */
115
122
  export declare function readAllowedTextFile(requestedPath: string, allowedRoots: readonly string[], offset?: number, limit?: number): Promise<{
116
123
  readonly path: string;
@@ -177,8 +184,13 @@ export declare function readGitHistoryFileDiff(repository: string, commit: strin
177
184
  readonly text?: string;
178
185
  readonly summary?: string;
179
186
  readonly truncated: boolean;
180
- readonly beforePreview?: BinaryDiffPreview;
181
- readonly afterPreview?: BinaryDiffPreview;
187
+ }>;
188
+ export declare function readGitHistoryFileContentRange(repository: string, commit: string, requestedPath: string, offset: number, limit: number, refs?: readonly string[]): Promise<{
189
+ readonly path: string;
190
+ readonly size: number;
191
+ readonly offset: number;
192
+ readonly contentBase64: string;
193
+ readonly nextOffset: number | null;
182
194
  }>;
183
195
  export declare function readCurrentChanges(workspacePath: string): Promise<readonly {
184
196
  readonly path: string;
@@ -192,15 +204,9 @@ export declare function readCurrentChangeDiff(workspacePath: string, requestedPa
192
204
  readonly truncated: boolean;
193
205
  readonly afterText?: string;
194
206
  readonly afterTruncated?: boolean;
195
- readonly beforePreview?: BinaryDiffPreview;
196
- readonly afterPreview?: BinaryDiffPreview;
197
207
  }>;
198
208
  /** Current Changes must not expose an external link. */
199
209
  export declare function isWorkspaceChangeVisible(workspacePath: string, requestedPath: string): Promise<boolean>;
200
- interface BinaryDiffPreview {
201
- readonly contentBase64?: string;
202
- readonly truncated: boolean;
203
- }
204
210
  export declare function restoreCurrentChange(workspacePath: string, requestedPath: string, state: 'STAGED' | 'UNSTAGED' | 'UNTRACKED'): Promise<void>;
205
211
  declare function gitChange(status: string): 'ADDED' | 'MODIFIED' | 'DELETED' | 'RENAMED' | 'COPIED' | 'TYPE_CHANGED';
206
212
  export declare class GitCommandError extends Error {
package/dist/workspace.js CHANGED
@@ -1,13 +1,12 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { watch } from 'node:fs';
3
- import { lstat, mkdir, opendir, readdir, readFile, realpath, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
3
+ import { lstat, mkdir, opendir, open, readdir, readFile, realpath, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
4
4
  import { homedir, platform as hostPlatform } from 'node:os';
5
5
  import { dirname, isAbsolute, posix, relative, resolve, sep, win32 } from 'node:path';
6
6
  import { spawn } from 'node:child_process';
7
7
  import { minimatch } from 'minimatch';
8
8
  const GIT_TIMEOUT_MS = 5_000;
9
9
  const GIT_OUTPUT_LIMIT = 512 * 1024;
10
- const GIT_BINARY_PREVIEW_LIMIT = 4 * 1024 * 1024;
11
10
  const FILE_READ_LIMIT = 512 * 1024;
12
11
  const DIRECTORY_PAGE_LIMIT = 200;
13
12
  const DIRECTORY_PICKER_LIMIT = 200;
@@ -665,6 +664,38 @@ export async function readWorkspaceTextFile(workspaceRoot, requestedPath, offset
665
664
  nextOffset
666
665
  };
667
666
  }
667
+ export async function readWorkspaceFileContentRange(workspaceRoot, requestedPath, offset = 0, limit = FILE_READ_LIMIT) {
668
+ if (!Number.isSafeInteger(offset) ||
669
+ offset < 0 ||
670
+ !Number.isSafeInteger(limit) ||
671
+ limit < 1 ||
672
+ limit > FILE_READ_LIMIT)
673
+ throw new Error('FILE_RANGE_INVALID');
674
+ const root = await realpath(workspaceRoot);
675
+ const file = await resolveWorkspaceEntry(root, requestedPath, false);
676
+ const metadata = await lstat(file);
677
+ if (!metadata.isFile())
678
+ throw new Error('FILE_NOT_REGULAR');
679
+ if (offset > metadata.size)
680
+ throw new Error('FILE_RANGE_INVALID');
681
+ const length = Math.min(limit, metadata.size - offset);
682
+ const source = Buffer.allocUnsafe(length);
683
+ const handle = await open(file, 'r');
684
+ try {
685
+ const { bytesRead } = await handle.read(source, 0, length, offset);
686
+ const end = offset + bytesRead;
687
+ return {
688
+ path: relative(root, file).split(sep).join('/'),
689
+ size: metadata.size,
690
+ offset,
691
+ contentBase64: source.subarray(0, bytesRead).toString('base64'),
692
+ nextOffset: end < metadata.size ? end : null
693
+ };
694
+ }
695
+ finally {
696
+ await handle.close();
697
+ }
698
+ }
668
699
  /** Reads one absolute file only when the resolved target remains within a Node allowed root. */
669
700
  export async function readAllowedTextFile(requestedPath, allowedRoots, offset = 0, limit = FILE_READ_LIMIT) {
670
701
  if (!isAbsolute(requestedPath))
@@ -1212,20 +1243,44 @@ export async function readGitHistoryFileDiff(repository, commit, requestedPath,
1212
1243
  ]);
1213
1244
  const binary = result.output.includes(0) || result.text.includes('Binary files ');
1214
1245
  if (binary) {
1215
- const [beforePreview, afterPreview] = await Promise.all([
1216
- readGitBinaryPreview(repository, `${commit}^`, previousPath ?? path),
1217
- readGitBinaryPreview(repository, commit, path)
1218
- ]);
1219
1246
  return {
1220
1247
  binary: true,
1221
1248
  summary: `binary diff sha256=${createHash('sha256').update(result.output).digest('hex')} bytes=${result.output.length}`,
1222
- truncated: result.truncated,
1223
- ...(beforePreview === undefined ? {} : { beforePreview }),
1224
- ...(afterPreview === undefined ? {} : { afterPreview })
1249
+ truncated: result.truncated
1225
1250
  };
1226
1251
  }
1227
1252
  return { binary: false, text: result.text, truncated: result.truncated };
1228
1253
  }
1254
+ export async function readGitHistoryFileContentRange(repository, commit, requestedPath, offset, limit, refs) {
1255
+ if (!Number.isSafeInteger(offset) ||
1256
+ offset < 0 ||
1257
+ !Number.isSafeInteger(limit) ||
1258
+ limit < 1 ||
1259
+ limit > 20 * 1024 * 1024 ||
1260
+ offset + limit > 20 * 1024 * 1024)
1261
+ throw new Error('FILE_RANGE_INVALID');
1262
+ await assertHistoryCommitReachable(repository, commit, refs);
1263
+ const path = safeGitWorkspacePath(requestedPath);
1264
+ const object = `${commit}:${path}`;
1265
+ const sizeResult = await runGit(repository, ['cat-file', '-s', object]);
1266
+ const size = Number(sizeResult.text.trim());
1267
+ if (!Number.isSafeInteger(size) || size < 0 || offset > size)
1268
+ throw new Error('FILE_RANGE_INVALID');
1269
+ if (offset === size)
1270
+ return { path, size, offset, contentBase64: '', nextOffset: null };
1271
+ const result = await runGitAllowFailure(repository, ['cat-file', 'blob', object], offset + limit);
1272
+ if (result.code !== 0)
1273
+ throw new GitCommandError(result.code, result.stderr);
1274
+ const content = result.output.subarray(offset, Math.min(size, offset + limit));
1275
+ const nextOffset = offset + content.length;
1276
+ return {
1277
+ path,
1278
+ size,
1279
+ offset,
1280
+ contentBase64: content.toString('base64'),
1281
+ nextOffset: nextOffset < size ? nextOffset : null
1282
+ };
1283
+ }
1229
1284
  export async function readCurrentChanges(workspacePath) {
1230
1285
  return (await readCurrentChangesSummary(workspacePath)).changes;
1231
1286
  }
@@ -1236,16 +1291,10 @@ export async function readCurrentChangeDiff(workspacePath, requestedPath) {
1236
1291
  const after = await readWorkingTreeText(workspacePath, path);
1237
1292
  const binary = result.output.includes(0) || result.text.includes('Binary files ') || after?.binary === true;
1238
1293
  if (binary) {
1239
- const [beforePreview, afterPreview] = await Promise.all([
1240
- readGitBinaryPreview(workspacePath, 'HEAD', path),
1241
- readWorkingTreeBinaryPreview(workspacePath, path)
1242
- ]);
1243
1294
  return {
1244
1295
  binary: true,
1245
1296
  summary: `binary diff sha256=${createHash('sha256').update(result.output).digest('hex')} bytes=${result.output.length}`,
1246
- truncated: result.truncated || after?.truncated === true,
1247
- ...(beforePreview === undefined ? {} : { beforePreview }),
1248
- ...(afterPreview === undefined ? {} : { afterPreview })
1297
+ truncated: result.truncated || after?.truncated === true
1249
1298
  };
1250
1299
  }
1251
1300
  return {
@@ -1315,32 +1364,6 @@ async function assertCurrentChangePathAccessible(workspacePath, path) {
1315
1364
  return;
1316
1365
  await resolveWorkspaceEntry(root, path, false);
1317
1366
  }
1318
- async function readGitBinaryPreview(repository, ref, path) {
1319
- const result = await runGitAllowFailure(repository, ['show', `${ref}:${path}`], GIT_BINARY_PREVIEW_LIMIT);
1320
- if (result.code !== 0)
1321
- return undefined;
1322
- return result.truncated
1323
- ? { truncated: true }
1324
- : { contentBase64: result.output.toString('base64'), truncated: false };
1325
- }
1326
- async function readWorkingTreeBinaryPreview(workspacePath, path) {
1327
- const root = await realpath(workspacePath);
1328
- let file;
1329
- try {
1330
- file = await resolveWorkspaceEntry(root, path, false);
1331
- }
1332
- catch (error) {
1333
- if (error instanceof Error && error.message === 'FILE_NOT_FOUND')
1334
- return undefined;
1335
- throw error;
1336
- }
1337
- const metadata = await lstat(file);
1338
- if (!metadata.isFile())
1339
- return undefined;
1340
- if (metadata.size > GIT_BINARY_PREVIEW_LIMIT)
1341
- return { truncated: true };
1342
- return { contentBase64: (await readFile(file)).toString('base64'), truncated: false };
1343
- }
1344
1367
  async function readWorkingTreeText(workspacePath, path) {
1345
1368
  const root = await realpath(workspacePath);
1346
1369
  let file;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.9.4",
3
+ "version": "0.9.5",
4
4
  "description": "MyAgentRoam Node runtime CLI.",
5
5
  "type": "module",
6
6
  "files": [
@@ -24,7 +24,7 @@
24
24
  "node-pty": "1.1.0",
25
25
  "ws": "^8.21.3",
26
26
  "zod": "4.4.3",
27
- "@myagentroam/protocol": "^0.9.4"
27
+ "@myagentroam/protocol": "^0.9.5"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/ws": "^8.18.1"