@myagentroam/node 0.11.0 → 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
@@ -87,7 +87,8 @@ import { NodeControlChannel } from './service/node-control-channel.js';
87
87
  import { NodeControlMessageService } from './service/node-control-message-service.js';
88
88
  import { NodeRequestService } from './service/node-request-service.js';
89
89
  import { WorkbenchManifestService } from './service/workbench-manifest-service.js';
90
- import { NodeRunEventBridge, workbenchPayloadSessionId } from './service/node-run-event-bridge.js';
90
+ import { NodeRunEventBridge } from './service/node-run-event-bridge.js';
91
+ import { workbenchPayloadSessionId } from './service/workbench-event-projection.js';
91
92
  import { NodeWorkspaceCoordinator } from './service/node-workspace-coordinator.js';
92
93
  import { FakeManagedRunService } from './service/fake-managed-run-service.js';
93
94
  import { RunnerChannelMessageService } from './service/runner-channel-message-service.js';
@@ -203,9 +204,18 @@ export class NodeConnector {
203
204
  configPath;
204
205
  reconnect;
205
206
  claudeApprovalTimeoutMs;
206
- workbenchEventService = new WorkbenchEventService((envelope) => this.sendEnvelope(envelope), (category, payload) => {
207
- const sessionId = workbenchPayloadSessionId(this.runtime, category, payload);
208
- return sessionId === undefined || !this.sessionIdentityService.bootstrapIds.has(sessionId);
207
+ workbenchEventService = new WorkbenchEventService({
208
+ send: (envelope) => this.sendEnvelope(envelope),
209
+ accepts: (category, payload) => {
210
+ const sessionId = workbenchPayloadSessionId((id) => this.runtime.getRun(id), category, payload);
211
+ return sessionId === undefined || !this.sessionIdentityService.bootstrapIds.has(sessionId);
212
+ },
213
+ projection: {
214
+ getRun: (id) => this.runtime.getRun(id),
215
+ conversationTurnForRun: (id) => this.runtime.conversationTurnForRun(id),
216
+ progress: (sessionId) => marctlSessionProgress(this.runtime, sessionId)
217
+ },
218
+ emitRealtime: (sessionId, event) => this.nativeSessionWatchService.emitRealtime(sessionId, event)
209
219
  });
210
220
  fakeRunner;
211
221
  fakeScript;
@@ -1146,8 +1156,10 @@ export class NodeConnector {
1146
1156
  nodeLog('node.connection.replaced', {
1147
1157
  message: 'This Node connection was replaced by a newer instance with the same identity. Check for duplicate processes.'
1148
1158
  });
1149
- if (code === 4003 || code === 1001)
1150
- void this.terminalManager.shutdown();
1159
+ // Credential revocation ends terminals, but the control channel can still reconnect.
1160
+ // Only final Node disposal shuts down the manager and its idle collector.
1161
+ if (code === 4003)
1162
+ void this.terminalManager.closeAll();
1151
1163
  }
1152
1164
  });
1153
1165
  this.controlMessages = new NodeControlMessageService({
@@ -1331,7 +1343,7 @@ export class NodeConnector {
1331
1343
  this.workspaceCoordinator.clearWatches(workspaceId);
1332
1344
  this.workspaceWorkbenchService.clearWorkspace(workspaceId);
1333
1345
  this.promptCompletion?.invalidateWorkspace(workspaceId);
1334
- });
1346
+ }, (workspaceId) => this.workspaceCoordinator.gitPolicyChanged(workspaceId));
1335
1347
  this.workspaceWorkbenchService = new WorkspaceWorkbenchService({
1336
1348
  requireWorkspace: (workspaceId) => this.workspaceCoordinator.require(workspaceId),
1337
1349
  inspectWorkspace: (workspace) => this.workspaceCoordinator.inspectMetadata(workspace),
@@ -1718,24 +1730,7 @@ export class NodeConnector {
1718
1730
  }
1719
1731
  this.emitWorkbenchEvent('session', payload);
1720
1732
  };
1721
- emitWorkbenchEvent = (category, payload) => {
1722
- payload = this.runEventBridge.projectWorkbenchPayload(category, payload);
1723
- if (payload === undefined)
1724
- return;
1725
- const event = this.workbenchEventService.next(category, payload);
1726
- if (event === undefined)
1727
- return;
1728
- const notification = category === 'conversation' &&
1729
- isPlainRecord(payload) &&
1730
- isPlainRecord(payload.event) &&
1731
- payload.event.eventType === 'notification';
1732
- const sessionId = workbenchPayloadSessionId(this.runtime, category, payload);
1733
- if (!notification &&
1734
- sessionId !== undefined &&
1735
- this.nativeSessionWatchService.emitRealtime(sessionId, event))
1736
- return;
1737
- this.workbenchEventService.publish(event);
1738
- };
1733
+ emitWorkbenchEvent = (category, payload) => this.workbenchEventService.dispatch(category, payload);
1739
1734
  discoverWorkspaceSessions = (workspaceId, onRunnerDiscovered) => this.nativeSessionProjectionService.discoverWorkspace(workspaceId, onRunnerDiscovered);
1740
1735
  invalidateWorkspaceWatchTopics = (workspaceId) => this.workspaceCoordinator.invalidate(workspaceId);
1741
1736
  reconcilePersistedRuns = () => undefined;
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,122 +1,9 @@
1
- import { conversationSegmentIdentityForItem, conversationSegments } from './conversation-segment-service.js';
2
- import { runConversationItemId } from '../runtime-state.js';
3
- import { marctlSessionProgress } from './session-recent-activity.js';
4
- import { isNodeAgentRun, isNodeConversationTurn, isPlainRecord } from '../runner/codex/conversation-parser.js';
5
- export function workbenchPayloadSessionId(runtime, category, payload) {
6
- if (!isPlainRecord(payload))
7
- return undefined;
8
- if (category === 'session') {
9
- if (isPlainRecord(payload.session)) {
10
- const id = payload.session.id;
11
- if (typeof id === 'string')
12
- return id;
13
- }
14
- return typeof payload.sessionId === 'string' ? payload.sessionId : undefined;
15
- }
16
- if (category === 'run' && isNodeAgentRun(payload.run))
17
- return payload.run.sessionId;
18
- if (category === 'conversation') {
19
- if (isNodeConversationTurn(payload.turn))
20
- return payload.turn.sessionId;
21
- if (typeof payload.sessionId === 'string')
22
- return payload.sessionId;
23
- if (isPlainRecord(payload.event)) {
24
- const runId = payload.event.runId;
25
- return typeof runId === 'string' ? runtime.getRun(runId)?.sessionId : undefined;
26
- }
27
- }
28
- if (category === 'queue') {
29
- const id = payload.sessionId;
30
- return typeof id === 'string' ? id : undefined;
31
- }
32
- return undefined;
33
- }
34
1
  /** Bridges process-local Run state to normalized control and Workbench events. */
35
2
  export class NodeRunEventBridge {
36
3
  options;
37
4
  constructor(options) {
38
5
  this.options = options;
39
6
  }
40
- /** The sole public projection boundary for normalized Run callbacks. */
41
- projectWorkbenchPayload(category, payload) {
42
- if (category === 'conversation' &&
43
- isPlainRecord(payload) &&
44
- isPlainRecord(payload.event) &&
45
- payload.event.eventType !== 'notification') {
46
- const runId = typeof payload.event.runId === 'string' ? payload.event.runId : undefined;
47
- const sequence = typeof payload.event.sequence === 'number' ? payload.event.sequence : undefined;
48
- const turn = runId === undefined ? undefined : this.options.runtime.conversationTurnForRun(runId);
49
- if (turn === undefined)
50
- return;
51
- const rawPayload = isPlainRecord(payload.event.payload) ? payload.event.payload : undefined;
52
- const text = payload.event.eventType === 'text.delta' && typeof rawPayload?.text === 'string'
53
- ? rawPayload.text
54
- : undefined;
55
- const nativeItemId = typeof rawPayload?.itemId === 'string'
56
- ? rawPayload.itemId
57
- : typeof rawPayload?.approvalId === 'string'
58
- ? `approval:${rawPayload.approvalId}`
59
- : undefined;
60
- const item = text !== undefined && sequence !== undefined
61
- ? turn.items.find((candidate) => candidate.kind === 'assistant_message' && candidate.revision === sequence)
62
- : nativeItemId === undefined
63
- ? undefined
64
- : turn.items.find((candidate) => candidate.id ===
65
- runConversationItemId(runId, nativeItemId, typeof rawPayload?.kind === 'string' ? rawPayload.kind : undefined));
66
- if (text !== undefined && item?.kind === 'assistant_message') {
67
- const segment = conversationSegmentIdentityForItem(turn, item.id, item.kind);
68
- payload = {
69
- sessionId: turn.sessionId,
70
- delta: {
71
- turnId: turn.id,
72
- segmentId: segment.id,
73
- segmentIndex: segment.index,
74
- runId: turn.runId,
75
- itemId: item.id,
76
- text,
77
- createdAt: typeof payload.event.createdAt === 'number' ? payload.event.createdAt : Date.now()
78
- }
79
- };
80
- }
81
- else if (item !== undefined) {
82
- const segment = conversationSegmentIdentityForItem(turn, item.id, item.kind);
83
- payload = {
84
- sessionId: turn.sessionId,
85
- runId: turn.runId,
86
- ...(['assistant_message', 'tool_call', 'command_execution', 'file_change'].includes(item.kind) &&
87
- (rawPayload?.append === undefined || item.status !== 'IN_PROGRESS')
88
- ? { progress: marctlSessionProgress(this.options.runtime, turn.sessionId) }
89
- : {}),
90
- item: {
91
- turnId: turn.id,
92
- itemId: item.id,
93
- kind: item.kind,
94
- status: item.status,
95
- payload: item.payload,
96
- startedAt: item.startedAt,
97
- completedAt: item.completedAt,
98
- segmentId: segment.id,
99
- segmentIndex: segment.index
100
- }
101
- };
102
- }
103
- else {
104
- if (payload.event.eventType !== 'run.started' && payload.event.eventType !== 'run.running')
105
- return;
106
- payload = { turn };
107
- }
108
- }
109
- if (category === 'conversation' &&
110
- isPlainRecord(payload) &&
111
- isNodeConversationTurn(payload.turn))
112
- return { ...payload, segments: conversationSegments(payload.turn) };
113
- if (category === 'run' && isPlainRecord(payload) && isNodeAgentRun(payload.run))
114
- return {
115
- ...payload,
116
- progress: marctlSessionProgress(this.options.runtime, payload.run.sessionId)
117
- };
118
- return payload;
119
- }
120
7
  emitConversationItem(runId, item) {
121
8
  this.emitRun(runId, 'conversation.item', item);
122
9
  }
@@ -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 {
@@ -16,24 +16,22 @@ export class ScheduledSessionCoordinator {
16
16
  const store = this.options.store();
17
17
  store.finish(run.clientMessageId, status === 'SUCCEEDED' ? 'SUCCEEDED' : 'FAILED', status === 'SUCCEEDED' ? undefined : `SCHEDULE_RUN_${status}`, run.id);
18
18
  const task = store.getById(run.clientMessageId.split(':', 1)[0]);
19
- if (task !== undefined) {
20
- if (task.notifyOnTerminal)
21
- this.publishTaskNotification(task, run.clientMessageId, {
22
- kind: 'SCHEDULE_TERMINAL',
23
- outcome: status,
24
- runId: run.id
25
- });
26
- else if (status !== 'SUCCEEDED')
27
- this.notify(task, run.clientMessageId, `SCHEDULE_RUN_${status}`, 'FAILED');
28
- }
19
+ if (task !== undefined)
20
+ this.publishTaskNotification(task, run.clientMessageId, {
21
+ kind: 'SCHEDULE_TERMINAL',
22
+ outcome: status,
23
+ runId: run.id
24
+ });
29
25
  }
30
26
  notify = (task, occurrenceId, code, status) => this.publishTaskNotification(task, occurrenceId, {
31
27
  kind: status === 'FAILED' ? 'SCHEDULE_FAILED' : 'SCHEDULE_SKIPPED',
32
28
  code
33
29
  });
34
30
  publishTaskNotification = (task, occurrenceId, event) => {
31
+ const currentTask = this.options.store().getById(task.id) ?? task;
32
+ if (!currentTask.notifyOnTerminal)
33
+ return;
35
34
  const occurredAt = Date.now();
36
- const recipient = this.options.store().getById(task.id)?.ownerUserId ?? task.ownerUserId;
37
35
  this.options.emitNotification({
38
36
  event: {
39
37
  runId: event.kind === 'SCHEDULE_TERMINAL' ? event.runId : occurrenceId,
@@ -41,7 +39,7 @@ export class ScheduledSessionCoordinator {
41
39
  eventType: 'notification',
42
40
  payload: {
43
41
  targetType: 'WORKSPACE_SESSION',
44
- userId: recipient,
42
+ userId: currentTask.ownerUserId,
45
43
  workspaceId: task.workspaceId,
46
44
  sessionId: task.sessionId,
47
45
  sessionTitle: this.options.sessionTitle(task.sessionId),
@@ -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 {