@chatroomcp/chatroom 0.2.2 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +5 -3
  2. package/dist/app/application.js +4 -0
  3. package/dist/infrastructure/database/mcp-tool-settings-repository.d.ts +8 -0
  4. package/dist/infrastructure/database/mcp-tool-settings-repository.js +22 -0
  5. package/dist/infrastructure/database/migrations.js +22 -0
  6. package/dist/infrastructure/database/schema.d.ts +2 -2
  7. package/dist/infrastructure/database/schema.js +7 -1
  8. package/dist/mcp/server/plugin-mcp-registrar.d.ts +3 -1
  9. package/dist/mcp/server/plugin-mcp-registrar.js +13 -2
  10. package/dist/mcp/server/tool-control.d.ts +31 -0
  11. package/dist/mcp/server/tool-control.js +71 -0
  12. package/dist/native/linux/chatroom-computer-helper +0 -0
  13. package/dist/native/macos/ChatRoomComputerHelper.app/Contents/MacOS/chatroom-computer-helper +0 -0
  14. package/dist/native/windows/chatroom-computer-helper.exe +0 -0
  15. package/dist/plugins/computer/computer-service.d.ts +2 -0
  16. package/dist/plugins/computer/computer-service.js +10 -1
  17. package/dist/plugins/plugin-manager.js +6 -1
  18. package/dist/plugins/process/infrastructure/pty-process-backend.js +4 -0
  19. package/dist/plugins/types.d.ts +2 -0
  20. package/dist/plugins/web/api-types.d.ts +1 -0
  21. package/dist/plugins/web/http/api-router.js +9 -0
  22. package/dist/plugins/web/http/computer-api-router.js +4 -3
  23. package/dist/plugins/web/http/workspace-api-router.js +36 -0
  24. package/dist/plugins/web/plugin.js +1 -1
  25. package/dist/plugins/web/runtime.d.ts +3 -1
  26. package/dist/plugins/web/runtime.js +3 -1
  27. package/dist/plugins/workspace/mcp.js +4 -1
  28. package/dist/plugins/workspace/metadata.d.ts +2 -0
  29. package/dist/plugins/workspace/metadata.js +20 -10
  30. package/dist/plugins/workspace/types.d.ts +5 -0
  31. package/dist/plugins/workspace/workspace-fs.d.ts +3 -0
  32. package/dist/plugins/workspace/workspace-fs.js +93 -4
  33. package/dist/plugins/workspace/workspace-service.d.ts +4 -0
  34. package/dist/plugins/workspace/workspace-service.js +71 -16
  35. package/dist/web/assets/index-B_OJ1D0L.js +30 -0
  36. package/dist/web/assets/index-DSCBSalV.css +1 -0
  37. package/dist/web/index.html +2 -2
  38. package/package.json +1 -1
  39. package/dist/web/assets/index-DFKEvyzR.css +0 -1
  40. package/dist/web/assets/index-vzPPspuL.js +0 -26
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
  English | <a href="./docs/README.zh-CN.md">简体中文</a> | <a href="https://doc.chatroomcp.com/">Documentation</a>
9
9
  </p>
10
10
 
11
- ChatRoom is a local MCP runtime that lets ChatGPT work with projects and processes on your device. Its WebUI provides workspace browsing, Git repository management, process monitoring, ChatRoom Cloud controls, and plugin-attributed operation logs.
11
+ ChatRoom is a local MCP runtime that lets ChatGPT work with projects on your local device, run processes, or perform computer control. Its WebUI provides workspace browsing, Git repository and process management, computer control, ChatRoom Cloud management, and plugin-attributed operation logs.
12
12
 
13
13
  ## Install
14
14
 
@@ -20,6 +20,8 @@ chatroom init
20
20
  chatroom serve
21
21
  ```
22
22
 
23
+ Windows users can also use [ChatRoom-Windows-Portable](https://github.com/dayearnew/ChatRoom-Windows-Portable) without configuring any dependencies themselves.
24
+
23
25
  Default local endpoints:
24
26
 
25
27
  ```text
@@ -27,13 +29,13 @@ WebUI http://127.0.0.1:8765/
27
29
  MCP http://127.0.0.1:8765/mcp
28
30
  ```
29
31
 
30
- `chatroom init` creates `~/.config/chatroom/config.json` and uses `~/Projects` as the default allowed workspace root.
32
+ `chatroom init` creates the [configuration file](https://doc.chatroomcp.com/configuration) and uses `~/Projects` as the default allowed workspace root.
31
33
 
32
34
  ## Use with ChatGPT
33
35
 
34
36
  ChatGPT connects to ChatRoom through its MCP endpoint. For remote access, either enable ChatRoom Cloud from the WebUI or expose ChatRoom through your own HTTPS ingress and configure the corresponding public URL.
35
37
 
36
- Create a custom MCP app in ChatGPT Developer Mode, use the ChatRoom `/mcp` URL, and complete OAuth authorization with the `ownerToken` stored in `~/.config/chatroom/config.json`.
38
+ Create a custom MCP app in ChatGPT Developer Mode, use the ChatRoom `/mcp` URL, and complete OAuth authorization with the `ownerToken` in the [configuration file](https://doc.chatroomcp.com/configuration).
37
39
 
38
40
  See [Use ChatRoom with ChatGPT](https://doc.chatroomcp.com/chatgpt) for the current ChatGPT setup flow.
39
41
 
@@ -1,5 +1,6 @@
1
1
  import { AppDatabase } from "../infrastructure/database/app-database.js";
2
2
  import { OperationRepository } from "../infrastructure/database/operation-repository.js";
3
+ import { McpToolSettingsRepository } from "../infrastructure/database/mcp-tool-settings-repository.js";
3
4
  import { OAuthRepository } from "../infrastructure/database/oauth-repository.js";
4
5
  import { PasskeyRepository } from "../infrastructure/database/passkey-repository.js";
5
6
  import { WebSessionRepository } from "../infrastructure/database/web-session-repository.js";
@@ -17,6 +18,7 @@ import { createCloudPlugin, CloudService } from "../plugins/cloud/plugin.js";
17
18
  import { createComputerPlugin, ComputerServiceToken, } from "../plugins/computer/plugin.js";
18
19
  import { createWebPlugin, WebServiceToken } from "../plugins/web/plugin.js";
19
20
  import { createChatRoomMcpHandler } from "../mcp/server/create-mcp-server.js";
21
+ import { McpToolControl } from "../mcp/server/tool-control.js";
20
22
  import { HttpServer } from "../infrastructure/http/http-server.js";
21
23
  export async function createApplication(config) {
22
24
  const database = new AppDatabase(config.databasePath);
@@ -24,6 +26,7 @@ export async function createApplication(config) {
24
26
  const eventBus = new RuntimeEventBus();
25
27
  const operations = new OperationLog(new OperationRepository(database), eventBus, config.operations.maxPayloadBytes);
26
28
  operations.reconcileInterrupted();
29
+ const mcpTools = new McpToolControl(new McpToolSettingsRepository(database));
27
30
  const externalAccess = new ExternalAccessRegistry(config.auth);
28
31
  const auth = new AuthService(new OAuthRepository(database), new WebSessionRepository(database), config.auth);
29
32
  const passkeys = new PasskeyService(new PasskeyRepository(database));
@@ -32,6 +35,7 @@ export async function createApplication(config) {
32
35
  config,
33
36
  database,
34
37
  operations,
38
+ mcpTools,
35
39
  events: eventBus,
36
40
  externalAccess,
37
41
  services,
@@ -0,0 +1,8 @@
1
+ import type { McpToolSettingStore } from "../../mcp/server/tool-control.js";
2
+ import type { AppDatabase } from "./app-database.js";
3
+ export declare class McpToolSettingsRepository implements McpToolSettingStore {
4
+ private readonly database;
5
+ constructor(database: AppDatabase);
6
+ disabledTools(): string[];
7
+ setEnabled(name: string, enabled: boolean): void;
8
+ }
@@ -0,0 +1,22 @@
1
+ export class McpToolSettingsRepository {
2
+ database;
3
+ constructor(database) {
4
+ this.database = database;
5
+ }
6
+ disabledTools() {
7
+ return this.database.raw
8
+ .prepare("SELECT tool_name FROM mcp_tool_settings WHERE enabled=0")
9
+ .all().map((row) => row.tool_name);
10
+ }
11
+ setEnabled(name, enabled) {
12
+ this.database.raw
13
+ .prepare(`
14
+ INSERT INTO mcp_tool_settings(tool_name, enabled, updated_at)
15
+ VALUES(?,?,?)
16
+ ON CONFLICT(tool_name) DO UPDATE SET
17
+ enabled=excluded.enabled,
18
+ updated_at=excluded.updated_at
19
+ `)
20
+ .run(name, enabled ? 1 : 0, new Date().toISOString());
21
+ }
22
+ }
@@ -9,6 +9,10 @@ export function migrateDatabase(db) {
9
9
  migrate0To1(db);
10
10
  version = 1;
11
11
  break;
12
+ case 1:
13
+ migrate1To2(db);
14
+ version = 2;
15
+ break;
12
16
  default:
13
17
  throw new Error(`Unsupported ChatRoom database schema: ${version}`);
14
18
  }
@@ -35,6 +39,24 @@ function migrate0To1(db) {
35
39
  throw error;
36
40
  }
37
41
  }
42
+ function migrate1To2(db) {
43
+ db.exec("BEGIN IMMEDIATE;");
44
+ try {
45
+ db.exec(`
46
+ CREATE TABLE IF NOT EXISTS mcp_tool_settings (
47
+ tool_name TEXT PRIMARY KEY,
48
+ enabled INTEGER NOT NULL CHECK(enabled IN (0, 1)),
49
+ updated_at TEXT NOT NULL
50
+ );
51
+ PRAGMA user_version = 2;
52
+ COMMIT;
53
+ `);
54
+ }
55
+ catch (error) {
56
+ db.exec("ROLLBACK;");
57
+ throw error;
58
+ }
59
+ }
38
60
  function userVersion(db) {
39
61
  const row = db.prepare("PRAGMA user_version").get();
40
62
  const version = Number(row?.user_version ?? 0);
@@ -1,2 +1,2 @@
1
- export declare const DATABASE_SCHEMA_VERSION = 1;
2
- export declare const DATABASE_SCHEMA = "\n CREATE TABLE IF NOT EXISTS operations (\n operation_id TEXT PRIMARY KEY,\n plugin_id TEXT NOT NULL,\n source TEXT NOT NULL,\n action TEXT NOT NULL,\n status TEXT NOT NULL,\n process_id TEXT,\n input_json TEXT NOT NULL,\n output_json TEXT NOT NULL,\n error_json TEXT NOT NULL,\n input_truncated INTEGER NOT NULL DEFAULT 0,\n output_truncated INTEGER NOT NULL DEFAULT 0,\n started_at TEXT NOT NULL,\n finished_at TEXT,\n duration_ms INTEGER\n );\n CREATE INDEX IF NOT EXISTS operations_started_idx ON operations(started_at DESC);\n CREATE INDEX IF NOT EXISTS operations_plugin_idx ON operations(plugin_id, started_at DESC);\n CREATE INDEX IF NOT EXISTS operations_status_idx ON operations(status, started_at DESC);\n\n CREATE TABLE IF NOT EXISTS oauth_clients (\n client_id TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n redirect_uris_json TEXT NOT NULL,\n created_at TEXT NOT NULL\n );\n CREATE TABLE IF NOT EXISTS oauth_codes (\n code_hash TEXT PRIMARY KEY,\n client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,\n redirect_uri TEXT NOT NULL,\n code_challenge TEXT NOT NULL,\n scope TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n used_at TEXT\n );\n CREATE INDEX IF NOT EXISTS oauth_code_expiry_idx ON oauth_codes(expires_at);\n CREATE TABLE IF NOT EXISTS oauth_tokens (\n token_hash TEXT PRIMARY KEY,\n client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,\n scope TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n revoked_at TEXT,\n created_at TEXT NOT NULL\n );\n CREATE INDEX IF NOT EXISTS oauth_token_expiry_idx ON oauth_tokens(expires_at);\n CREATE TABLE IF NOT EXISTS oauth_refresh_tokens (\n token_hash TEXT PRIMARY KEY,\n client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,\n scope TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n revoked_at TEXT,\n created_at TEXT NOT NULL\n );\n CREATE INDEX IF NOT EXISTS oauth_refresh_expiry_idx ON oauth_refresh_tokens(expires_at);\n\n CREATE TABLE IF NOT EXISTS web_sessions (\n token_hash TEXT PRIMARY KEY,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL\n );\n CREATE INDEX IF NOT EXISTS web_session_expiry_idx ON web_sessions(expires_at);\n\n CREATE TABLE IF NOT EXISTS passkeys (\n credential_id TEXT PRIMARY KEY,\n public_key BLOB NOT NULL,\n counter INTEGER NOT NULL,\n transports_json TEXT NOT NULL,\n device_type TEXT NOT NULL CHECK(device_type IN ('singleDevice','multiDevice')),\n backed_up INTEGER NOT NULL,\n name TEXT NOT NULL,\n created_at TEXT NOT NULL,\n last_used_at TEXT NOT NULL\n );\n CREATE INDEX IF NOT EXISTS passkeys_last_used_idx ON passkeys(last_used_at DESC);\n\n CREATE TABLE IF NOT EXISTS computer_settings (\n id INTEGER PRIMARY KEY CHECK(id = 1),\n enabled INTEGER NOT NULL DEFAULT 0,\n remote_access INTEGER NOT NULL DEFAULT 1,\n updated_at TEXT NOT NULL\n );\n\n";
1
+ export declare const DATABASE_SCHEMA_VERSION = 2;
2
+ export declare const DATABASE_SCHEMA = "\n CREATE TABLE IF NOT EXISTS operations (\n operation_id TEXT PRIMARY KEY,\n plugin_id TEXT NOT NULL,\n source TEXT NOT NULL,\n action TEXT NOT NULL,\n status TEXT NOT NULL,\n process_id TEXT,\n input_json TEXT NOT NULL,\n output_json TEXT NOT NULL,\n error_json TEXT NOT NULL,\n input_truncated INTEGER NOT NULL DEFAULT 0,\n output_truncated INTEGER NOT NULL DEFAULT 0,\n started_at TEXT NOT NULL,\n finished_at TEXT,\n duration_ms INTEGER\n );\n CREATE INDEX IF NOT EXISTS operations_started_idx ON operations(started_at DESC);\n CREATE INDEX IF NOT EXISTS operations_plugin_idx ON operations(plugin_id, started_at DESC);\n CREATE INDEX IF NOT EXISTS operations_status_idx ON operations(status, started_at DESC);\n\n CREATE TABLE IF NOT EXISTS oauth_clients (\n client_id TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n redirect_uris_json TEXT NOT NULL,\n created_at TEXT NOT NULL\n );\n CREATE TABLE IF NOT EXISTS oauth_codes (\n code_hash TEXT PRIMARY KEY,\n client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,\n redirect_uri TEXT NOT NULL,\n code_challenge TEXT NOT NULL,\n scope TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n used_at TEXT\n );\n CREATE INDEX IF NOT EXISTS oauth_code_expiry_idx ON oauth_codes(expires_at);\n CREATE TABLE IF NOT EXISTS oauth_tokens (\n token_hash TEXT PRIMARY KEY,\n client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,\n scope TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n revoked_at TEXT,\n created_at TEXT NOT NULL\n );\n CREATE INDEX IF NOT EXISTS oauth_token_expiry_idx ON oauth_tokens(expires_at);\n CREATE TABLE IF NOT EXISTS oauth_refresh_tokens (\n token_hash TEXT PRIMARY KEY,\n client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,\n scope TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n revoked_at TEXT,\n created_at TEXT NOT NULL\n );\n CREATE INDEX IF NOT EXISTS oauth_refresh_expiry_idx ON oauth_refresh_tokens(expires_at);\n\n CREATE TABLE IF NOT EXISTS web_sessions (\n token_hash TEXT PRIMARY KEY,\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL\n );\n CREATE INDEX IF NOT EXISTS web_session_expiry_idx ON web_sessions(expires_at);\n\n CREATE TABLE IF NOT EXISTS passkeys (\n credential_id TEXT PRIMARY KEY,\n public_key BLOB NOT NULL,\n counter INTEGER NOT NULL,\n transports_json TEXT NOT NULL,\n device_type TEXT NOT NULL CHECK(device_type IN ('singleDevice','multiDevice')),\n backed_up INTEGER NOT NULL,\n name TEXT NOT NULL,\n created_at TEXT NOT NULL,\n last_used_at TEXT NOT NULL\n );\n CREATE INDEX IF NOT EXISTS passkeys_last_used_idx ON passkeys(last_used_at DESC);\n\n CREATE TABLE IF NOT EXISTS computer_settings (\n id INTEGER PRIMARY KEY CHECK(id = 1),\n enabled INTEGER NOT NULL DEFAULT 0,\n remote_access INTEGER NOT NULL DEFAULT 1,\n updated_at TEXT NOT NULL\n );\n\n CREATE TABLE IF NOT EXISTS mcp_tool_settings (\n tool_name TEXT PRIMARY KEY,\n enabled INTEGER NOT NULL CHECK(enabled IN (0, 1)),\n updated_at TEXT NOT NULL\n );\n\n";
@@ -1,4 +1,4 @@
1
- export const DATABASE_SCHEMA_VERSION = 1;
1
+ export const DATABASE_SCHEMA_VERSION = 2;
2
2
  export const DATABASE_SCHEMA = `
3
3
  CREATE TABLE IF NOT EXISTS operations (
4
4
  operation_id TEXT PRIMARY KEY,
@@ -82,4 +82,10 @@ export const DATABASE_SCHEMA = `
82
82
  updated_at TEXT NOT NULL
83
83
  );
84
84
 
85
+ CREATE TABLE IF NOT EXISTS mcp_tool_settings (
86
+ tool_name TEXT PRIMARY KEY,
87
+ enabled INTEGER NOT NULL CHECK(enabled IN (0, 1)),
88
+ updated_at TEXT NOT NULL
89
+ );
90
+
85
91
  `;
@@ -1,5 +1,6 @@
1
1
  import type { McpServer, StandardSchemaWithJSON, ToolAnnotations, CallToolResult } from "@modelcontextprotocol/server";
2
2
  import type { OperationLog } from "../../operations/operation-log.js";
3
+ import type { McpToolControl } from "./tool-control.js";
3
4
  type PluginToolInput<Schema extends StandardSchemaWithJSON> = StandardSchemaWithJSON.InferOutput<Schema>;
4
5
  type PluginToolAction<Input> = string | ((input: Input) => string);
5
6
  export interface PluginToolExecution {
@@ -23,7 +24,8 @@ export declare class PluginMcpRegistrar {
23
24
  private readonly server;
24
25
  private readonly operations;
25
26
  private readonly pluginId;
26
- constructor(server: McpServer, operations: OperationLog, pluginId: string);
27
+ private readonly toolControl;
28
+ constructor(server: McpServer | null, operations: OperationLog, pluginId: string, toolControl: McpToolControl);
27
29
  registerTool<InputSchema extends StandardSchemaWithJSON, OutputSchema extends StandardSchemaWithJSON>(name: string, config: PluginToolConfig<InputSchema, OutputSchema>, handler: (input: PluginToolInput<InputSchema>, execution: PluginToolExecution) => Promise<unknown> | unknown): void;
28
30
  }
29
31
  export {};
@@ -4,12 +4,22 @@ export class PluginMcpRegistrar {
4
4
  server;
5
5
  operations;
6
6
  pluginId;
7
- constructor(server, operations, pluginId) {
7
+ toolControl;
8
+ constructor(server, operations, pluginId, toolControl) {
8
9
  this.server = server;
9
10
  this.operations = operations;
10
11
  this.pluginId = pluginId;
12
+ this.toolControl = toolControl;
11
13
  }
12
14
  registerTool(name, config, handler) {
15
+ this.toolControl.define({
16
+ name,
17
+ pluginId: this.pluginId,
18
+ title: config.title,
19
+ description: config.description,
20
+ });
21
+ if (!this.server)
22
+ return;
13
23
  const { action, audit, present, ...toolConfig } = config;
14
24
  const callback = mcpTool(async (input) => {
15
25
  const operation = this.operations.start({
@@ -44,7 +54,8 @@ export class PluginMcpRegistrar {
44
54
  throw error;
45
55
  }
46
56
  }, present);
47
- this.server.registerTool(name, toolConfig, callback);
57
+ const registered = this.server.registerTool(name, toolConfig, callback);
58
+ this.toolControl.attach(name, registered);
48
59
  }
49
60
  }
50
61
  function operationReferences(input) {
@@ -0,0 +1,31 @@
1
+ import type { RegisteredTool } from "@modelcontextprotocol/server";
2
+ export interface McpToolSettingStore {
3
+ disabledTools(): string[];
4
+ setEnabled(name: string, enabled: boolean): void;
5
+ }
6
+ export interface McpToolSummary {
7
+ name: string;
8
+ pluginId: string;
9
+ title: string;
10
+ description: string;
11
+ enabled: boolean;
12
+ }
13
+ interface McpToolDefinition {
14
+ name: string;
15
+ pluginId: string;
16
+ title: string;
17
+ description: string;
18
+ }
19
+ export declare class McpToolControl {
20
+ private readonly store;
21
+ private readonly disabled;
22
+ private readonly definitions;
23
+ private readonly handles;
24
+ constructor(store: McpToolSettingStore);
25
+ define(definition: McpToolDefinition): void;
26
+ attach(name: string, tool: RegisteredTool): void;
27
+ list(): McpToolSummary[];
28
+ setEnabled(name: string, enabled: boolean): McpToolSummary;
29
+ private updateHandles;
30
+ }
31
+ export {};
@@ -0,0 +1,71 @@
1
+ import { ChatRoomError } from "../../core/errors/chatroom-error.js";
2
+ export class McpToolControl {
3
+ store;
4
+ disabled = new Set();
5
+ definitions = new Map();
6
+ handles = new Map();
7
+ constructor(store) {
8
+ this.store = store;
9
+ for (const name of store.disabledTools())
10
+ this.disabled.add(name);
11
+ }
12
+ define(definition) {
13
+ const existing = this.definitions.get(definition.name);
14
+ if (existing && existing.pluginId !== definition.pluginId)
15
+ throw new Error(`MCP tool already belongs to another plugin: ${definition.name}`);
16
+ this.definitions.set(definition.name, definition);
17
+ }
18
+ attach(name, tool) {
19
+ let tools = this.handles.get(name);
20
+ if (!tools) {
21
+ tools = new Set();
22
+ this.handles.set(name, tools);
23
+ }
24
+ tools.add(new WeakRef(tool));
25
+ if (this.disabled.has(name))
26
+ tool.disable();
27
+ }
28
+ list() {
29
+ return [...this.definitions.values()]
30
+ .map((definition) => ({
31
+ ...definition,
32
+ enabled: !this.disabled.has(definition.name),
33
+ }))
34
+ .sort((left, right) => left.pluginId.localeCompare(right.pluginId) ||
35
+ left.name.localeCompare(right.name));
36
+ }
37
+ setEnabled(name, enabled) {
38
+ const definition = this.definitions.get(name);
39
+ if (!definition)
40
+ throw new ChatRoomError("NOT_FOUND", `Unknown MCP tool: ${name}`);
41
+ if (enabled === !this.disabled.has(name))
42
+ return { ...definition, enabled };
43
+ this.store.setEnabled(name, enabled);
44
+ if (enabled)
45
+ this.disabled.delete(name);
46
+ else
47
+ this.disabled.add(name);
48
+ this.updateHandles(name, enabled);
49
+ return { ...definition, enabled };
50
+ }
51
+ updateHandles(name, enabled) {
52
+ const handles = this.handles.get(name);
53
+ if (!handles)
54
+ return;
55
+ for (const reference of [...handles]) {
56
+ const tool = reference.deref();
57
+ if (!tool) {
58
+ handles.delete(reference);
59
+ continue;
60
+ }
61
+ if (tool.enabled === enabled)
62
+ continue;
63
+ if (enabled)
64
+ tool.enable();
65
+ else
66
+ tool.disable();
67
+ }
68
+ if (!handles.size)
69
+ this.handles.delete(name);
70
+ }
71
+ }
@@ -9,10 +9,12 @@ export declare class ComputerService {
9
9
  private revision;
10
10
  private currentSnapshotId;
11
11
  private latestSnapshotValue;
12
+ private latestSnapshotCapturedAt;
12
13
  private queue;
13
14
  constructor(backend: ComputerBackend, settingsRepository: ComputerSettingsStore, events: RuntimeEventBus);
14
15
  settings(): ComputerSettings;
15
16
  latestSnapshot(scope: ComputerAccessScope): ComputerSnapshot | null;
17
+ latestSnapshotTimestamp(): string | null;
16
18
  requestPermission(permission: ComputerPermission): Promise<ComputerStatus>;
17
19
  status(): Promise<ComputerStatus>;
18
20
  setSettings(patch: Partial<Pick<ComputerSettings, "enabled" | "remoteAccess">>): ComputerSettings;
@@ -7,6 +7,7 @@ export class ComputerService {
7
7
  revision = 0;
8
8
  currentSnapshotId = null;
9
9
  latestSnapshotValue = null;
10
+ latestSnapshotCapturedAt = null;
10
11
  queue = Promise.resolve();
11
12
  constructor(backend, settingsRepository, events) {
12
13
  this.backend = backend;
@@ -23,6 +24,9 @@ export class ComputerService {
23
24
  this.assertScopeAllowed(scope, settings);
24
25
  return this.latestSnapshotValue;
25
26
  }
27
+ latestSnapshotTimestamp() {
28
+ return this.latestSnapshotValue ? this.latestSnapshotCapturedAt : null;
29
+ }
26
30
  requestPermission(permission) {
27
31
  return this.serial(async () => {
28
32
  const base = await this.backend.requestPermission(permission);
@@ -38,6 +42,7 @@ export class ComputerService {
38
42
  if (!next.enabled) {
39
43
  this.currentSnapshotId = null;
40
44
  this.latestSnapshotValue = null;
45
+ this.latestSnapshotCapturedAt = null;
41
46
  void this.backend.dispose().catch(() => undefined);
42
47
  }
43
48
  this.events.emit({ type: "computer-settings", settings: next });
@@ -52,6 +57,7 @@ export class ComputerService {
52
57
  value.snapshotId = this.currentSnapshotId;
53
58
  value.revision = this.revision;
54
59
  this.latestSnapshotValue = value;
60
+ this.latestSnapshotCapturedAt = new Date().toISOString();
55
61
  return value;
56
62
  });
57
63
  }
@@ -67,14 +73,17 @@ export class ComputerService {
67
73
  this.currentSnapshotId = null;
68
74
  const result = await this.backend.action(request, ++this.revision);
69
75
  this.currentSnapshotId = result.snapshot?.snapshotId ?? null;
70
- if (result.snapshot)
76
+ if (result.snapshot) {
71
77
  this.latestSnapshotValue = result.snapshot;
78
+ this.latestSnapshotCapturedAt = new Date().toISOString();
79
+ }
72
80
  return result;
73
81
  });
74
82
  }
75
83
  async shutdown() {
76
84
  this.currentSnapshotId = null;
77
85
  this.latestSnapshotValue = null;
86
+ this.latestSnapshotCapturedAt = null;
78
87
  await this.backend.dispose();
79
88
  }
80
89
  assertActionBudget(request) {
@@ -12,12 +12,17 @@ export class PluginManager {
12
12
  await plugin.activate(this.context);
13
13
  this.active.push(plugin);
14
14
  }
15
+ for (const plugin of this.active) {
16
+ if (!plugin.registerMcp)
17
+ continue;
18
+ plugin.registerMcp(new PluginMcpRegistrar(null, this.context.operations, plugin.id, this.context.mcpTools));
19
+ }
15
20
  }
16
21
  registerMcp(server) {
17
22
  for (const plugin of this.active) {
18
23
  if (!plugin.registerMcp)
19
24
  continue;
20
- plugin.registerMcp(new PluginMcpRegistrar(server, this.context.operations, plugin.id));
25
+ plugin.registerMcp(new PluginMcpRegistrar(server, this.context.operations, plugin.id, this.context.mcpTools));
21
26
  }
22
27
  }
23
28
  async stop() {
@@ -32,6 +32,10 @@ class PtyBackendProcess {
32
32
  this.processHandle.write(data);
33
33
  }
34
34
  kill(signal = "SIGTERM") {
35
+ if (process.platform === "win32") {
36
+ this.processHandle.kill();
37
+ return;
38
+ }
35
39
  this.processHandle.kill(signal);
36
40
  }
37
41
  onStdout(listener) {
@@ -2,6 +2,7 @@ import type { ChatRoomConfig } from "../config/types.js";
2
2
  import type { AppDatabase } from "../infrastructure/database/app-database.js";
3
3
  import type { PluginMcpRegistrar } from "../mcp/server/plugin-mcp-registrar.js";
4
4
  import type { OperationLog } from "../operations/operation-log.js";
5
+ import type { McpToolControl } from "../mcp/server/tool-control.js";
5
6
  import type { RuntimeEventBus } from "../app/event-bus.js";
6
7
  import type { ExternalAccessRegistry } from "../app/external-access-registry.js";
7
8
  export interface ServiceToken<T> {
@@ -19,6 +20,7 @@ export interface PluginContext {
19
20
  config: ChatRoomConfig;
20
21
  database: AppDatabase;
21
22
  operations: OperationLog;
23
+ mcpTools: McpToolControl;
22
24
  events: RuntimeEventBus;
23
25
  externalAccess: ExternalAccessRegistry;
24
26
  services: ServiceRegistry;
@@ -7,6 +7,7 @@ export type { ComputerDisplay, ComputerPermission, ComputerStatus, GitBranch, Gi
7
7
  export interface ComputerPreviewView {
8
8
  snapshotId: string;
9
9
  revision: number;
10
+ capturedAt: string | null;
10
11
  display: ComputerDisplay | null;
11
12
  activeApp: string | null;
12
13
  activeWindow: string | null;
@@ -55,6 +55,15 @@ export function createApiRouter(application, eventBus, auth, passkeys, ingress,
55
55
  });
56
56
  router.use(apiAuthentication(auth, ingress));
57
57
  router.get("/runtime", (_req, res) => res.json(runtimeStatus()));
58
+ router.get("/mcp/tools", (_req, res) => {
59
+ res.json(application.mcpTools.list());
60
+ });
61
+ router.patch("/mcp/tools/:toolName", (req, res) => {
62
+ const body = req.body;
63
+ if (typeof body.enabled !== "boolean")
64
+ throw new ChatRoomError("INVALID_INPUT", "enabled must be a boolean");
65
+ res.json(application.mcpTools.setEnabled(requireString(req.params.toolName, "toolName"), body.enabled));
66
+ });
58
67
  router.get("/auth/passkeys", (_req, res) => res.json(passkeys.list()));
59
68
  router.post("/auth/passkeys/register/options", asyncRoute(async (req, res) => {
60
69
  res.json(await passkeys.registrationOptions(ingress.webAuthnOrigin(req)));
@@ -40,7 +40,7 @@ export function createComputerApiRouter(computer, operations, ingress) {
40
40
  }));
41
41
  router.get("/computer/preview", (req, res) => {
42
42
  const scope = ingress.isExternalWeb(req) ? "remote" : "local";
43
- res.json(presentPreview(computer.latestSnapshot(scope)));
43
+ res.json(presentPreview(computer.latestSnapshot(scope), computer.latestSnapshotTimestamp()));
44
44
  });
45
45
  router.post("/computer/snapshot", asyncRoute(async (req, res) => {
46
46
  const scope = ingress.isExternalWeb(req) ? "remote" : "local";
@@ -48,16 +48,17 @@ export function createComputerApiRouter(computer, operations, ingress) {
48
48
  includeScreenshot: true,
49
49
  includeElements: true,
50
50
  });
51
- res.json(presentPreview(value));
51
+ res.json(presentPreview(value, computer.latestSnapshotTimestamp()));
52
52
  }));
53
53
  return router;
54
54
  }
55
- function presentPreview(value) {
55
+ function presentPreview(value, capturedAt) {
56
56
  if (!value)
57
57
  return null;
58
58
  return {
59
59
  snapshotId: value.snapshotId,
60
60
  revision: value.revision,
61
+ capturedAt,
61
62
  display: value.display,
62
63
  activeApp: value.activeApp,
63
64
  activeWindow: value.activeWindow,
@@ -1,11 +1,26 @@
1
1
  import { Router } from "express";
2
2
  import { ChatRoomError } from "../../../core/errors/chatroom-error.js";
3
3
  import { asyncRoute, requireString, } from "../../../presentation/http/http-utils.js";
4
+ const MAX_WRITE_BYTES = 1024 * 1024;
4
5
  export function createWorkspaceApiRouter(application) {
5
6
  const router = Router();
6
7
  router.get("/workspaces", asyncRoute(async (_req, res) => {
7
8
  res.json(await application.workspaces.list());
8
9
  }));
10
+ router.post("/workspaces", asyncRoute(async (req, res) => {
11
+ const body = bodyRecord(req.body);
12
+ const parent = requireString(body.parent, "parent");
13
+ const name = requireString(body.name, "name");
14
+ res.json(await application.operations.run({
15
+ pluginId: "workspace",
16
+ source: "gui",
17
+ action: "create",
18
+ input: { parent, name },
19
+ }, () => application.workspaces.createProject(parent, name)));
20
+ }));
21
+ router.get("/workspace/roots", (_req, res) => {
22
+ res.json(application.workspaces.roots());
23
+ });
9
24
  router.get("/workspace", asyncRoute(async (req, res) => {
10
25
  res.json(await application.workspaces.info(requireString(req.query.root, "root")));
11
26
  }));
@@ -20,6 +35,22 @@ export function createWorkspaceApiRouter(application) {
20
35
  maxBytes: 2 * 1024 * 1024,
21
36
  }));
22
37
  }));
38
+ router.put("/workspace/file", asyncRoute(async (req, res) => {
39
+ const body = bodyRecord(req.body);
40
+ const fs = await application.workspaces.fs(requireString(body.root, "root"));
41
+ const filePath = requireString(body.path, "path");
42
+ if (typeof body.content !== "string")
43
+ throw new ChatRoomError("INVALID_INPUT", "content must be a string");
44
+ const bytes = Buffer.byteLength(body.content, "utf8");
45
+ if (bytes > MAX_WRITE_BYTES)
46
+ throw new ChatRoomError("INVALID_INPUT", `File content exceeds ${MAX_WRITE_BYTES} bytes`);
47
+ res.json(await application.operations.run({
48
+ pluginId: "workspace",
49
+ source: "gui",
50
+ action: "file.write",
51
+ input: { root: fs.root, path: filePath, bytes },
52
+ }, () => fs.write(filePath, body.content)));
53
+ }));
23
54
  router.get("/workspace/file/image", asyncRoute(async (req, res) => {
24
55
  const filePath = requireString(req.query.path, "path");
25
56
  const mime = imageMime(filePath);
@@ -37,6 +68,11 @@ export function createWorkspaceApiRouter(application) {
37
68
  }));
38
69
  return router;
39
70
  }
71
+ function bodyRecord(value) {
72
+ return value && typeof value === "object" && !Array.isArray(value)
73
+ ? value
74
+ : {};
75
+ }
40
76
  function imageMime(filePath) {
41
77
  switch (filePath.split(".").pop()?.toLowerCase()) {
42
78
  case "png":
@@ -10,7 +10,7 @@ export function createWebPlugin() {
10
10
  id: "web",
11
11
  activate(context) {
12
12
  context.services.provide(WebServiceToken, {
13
- application: new WebRuntime(context.services.require(WorkspaceServiceToken), context.services.require(GitServiceToken), context.operations, context.services.require(ProcessService), context.services.require(ComputerServiceToken)),
13
+ application: new WebRuntime(context.services.require(WorkspaceServiceToken), context.services.require(GitServiceToken), context.operations, context.services.require(ProcessService), context.services.require(ComputerServiceToken), context.mcpTools),
14
14
  });
15
15
  },
16
16
  };
@@ -3,13 +3,15 @@ import type { ComputerService } from "../computer/computer-service.js";
3
3
  import type { GitService } from "../git/git-service.js";
4
4
  import type { ProcessSupervisor } from "../process/process-supervisor.js";
5
5
  import type { WorkspaceService } from "../workspace/workspace-service.js";
6
+ import type { McpToolControl } from "../../mcp/server/tool-control.js";
6
7
  export declare class WebRuntime {
7
8
  readonly workspaces: WorkspaceService;
8
9
  readonly git: GitService;
9
10
  readonly operations: OperationLog;
10
11
  readonly processes: ProcessSupervisor;
11
12
  readonly computer: ComputerService;
12
- constructor(workspaces: WorkspaceService, git: GitService, operations: OperationLog, processes: ProcessSupervisor, computer: ComputerService);
13
+ readonly mcpTools: McpToolControl;
14
+ constructor(workspaces: WorkspaceService, git: GitService, operations: OperationLog, processes: ProcessSupervisor, computer: ComputerService, mcpTools: McpToolControl);
13
15
  processKill(processId: string, force?: boolean): Promise<import("./api-types.js").ProcessSnapshot>;
14
16
  listProcesses(): import("./api-types.js").ProcessSnapshot[];
15
17
  getProcess(processId: string): import("./api-types.js").ProcessSnapshot;
@@ -4,12 +4,14 @@ export class WebRuntime {
4
4
  operations;
5
5
  processes;
6
6
  computer;
7
- constructor(workspaces, git, operations, processes, computer) {
7
+ mcpTools;
8
+ constructor(workspaces, git, operations, processes, computer, mcpTools) {
8
9
  this.workspaces = workspaces;
9
10
  this.git = git;
10
11
  this.operations = operations;
11
12
  this.processes = processes;
12
13
  this.computer = computer;
14
+ this.mcpTools = mcpTools;
13
15
  }
14
16
  processKill(processId, force = false) {
15
17
  return this.operations.run({
@@ -2,6 +2,9 @@ import { z } from "zod";
2
2
  import { closedRead } from "../../mcp/server/tool-support.js";
3
3
  const workspaceInfoSchema = z.object({
4
4
  root: z.string(),
5
+ name: z.string(),
6
+ summary: z.string().nullable(),
7
+ presetPrompt: z.string().nullable(),
5
8
  instructions: z.string().nullable(),
6
9
  skills: z.array(z.object({
7
10
  name: z.string(),
@@ -12,7 +15,7 @@ const workspaceInfoSchema = z.object({
12
15
  export function registerWorkspaceTools(mcp, workspaces) {
13
16
  mcp.registerTool("workspace_info", {
14
17
  title: "Workspace info",
15
- description: "Read project instructions and skill metadata for a directory within the configured allowed roots.",
18
+ description: "Read the complete workspace context for a project, including its summary, preset prompt, project instructions, and skill metadata.",
16
19
  inputSchema: z.object({ root: z.string() }),
17
20
  outputSchema: workspaceInfoSchema,
18
21
  annotations: closedRead,
@@ -1,4 +1,6 @@
1
1
  import type { WorkspaceFs } from "./workspace-fs.js";
2
2
  import type { WorkspaceSkill } from "./types.js";
3
+ export declare function readSummary(fs: WorkspaceFs): Promise<string | null>;
4
+ export declare function readPresetPrompt(fs: WorkspaceFs): Promise<string | null>;
3
5
  export declare function readInstructions(fs: WorkspaceFs): Promise<string | null>;
4
6
  export declare function readSkills(fs: WorkspaceFs): Promise<WorkspaceSkill[]>;