@myagentroam/node 0.1.7 → 0.9.0

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 (56) hide show
  1. package/dist/capabilities.js +6 -3
  2. package/dist/claude-agent-sdk.d.ts +1 -0
  3. package/dist/claude-agent-sdk.js +8 -1
  4. package/dist/codex-app-server.d.ts +6 -0
  5. package/dist/codex-app-server.js +6 -0
  6. package/dist/config.d.ts +3 -5
  7. package/dist/config.js +9 -20
  8. package/dist/connector/node-connector-options.d.ts +1 -0
  9. package/dist/connector.js +12 -3
  10. package/dist/database.d.ts +23 -0
  11. package/dist/database.js +84 -2
  12. package/dist/main.js +4 -1
  13. package/dist/migrations/v004.d.ts +4 -0
  14. package/dist/migrations/v004.js +35 -0
  15. package/dist/opencode-server.d.ts +1 -0
  16. package/dist/opencode-server.js +13 -1
  17. package/dist/operational.js +4 -1
  18. package/dist/rotating-log.d.ts +17 -0
  19. package/dist/rotating-log.js +67 -0
  20. package/dist/runner/abstract-runner.d.ts +12 -2
  21. package/dist/runner/abstract-runner.js +76 -2
  22. package/dist/runner/claude/managed-run-controller.js +2 -1
  23. package/dist/runner/claude-code-runner.d.ts +1 -0
  24. package/dist/runner/claude-code-runner.js +14 -0
  25. package/dist/runner/codex/managed-run-controller.js +5 -3
  26. package/dist/runner/codex-runner.d.ts +16 -2
  27. package/dist/runner/codex-runner.js +215 -4
  28. package/dist/runner/opencode/managed-run-controller.js +13 -3
  29. package/dist/runner/opencode-runner.d.ts +2 -1
  30. package/dist/runner/opencode-runner.js +23 -3
  31. package/dist/runner/runner-registry.d.ts +1 -1
  32. package/dist/runner/runner-registry.js +2 -2
  33. package/dist/runner-profiles.js +5 -25
  34. package/dist/runtime-command-detector.d.ts +3 -0
  35. package/dist/runtime-command-detector.js +78 -0
  36. package/dist/service/mcp-installation-verifier.d.ts +9 -0
  37. package/dist/service/mcp-installation-verifier.js +87 -0
  38. package/dist/service/mcp-node-operation-service.d.ts +11 -0
  39. package/dist/service/mcp-node-operation-service.js +90 -0
  40. package/dist/service/mcp-package-installer.d.ts +8 -0
  41. package/dist/service/mcp-package-installer.js +136 -0
  42. package/dist/service/node-connection-lifecycle-service.js +1 -2
  43. package/dist/service/runner-service.d.ts +4 -2
  44. package/dist/service/runner-service.js +5 -2
  45. package/dist/service/session-lifecycle-service.js +7 -4
  46. package/dist/service/skill-directory-service.d.ts +17 -0
  47. package/dist/service/skill-directory-service.js +203 -0
  48. package/dist/service/skill-install-service.d.ts +21 -0
  49. package/dist/service/skill-install-service.js +504 -0
  50. package/dist/service/skill-node-operation-service.d.ts +44 -0
  51. package/dist/service/skill-node-operation-service.js +203 -0
  52. package/dist/service/workbench-manifest-service.d.ts +4 -2
  53. package/dist/service/workspace-queue-workbench-service.d.ts +2 -0
  54. package/dist/service/workspace-queue-workbench-service.js +2 -1
  55. package/dist/supervisor.js +1 -20
  56. package/package.json +2 -2
@@ -3,6 +3,7 @@ import nodePackage from '../package.json' with { type: 'json' };
3
3
  import { probeClaudeCode, probeClaudeCodeAsync } from './claude-agent-sdk.js';
4
4
  import { probeCodex, probeCodexAsync } from './codex-app-server.js';
5
5
  import { probeOpenCode, probeOpenCodeAsync } from './opencode-runtime.js';
6
+ import { detectRuntimeCommands } from './runtime-command-detector.js';
6
7
  const nodeAppVersion = typeof nodePackage.version === 'string' ? nodePackage.version : 'unknown';
7
8
  function reportedPlatform() {
8
9
  return process.platform === 'win32' ? 'windows' : 'linux';
@@ -59,10 +60,11 @@ export async function detectCapabilitiesAsync(commands = {}) {
59
60
  if (process.platform !== 'win32' && process.platform !== 'linux') {
60
61
  throw new Error('NODE_PLATFORM_UNSUPPORTED');
61
62
  }
62
- const [codex, claudeCode, openCode] = await Promise.all([
63
+ const [codex, claudeCode, openCode, runtimeCommands] = await Promise.all([
63
64
  probeCodexAsync(commands.codex),
64
65
  probeClaudeCodeAsync(commands.claudeCode),
65
- probeOpenCodeAsync(commands.openCode)
66
+ probeOpenCodeAsync(commands.openCode),
67
+ detectRuntimeCommands()
66
68
  ]);
67
69
  return {
68
70
  platform: reportedPlatform(),
@@ -71,7 +73,8 @@ export async function detectCapabilitiesAsync(commands = {}) {
71
73
  nodeVersion: process.version,
72
74
  metadata: {
73
75
  osRelease: release(),
74
- osVersion: version()
76
+ osVersion: version(),
77
+ runtimeCommands
75
78
  },
76
79
  codex: {
77
80
  available: codex.available && codex.compatible,
@@ -22,6 +22,7 @@ export interface ClaudeQueryInput {
22
22
  readonly onMessage: (message: SDKMessage) => void;
23
23
  readonly onChannelReply?: (content: string) => void;
24
24
  readonly environment?: Readonly<Record<string, string>>;
25
+ readonly mcpServers?: NonNullable<Options['mcpServers']>;
25
26
  }
26
27
  /** Image blocks accepted by the Claude Agent SDK message input. */
27
28
  export interface ClaudeImageAttachment {
@@ -66,7 +66,14 @@ export class ClaudeAgentSdkAdapter {
66
66
  settings: { showThinkingSummaries: true },
67
67
  abortController,
68
68
  canUseTool: input.onPermission,
69
- ...(channelEnabled ? { mcpServers: { myagentroam: channelMcpServer(input) } } : {})
69
+ ...(input.mcpServers !== undefined || channelEnabled
70
+ ? {
71
+ mcpServers: {
72
+ ...(input.mcpServers ?? {}),
73
+ ...(channelEnabled ? { myagentroam: channelMcpServer(input) } : {})
74
+ }
75
+ }
76
+ : {})
70
77
  }
71
78
  });
72
79
  const completed = consume(stream, input.onMessage);
@@ -49,6 +49,7 @@ export interface CodexRunConfiguration {
49
49
  readonly access?: string | null;
50
50
  readonly serviceTier?: 'fast';
51
51
  readonly collaborationMode?: 'default' | 'plan';
52
+ readonly mcpServers?: unknown;
52
53
  }
53
54
  /** Public App Server turn inputs supported by the Workbench Composer. */
54
55
  export type CodexComposerInput = {
@@ -84,6 +85,11 @@ export declare class CodexAppServerClient {
84
85
  startThread(cwd: string, configuration?: CodexRunConfiguration): Promise<unknown>;
85
86
  listThreads(): Promise<unknown>;
86
87
  listCollaborationModes(): Promise<unknown>;
88
+ listModels(params: {
89
+ readonly cursor: string | null;
90
+ readonly limit: number;
91
+ readonly includeHidden: boolean;
92
+ }): Promise<unknown>;
87
93
  readThread(threadId: string, includeTurns?: boolean): Promise<unknown>;
88
94
  forkThread(threadId: string, cwd: string, lastTurnId: string, configuration?: CodexRunConfiguration): Promise<unknown>;
89
95
  setThreadName(threadId: string, name: string): Promise<unknown>;
@@ -170,6 +170,9 @@ export class CodexAppServerClient {
170
170
  listCollaborationModes() {
171
171
  return this.request('collaborationMode/list', {});
172
172
  }
173
+ listModels(params) {
174
+ return this.request('model/list', params);
175
+ }
173
176
  readThread(threadId, includeTurns = true) {
174
177
  return this.request('thread/read', { threadId, includeTurns });
175
178
  }
@@ -263,6 +266,9 @@ export class CodexAppServerClient {
263
266
  function codexThreadConfiguration(cwd, configuration) {
264
267
  return {
265
268
  ...modelOption(configuration.model),
269
+ ...(configuration.mcpServers === undefined
270
+ ? {}
271
+ : { config: { mcp_servers: configuration.mcpServers } }),
266
272
  ...codexPermissionConfiguration(cwd, configuration.access, 'thread')
267
273
  };
268
274
  }
package/dist/config.d.ts CHANGED
@@ -2,19 +2,17 @@ export interface NodeConfig {
2
2
  readonly serverUrl: string;
3
3
  /** Explicit roots or the platform default resolved while loading the config. */
4
4
  readonly allowedRoots: readonly string[];
5
- /** Absolute or config-relative location of this Node's private runtime SQLite. */
6
- readonly databasePath?: string;
5
+ /** Absolute or config-relative root for SQLite, upgrades and other private Node data. */
6
+ readonly dataDirectory?: string;
7
7
  readonly nodeId?: string;
8
8
  readonly credential?: string;
9
9
  readonly registrationToken?: string;
10
- readonly upgrade?: {
11
- readonly registryUrl?: string;
12
- };
13
10
  }
14
11
  export declare function nodeConfigPath(): string;
15
12
  export declare function loadNodeConfig(path?: string): Promise<NodeConfig>;
16
13
  /** Uses the Node service account's scope when no Workspace roots are configured. */
17
14
  export declare function defaultAllowedRoots(platform?: NodeJS.Platform, homePath?: string, currentDirectory?: string): readonly string[];
18
15
  export declare function nodeDatabasePath(config: NodeConfig, configPath?: string): string;
16
+ export declare function nodeDataDirectory(config: NodeConfig, configPath?: string): string;
19
17
  /** Credentials are always written with owner-only POSIX permissions where supported. */
20
18
  export declare function saveNodeConfig(config: NodeConfig, path?: string): Promise<void>;
package/dist/config.js CHANGED
@@ -10,6 +10,7 @@ export async function loadNodeConfig(path = nodeConfigPath()) {
10
10
  parsed === null ||
11
11
  !('serverUrl' in parsed) ||
12
12
  typeof parsed.serverUrl !== 'string' ||
13
+ 'databasePath' in parsed ||
13
14
  ('allowedRoots' in parsed &&
14
15
  (!Array.isArray(parsed.allowedRoots) ||
15
16
  !parsed.allowedRoots.every((root) => typeof root === 'string')))) {
@@ -20,15 +21,7 @@ export async function loadNodeConfig(path = nodeConfigPath()) {
20
21
  const nodeId = optionalString('nodeId');
21
22
  const credential = optionalString('credential');
22
23
  const registrationToken = optionalString('registrationToken');
23
- const databasePath = optionalString('databasePath');
24
- const upgradeInput = input['upgrade'];
25
- if (upgradeInput !== undefined &&
26
- (upgradeInput === null ||
27
- typeof upgradeInput !== 'object' ||
28
- Array.isArray(upgradeInput) ||
29
- ('registryUrl' in upgradeInput &&
30
- typeof upgradeInput['registryUrl'] !== 'string')))
31
- throw new Error('NODE_CONFIG_INVALID');
24
+ const dataDirectory = optionalString('dataDirectory');
32
25
  const allowedRoots = Array.isArray(input['allowedRoots'])
33
26
  ? input['allowedRoots']
34
27
  : defaultAllowedRoots();
@@ -38,16 +31,7 @@ export async function loadNodeConfig(path = nodeConfigPath()) {
38
31
  ...(nodeId === undefined ? {} : { nodeId }),
39
32
  ...(credential === undefined ? {} : { credential }),
40
33
  ...(registrationToken === undefined ? {} : { registrationToken }),
41
- ...(databasePath === undefined ? {} : { databasePath }),
42
- ...(upgradeInput === undefined
43
- ? {}
44
- : {
45
- upgrade: {
46
- ...(upgradeInput['registryUrl'] === undefined
47
- ? {}
48
- : { registryUrl: upgradeInput['registryUrl'] })
49
- }
50
- })
34
+ ...(dataDirectory === undefined ? {} : { dataDirectory })
51
35
  };
52
36
  }
53
37
  /** Uses the Node service account's scope when no Workspace roots are configured. */
@@ -55,7 +39,12 @@ export function defaultAllowedRoots(platform = process.platform, homePath = home
55
39
  return platform === 'win32' ? [win32.parse(currentDirectory).root] : [homePath];
56
40
  }
57
41
  export function nodeDatabasePath(config, configPath = nodeConfigPath()) {
58
- return config.databasePath ?? resolve(dirname(configPath), 'node-runtime.sqlite');
42
+ return resolve(nodeDataDirectory(config, configPath), 'node-runtime.sqlite');
43
+ }
44
+ export function nodeDataDirectory(config, configPath = nodeConfigPath()) {
45
+ if (config.dataDirectory !== undefined)
46
+ return resolve(dirname(configPath), config.dataDirectory);
47
+ return resolve(dirname(configPath));
59
48
  }
60
49
  /** Credentials are always written with owner-only POSIX permissions where supported. */
61
50
  export async function saveNodeConfig(config, path = nodeConfigPath()) {
@@ -16,6 +16,7 @@ export interface NodeConnectorOptions {
16
16
  readonly fakeRunner?: FakeRunner;
17
17
  readonly fakeScript?: (input: unknown, runId: string) => readonly FakeRunnerStep[];
18
18
  readonly codexClient?: CodexAppServerClient;
19
+ readonly codexProfileClientFactory?: () => CodexAppServerClient;
19
20
  readonly claudeClient?: ClaudeAgentSdkAdapter;
20
21
  readonly openCodeClient?: OpenCodeServerClient;
21
22
  readonly database?: NodeDatabase;
package/dist/connector.js CHANGED
@@ -18,6 +18,8 @@ import { RunnerRegistry } from './runner/runner-registry.js';
18
18
  import { OpenCodeRunner } from './runner/opencode-runner.js';
19
19
  import { OpenCodeManagedRunController } from './runner/opencode/managed-run-controller.js';
20
20
  import { NodeOperationRouter } from './connector/node-operation-router.js';
21
+ import { SkillNodeOperationService } from './service/skill-node-operation-service.js';
22
+ import { McpNodeOperationService } from './service/mcp-node-operation-service.js';
21
23
  import { RunnerService } from './service/runner-service.js';
22
24
  import { TerminalService } from './service/terminal-service.js';
23
25
  import { WorkspaceService } from './service/workspace-service.js';
@@ -229,7 +231,7 @@ export class NodeConnector {
229
231
  options.fakeRunner === undefined ? undefined : new FakeTestRunner(options.fakeRunner);
230
232
  this.fakeScript = options.fakeScript;
231
233
  this.runAttachmentService = new RunAttachmentService((session) => this.runners.require(session.runner).nativeImageRoot(session));
232
- this.codexClient = new CodexRunner(options.codexClient);
234
+ this.codexClient = new CodexRunner(options.codexClient, options.codexProfileClientFactory);
233
235
  this.codexRunController = new CodexManagedRunController(this.codexClient, {
234
236
  available: () => this.capabilities.codex.available,
235
237
  intercepted: () => this.fakeRunner !== undefined,
@@ -313,9 +315,9 @@ export class NodeConnector {
313
315
  commandStates: (sessionId) => this.commandStates.list(sessionId)
314
316
  });
315
317
  this.runners = new RunnerRegistry([
318
+ this.openCodeClient,
316
319
  this.codexClient,
317
320
  this.claudeClient,
318
- this.openCodeClient,
319
321
  ...(this.fakeRunner === undefined ? [] : [this.fakeRunner])
320
322
  ]);
321
323
  this.managedRunnerSessions = new ManagedRunnerSessionService(this.runtime, this.runners, (session) => this.emitWorkbenchEvent('session', {
@@ -403,7 +405,8 @@ export class NodeConnector {
403
405
  control: (operation, payload) => this.controlMessages.handle(JSON.stringify(createEnvelope(operation, payload))),
404
406
  stopped: () => this.stopped,
405
407
  markInterrupted: (runId) => this.runState.interruptRequested.add(runId),
406
- prepareInput: (runner, input, collaborationMode) => this.runners.require(runner).prepareMessageInput(input, collaborationMode)
408
+ prepareInput: (runner, input, collaborationMode) => this.runners.require(runner).prepareMessageInput(input, collaborationMode),
409
+ mcps: (workspaceId) => this.database?.effectiveMcpInstallations(workspaceId) ?? []
407
410
  });
408
411
  this.sessionIdentityService = new SessionIdentityService({
409
412
  runtime: this.runtime,
@@ -708,6 +711,12 @@ export class NodeConnector {
708
711
  this.operationRouter.registerAll(this.sessionCatalogService.operations());
709
712
  this.operationRouter.registerAll(this.sessionCommandService.operations());
710
713
  this.operationRouter.registerAll(this.workspaceQueueWorkbenchService.operations());
714
+ this.operationRouter.registerAll(new SkillNodeOperationService(requireDatabase, () => {
715
+ if (this.config === undefined)
716
+ throw new Error('NODE_CONFIG_UNAVAILABLE');
717
+ return this.config;
718
+ }).operations());
719
+ this.operationRouter.registerAll(new McpNodeOperationService(requireDatabase).operations());
711
720
  this.operationRouter.register('node.metrics', () => ({
712
721
  metrics: {
713
722
  ...this.metrics.snapshot(),
@@ -1,6 +1,19 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
2
  import { type AgentRunStatus, type RunnerDefaultConfiguration, type RunnerName } from '@myagentroam/protocol';
3
+ import { type McpCatalogEntry, type McpInstallationConfiguration, type McpRuntimeDescriptor } from '@myagentroam/protocol';
3
4
  import type { NativeSessionHistory } from './native-session-history.js';
5
+ export interface NodeMcpInstallation {
6
+ readonly targetKind: 'NODE' | 'WORKSPACE';
7
+ readonly targetId: string;
8
+ readonly catalogEntryId: string;
9
+ readonly serverName: string;
10
+ readonly displayName: string;
11
+ readonly version: string;
12
+ readonly enabled: boolean;
13
+ readonly runtime: McpRuntimeDescriptor;
14
+ readonly configuration: McpInstallationConfiguration;
15
+ readonly installedAt: number;
16
+ }
4
17
  export type RunRecoveryState = 'ACTIVE' | 'SUSPECT' | 'LOST';
5
18
  export type ApprovalStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'EXPIRED';
6
19
  /** Whether a native session ID was created by this Node or merely discovered. */
@@ -153,6 +166,16 @@ export declare class NodeDatabase {
153
166
  close(): void;
154
167
  getRunnerDefaults(): readonly RunnerDefaultConfiguration[];
155
168
  saveRunnerDefaults(input: RunnerDefaultConfiguration): RunnerDefaultConfiguration;
169
+ listMcpInstallations(targetKind: 'NODE' | 'WORKSPACE', targetId?: string): readonly NodeMcpInstallation[];
170
+ saveMcpInstallation(input: {
171
+ readonly targetKind: 'NODE' | 'WORKSPACE';
172
+ readonly targetId?: string;
173
+ readonly entry: McpCatalogEntry;
174
+ readonly enabled: boolean;
175
+ readonly configuration: NodeMcpInstallation['configuration'];
176
+ }): NodeMcpInstallation;
177
+ removeMcpInstallation(targetKind: 'NODE' | 'WORKSPACE', targetId: string, serverName: string): boolean;
178
+ effectiveMcpInstallations(workspaceId: string): readonly NodeMcpInstallation[];
156
179
  /**
157
180
  * Stores only terminal lifecycle metadata. PTY input/output must remain in
158
181
  * the Node-local bounded replay buffer and must never be passed here.
package/dist/database.js CHANGED
@@ -3,10 +3,12 @@ import { closeSync, existsSync, mkdirSync, openSync, rmSync } from 'node:fs';
3
3
  import { dirname } from 'node:path';
4
4
  import { DatabaseSync } from 'node:sqlite';
5
5
  import { runnerDefaultConfigurationSchema } from '@myagentroam/protocol';
6
+ import { mcpCatalogEntrySchema, mcpInstallationConfigurationSchema, mcpRuntimeDescriptorSchema } from '@myagentroam/protocol';
6
7
  import { assertNodeSchema, NODE_SCHEMA_V1 } from './migrations/v001.js';
7
8
  import { assertNodeSchemaV2, migrateNodeSchemaV2 } from './migrations/v002.js';
8
- import { assertNodeSchemaV3, migrateNodeSchemaV3, NODE_SCHEMA_VERSION_V3 } from './migrations/v003.js';
9
- const NODE_SCHEMA_VERSION = NODE_SCHEMA_VERSION_V3;
9
+ import { assertNodeSchemaV3, migrateNodeSchemaV3 } from './migrations/v003.js';
10
+ import { assertNodeSchemaV4, migrateNodeSchemaV4, NODE_SCHEMA_VERSION_V4 } from './migrations/v004.js';
11
+ const NODE_SCHEMA_VERSION = NODE_SCHEMA_VERSION_V4;
10
12
  export class NodeDatabase {
11
13
  now;
12
14
  activeWorkspaceLeaseCount() {
@@ -63,6 +65,57 @@ export class NodeDatabase {
63
65
  .run(input.runner, input.model, input.effort, input.access);
64
66
  return input;
65
67
  }
68
+ listMcpInstallations(targetKind, targetId = '') {
69
+ const rows = this.raw
70
+ .prepare(`SELECT target_kind,target_id,catalog_entry_id,server_name,display_name,version,
71
+ enabled,runtime,configuration,installed_at
72
+ FROM mcp_installations WHERE target_kind=? AND target_id=? ORDER BY display_name,server_name`)
73
+ .all(targetKind, targetKind === 'NODE' ? '' : targetId);
74
+ return rows.map(mcpInstallation);
75
+ }
76
+ saveMcpInstallation(input) {
77
+ const entry = mcpCatalogEntrySchema.parse(input.entry);
78
+ const configuration = mcpInstallationConfigurationSchema.parse(input.configuration);
79
+ const targetId = input.targetKind === 'NODE' ? '' : (input.targetId ?? '');
80
+ if (input.targetKind === 'WORKSPACE' && this.getWorkspace(targetId) === undefined)
81
+ throw new Error('WORKSPACE_NOT_FOUND');
82
+ const installedAt = this.now();
83
+ this.raw
84
+ .prepare(`INSERT INTO mcp_installations
85
+ (target_kind,target_id,catalog_entry_id,server_name,display_name,version,enabled,runtime,configuration,installed_at)
86
+ VALUES (?,?,?,?,?,?,?,?,?,?)
87
+ ON CONFLICT(target_kind,target_id,server_name) DO UPDATE SET
88
+ catalog_entry_id=excluded.catalog_entry_id,display_name=excluded.display_name,
89
+ version=excluded.version,enabled=excluded.enabled,runtime=excluded.runtime,
90
+ configuration=excluded.configuration,installed_at=excluded.installed_at`)
91
+ .run(input.targetKind, targetId, entry.id, entry.serverName, entry.displayName, entry.version, input.enabled ? 1 : 0, JSON.stringify(entry.runtime), JSON.stringify(configuration), installedAt);
92
+ return {
93
+ targetKind: input.targetKind,
94
+ targetId,
95
+ catalogEntryId: entry.id,
96
+ serverName: entry.serverName,
97
+ displayName: entry.displayName,
98
+ version: entry.version,
99
+ enabled: input.enabled,
100
+ runtime: entry.runtime,
101
+ configuration,
102
+ installedAt
103
+ };
104
+ }
105
+ removeMcpInstallation(targetKind, targetId, serverName) {
106
+ return (Number(this.raw
107
+ .prepare('DELETE FROM mcp_installations WHERE target_kind=? AND target_id=? AND server_name=?')
108
+ .run(targetKind, targetKind === 'NODE' ? '' : targetId, serverName).changes) > 0);
109
+ }
110
+ effectiveMcpInstallations(workspaceId) {
111
+ const effective = new Map(this.listMcpInstallations('NODE').map((installation) => [
112
+ installation.serverName,
113
+ installation
114
+ ]));
115
+ for (const installation of this.listMcpInstallations('WORKSPACE', workspaceId))
116
+ effective.set(installation.serverName, installation);
117
+ return [...effective.values()].filter((installation) => installation.enabled);
118
+ }
66
119
  /**
67
120
  * Stores only terminal lifecycle metadata. PTY input/output must remain in
68
121
  * the Node-local bounded replay buffer and must never be passed here.
@@ -138,6 +191,9 @@ export class NodeDatabase {
138
191
  // record itself never represents a filesystem deletion.
139
192
  this.raw.prepare('DELETE FROM agent_sessions WHERE workspace_id = ?').run(id);
140
193
  this.raw.prepare('DELETE FROM legacy_agent_sessions WHERE workspace_id = ?').run(id);
194
+ this.raw
195
+ .prepare("DELETE FROM mcp_installations WHERE target_kind='WORKSPACE' AND target_id = ?")
196
+ .run(id);
141
197
  this.raw.prepare('DELETE FROM workspaces WHERE id = ?').run(id);
142
198
  this.raw.exec('COMMIT');
143
199
  return current;
@@ -1028,6 +1084,8 @@ function migrate(database) {
1028
1084
  database.exec('COMMIT');
1029
1085
  migrateNodeSchemaV3(database);
1030
1086
  assertNodeSchemaV3(database);
1087
+ migrateNodeSchemaV4(database);
1088
+ assertNodeSchemaV4(database);
1031
1089
  return;
1032
1090
  }
1033
1091
  catch (error) {
@@ -1066,6 +1124,8 @@ function migrate(database) {
1066
1124
  assertNodeSchemaV2(database);
1067
1125
  migrateNodeSchemaV3(database);
1068
1126
  assertNodeSchemaV3(database);
1127
+ migrateNodeSchemaV4(database);
1128
+ assertNodeSchemaV4(database);
1069
1129
  return;
1070
1130
  }
1071
1131
  if (current === 0) {
@@ -1092,7 +1152,29 @@ function migrate(database) {
1092
1152
  .get();
1093
1153
  if (numberValue(afterV2, 'version') === 2)
1094
1154
  migrateNodeSchemaV3(database);
1155
+ const afterV3 = database
1156
+ .prepare('SELECT MAX(version) AS version FROM node_schema_migrations')
1157
+ .get();
1158
+ if (numberValue(afterV3, 'version') === 3)
1159
+ migrateNodeSchemaV4(database);
1095
1160
  assertNodeSchemaV3(database);
1161
+ assertNodeSchemaV4(database);
1162
+ }
1163
+ function mcpInstallation(row) {
1164
+ const runtime = mcpRuntimeDescriptorSchema.parse(JSON.parse(stringValue(row, 'runtime')));
1165
+ const configuration = mcpInstallationConfigurationSchema.parse(JSON.parse(stringValue(row, 'configuration')));
1166
+ return {
1167
+ targetKind: stringValue(row, 'target_kind'),
1168
+ targetId: stringValue(row, 'target_id'),
1169
+ catalogEntryId: stringValue(row, 'catalog_entry_id'),
1170
+ serverName: stringValue(row, 'server_name'),
1171
+ displayName: stringValue(row, 'display_name'),
1172
+ version: stringValue(row, 'version'),
1173
+ enabled: numberValue(row, 'enabled') === 1,
1174
+ runtime,
1175
+ configuration,
1176
+ installedAt: numberValue(row, 'installed_at')
1177
+ };
1096
1178
  }
1097
1179
  function sessionMetadata(row) {
1098
1180
  const raw = JSON.parse(stringValue(row, 'metadata'));
package/dist/main.js CHANGED
@@ -1,14 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import { NodeConnector } from './connector.js';
4
- import { loadNodeConfig, nodeConfigPath, nodeDatabasePath } from './config.js';
4
+ import { loadNodeConfig, nodeConfigPath, nodeDatabasePath, nodeDataDirectory } from './config.js';
5
5
  import { getNodeHealth } from './health.js';
6
6
  import { removeServiceDefinition, writeServiceDefinition } from './service.js';
7
7
  import { superviseNode } from './supervisor.js';
8
+ import { configureNodeLog } from './rotating-log.js';
8
9
  const [command = 'status', ...args] = process.argv.slice(2);
9
10
  const config = readOption(args, '--config') ?? nodeConfigPath();
10
11
  const output = readOption(args, '--output') ?? defaultServicePath();
11
12
  if (command === 'run') {
13
+ configureNodeLog(nodeDataDirectory(await loadNodeConfig(config), config));
12
14
  const connector = new NodeConnector({ configPath: config });
13
15
  await connector.start();
14
16
  for (const signal of ['SIGINT', 'SIGTERM']) {
@@ -18,6 +20,7 @@ if (command === 'run') {
18
20
  else if (command === 'supervise') {
19
21
  if (process.argv[1] === undefined)
20
22
  throw new Error('NODE_ENTRYPOINT_UNAVAILABLE');
23
+ configureNodeLog(nodeDataDirectory(await loadNodeConfig(config), config));
21
24
  await superviseNode(config, process.argv[1]);
22
25
  }
23
26
  else if (command === 'install') {
@@ -0,0 +1,4 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ export declare const NODE_SCHEMA_VERSION_V4 = 4;
3
+ export declare function migrateNodeSchemaV4(database: DatabaseSync): void;
4
+ export declare function assertNodeSchemaV4(database: DatabaseSync): void;
@@ -0,0 +1,35 @@
1
+ export const NODE_SCHEMA_VERSION_V4 = 4;
2
+ export function migrateNodeSchemaV4(database) {
3
+ database.exec('BEGIN IMMEDIATE');
4
+ try {
5
+ database.exec(`CREATE TABLE mcp_installations (
6
+ target_kind TEXT NOT NULL CHECK (target_kind IN ('NODE','WORKSPACE')),
7
+ target_id TEXT NOT NULL,
8
+ catalog_entry_id TEXT NOT NULL,
9
+ server_name TEXT NOT NULL,
10
+ display_name TEXT NOT NULL,
11
+ version TEXT NOT NULL,
12
+ enabled INTEGER NOT NULL CHECK (enabled IN (0,1)),
13
+ runtime TEXT NOT NULL CHECK (json_valid(runtime)),
14
+ configuration TEXT NOT NULL CHECK (json_valid(configuration)),
15
+ installed_at INTEGER NOT NULL,
16
+ PRIMARY KEY (target_kind,target_id,server_name),
17
+ CHECK ((target_kind='NODE' AND target_id='') OR (target_kind='WORKSPACE' AND target_id<>''))
18
+ ) STRICT;`);
19
+ database
20
+ .prepare('INSERT INTO node_schema_migrations (version, applied_at) VALUES (?, ?)')
21
+ .run(NODE_SCHEMA_VERSION_V4, Date.now());
22
+ database.exec('COMMIT');
23
+ }
24
+ catch (error) {
25
+ database.exec('ROLLBACK');
26
+ throw error;
27
+ }
28
+ }
29
+ export function assertNodeSchemaV4(database) {
30
+ const row = database
31
+ .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='mcp_installations'")
32
+ .get();
33
+ if (typeof row?.sql !== 'string' || !row.sql.includes("target_kind IN ('NODE','WORKSPACE')"))
34
+ throw new Error('NODE_DATABASE_SCHEMA_INVALID:mcp_installations');
35
+ }
@@ -16,6 +16,7 @@ export declare class OpenCodeServerClient {
16
16
  start(): Promise<void>;
17
17
  private startServer;
18
18
  providers(directory: string): Promise<OpenCodeProviderCatalog>;
19
+ configureMcp(directory: string, mcp: unknown): Promise<void>;
19
20
  sessions(directory: string, limit?: number): Promise<readonly unknown[]>;
20
21
  sessionStatus(directory: string): Promise<Readonly<Record<string, unknown>>>;
21
22
  createSession(directory: string, title?: string): Promise<unknown>;
@@ -1,7 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { randomBytes } from 'node:crypto';
3
3
  import { createServer } from 'node:net';
4
- import { createOpencodeClient } from '@opencode-ai/sdk/v2/client';
5
4
  import { minimalRunnerEnvironment, platformCliInvocation } from './operational.js';
6
5
  import { terminateProcessTree } from './process-tree.js';
7
6
  const START_TIMEOUT_MS = 10_000;
@@ -35,6 +34,7 @@ export class OpenCodeServerClient {
35
34
  }
36
35
  }
37
36
  async startServer() {
37
+ const { createOpencodeClient } = await import('@opencode-ai/sdk/v2/client');
38
38
  const port = await availablePort();
39
39
  const password = randomBytes(24).toString('base64url');
40
40
  const invocation = platformCliInvocation(this.options.command ?? 'opencode', [
@@ -77,6 +77,18 @@ export class OpenCodeServerClient {
77
77
  const client = await this.requireClient();
78
78
  return unwrap(await client.provider.list({ directory }));
79
79
  }
80
+ async configureMcp(directory, mcp) {
81
+ const client = await this.requireClient();
82
+ if (typeof mcp !== 'object' || mcp === null || Array.isArray(mcp))
83
+ return;
84
+ for (const [name, config] of Object.entries(mcp)) {
85
+ await client.mcp.add({
86
+ directory,
87
+ name,
88
+ config: config
89
+ });
90
+ }
91
+ }
80
92
  async sessions(directory, limit = 100) {
81
93
  const client = await this.requireClient();
82
94
  return unwrap(await client.session.list({ directory, limit }));
@@ -1,4 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { writeNodeLog } from './rotating-log.js';
2
3
  const secretKey = /(?:password|credential|token|cookie|authorization|secret|api[_-]?key)/i;
3
4
  export function runRunnerProbe(command, args, options) {
4
5
  return new Promise((resolve) => {
@@ -115,5 +116,7 @@ export function platformCliInvocation(command, args, platform = process.platform
115
116
  }
116
117
  export function nodeLog(event, fields = {}) {
117
118
  const safeFields = redactLogValue(fields);
118
- process.stdout.write(`${JSON.stringify({ timestamp: new Date().toISOString(), level: 'info', component: 'mar-node', event, ...safeFields })}\n`);
119
+ const line = `${JSON.stringify({ timestamp: new Date().toISOString(), level: 'info', component: 'mar-node', event, ...safeFields })}\n`;
120
+ process.stdout.write(line);
121
+ writeNodeLog(line);
119
122
  }
@@ -0,0 +1,17 @@
1
+ export declare function configureNodeLog(dataDirectory: string): void;
2
+ export declare function writeNodeLog(content: string): void;
3
+ export declare class RotatingNodeLog {
4
+ private readonly maxBytes;
5
+ private readonly retentionMs;
6
+ private readonly now;
7
+ private readonly activePath;
8
+ private bytes;
9
+ private day;
10
+ private rotation;
11
+ private lastCleanup;
12
+ constructor(directory: string, maxBytes?: number, retentionMs?: number, now?: () => number);
13
+ write(content: string): void;
14
+ private currentDay;
15
+ private rotate;
16
+ private cleanup;
17
+ }
@@ -0,0 +1,67 @@
1
+ import { appendFileSync, mkdirSync, readdirSync, renameSync, statSync, unlinkSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ const DAY_MS = 24 * 60 * 60 * 1_000;
4
+ let sink;
5
+ export function configureNodeLog(dataDirectory) {
6
+ sink = new RotatingNodeLog(resolve(dataDirectory, 'logs'));
7
+ }
8
+ export function writeNodeLog(content) {
9
+ sink?.write(content);
10
+ }
11
+ export class RotatingNodeLog {
12
+ maxBytes;
13
+ retentionMs;
14
+ now;
15
+ activePath;
16
+ bytes = 0;
17
+ day = '';
18
+ rotation = 0;
19
+ lastCleanup = 0;
20
+ constructor(directory, maxBytes = 100 * 1024 * 1024, retentionMs = 7 * DAY_MS, now = Date.now) {
21
+ this.maxBytes = maxBytes;
22
+ this.retentionMs = retentionMs;
23
+ this.now = now;
24
+ mkdirSync(directory, { recursive: true });
25
+ this.activePath = resolve(directory, 'node.log');
26
+ try {
27
+ const stat = statSync(this.activePath);
28
+ this.bytes = stat.size;
29
+ this.day = new Date(stat.mtimeMs).toISOString().slice(0, 10);
30
+ }
31
+ catch {
32
+ this.day = this.currentDay();
33
+ }
34
+ this.cleanup();
35
+ }
36
+ write(content) {
37
+ const length = Buffer.byteLength(content);
38
+ const day = this.currentDay();
39
+ if (this.bytes > 0 && (this.bytes + length > this.maxBytes || day !== this.day))
40
+ this.rotate();
41
+ appendFileSync(this.activePath, content, { encoding: 'utf8', mode: 0o600 });
42
+ this.bytes += length;
43
+ this.day = day;
44
+ if (this.now() - this.lastCleanup >= DAY_MS)
45
+ this.cleanup();
46
+ }
47
+ currentDay() {
48
+ return new Date(this.now()).toISOString().slice(0, 10);
49
+ }
50
+ rotate() {
51
+ const suffix = `${this.day}-${this.now()}-${String(this.rotation++).padStart(3, '0')}`;
52
+ renameSync(this.activePath, resolve(this.activePath, '..', `node-${suffix}.log`));
53
+ this.bytes = 0;
54
+ }
55
+ cleanup() {
56
+ const directory = resolve(this.activePath, '..');
57
+ const cutoff = this.now() - this.retentionMs;
58
+ for (const entry of readdirSync(directory)) {
59
+ if (!entry.startsWith('node-') || !entry.endsWith('.log'))
60
+ continue;
61
+ const path = resolve(directory, entry);
62
+ if (statSync(path).mtimeMs < cutoff)
63
+ unlinkSync(path);
64
+ }
65
+ this.lastCleanup = this.now();
66
+ }
67
+ }