@myagentroam/node 0.11.1 → 0.11.2

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
@@ -1343,7 +1343,7 @@ export class NodeConnector {
1343
1343
  this.workspaceCoordinator.clearWatches(workspaceId);
1344
1344
  this.workspaceWorkbenchService.clearWorkspace(workspaceId);
1345
1345
  this.promptCompletion?.invalidateWorkspace(workspaceId);
1346
- });
1346
+ }, (workspaceId) => this.workspaceCoordinator.gitPolicyChanged(workspaceId));
1347
1347
  this.workspaceWorkbenchService = new WorkspaceWorkbenchService({
1348
1348
  requireWorkspace: (workspaceId) => this.workspaceCoordinator.require(workspaceId),
1349
1349
  inspectWorkspace: (workspace) => this.workspaceCoordinator.inspectMetadata(workspace),
package/dist/database.js CHANGED
@@ -2,6 +2,7 @@ import { NODE_SCHEMA_MIGRATION_V17 } from './migrations/v017.js';
2
2
  import { NODE_SCHEMA_MIGRATION_V18 } from './migrations/v018.js';
3
3
  import { NODE_SCHEMA_MIGRATION_V19 } from './migrations/v019.js';
4
4
  import { NODE_SCHEMA_MIGRATION_V20 } from './migrations/v020.js';
5
+ import { NODE_SCHEMA_MIGRATION_V21 } from './migrations/v021.js';
5
6
  import { normalizeSessionPrompt } from './util/personal-instructions.js';
6
7
  import { randomUUID } from 'node:crypto';
7
8
  import { closeSync, existsSync, mkdirSync, openSync, rmSync } from 'node:fs';
@@ -23,7 +24,8 @@ export const NODE_SCHEMA_MIGRATIONS = [
23
24
  NODE_SCHEMA_MIGRATION_V17,
24
25
  NODE_SCHEMA_MIGRATION_V18,
25
26
  NODE_SCHEMA_MIGRATION_V19,
26
- NODE_SCHEMA_MIGRATION_V20
27
+ NODE_SCHEMA_MIGRATION_V20,
28
+ NODE_SCHEMA_MIGRATION_V21
27
29
  ];
28
30
  const LEGACY_NODE_SCHEMA_V12 = NODE_SCHEMA_VERSION_V13 - 1;
29
31
  const OBSOLETE_NODE_SCHEMA_V16 = 16;
@@ -71,27 +73,22 @@ export class NodeDatabase {
71
73
  this.raw.close();
72
74
  }
73
75
  getRunnerDefaults() {
74
- const rows = this.raw
75
- .prepare('SELECT runner, model, effort, access FROM runner_defaults ORDER BY runner')
76
- .all();
77
- return rows.flatMap((row) => {
78
- const parsed = runnerDefaultConfigurationSchema.safeParse({
79
- runner: stringValue(row, 'runner'),
80
- model: stringValue(row, 'model'),
81
- effort: stringValue(row, 'effort'),
82
- access: stringValue(row, 'access')
83
- });
76
+ return Object.entries(this.getNodeSettings().runnerDefaults ?? {})
77
+ .sort(([a], [b]) => a.localeCompare(b))
78
+ .flatMap(([runner, settings]) => {
79
+ const parsed = runnerDefaultConfigurationSchema.safeParse({ runner, ...settings });
84
80
  return parsed.success ? [parsed.data] : [];
85
81
  });
86
82
  }
83
+ getNodeSettings() {
84
+ const row = this.raw.prepare('SELECT settings FROM node_settings WHERE id = 1').get();
85
+ return JSON.parse(stringValue(row, 'settings'));
86
+ }
87
87
  saveRunnerDefaults(input) {
88
+ const { runner, ...settings } = runnerDefaultConfigurationSchema.parse(input);
88
89
  this.raw
89
- .prepare(`INSERT INTO runner_defaults (runner, model, effort, access)
90
- VALUES (?, ?, ?, ?)
91
- ON CONFLICT(runner) DO UPDATE SET
92
- model = excluded.model, effort = excluded.effort,
93
- access = excluded.access`)
94
- .run(input.runner, input.model, input.effort, input.access);
90
+ .prepare(`UPDATE node_settings SET settings = json_patch(settings, ?) WHERE id = 1`)
91
+ .run(JSON.stringify({ runnerDefaults: { [runner]: settings } }));
95
92
  return input;
96
93
  }
97
94
  listMcpInstallations(targetKind, targetId = '') {
@@ -188,45 +185,64 @@ export class NodeDatabase {
188
185
  }
189
186
  saveWorkspace(input) {
190
187
  const current = this.raw
191
- .prepare('SELECT id, preserve_git_config FROM workspaces WHERE path = ?')
188
+ .prepare('SELECT id, settings FROM workspaces WHERE path = ?')
192
189
  .get(input.path);
190
+ const settings = current === undefined
191
+ ? {
192
+ git: {
193
+ preserveGitConfig: input.preserveGitConfig ?? false,
194
+ includeParentRepository: input.includeParentRepository ?? true
195
+ }
196
+ }
197
+ : JSON.parse(stringValue(current, 'settings'));
193
198
  const workspace = {
194
199
  id: current === undefined ? (input.id ?? randomUUID()) : stringValue(current, 'id'),
195
200
  path: input.path,
196
201
  kind: input.kind,
197
202
  gitSummary: input.gitSummary,
198
- preserveGitConfig: current === undefined
199
- ? (input.preserveGitConfig ?? false)
200
- : Number(current.preserve_git_config) === 1
203
+ settings,
204
+ includeParentRepository: settings.git?.includeParentRepository ?? true,
205
+ preserveGitConfig: settings.git?.preserveGitConfig ?? false
201
206
  };
202
207
  const timestamp = this.now();
203
208
  this.raw
204
- .prepare(`INSERT INTO workspaces (id, path, kind, git_summary, created_at, updated_at, preserve_git_config)
209
+ .prepare(`INSERT INTO workspaces (id, path, kind, git_summary, created_at, updated_at, settings)
205
210
  VALUES (?, ?, ?, ?, ?, ?, ?)
206
211
  ON CONFLICT(path) DO UPDATE SET kind = excluded.kind,
207
212
  git_summary = excluded.git_summary, updated_at = excluded.updated_at`)
208
- .run(workspace.id, workspace.path, workspace.kind, workspace.gitSummary === null ? null : JSON.stringify(workspace.gitSummary), timestamp, timestamp, workspace.preserveGitConfig ? 1 : 0);
213
+ .run(workspace.id, workspace.path, workspace.kind, workspace.gitSummary === null ? null : JSON.stringify(workspace.gitSummary), timestamp, timestamp, JSON.stringify(settings));
209
214
  return workspace;
210
215
  }
211
216
  listWorkspaces() {
212
217
  return this.raw
213
- .prepare('SELECT id, path, kind, git_summary, preserve_git_config FROM workspaces ORDER BY created_at ASC')
218
+ .prepare('SELECT id, path, kind, git_summary, settings FROM workspaces ORDER BY created_at ASC')
214
219
  .all()
215
220
  .map((row) => workspace(row));
216
221
  }
217
222
  getWorkspace(id) {
218
223
  const row = this.raw
219
- .prepare('SELECT id, path, kind, git_summary, preserve_git_config FROM workspaces WHERE id = ?')
224
+ .prepare('SELECT id, path, kind, git_summary, settings FROM workspaces WHERE id = ?')
220
225
  .get(id);
221
226
  return row === undefined ? undefined : workspace(row);
222
227
  }
223
- /** Unregisters a Workspace registration without deleting its local directory. */
224
- setWorkspaceGitPolicy(id, preserveGitConfig) {
228
+ getWorkspaceByPath(path) {
229
+ const row = this.raw
230
+ .prepare('SELECT id, path, kind, git_summary, settings FROM workspaces WHERE path = ?')
231
+ .get(path);
232
+ return row === undefined ? undefined : workspace(row);
233
+ }
234
+ /** Updates only supplied policy fields without replacing Workspace metadata. */
235
+ setWorkspaceGitPolicy(id, preserveGitConfig, includeParentRepository) {
225
236
  if (this.getWorkspace(id) === undefined)
226
237
  throw new Error('WORKSPACE_NOT_FOUND');
227
238
  this.raw
228
- .prepare('UPDATE workspaces SET preserve_git_config=?, updated_at=? WHERE id=?')
229
- .run(preserveGitConfig ? 1 : 0, this.now(), id);
239
+ .prepare('UPDATE workspaces SET settings=json_patch(settings, ?), updated_at=? WHERE id=?')
240
+ .run(JSON.stringify({
241
+ git: {
242
+ preserveGitConfig,
243
+ ...(includeParentRepository === undefined ? {} : { includeParentRepository })
244
+ }
245
+ }), this.now(), id);
230
246
  return this.getWorkspace(id);
231
247
  }
232
248
  /** Unregisters a Workspace registration without deleting its local directory. */
@@ -474,7 +490,10 @@ export function migrate(database, catalog = NODE_SCHEMA_MIGRATIONS) {
474
490
  throw new Error('NODE_DATABASE_VERSION_UNSUPPORTED');
475
491
  if (migratedVersions.some((version, index) => version !== NODE_SCHEMA_VERSION_V13 + index))
476
492
  throw new Error('NODE_DATABASE_MIGRATION_HISTORY_INVALID');
477
- assertNodeSchemaV13(database);
493
+ if (current < NODE_SCHEMA_MIGRATION_V21.version)
494
+ assertNodeSchemaV13(database);
495
+ else
496
+ catalog[current - NODE_SCHEMA_VERSION_V13 - 1].assert(database);
478
497
  for (const entry of catalog) {
479
498
  if (entry.version <= current)
480
499
  continue;
@@ -623,9 +642,12 @@ function acquireNodeMigrationLock(databasePath) {
623
642
  }
624
643
  function workspace(row) {
625
644
  const summary = nullableString(row, 'git_summary');
645
+ const settings = JSON.parse(stringValue(row, 'settings'));
626
646
  return {
627
647
  id: stringValue(row, 'id'),
628
- preserveGitConfig: Number(row.preserve_git_config) === 1,
648
+ settings,
649
+ preserveGitConfig: settings.git?.preserveGitConfig ?? false,
650
+ includeParentRepository: settings.git?.includeParentRepository ?? true,
629
651
  path: stringValue(row, 'path'),
630
652
  kind: stringValue(row, 'kind'),
631
653
  gitSummary: summary === null ? null : JSON.parse(summary)
@@ -0,0 +1,64 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { NODE_SCHEMA_V13 } from './v013.js';
3
+ import { NODE_SCHEMA_MIGRATION_V14 } from './v014.js';
4
+ import { NODE_SCHEMA_MIGRATION_V15 } from './v015.js';
5
+ import { NODE_SCHEMA_MIGRATION_V16 } from './v016.js';
6
+ import { NODE_SCHEMA_MIGRATION_V17 } from './v017.js';
7
+ import { NODE_SCHEMA_MIGRATION_V18 } from './v018.js';
8
+ import { NODE_SCHEMA_MIGRATION_V19 } from './v019.js';
9
+ import { NODE_SCHEMA_MIGRATION_V20 } from './v020.js';
10
+ const sql = `ALTER TABLE workspaces ADD COLUMN settings TEXT NOT NULL DEFAULT '{}'
11
+ CHECK (json_valid(settings) AND json_type(settings) = 'object');
12
+ UPDATE workspaces SET settings = json_object('git', json_object(
13
+ 'preserveGitConfig', json(CASE WHEN preserve_git_config = 1 THEN 'true' ELSE 'false' END),
14
+ 'includeParentRepository', json('true')));
15
+ ALTER TABLE workspaces DROP COLUMN preserve_git_config;
16
+ CREATE TABLE node_settings (
17
+ id INTEGER PRIMARY KEY CHECK (id = 1),
18
+ settings TEXT NOT NULL CHECK (json_valid(settings) AND json_type(settings) = 'object')
19
+ ) STRICT;
20
+ INSERT INTO node_settings (id, settings)
21
+ SELECT 1, json_object('runnerDefaults', json_group_object(runner,
22
+ json_object('model', model, 'effort', effort, 'access', access))) FROM runner_defaults;
23
+ DROP TABLE runner_defaults;`;
24
+ export const NODE_SCHEMA_MIGRATION_V21 = {
25
+ version: 21,
26
+ sql,
27
+ preflight: (database) => NODE_SCHEMA_MIGRATION_V20.assert(database),
28
+ assert(database) {
29
+ const reference = new DatabaseSync(':memory:');
30
+ try {
31
+ reference.exec(NODE_SCHEMA_V13);
32
+ for (const migration of [
33
+ NODE_SCHEMA_MIGRATION_V14,
34
+ NODE_SCHEMA_MIGRATION_V15,
35
+ NODE_SCHEMA_MIGRATION_V16,
36
+ NODE_SCHEMA_MIGRATION_V17,
37
+ NODE_SCHEMA_MIGRATION_V18,
38
+ NODE_SCHEMA_MIGRATION_V19,
39
+ NODE_SCHEMA_MIGRATION_V20
40
+ ])
41
+ reference.exec(migration.sql);
42
+ reference.exec(sql);
43
+ const schema = (db) => db
44
+ .prepare("SELECT type,name,sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY type,name")
45
+ .all()
46
+ .map((row) => [
47
+ row.type,
48
+ row.name,
49
+ row.name === 'node_schema_migrations'
50
+ ? String(row.sql).replace(/\s+/g, '')
51
+ : String(row.sql).replace(/\s+/g, ' ').trim()
52
+ ]);
53
+ const expected = schema(reference);
54
+ const actual = schema(database);
55
+ const mismatch = expected.find((row) => !actual.some((value) => JSON.stringify(value) === JSON.stringify(row))) ??
56
+ actual.find((row) => !expected.some((value) => JSON.stringify(value) === JSON.stringify(row)));
57
+ if (mismatch !== undefined)
58
+ throw new Error(`NODE_DATABASE_SCHEMA_INVALID:${String(mismatch[1])}`);
59
+ }
60
+ finally {
61
+ reference.close();
62
+ }
63
+ }
64
+ };
@@ -1,3 +1,5 @@
1
+ import { realpath } from 'node:fs/promises';
2
+ import { nodeLog } from '../operational.js';
1
3
  import { inspectWorkspace, normalizeWorkspacePath } from '../workspace.js';
2
4
  import { ensureWorkspaceGitExclude } from './workspace-git-exclude-service.js';
3
5
  /** Coordinates Workspace invalidation, inspection and persisted Terminal summaries. */
@@ -18,7 +20,7 @@ export class NodeWorkspaceCoordinator {
18
20
  const config = this.options.config();
19
21
  if (config === undefined)
20
22
  throw new Error('NODE_CONFIG_UNAVAILABLE');
21
- const inspection = await inspectWorkspace(workspace.path, config.allowedRoots);
23
+ const inspection = await inspectWorkspace(workspace.path, config.allowedRoots, workspace.includeParentRepository ?? true);
22
24
  if (normalizeWorkspacePath(inspection.path) !== normalizeWorkspacePath(workspace.path))
23
25
  throw new Error('WORKSPACE_NOT_ALLOWED');
24
26
  return inspection;
@@ -29,8 +31,14 @@ export class NodeWorkspaceCoordinator {
29
31
  throw new Error('NODE_DATABASE_UNAVAILABLE');
30
32
  if (normalizeWorkspacePath(inspection.path) !== normalizeWorkspacePath(workspace.path))
31
33
  throw new Error('WORKSPACE_NOT_ALLOWED');
34
+ const current = this.require(workspace.id);
35
+ if ((current.includeParentRepository ?? true) !== (workspace.includeParentRepository ?? true))
36
+ return current;
32
37
  if (workspace.kind !== 'GIT_WORKSPACE' && inspection.kind === 'GIT_WORKSPACE')
33
- await ensureWorkspaceGitExclude(workspace.path);
38
+ await ensureWorkspaceGitExclude(workspace.path, workspace.includeParentRepository ?? true);
39
+ if ((this.require(workspace.id).includeParentRepository ?? true) !==
40
+ (workspace.includeParentRepository ?? true))
41
+ return this.require(workspace.id);
34
42
  const saved = database.saveWorkspace({
35
43
  id: workspace.id,
36
44
  path: workspace.path,
@@ -57,6 +65,14 @@ export class NodeWorkspaceCoordinator {
57
65
  invalidateChanges(workspaceId) {
58
66
  this.options.changes.invalidate(workspaceId);
59
67
  }
68
+ gitPolicyChanged(workspaceId) {
69
+ this.invalidate(workspaceId);
70
+ const workspace = this.require(workspaceId);
71
+ void this.inspectMetadata(workspace)
72
+ .then((inspection) => this.commitMetadata(workspace, inspection))
73
+ .then(() => this.invalidate(workspaceId))
74
+ .catch(() => nodeLog('workspace.git-policy.refresh-failed', { workspaceId }));
75
+ }
60
76
  persistTerminal(summary) {
61
77
  const database = this.options.database();
62
78
  if (database === undefined)
@@ -87,7 +103,9 @@ export class NodeWorkspaceCoordinator {
87
103
  return;
88
104
  }
89
105
  try {
90
- const inspection = await inspectWorkspace(requestedPath, config.allowedRoots);
106
+ const path = await realpath(requestedPath);
107
+ const current = this.options.database()?.getWorkspaceByPath(path);
108
+ const inspection = await inspectWorkspace(path, config.allowedRoots, current?.includeParentRepository ?? true);
91
109
  this.options.send('workspace.inspect.result', inspection, envelope.id);
92
110
  }
93
111
  catch {
@@ -22,11 +22,11 @@ export class SkillDirectoryService {
22
22
  : agents.map(withoutSource)
23
23
  };
24
24
  }
25
- async inspectWorkspace(workspace) {
25
+ async inspectWorkspace(workspace, includeParentRepository = true) {
26
26
  const agents = await scanRoot(path.join(workspace, '.agents', 'skills'), 'AGENTS');
27
27
  const claude = await scanRoot(path.join(workspace, '.claude', 'skills'), 'CLAUDE');
28
28
  const skills = mergeWorkspaceCopies(agents, claude);
29
- const gitExcludeBroken = await hasBrokenWorkspaceSkillGitExclude(workspace, skills.map((skill) => skill.name));
29
+ const gitExcludeBroken = await hasBrokenWorkspaceSkillGitExclude(workspace, skills.map((skill) => skill.name), includeParentRepository);
30
30
  return {
31
31
  targetKind: 'WORKSPACE',
32
32
  compatibilityMode: 'MANAGED_COPY',
@@ -4,6 +4,7 @@ import { promisify } from 'node:util';
4
4
  import { lstat, mkdir, readFile, readdir, readlink, realpath, rename, rm, symlink, writeFile } from 'node:fs/promises';
5
5
  import path from 'node:path';
6
6
  import { skillInstallMetadataSchema } from '@myagentroam/protocol';
7
+ import { GitCommandError, resolveWorkspaceGitRoot } from '../workspace.js';
7
8
  import { rewriteWorkspaceGitExclude, updateWorkspaceSkillGitExclude } from './workspace-git-exclude-service.js';
8
9
  export class SkillInstallService {
9
10
  async recoverNodeHome(home) {
@@ -59,15 +60,15 @@ export class SkillInstallService {
59
60
  await mkdir(claudeRoot, { recursive: true });
60
61
  await assertContainedDirectory(workspace, agentsRoot);
61
62
  await assertContainedDirectory(workspace, claudeRoot);
62
- await replacePair(agentsRoot, claudeRoot, name, bundle, metadata, () => updateWorkspaceSkillGitExclude(workspace, name, true), () => updateWorkspaceSkillGitExclude(workspace, name, false));
63
+ await replacePair(agentsRoot, claudeRoot, name, bundle, metadata, () => updateWorkspaceSkillGitExclude(workspace, name, true, options.includeParentRepository ?? true), () => updateWorkspaceSkillGitExclude(workspace, name, false, options.includeParentRepository ?? true));
63
64
  }
64
- async removeWorkspace(workspace, name) {
65
+ async removeWorkspace(workspace, name, includeParentRepository = true) {
65
66
  assertName(name);
66
67
  const agentsRoot = path.join(workspace, '.agents', 'skills');
67
68
  const claudeRoot = path.join(workspace, '.claude', 'skills');
68
69
  await assertExistingContainedDirectory(workspace, agentsRoot);
69
70
  await assertExistingContainedDirectory(workspace, claudeRoot);
70
- await removePair(agentsRoot, claudeRoot, name, () => updateWorkspaceSkillGitExclude(workspace, name, false), () => updateWorkspaceSkillGitExclude(workspace, name, true));
71
+ await removePair(agentsRoot, claudeRoot, name, () => updateWorkspaceSkillGitExclude(workspace, name, false, includeParentRepository), () => updateWorkspaceSkillGitExclude(workspace, name, true, includeParentRepository));
71
72
  }
72
73
  async removeNodeHome(home, name) {
73
74
  assertName(name);
@@ -81,7 +82,7 @@ export class SkillInstallService {
81
82
  else
82
83
  await removeSingle(agentsRoot, name);
83
84
  }
84
- async repairWorkspaceGitExclude(workspace) {
85
+ async repairWorkspaceGitExclude(workspace, includeParentRepository = true) {
85
86
  const root = path.join(workspace, '.agents', 'skills');
86
87
  const names = new Set();
87
88
  try {
@@ -101,13 +102,20 @@ export class SkillInstallService {
101
102
  if (!isMissing(error))
102
103
  throw error;
103
104
  }
104
- await rewriteWorkspaceGitExclude(workspace, names);
105
+ await rewriteWorkspaceGitExclude(workspace, names, includeParentRepository);
105
106
  }
106
107
  }
107
108
  export async function workspaceSkillTracked(workspace, name) {
108
109
  assertName(name);
109
- if (!(await exists(path.join(workspace, '.git'))))
110
- return false;
110
+ try {
111
+ await resolveWorkspaceGitRoot(workspace);
112
+ }
113
+ catch (error) {
114
+ if ((error instanceof GitCommandError && error.code === 128) ||
115
+ error.code === 'ENOENT')
116
+ return false;
117
+ throw error;
118
+ }
111
119
  try {
112
120
  const result = await promisify(execFile)('git', ['-C', workspace, 'ls-files', '--', `.agents/skills/${name}`, `.claude/skills/${name}`], { timeout: 5_000, windowsHide: true });
113
121
  return result.stdout.trim() !== '';
@@ -28,14 +28,14 @@ export class SkillNodeOperationService {
28
28
  'workspace.skills.inspect': async (data) => {
29
29
  const workspace = this.workspacePath(record(data)['workspaceId']);
30
30
  await this.installer.recoverWorkspace(workspace);
31
- return this.directories.inspectWorkspace(workspace);
31
+ return this.directories.inspectWorkspace(workspace, this.database().getWorkspaceByPath(workspace)?.includeParentRepository ?? true);
32
32
  },
33
33
  'workspace.skills.install': (data) => this.install('WORKSPACE', data),
34
34
  'workspace.skills.remove': (data) => this.remove('WORKSPACE', data),
35
35
  'workspace.skills.git-exclude-repair': async (data) => {
36
36
  const workspace = this.workspacePath(record(data)['workspaceId']);
37
37
  await this.installer.recoverWorkspace(workspace);
38
- await this.installer.repairWorkspaceGitExclude(workspace);
38
+ await this.installer.repairWorkspaceGitExclude(workspace, this.database().getWorkspaceByPath(workspace)?.includeParentRepository ?? true);
39
39
  return { repaired: true };
40
40
  },
41
41
  'skill.install.job.get': (data) => this.job(data)
@@ -113,7 +113,8 @@ export class SkillNodeOperationService {
113
113
  await this.installer.installNodeHome(homedir(), input.name, bundle, input.metadata);
114
114
  else
115
115
  await this.installer.installWorkspace(input.workspace, input.name, bundle, input.metadata, {
116
- confirmTracked: true
116
+ confirmTracked: true,
117
+ includeParentRepository: this.database().getWorkspaceByPath(input.workspace)?.includeParentRepository ?? true
117
118
  });
118
119
  }
119
120
  async remove(target, data) {
@@ -131,7 +132,7 @@ export class SkillNodeOperationService {
131
132
  }
132
133
  else {
133
134
  await this.installer.recoverWorkspace(workspace);
134
- await this.installer.removeWorkspace(workspace, name);
135
+ await this.installer.removeWorkspace(workspace, name, this.database().getWorkspaceByPath(workspace)?.includeParentRepository ?? true);
135
136
  }
136
137
  }
137
138
  finally {
@@ -2,7 +2,7 @@ import { lstat } from 'node:fs/promises';
2
2
  import { basename, posix, resolve } from 'node:path';
3
3
  import { publicErrorMessage } from '@myagentroam/protocol';
4
4
  import { nodeLog } from '../operational.js';
5
- import { GitCommandError, isWorkspaceChangeVisible, listGitWorktrees, listGitSubmodules, readCurrentChangesSummary, resolveGitRepository } from '../workspace.js';
5
+ import { GitCommandError, isWorkspaceChangeVisible, listGitWorktrees, listGitSubmodules, readCurrentChangesSummary, resolveGitRepository, resolveWorkspaceGitRoot } from '../workspace.js';
6
6
  const CACHE_TTL_MS = 5_000;
7
7
  const REPOSITORY_READ_CONCURRENCY = 4;
8
8
  const WORKTREE_READ_CONCURRENCY = 4;
@@ -13,6 +13,9 @@ export class WorkspaceChangeService {
13
13
  constructor(state) {
14
14
  this.state = state;
15
15
  }
16
+ revision(workspaceId) {
17
+ return this.state.changesVersions.get(workspaceId) ?? 0;
18
+ }
16
19
  invalidate(workspaceId) {
17
20
  this.state.changesReads.delete(workspaceId);
18
21
  this.state.changesCache.delete(workspaceId);
@@ -34,7 +37,9 @@ export class WorkspaceChangeService {
34
37
  return existing;
35
38
  // Invalidation may arrive during this shared read; its old result must not repopulate the cache.
36
39
  const version = this.state.changesVersions.get(workspace.id) ?? 0;
37
- const request = this.state.changesLimiter.run(async () => {
40
+ const request = this.state.changesLimiter
41
+ .run(async () => {
42
+ const repositoryRoot = await resolveWorkspaceGitRoot(workspace.path, workspace.includeParentRepository ?? true);
38
43
  const startedAt = performance.now();
39
44
  const errors = [];
40
45
  const failure = (code, error) => ({
@@ -66,7 +71,7 @@ export class WorkspaceChangeService {
66
71
  };
67
72
  const readRepository = async (repositoryPath, repositoryOverride) => {
68
73
  const repository = repositoryOverride ??
69
- (await resolveGitRepository(workspace.path, repositoryPath, [repositoryPath]));
74
+ (await resolveGitRepository(repositoryRoot, repositoryPath, [repositoryPath]));
70
75
  if (repositoryPath !== '' || repositoryOverride !== undefined) {
71
76
  const marker = await lstat(resolve(repository, '.git'));
72
77
  if (marker.isSymbolicLink() || (!marker.isFile() && !marker.isDirectory()))
@@ -89,19 +94,14 @@ export class WorkspaceChangeService {
89
94
  changes
90
95
  };
91
96
  };
92
- const root = await readRepository('').catch((error) => {
93
- if (error?.code === 'ENOENT' &&
94
- error.syscall?.startsWith('spawn') === true)
95
- throw new Error('GIT_NOT_FOUND');
96
- throw error;
97
- });
97
+ const root = await readRepository('');
98
98
  const children = [];
99
99
  let parents = [''];
100
100
  let submodulesLimited = false;
101
101
  while (parents.length > 0 && !submodulesLimited) {
102
102
  const discovered = await mapWithConcurrency(parents, REPOSITORY_READ_CONCURRENCY, async (parent) => {
103
103
  try {
104
- const path = await resolveGitRepository(workspace.path, parent, [parent]);
104
+ const path = await resolveGitRepository(repositoryRoot, parent, [parent]);
105
105
  return (await listGitSubmodules(path)).map((entry) => ({
106
106
  ...entry,
107
107
  path: parent === '' ? entry.path : `${parent}/${entry.path}`
@@ -152,7 +152,7 @@ export class WorkspaceChangeService {
152
152
  .map((entry) => entry.repositoryPath);
153
153
  }
154
154
  children.sort((left, right) => left.repositoryPath.localeCompare(right.repositoryPath));
155
- const worktreeList = await listGitWorktrees(workspace.path).catch((error) => {
155
+ const worktreeList = await listGitWorktrees(repositoryRoot).catch((error) => {
156
156
  discoveryFailure('WORKTREE', '', 'GIT_WORKTREE_DISCOVERY_FAILED', error);
157
157
  return { entries: [], truncated: false };
158
158
  });
@@ -229,6 +229,13 @@ export class WorkspaceChangeService {
229
229
  diffAvailable: true
230
230
  });
231
231
  const value = {
232
+ repositoryRoot,
233
+ workspace: {
234
+ id: workspace.id,
235
+ path: workspace.path,
236
+ kind: 'GIT_WORKSPACE',
237
+ gitSummary: { branch: root.branch, repositoryRoot }
238
+ },
232
239
  partial: errors.length > 0 ||
233
240
  children.some((group) => group.status === 'UNAVAILABLE') ||
234
241
  linkedRepositories.some((group) => group.status === 'UNAVAILABLE') ||
@@ -240,7 +247,7 @@ export class WorkspaceChangeService {
240
247
  {
241
248
  ...root,
242
249
  source: 'ROOT',
243
- physicalPath: currentWorktree?.path ?? workspace.path,
250
+ physicalPath: currentWorktree?.path ?? repositoryRoot,
244
251
  current: true,
245
252
  ...(currentWorktree === undefined ? {} : { head: currentWorktree.head }),
246
253
  status: root.dirty ? 'DIRTY' : 'CLEAN',
@@ -267,8 +274,17 @@ export class WorkspaceChangeService {
267
274
  durationMs: Math.round(performance.now() - startedAt)
268
275
  });
269
276
  if ((this.state.changesVersions.get(workspace.id) ?? 0) === version)
270
- this.state.changesCache.set(workspace.id, { value, expiresAt: Date.now() + CACHE_TTL_MS });
277
+ this.state.changesCache.set(workspace.id, {
278
+ value,
279
+ expiresAt: Date.now() + CACHE_TTL_MS
280
+ });
271
281
  return value;
282
+ })
283
+ .catch((error) => {
284
+ if (error?.code === 'ENOENT' &&
285
+ error.syscall?.startsWith('spawn') === true)
286
+ throw new Error('GIT_NOT_FOUND');
287
+ throw error;
272
288
  });
273
289
  this.state.changesReads.set(workspace.id, request);
274
290
  try {
@@ -176,7 +176,9 @@ export class WorkspaceFileService {
176
176
  return { size: metadata.size, revision: metadata.sourceRevision };
177
177
  }
178
178
  async write(workspace, input) {
179
- if (typeof input.path !== 'string' ||
179
+ if (input.repositoryPath !== undefined ||
180
+ input.worktreePath !== undefined ||
181
+ typeof input.path !== 'string' ||
180
182
  typeof input.content !== 'string' ||
181
183
  typeof input.expectedRevision !== 'string')
182
184
  throw new Error('FILE_WRITE_INVALID');
@@ -1,53 +1,91 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { execFile } from 'node:child_process';
3
- import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { promisify } from 'node:util';
6
+ import { GitCommandError, resolveWorkspaceGitRoot } from '../workspace.js';
6
7
  const MAR_TEMP_DIRECTORY = '/.mar/';
7
8
  const BLOCK_BEGIN = '# BEGIN MyAgentRoam';
8
9
  const BLOCK_END = '# END MyAgentRoam';
9
10
  const LEGACY_BLOCK_BEGIN = '# BEGIN MyAgentRoam Skills';
10
11
  const LEGACY_BLOCK_END = '# END MyAgentRoam Skills';
11
- export async function ensureWorkspaceGitExclude(workspace) {
12
- await mutateGitExclude(workspace, () => undefined);
12
+ export async function ensureWorkspaceGitExclude(workspace, includeParentRepository = true) {
13
+ await mutateGitExclude(workspace, () => undefined, includeParentRepository);
13
14
  }
14
- export async function updateWorkspaceSkillGitExclude(workspace, name, add) {
15
- await mutateGitExclude(workspace, (entries) => {
15
+ export async function updateWorkspaceSkillGitExclude(workspace, name, add, includeParentRepository = true) {
16
+ await mutateGitExclude(workspace, (entries, prefix) => {
16
17
  for (const root of ['.agents', '.claude']) {
17
- const line = `/${root}/skills/${name}/`;
18
+ const line = `${prefix}${root}/skills/${name}/`;
18
19
  if (add)
19
20
  entries.add(line);
20
21
  else
21
22
  entries.delete(line);
22
23
  }
23
- });
24
+ }, includeParentRepository);
24
25
  }
25
- export async function rewriteWorkspaceGitExclude(workspace, names) {
26
- await mutateGitExclude(workspace, (entries) => {
27
- entries.clear();
26
+ export async function rewriteWorkspaceGitExclude(workspace, names, includeParentRepository = true) {
27
+ await mutateGitExclude(workspace, (entries, prefix) => {
28
+ for (const entry of entries)
29
+ if (['.agents', '.claude'].some((root) => entry.startsWith(`${prefix}${root}/skills/`)))
30
+ entries.delete(entry);
28
31
  for (const name of names)
29
32
  for (const root of ['.agents', '.claude'])
30
- entries.add(`/${root}/skills/${name}/`);
31
- });
33
+ entries.add(`${prefix}${root}/skills/${name}/`);
34
+ }, includeParentRepository);
32
35
  }
33
- export async function hasBrokenWorkspaceSkillGitExclude(workspace, names) {
34
- if (!(await exists(path.join(workspace, '.git'))))
35
- return false;
36
+ export async function hasBrokenWorkspaceSkillGitExclude(workspace, names, includeParentRepository = true) {
36
37
  try {
37
- const exclude = await resolveGitExclude(workspace);
38
+ const target = await gitExcludeTarget(workspace, includeParentRepository);
39
+ if (target === undefined)
40
+ return false;
41
+ const { exclude, prefix } = target;
38
42
  const content = await readFile(exclude, 'utf8');
39
43
  return names
40
- .flatMap((name) => [`/.agents/skills/${name}/`, `/.claude/skills/${name}/`])
44
+ .flatMap((name) => [`${prefix}.agents/skills/${name}/`, `${prefix}.claude/skills/${name}/`])
41
45
  .some((entry) => !content.includes(entry));
42
46
  }
43
47
  catch {
44
48
  return true;
45
49
  }
46
50
  }
47
- async function mutateGitExclude(workspace, mutate) {
48
- if (!(await exists(path.join(workspace, '.git'))))
51
+ const mutationTails = new Map();
52
+ async function gitExcludeTarget(workspace, includeParentRepository) {
53
+ let root;
54
+ try {
55
+ root = await resolveWorkspaceGitRoot(workspace, includeParentRepository);
56
+ }
57
+ catch (error) {
58
+ if ((error instanceof GitCommandError && error.code === 128) ||
59
+ (error instanceof Error && error.message === 'WORKSPACE_NOT_GIT') ||
60
+ error.code === 'ENOENT')
61
+ return undefined;
62
+ throw error;
63
+ }
64
+ const relative = path
65
+ .relative(root, await realpath(workspace))
66
+ .split(path.sep)
67
+ .join('/');
68
+ const escaped = relative.replace(/[\\*?\[\]#! ]/g, '\\$&');
69
+ return { exclude: await resolveGitExclude(root), prefix: relative === '' ? '/' : `/${escaped}/` };
70
+ }
71
+ async function mutateGitExclude(workspace, mutate, includeParentRepository) {
72
+ const target = await gitExcludeTarget(workspace, includeParentRepository);
73
+ if (target === undefined)
49
74
  return;
50
- const exclude = await resolveGitExclude(workspace);
75
+ const { exclude, prefix } = target;
76
+ const request = (mutationTails.get(exclude) ?? Promise.resolve())
77
+ .catch(() => undefined)
78
+ .then(() => writeGitExclude(exclude, (entries) => mutate(entries, prefix)));
79
+ mutationTails.set(exclude, request);
80
+ try {
81
+ await request;
82
+ }
83
+ finally {
84
+ if (mutationTails.get(exclude) === request)
85
+ mutationTails.delete(exclude);
86
+ }
87
+ }
88
+ async function writeGitExclude(exclude, mutate) {
51
89
  await mkdir(path.dirname(exclude), { recursive: true });
52
90
  let content = '';
53
91
  try {
@@ -62,7 +100,7 @@ async function mutateGitExclude(workspace, mutate) {
62
100
  }
63
101
  const pattern = managedBlockPattern();
64
102
  const existing = [...content.matchAll(pattern)].map((match) => match[0]).join('\n');
65
- const entries = new Set(existing.split(/\r?\n/u).filter((line) => line.startsWith('/.')));
103
+ const entries = new Set(existing.split(/\r?\n/u).filter((line) => line.startsWith('/')));
66
104
  entries.delete(MAR_TEMP_DIRECTORY);
67
105
  mutate(entries);
68
106
  const block = entries.size === 0 ? '' : `${BLOCK_BEGIN}\n${[...entries].sort().join('\n')}\n${BLOCK_END}\n`;
@@ -97,17 +135,6 @@ async function resolveGitExclude(workspace) {
97
135
  throw new Error('WORKSPACE_SKILL_GIT_EXCLUDE_FAILED');
98
136
  }
99
137
  }
100
- async function exists(value) {
101
- try {
102
- await lstat(value);
103
- return true;
104
- }
105
- catch (error) {
106
- if (isMissing(error))
107
- return false;
108
- throw error;
109
- }
110
- }
111
138
  function isMissing(error) {
112
139
  return (error instanceof Error && 'code' in error && error.code === 'ENOENT');
113
140
  }
@@ -1,4 +1,4 @@
1
- import { rmdir } from 'node:fs/promises';
1
+ import { realpath, rmdir } from 'node:fs/promises';
2
2
  import { createWorkspaceDirectory, inspectWorkspace, listWorkspaceDirectories } from '../workspace.js';
3
3
  import { ensureWorkspaceGitExclude } from './workspace-git-exclude-service.js';
4
4
  import { workspaceGitSettingsSchema } from '@myagentroam/protocol';
@@ -7,11 +7,13 @@ export class WorkspaceService {
7
7
  config;
8
8
  runtime;
9
9
  onRemoved;
10
- constructor(database, config, runtime, onRemoved) {
10
+ onGitPolicyChanged;
11
+ constructor(database, config, runtime, onRemoved, onGitPolicyChanged = () => undefined) {
11
12
  this.database = database;
12
13
  this.config = config;
13
14
  this.runtime = runtime;
14
15
  this.onRemoved = onRemoved;
16
+ this.onGitPolicyChanged = onGitPolicyChanged;
15
17
  }
16
18
  operations() {
17
19
  return {
@@ -36,14 +38,28 @@ export class WorkspaceService {
36
38
  const path = typeof input.createDirectoryName === 'string'
37
39
  ? (createdPath = await createWorkspaceDirectory(input.path, input.createDirectoryName, this.config().allowedRoots))
38
40
  : input.path;
39
- const inspected = await inspectWorkspace(path, this.config().allowedRoots);
40
- await ensureWorkspaceGitExclude(inspected.path);
41
+ const canonicalPath = await realpath(path);
42
+ const current = this.database().getWorkspaceByPath(canonicalPath);
43
+ const includeParentRepository = current?.includeParentRepository ?? true;
44
+ const inspected = await inspectWorkspace(canonicalPath, this.config().allowedRoots, includeParentRepository);
45
+ let latest = this.database().getWorkspaceByPath(canonicalPath);
46
+ if (latest !== undefined &&
47
+ (latest.includeParentRepository ?? true) !== includeParentRepository)
48
+ return { workspace: latest };
49
+ if (inspected.kind === 'GIT_WORKSPACE') {
50
+ await ensureWorkspaceGitExclude(inspected.path, includeParentRepository);
51
+ latest = this.database().getWorkspaceByPath(canonicalPath);
52
+ if (latest !== undefined &&
53
+ (latest.includeParentRepository ?? true) !== includeParentRepository)
54
+ return { workspace: latest };
55
+ }
41
56
  return {
42
57
  workspace: this.database().saveWorkspace({
43
58
  path: inspected.path,
44
59
  kind: inspected.kind,
45
60
  gitSummary: inspected.gitSummary ?? null,
46
- preserveGitConfig: input.preserveGitConfig === true
61
+ preserveGitConfig: input.preserveGitConfig === true,
62
+ includeParentRepository
47
63
  })
48
64
  };
49
65
  }
@@ -80,13 +96,22 @@ export class WorkspaceService {
80
96
  if (current === undefined)
81
97
  throw new Error('WORKSPACE_NOT_FOUND');
82
98
  if (!update)
83
- return { preserveGitConfig: current.preserveGitConfig ?? true };
99
+ return {
100
+ preserveGitConfig: current.preserveGitConfig ?? true,
101
+ includeParentRepository: current.includeParentRepository ?? true
102
+ };
84
103
  const parsed = workspaceGitSettingsSchema.safeParse({
85
- preserveGitConfig: input.preserveGitConfig
104
+ preserveGitConfig: input.preserveGitConfig,
105
+ includeParentRepository: input.includeParentRepository
86
106
  });
87
107
  if (!parsed.success)
88
108
  throw new Error('GIT_CONFIGURATION_INVALID');
89
- const result = this.database().setWorkspaceGitPolicy(input.workspaceId, parsed.data.preserveGitConfig);
90
- return { preserveGitConfig: result.preserveGitConfig };
109
+ const result = this.database().setWorkspaceGitPolicy(input.workspaceId, parsed.data.preserveGitConfig, parsed.data.includeParentRepository);
110
+ if (result.includeParentRepository !== current.includeParentRepository)
111
+ this.onGitPolicyChanged(current.id);
112
+ return {
113
+ preserveGitConfig: result.preserveGitConfig,
114
+ includeParentRepository: result.includeParentRepository
115
+ };
91
116
  }
92
117
  }
@@ -15,15 +15,23 @@ export class WorkspaceWatchSnapshotService {
15
15
  if (watch.topics.files !== undefined)
16
16
  this.ensureFilesRefreshTimer();
17
17
  await Promise.all(this.subscribedTopics(watch).map(async (topic) => {
18
+ const revision = this.readRevision(workspace.id, topic);
18
19
  try {
19
20
  const payload = await this.read(workspace, topic, watch);
20
21
  if (!this.matches(workspace.id, watch))
21
22
  return;
23
+ if (revision !== this.readRevision(workspace.id, topic)) {
24
+ void this.invalidate(workspace.id, topic);
25
+ return;
26
+ }
22
27
  this.signatures.set(this.viewKey(workspace.id, topic, watch), signature(payload));
23
28
  this.deliver(workspace, watch, topic, payload);
24
29
  }
25
30
  catch {
26
- this.fail(workspace.id, watch, topic);
31
+ if (revision === this.readRevision(workspace.id, topic))
32
+ this.fail(workspace.id, watch, topic);
33
+ else
34
+ void this.invalidate(workspace.id, topic);
27
35
  }
28
36
  }));
29
37
  }
@@ -119,7 +127,22 @@ export class WorkspaceWatchSnapshotService {
119
127
  const first = subscribers[0];
120
128
  if (first === undefined)
121
129
  return;
122
- const payload = await this.read(workspace, topic, first);
130
+ const revision = this.readRevision(workspaceId, topic);
131
+ let payload;
132
+ try {
133
+ payload = await this.read(workspace, topic, first);
134
+ }
135
+ catch (error) {
136
+ if (revision !== this.readRevision(workspaceId, topic)) {
137
+ void this.invalidate(workspaceId, topic);
138
+ return;
139
+ }
140
+ throw error;
141
+ }
142
+ if (revision !== this.readRevision(workspaceId, topic)) {
143
+ void this.invalidate(workspaceId, topic);
144
+ return;
145
+ }
123
146
  const activeSubscribers = subscribers.filter((watch) => this.matches(workspaceId, watch));
124
147
  if (activeSubscribers.length === 0)
125
148
  return;
@@ -157,7 +180,7 @@ export class WorkspaceWatchSnapshotService {
157
180
  read(workspace, topic, watch) {
158
181
  const key = topic === 'files'
159
182
  ? `${workspace.id}:files:${JSON.stringify(watch.topics.files)}`
160
- : `${workspace.id}:${topic}`;
183
+ : `${workspace.id}:${topic}:${this.readRevision(workspace.id, topic)}`;
161
184
  const active = this.reads.get(key);
162
185
  if (active !== undefined)
163
186
  return active;
@@ -166,8 +189,12 @@ export class WorkspaceWatchSnapshotService {
166
189
  : topic === 'changes'
167
190
  ? workspace.kind === 'GIT_WORKSPACE'
168
191
  ? this.options.changes.current(workspace)
169
- : Promise.resolve(null)
170
- : this.options.files.list(workspace, watch.topics.files).then((files) => ({ files }))).finally(() => {
192
+ : Promise.resolve(workspace.includeParentRepository === false
193
+ ? { workspace, changes: [], repositories: [] }
194
+ : null)
195
+ : this.options.files
196
+ .list(workspace, watch.topics.files)
197
+ .then((files) => ({ files, workspace: this.options.requireWorkspace(workspace.id) }))).finally(() => {
171
198
  if (this.reads.get(key) === read)
172
199
  this.reads.delete(key);
173
200
  });
@@ -181,6 +208,9 @@ export class WorkspaceWatchSnapshotService {
181
208
  this.signatures.set(key, next);
182
209
  return true;
183
210
  }
211
+ readRevision(workspaceId, topic) {
212
+ return topic === 'changes' ? this.options.changes.revision(workspaceId) : 0;
213
+ }
184
214
  viewKey(workspaceId, topic, watch) {
185
215
  return topic === 'files'
186
216
  ? `${workspaceId}:files:${JSON.stringify(watch.topics.files)}`
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { listGitHistoryRefs, openGitHistoryFileContent, readGitHistory, readGitHistoryFileDiff, readGitHistoryFileContentRange, readGitHistoryFiles, readCurrentChangeDiff, readRestrictedDiff, resolveGitRepository, resolveGitRepositoryTarget, restoreCurrentChange } from '../workspace.js';
2
+ import { isAbsolute, win32 } from 'node:path';
3
+ import { listGitHistoryRefs, normalizeFilePreviewPath, openGitHistoryFileContent, readGitHistory, readGitHistoryFileDiff, readGitHistoryFileContentRange, readGitHistoryFiles, readCurrentChangeDiff, readRestrictedDiff, resolveGitRepository, resolveGitRepositoryTarget, resolveWorkspaceGitRoot, restoreCurrentChange } from '../workspace.js';
3
4
  import { WorkspaceCodeNavigationService } from './workspace-code-navigation-service.js';
4
5
  import { WorkspaceContentSearchService } from './workspace-content-search-service.js';
5
6
  import { WorkspaceWatchController } from './workspace-watch-controller.js';
@@ -98,7 +99,7 @@ export class WorkspaceWorkbenchService {
98
99
  }
99
100
  async readPredictionFile(workspaceId, path, worktreePath) {
100
101
  const workspace = this.options.requireWorkspace(workspaceId);
101
- const repository = await this.fileRepository(workspace, worktreePath ? { worktreePath } : {});
102
+ const repository = await this.fileRepository(workspace, worktreePath ? { worktreePath, path } : {});
102
103
  const { readWorkspaceTextFile } = await import('../workspace.js');
103
104
  return readWorkspaceTextFile(repository, path, 0, 64 * 1024);
104
105
  }
@@ -180,7 +181,7 @@ export class WorkspaceWorkbenchService {
180
181
  (input.worktreePath !== undefined && typeof input.worktreePath !== 'string'))
181
182
  throw new Error('GIT_DIFF_INVALID');
182
183
  const workspace = this.gitWorkspace(input.workspaceId);
183
- const repository = await resolveGitRepositoryTarget(workspace.path, input.repositoryPath ?? '', input.worktreePath);
184
+ const repository = await resolveGitRepositoryTarget(await resolveWorkspaceGitRoot(workspace.path, workspace.includeParentRepository ?? true), input.repositoryPath ?? '', input.worktreePath);
184
185
  return { diff: await readCurrentChangeDiff(repository, input.path) };
185
186
  }
186
187
  async restoreChange(input) {
@@ -191,7 +192,7 @@ export class WorkspaceWorkbenchService {
191
192
  (input.state !== 'STAGED' && input.state !== 'UNSTAGED' && input.state !== 'UNTRACKED'))
192
193
  throw new Error('GIT_CHANGE_RESTORE_UNAVAILABLE');
193
194
  const workspace = this.gitWorkspace(input.workspaceId);
194
- const repository = await resolveGitRepositoryTarget(workspace.path, input.repositoryPath ?? '', input.worktreePath);
195
+ const repository = await resolveGitRepositoryTarget(await resolveWorkspaceGitRoot(workspace.path, workspace.includeParentRepository ?? true), input.repositoryPath ?? '', input.worktreePath);
195
196
  await restoreCurrentChange(repository, input.path, input.state);
196
197
  this.options.invalidate(workspace.id);
197
198
  return { restored: true };
@@ -201,14 +202,22 @@ export class WorkspaceWorkbenchService {
201
202
  (input.repositoryPath !== undefined && typeof input.repositoryPath !== 'string'))
202
203
  throw new Error('GIT_REPOSITORY_INVALID');
203
204
  const workspace = this.gitWorkspace(input.workspaceId);
204
- return resolveGitRepository(workspace.path, input.repositoryPath ?? '');
205
+ return resolveGitRepository(await resolveWorkspaceGitRoot(workspace.path, workspace.includeParentRepository ?? true), input.repositoryPath ?? '');
205
206
  }
206
207
  async fileRepository(workspace, input) {
207
- if (input.worktreePath === undefined)
208
+ if (input.worktreePath === undefined && input.repositoryPath === undefined)
208
209
  return workspace.path;
209
- if (typeof input.worktreePath !== 'string')
210
+ if ((input.worktreePath !== undefined && typeof input.worktreePath !== 'string') ||
211
+ (input.repositoryPath !== undefined && typeof input.repositoryPath !== 'string'))
210
212
  throw new Error('GIT_REPOSITORY_INVALID');
211
- return resolveGitRepositoryTarget(workspace.path, '', input.worktreePath);
213
+ if (typeof input.path !== 'string' ||
214
+ input.path.startsWith('mar-attachment:') ||
215
+ input.previewDirectory !== undefined)
216
+ throw new Error('FILE_PATH_ESCAPE');
217
+ const path = normalizeFilePreviewPath(input.path);
218
+ if (isAbsolute(path) || win32.isAbsolute(path))
219
+ throw new Error('FILE_PATH_ESCAPE');
220
+ return resolveGitRepositoryTarget(await resolveWorkspaceGitRoot(workspace.path, workspace.includeParentRepository ?? true), input.repositoryPath ?? '', input.worktreePath);
212
221
  }
213
222
  refs(input) {
214
223
  if (input.refs === undefined)
@@ -280,6 +289,9 @@ export class WorkspaceWorkbenchService {
280
289
  const input = {
281
290
  workspaceId: workspace.id,
282
291
  path: descriptor.resource.path,
292
+ ...(descriptor.resource.repositoryPath === undefined
293
+ ? {}
294
+ : { repositoryPath: descriptor.resource.repositoryPath }),
283
295
  ...(descriptor.resource.worktreePath === undefined
284
296
  ? {}
285
297
  : { worktreePath: descriptor.resource.worktreePath })
@@ -372,12 +384,12 @@ export class WorkspaceWorkbenchService {
372
384
  typeof input.toRef !== 'string')
373
385
  throw new Error('GIT_DIFF_INVALID');
374
386
  const workspace = this.gitWorkspace(input.workspaceId);
375
- return { diff: await readRestrictedDiff(workspace.path, input.fromRef, input.toRef) };
387
+ return {
388
+ diff: await readRestrictedDiff(await resolveWorkspaceGitRoot(workspace.path, workspace.includeParentRepository ?? true), input.fromRef, input.toRef)
389
+ };
376
390
  }
377
391
  gitWorkspace(workspaceId) {
378
392
  const workspace = this.options.requireWorkspace(workspaceId);
379
- if (workspace.kind !== 'GIT_WORKSPACE')
380
- throw new Error('WORKSPACE_NOT_GIT');
381
393
  return workspace;
382
394
  }
383
395
  }
package/dist/workspace.js CHANGED
@@ -1187,7 +1187,7 @@ export function isWithinAllowedRoot(target, roots, platform = hostPlatform()) {
1187
1187
  });
1188
1188
  }
1189
1189
  /** Resolving both candidate and roots rejects Linux symlinks which point outside a root. */
1190
- export async function inspectWorkspace(requestedPath, allowedRoots) {
1190
+ export async function inspectWorkspace(requestedPath, allowedRoots, includeParentRepository = true) {
1191
1191
  const resolvedPath = await realpath(requestedPath);
1192
1192
  const resolvedRoots = await Promise.all(allowedRoots.map((root) => realpath(root)));
1193
1193
  const directory = await stat(resolvedPath);
@@ -1195,16 +1195,40 @@ export async function inspectWorkspace(requestedPath, allowedRoots) {
1195
1195
  throw new Error('WORKSPACE_NOT_ALLOWED');
1196
1196
  }
1197
1197
  try {
1198
- const summary = await readGitSummary(resolvedPath);
1198
+ const repositoryRoot = await resolveWorkspaceGitRoot(resolvedPath, includeParentRepository);
1199
+ const summary = { ...(await readGitSummary(repositoryRoot)), repositoryRoot };
1199
1200
  return { path: resolvedPath, kind: 'GIT_WORKSPACE', gitSummary: summary };
1200
1201
  }
1201
1202
  catch (error) {
1202
- if ((error instanceof GitCommandError && error.code === 128) || isGitUnavailable(error)) {
1203
+ if ((error instanceof GitCommandError && error.code === 128) ||
1204
+ isGitUnavailable(error) ||
1205
+ (error instanceof Error && error.message === 'WORKSPACE_NOT_GIT')) {
1203
1206
  return { path: resolvedPath, kind: 'DIRECTORY' };
1204
1207
  }
1205
1208
  throw error;
1206
1209
  }
1207
1210
  }
1211
+ /** Resolves the working tree without changing the registered Workspace identity. */
1212
+ export async function resolveWorkspaceGitRoot(workspacePath, includeParentRepository = true) {
1213
+ const workspace = await resolveRegisteredWorkspaceRoot(workspacePath);
1214
+ if (!includeParentRepository) {
1215
+ const marker = await lstat(resolve(workspace, '.git')).catch((error) => {
1216
+ if (error.code === 'ENOENT')
1217
+ return undefined;
1218
+ throw error;
1219
+ });
1220
+ if (marker === undefined ||
1221
+ marker.isSymbolicLink() ||
1222
+ (!marker.isFile() && !marker.isDirectory()))
1223
+ throw new Error('WORKSPACE_NOT_GIT');
1224
+ }
1225
+ const result = await runReadOnlyGit(workspace, ['rev-parse', '--show-toplevel']);
1226
+ const root = await realpath(result.text.replace(/\n$/u, ''));
1227
+ if (!includeParentRepository &&
1228
+ normalizeWorkspacePath(root) !== normalizeWorkspacePath(workspace))
1229
+ throw new Error('WORKSPACE_NOT_GIT');
1230
+ return root;
1231
+ }
1208
1232
  export async function readGitSummary(workspacePath) {
1209
1233
  const commonDir = (await runReadOnlyGit(workspacePath, ['rev-parse', '--git-common-dir'])).text.trim();
1210
1234
  const headResult = await runReadOnlyGitAllowFailure(workspacePath, [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.11.1",
3
+ "version": "0.11.2",
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.11.1",
41
- "@myagentroam/code-navigation": "0.11.0",
40
+ "@myagentroam/agent": "0.11.2",
41
+ "@myagentroam/protocol": "0.11.2",
42
42
  "@myagentroam/marctl": "0.11.0",
43
- "@myagentroam/protocol": "0.11.1"
43
+ "@myagentroam/code-navigation": "0.11.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/ws": "^8.18.1"