@zhanguym/chatroom 1.0.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 (223) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +52 -0
  3. package/assets/chatroom.svg +15 -0
  4. package/chatroom.config.example.json +24 -0
  5. package/dist/app/application.d.ts +20 -0
  6. package/dist/app/application.js +75 -0
  7. package/dist/app/event-bus.d.ts +25 -0
  8. package/dist/app/event-bus.js +11 -0
  9. package/dist/app/external-access-registry.d.ts +17 -0
  10. package/dist/app/external-access-registry.js +41 -0
  11. package/dist/app/lifecycle.d.ts +14 -0
  12. package/dist/app/lifecycle.js +130 -0
  13. package/dist/auth/auth-service.d.ts +88 -0
  14. package/dist/auth/auth-service.js +221 -0
  15. package/dist/auth/ingress-policy.d.ts +23 -0
  16. package/dist/auth/ingress-policy.js +61 -0
  17. package/dist/auth/passkey-service.d.ts +39 -0
  18. package/dist/auth/passkey-service.js +145 -0
  19. package/dist/cli/index.d.ts +2 -0
  20. package/dist/cli/index.js +10 -0
  21. package/dist/cli/run-cli.d.ts +1 -0
  22. package/dist/cli/run-cli.js +41 -0
  23. package/dist/config/load-config.d.ts +7 -0
  24. package/dist/config/load-config.js +197 -0
  25. package/dist/config/platform-paths.d.ts +7 -0
  26. package/dist/config/platform-paths.js +34 -0
  27. package/dist/config/types.d.ts +24 -0
  28. package/dist/config/types.js +1 -0
  29. package/dist/core/auth/passkey-repository.d.ts +18 -0
  30. package/dist/core/auth/passkey-repository.js +1 -0
  31. package/dist/core/auth/repository.d.ts +36 -0
  32. package/dist/core/auth/repository.js +1 -0
  33. package/dist/core/auth/session-repository.d.ts +6 -0
  34. package/dist/core/auth/session-repository.js +1 -0
  35. package/dist/core/errors/chatroom-error.d.ts +9 -0
  36. package/dist/core/errors/chatroom-error.js +31 -0
  37. package/dist/core/logging/types.d.ts +31 -0
  38. package/dist/core/logging/types.js +1 -0
  39. package/dist/core/operations/bounded-value.d.ts +6 -0
  40. package/dist/core/operations/bounded-value.js +57 -0
  41. package/dist/core/operations/redactor.d.ts +4 -0
  42. package/dist/core/operations/redactor.js +69 -0
  43. package/dist/core/operations/repository.d.ts +18 -0
  44. package/dist/core/operations/repository.js +1 -0
  45. package/dist/core/operations/types.d.ts +28 -0
  46. package/dist/core/operations/types.js +8 -0
  47. package/dist/core/runtime/child-environment.d.ts +1 -0
  48. package/dist/core/runtime/child-environment.js +39 -0
  49. package/dist/core/runtime/command-runner.d.ts +16 -0
  50. package/dist/core/runtime/command-runner.js +48 -0
  51. package/dist/core/runtime/identity.d.ts +2 -0
  52. package/dist/core/runtime/identity.js +9 -0
  53. package/dist/infrastructure/database/app-database.d.ts +6 -0
  54. package/dist/infrastructure/database/app-database.js +22 -0
  55. package/dist/infrastructure/database/mcp-tool-settings-repository.d.ts +8 -0
  56. package/dist/infrastructure/database/mcp-tool-settings-repository.js +22 -0
  57. package/dist/infrastructure/database/oauth-repository.d.ts +20 -0
  58. package/dist/infrastructure/database/oauth-repository.js +140 -0
  59. package/dist/infrastructure/database/operation-repository.d.ts +16 -0
  60. package/dist/infrastructure/database/operation-repository.js +120 -0
  61. package/dist/infrastructure/database/passkey-repository.d.ts +11 -0
  62. package/dist/infrastructure/database/passkey-repository.js +57 -0
  63. package/dist/infrastructure/database/schema.d.ts +1 -0
  64. package/dist/infrastructure/database/schema.js +90 -0
  65. package/dist/infrastructure/database/web-session-repository.d.ts +10 -0
  66. package/dist/infrastructure/database/web-session-repository.js +26 -0
  67. package/dist/infrastructure/http/http-server.d.ts +28 -0
  68. package/dist/infrastructure/http/http-server.js +146 -0
  69. package/dist/infrastructure/http/ingress-middleware.d.ts +6 -0
  70. package/dist/infrastructure/http/ingress-middleware.js +58 -0
  71. package/dist/infrastructure/logging/file-log-store.d.ts +9 -0
  72. package/dist/infrastructure/logging/file-log-store.js +173 -0
  73. package/dist/infrastructure/logging/system-log.d.ts +22 -0
  74. package/dist/infrastructure/logging/system-log.js +117 -0
  75. package/dist/mcp/server/create-mcp-server.d.ts +3 -0
  76. package/dist/mcp/server/create-mcp-server.js +16 -0
  77. package/dist/mcp/server/plugin-mcp-registrar.d.ts +31 -0
  78. package/dist/mcp/server/plugin-mcp-registrar.js +70 -0
  79. package/dist/mcp/server/request-context.d.ts +3 -0
  80. package/dist/mcp/server/request-context.js +8 -0
  81. package/dist/mcp/server/tool-control.d.ts +31 -0
  82. package/dist/mcp/server/tool-control.js +76 -0
  83. package/dist/mcp/server/tool-support.d.ts +44 -0
  84. package/dist/mcp/server/tool-support.js +72 -0
  85. package/dist/native/linux/chatroom-computer-helper +0 -0
  86. package/dist/native/macos/ChatRoomComputerHelper.app/Contents/Info.plist +14 -0
  87. package/dist/native/macos/ChatRoomComputerHelper.app/Contents/MacOS/chatroom-computer-helper +0 -0
  88. package/dist/native/macos/ChatRoomComputerHelper.app/Contents/_CodeSignature/CodeResources +115 -0
  89. package/dist/native/windows/chatroom-computer-helper.exe +0 -0
  90. package/dist/operations/operation-log.d.ts +24 -0
  91. package/dist/operations/operation-log.js +98 -0
  92. package/dist/plugins/cloud/api-client.d.ts +50 -0
  93. package/dist/plugins/cloud/api-client.js +112 -0
  94. package/dist/plugins/cloud/controller.d.ts +58 -0
  95. package/dist/plugins/cloud/controller.js +425 -0
  96. package/dist/plugins/cloud/device-proof.d.ts +9 -0
  97. package/dist/plugins/cloud/device-proof.js +17 -0
  98. package/dist/plugins/cloud/plugin.d.ts +4 -0
  99. package/dist/plugins/cloud/plugin.js +17 -0
  100. package/dist/plugins/cloud/state-store.d.ts +7 -0
  101. package/dist/plugins/cloud/state-store.js +79 -0
  102. package/dist/plugins/cloud/tunnel-client.d.ts +84 -0
  103. package/dist/plugins/cloud/tunnel-client.js +427 -0
  104. package/dist/plugins/cloud/types.d.ts +72 -0
  105. package/dist/plugins/cloud/types.js +60 -0
  106. package/dist/plugins/computer/audit.d.ts +34 -0
  107. package/dist/plugins/computer/audit.js +42 -0
  108. package/dist/plugins/computer/computer-native-backend.d.ts +11 -0
  109. package/dist/plugins/computer/computer-native-backend.js +58 -0
  110. package/dist/plugins/computer/computer-native-host.d.ts +30 -0
  111. package/dist/plugins/computer/computer-native-host.js +411 -0
  112. package/dist/plugins/computer/computer-protocol.d.ts +22 -0
  113. package/dist/plugins/computer/computer-protocol.js +74 -0
  114. package/dist/plugins/computer/computer-schemas.d.ts +285 -0
  115. package/dist/plugins/computer/computer-schemas.js +152 -0
  116. package/dist/plugins/computer/computer-service.d.ts +29 -0
  117. package/dist/plugins/computer/computer-service.js +117 -0
  118. package/dist/plugins/computer/computer-settings-repository.d.ts +8 -0
  119. package/dist/plugins/computer/computer-settings-repository.js +41 -0
  120. package/dist/plugins/computer/mcp.d.ts +3 -0
  121. package/dist/plugins/computer/mcp.js +126 -0
  122. package/dist/plugins/computer/plugin.d.ts +4 -0
  123. package/dist/plugins/computer/plugin.js +29 -0
  124. package/dist/plugins/computer/types.d.ts +174 -0
  125. package/dist/plugins/computer/types.js +1 -0
  126. package/dist/plugins/git/git-service.d.ts +30 -0
  127. package/dist/plugins/git/git-service.js +428 -0
  128. package/dist/plugins/git/plugin.d.ts +4 -0
  129. package/dist/plugins/git/plugin.js +12 -0
  130. package/dist/plugins/git/types.d.ts +32 -0
  131. package/dist/plugins/git/types.js +1 -0
  132. package/dist/plugins/plugin-manager.d.ts +11 -0
  133. package/dist/plugins/plugin-manager.js +63 -0
  134. package/dist/plugins/process/backend.d.ts +18 -0
  135. package/dist/plugins/process/backend.js +1 -0
  136. package/dist/plugins/process/head-tail-buffer.d.ts +17 -0
  137. package/dist/plugins/process/head-tail-buffer.js +49 -0
  138. package/dist/plugins/process/infrastructure/pipe-process-backend.d.ts +8 -0
  139. package/dist/plugins/process/infrastructure/pipe-process-backend.js +71 -0
  140. package/dist/plugins/process/infrastructure/pty-process-backend.d.ts +8 -0
  141. package/dist/plugins/process/infrastructure/pty-process-backend.js +51 -0
  142. package/dist/plugins/process/mcp.d.ts +3 -0
  143. package/dist/plugins/process/mcp.js +58 -0
  144. package/dist/plugins/process/plugin.d.ts +4 -0
  145. package/dist/plugins/process/plugin.js +25 -0
  146. package/dist/plugins/process/process-supervisor.d.ts +39 -0
  147. package/dist/plugins/process/process-supervisor.js +244 -0
  148. package/dist/plugins/process/types.d.ts +30 -0
  149. package/dist/plugins/process/types.js +1 -0
  150. package/dist/plugins/types.d.ts +35 -0
  151. package/dist/plugins/types.js +17 -0
  152. package/dist/plugins/web/api-types.d.ts +56 -0
  153. package/dist/plugins/web/api-types.js +1 -0
  154. package/dist/plugins/web/http/api-router.d.ts +13 -0
  155. package/dist/plugins/web/http/api-router.js +47 -0
  156. package/dist/plugins/web/http/auth-api-router.d.ts +8 -0
  157. package/dist/plugins/web/http/auth-api-router.js +123 -0
  158. package/dist/plugins/web/http/cloud-api-router.d.ts +4 -0
  159. package/dist/plugins/web/http/cloud-api-router.js +29 -0
  160. package/dist/plugins/web/http/computer-api-router.d.ts +5 -0
  161. package/dist/plugins/web/http/computer-api-router.js +69 -0
  162. package/dist/plugins/web/http/git-api-router.d.ts +3 -0
  163. package/dist/plugins/web/http/git-api-router.js +85 -0
  164. package/dist/plugins/web/http/logs-api-router.d.ts +3 -0
  165. package/dist/plugins/web/http/logs-api-router.js +49 -0
  166. package/dist/plugins/web/http/process-api-router.d.ts +3 -0
  167. package/dist/plugins/web/http/process-api-router.js +16 -0
  168. package/dist/plugins/web/http/runtime-api-router.d.ts +10 -0
  169. package/dist/plugins/web/http/runtime-api-router.js +34 -0
  170. package/dist/plugins/web/http/workspace-api-router.d.ts +3 -0
  171. package/dist/plugins/web/http/workspace-api-router.js +91 -0
  172. package/dist/plugins/web/plugin.d.ts +8 -0
  173. package/dist/plugins/web/plugin.js +17 -0
  174. package/dist/plugins/web/runtime.d.ts +18 -0
  175. package/dist/plugins/web/runtime.js +31 -0
  176. package/dist/plugins/workspace/mcp.d.ts +3 -0
  177. package/dist/plugins/workspace/mcp.js +24 -0
  178. package/dist/plugins/workspace/metadata.d.ts +6 -0
  179. package/dist/plugins/workspace/metadata.js +111 -0
  180. package/dist/plugins/workspace/plugin.d.ts +4 -0
  181. package/dist/plugins/workspace/plugin.js +22 -0
  182. package/dist/plugins/workspace/types.d.ts +23 -0
  183. package/dist/plugins/workspace/types.js +1 -0
  184. package/dist/plugins/workspace/workspace-fs.d.ts +28 -0
  185. package/dist/plugins/workspace/workspace-fs.js +200 -0
  186. package/dist/plugins/workspace/workspace-service.d.ts +15 -0
  187. package/dist/plugins/workspace/workspace-service.js +140 -0
  188. package/dist/presentation/http/http-utils.d.ts +9 -0
  189. package/dist/presentation/http/http-utils.js +89 -0
  190. package/dist/presentation/http/oauth-router.d.ts +5 -0
  191. package/dist/presentation/http/oauth-router.js +172 -0
  192. package/dist/web/assets/CloudView-CHjVbp-5.css +1 -0
  193. package/dist/web/assets/CloudView-CvX0dkgu.js +1 -0
  194. package/dist/web/assets/CodeViewer-CH1DEJge.css +1 -0
  195. package/dist/web/assets/CodeViewer.vue_vue_type_script_setup_true_lang-B5TxGkUW.js +3 -0
  196. package/dist/web/assets/ComputerView-91Z9Cefw.css +1 -0
  197. package/dist/web/assets/ComputerView-OOyEqWml.js +1 -0
  198. package/dist/web/assets/McpToolsView-BTnolyS6.css +1 -0
  199. package/dist/web/assets/McpToolsView-DjXWTKrZ.js +1 -0
  200. package/dist/web/assets/OperationsView-DqTY51Cr.js +4 -0
  201. package/dist/web/assets/ProcessesView-C13BykIa.js +4 -0
  202. package/dist/web/assets/StateChip.vue_vue_type_script_setup_true_lang-DyOhJmR6.js +1 -0
  203. package/dist/web/assets/SystemLogsView-Ci0u4BTb.js +1 -0
  204. package/dist/web/assets/VSelect-CAxEESw-.css +1 -0
  205. package/dist/web/assets/VSelect-DrRpKyYI.js +1 -0
  206. package/dist/web/assets/VSpacer-C3wAY3F0.js +1 -0
  207. package/dist/web/assets/VSpacer-DfbUir7X.css +1 -0
  208. package/dist/web/assets/VSwitch-Cos8PNSP.css +1 -0
  209. package/dist/web/assets/VSwitch-CxusR4iO.js +1 -0
  210. package/dist/web/assets/VTabs-DSWgbwhc.css +1 -0
  211. package/dist/web/assets/VTabs-qBcuuXh3.js +1 -0
  212. package/dist/web/assets/WorkspacesView-BzQ0aH74.css +1 -0
  213. package/dist/web/assets/WorkspacesView-CijIvfsp.js +2 -0
  214. package/dist/web/assets/_plugin-vue_export-helper-DlAUqK2U.js +1 -0
  215. package/dist/web/assets/chatroom-B5p-5n3y.svg +15 -0
  216. package/dist/web/assets/index-BDkarAkX.js +2 -0
  217. package/dist/web/assets/index-BegKQ3Yd.js +19 -0
  218. package/dist/web/assets/index-CkjqXRSj.css +1 -0
  219. package/dist/web/assets/syntax-highlight-B5ArnyrC.js +3 -0
  220. package/dist/web/assets/useOperationLabels-BDjzGDkT.js +1 -0
  221. package/dist/web/assets/utils-rtPwWNel.js +1 -0
  222. package/dist/web/index.html +19 -0
  223. package/package.json +125 -0
@@ -0,0 +1,31 @@
1
+ export class WebRuntime {
2
+ workspaces;
3
+ git;
4
+ operations;
5
+ processes;
6
+ computer;
7
+ mcpTools;
8
+ constructor(workspaces, git, operations, processes, computer, mcpTools) {
9
+ this.workspaces = workspaces;
10
+ this.git = git;
11
+ this.operations = operations;
12
+ this.processes = processes;
13
+ this.computer = computer;
14
+ this.mcpTools = mcpTools;
15
+ }
16
+ processKill(processId, force = false) {
17
+ return this.operations.run({
18
+ pluginId: "process",
19
+ source: "gui",
20
+ action: force ? "kill" : "terminate",
21
+ processId,
22
+ input: { processId, force },
23
+ }, async () => this.processes.kill(processId, force));
24
+ }
25
+ listProcesses() {
26
+ return this.processes.summaries();
27
+ }
28
+ getProcess(processId) {
29
+ return this.processes.read(processId);
30
+ }
31
+ }
@@ -0,0 +1,3 @@
1
+ import type { PluginMcpRegistrar } from "#mcp/server/plugin-mcp-registrar";
2
+ import type { WorkspaceService } from "./workspace-service.js";
3
+ export declare function registerWorkspaceTools(mcp: PluginMcpRegistrar, workspaces: WorkspaceService): void;
@@ -0,0 +1,24 @@
1
+ import { z } from "zod";
2
+ import { closedRead } from "#mcp/server/tool-support";
3
+ const workspaceInfoSchema = z.object({
4
+ root: z.string(),
5
+ name: z.string(),
6
+ summary: z.string().nullable(),
7
+ presetPrompt: z.string().nullable(),
8
+ instructions: z.string().nullable(),
9
+ skills: z.array(z.object({
10
+ name: z.string(),
11
+ description: z.string(),
12
+ path: z.string(),
13
+ })),
14
+ });
15
+ export function registerWorkspaceTools(mcp, workspaces) {
16
+ mcp.registerTool("workspace_info", {
17
+ title: "Workspace info",
18
+ description: "Read the complete workspace context for a project, including its summary, preset prompt, project instructions, and skill metadata.",
19
+ inputSchema: z.object({ root: z.string() }),
20
+ outputSchema: workspaceInfoSchema,
21
+ annotations: closedRead,
22
+ action: "info",
23
+ }, (input) => workspaces.info(input.root));
24
+ }
@@ -0,0 +1,6 @@
1
+ import type { WorkspaceFs } from "./workspace-fs.js";
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>;
5
+ export declare function readInstructions(fs: WorkspaceFs): Promise<string | null>;
6
+ export declare function readSkills(fs: WorkspaceFs): Promise<WorkspaceSkill[]>;
@@ -0,0 +1,111 @@
1
+ import path from "node:path";
2
+ import { ChatRoomError } from "#core/errors/chatroom-error";
3
+ const SKILL_ROOTS = [".agents/skills", ".claude/skills", ".chatroom/skills"];
4
+ const MAX_METADATA_BYTES = 64 * 1024;
5
+ const MAX_SUMMARY_BYTES = 8 * 1024;
6
+ export function readSummary(fs) {
7
+ return readOptionalText(fs, ".chatroom/summary.md", MAX_SUMMARY_BYTES);
8
+ }
9
+ export function readPresetPrompt(fs) {
10
+ return readOptionalText(fs, ".chatroom/prompt.md", MAX_METADATA_BYTES);
11
+ }
12
+ export function readInstructions(fs) {
13
+ return readOptionalText(fs, "AGENTS.md", MAX_METADATA_BYTES);
14
+ }
15
+ export async function readSkills(fs) {
16
+ const entriesByRoot = await Promise.all(SKILL_ROOTS.map(async (root) => {
17
+ try {
18
+ return await fs.list(root, { recursive: true, maxEntries: 2000 });
19
+ }
20
+ catch (error) {
21
+ if (error instanceof ChatRoomError && error.code === "NOT_FOUND")
22
+ return [];
23
+ throw error;
24
+ }
25
+ }));
26
+ const paths = entriesByRoot.flatMap((entries) => entries
27
+ .filter((entry) => entry.type === "file" && entry.path.endsWith("/SKILL.md"))
28
+ .map((entry) => entry.path));
29
+ return Promise.all([...new Set(paths)].sort().map(async (skillPath) => {
30
+ const fallbackName = path.posix.basename(path.posix.dirname(skillPath));
31
+ try {
32
+ const file = await fs.read(skillPath, { maxBytes: MAX_METADATA_BYTES });
33
+ const metadata = parseSkillFrontmatter(file.content);
34
+ return {
35
+ name: metadata.name || fallbackName,
36
+ description: metadata.description,
37
+ path: skillPath,
38
+ };
39
+ }
40
+ catch (error) {
41
+ if (!(error instanceof ChatRoomError) || error.code !== "NOT_FOUND")
42
+ throw error;
43
+ return { name: fallbackName, description: "", path: skillPath };
44
+ }
45
+ }));
46
+ }
47
+ async function readOptionalText(fs, relativePath, maxBytes) {
48
+ try {
49
+ const content = (await fs.read(relativePath, { maxBytes })).content;
50
+ return content.trim() ? content : null;
51
+ }
52
+ catch (error) {
53
+ if (error instanceof ChatRoomError && error.code === "NOT_FOUND")
54
+ return null;
55
+ throw error;
56
+ }
57
+ }
58
+ function parseSkillFrontmatter(content) {
59
+ const lines = content
60
+ .replace(/^\uFEFF/, "")
61
+ .replaceAll("\r\n", "\n")
62
+ .split("\n");
63
+ if (lines[0]?.trim() !== "---")
64
+ return { name: "", description: "" };
65
+ const end = lines.findIndex((line, index) => index > 0 && ["---", "..."].includes(line.trim()));
66
+ if (end < 0)
67
+ return { name: "", description: "" };
68
+ const values = new Map();
69
+ for (let index = 1; index < end; index++) {
70
+ const match = /^([A-Za-z0-9_-]+):(?:\s*(.*))?$/.exec(lines[index] ?? "");
71
+ if (!match)
72
+ continue;
73
+ const key = match[1] ?? "";
74
+ if (key !== "name" && key !== "description")
75
+ continue;
76
+ const raw = match[2] ?? "";
77
+ if (/^[>|][+-]?\d?$/.test(raw.trim())) {
78
+ const block = [];
79
+ while (index + 1 < end) {
80
+ const next = lines[index + 1] ?? "";
81
+ if (next && !/^\s/.test(next))
82
+ break;
83
+ index++;
84
+ block.push(next.replace(/^\s+/, ""));
85
+ }
86
+ values.set(key, raw.trim().startsWith(">")
87
+ ? block.join(" ").replace(/\s+/g, " ").trim()
88
+ : block.join("\n").trim());
89
+ continue;
90
+ }
91
+ values.set(key, parseYamlScalar(raw));
92
+ }
93
+ return {
94
+ name: values.get("name")?.trim() ?? "",
95
+ description: values.get("description")?.trim() ?? "",
96
+ };
97
+ }
98
+ function parseYamlScalar(value) {
99
+ const trimmed = value.trim();
100
+ if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
101
+ try {
102
+ return JSON.parse(trimmed);
103
+ }
104
+ catch {
105
+ return trimmed.slice(1, -1);
106
+ }
107
+ }
108
+ if (trimmed.length >= 2 && trimmed.startsWith("'") && trimmed.endsWith("'"))
109
+ return trimmed.slice(1, -1).replaceAll("''", "'");
110
+ return trimmed;
111
+ }
@@ -0,0 +1,4 @@
1
+ import type { InternalPlugin } from "#plugins/types";
2
+ import { WorkspaceService } from "./workspace-service.js";
3
+ export declare const WorkspaceServiceToken: import("#plugins/types").ServiceToken<WorkspaceService>;
4
+ export declare function createWorkspacePlugin(): InternalPlugin;
@@ -0,0 +1,22 @@
1
+ import { createServiceToken } from "#plugins/types";
2
+ import { registerWorkspaceTools } from "./mcp.js";
3
+ import { WorkspaceService } from "./workspace-service.js";
4
+ export const WorkspaceServiceToken = createServiceToken("workspace");
5
+ export function createWorkspacePlugin() {
6
+ let service = null;
7
+ return {
8
+ id: "workspace",
9
+ async activate(context) {
10
+ service = await WorkspaceService.create(context.config.allowedRoots);
11
+ context.services.provide(WorkspaceServiceToken, service);
12
+ },
13
+ registerMcp(mcp) {
14
+ if (!service)
15
+ throw new Error("Workspace plugin is not active");
16
+ registerWorkspaceTools(mcp, service);
17
+ },
18
+ deactivate() {
19
+ service = null;
20
+ },
21
+ };
22
+ }
@@ -0,0 +1,23 @@
1
+ export interface WorkspaceEntry {
2
+ root: string;
3
+ name: string;
4
+ summary: string | null;
5
+ }
6
+ export interface WorkspaceSkill {
7
+ name: string;
8
+ description: string;
9
+ path: string;
10
+ }
11
+ export interface WorkspaceInfo {
12
+ root: string;
13
+ name: string;
14
+ summary: string | null;
15
+ presetPrompt: string | null;
16
+ instructions: string | null;
17
+ skills: WorkspaceSkill[];
18
+ }
19
+ export interface WorkspaceFile {
20
+ path: string;
21
+ type: "file" | "directory" | "symlink";
22
+ size: number;
23
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ import type { WorkspaceFile } from "./types.js";
2
+ export declare class WorkspaceFs {
3
+ readonly root: string;
4
+ private constructor();
5
+ static create(root: string): Promise<WorkspaceFs>;
6
+ read(relativePath: string, options?: {
7
+ maxBytes?: number;
8
+ }): Promise<{
9
+ content: string;
10
+ bytes: number;
11
+ truncated: boolean;
12
+ }>;
13
+ readBytes(relativePath: string, options?: {
14
+ maxBytes?: number;
15
+ }): Promise<{
16
+ data: Buffer;
17
+ bytes: number;
18
+ truncated: boolean;
19
+ }>;
20
+ write(relativePath: string, content: string): Promise<WorkspaceFile>;
21
+ list(relativePath?: string, options?: {
22
+ recursive?: boolean;
23
+ maxEntries?: number;
24
+ }): Promise<WorkspaceFile[]>;
25
+ private lexical;
26
+ private resolveWritable;
27
+ private resolveReadable;
28
+ }
@@ -0,0 +1,200 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstat, mkdir, open, readdir, realpath, rename, rm, stat, writeFile, } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { ChatRoomError } from "#core/errors/chatroom-error";
5
+ export class WorkspaceFs {
6
+ root;
7
+ constructor(root) {
8
+ this.root = root;
9
+ }
10
+ static async create(root) {
11
+ const canonical = await realpath(path.resolve(root)).catch(() => {
12
+ throw new ChatRoomError("NOT_FOUND", `Workspace root does not exist: ${root}`);
13
+ });
14
+ const info = await stat(canonical);
15
+ if (!info.isDirectory())
16
+ throw new ChatRoomError("INVALID_INPUT", `Workspace root is not a directory: ${root}`);
17
+ return new WorkspaceFs(canonical);
18
+ }
19
+ async read(relativePath, options = {}) {
20
+ const result = await this.readBytes(relativePath, options);
21
+ return {
22
+ content: result.data.toString("utf8"),
23
+ bytes: result.bytes,
24
+ truncated: result.truncated,
25
+ };
26
+ }
27
+ async readBytes(relativePath, options = {}) {
28
+ const target = await this.resolveReadable(relativePath);
29
+ const info = await stat(target);
30
+ if (!info.isFile())
31
+ throw new ChatRoomError("INVALID_INPUT", `Not a file: ${relativePath}`);
32
+ const maxBytes = options.maxBytes ?? 1024 * 1024;
33
+ const handle = await open(target, "r");
34
+ try {
35
+ const readSize = Math.min(info.size, maxBytes);
36
+ const buffer = Buffer.alloc(readSize);
37
+ const result = await handle.read(buffer, 0, readSize, 0);
38
+ return {
39
+ data: buffer.subarray(0, result.bytesRead),
40
+ bytes: info.size,
41
+ truncated: info.size > maxBytes,
42
+ };
43
+ }
44
+ finally {
45
+ await handle.close();
46
+ }
47
+ }
48
+ async write(relativePath, content) {
49
+ if (typeof content !== "string")
50
+ throw new ChatRoomError("INVALID_INPUT", "File content must be a string");
51
+ const normalized = normalizeRelative(relativePath);
52
+ if (normalized === ".")
53
+ throw new ChatRoomError("INVALID_INPUT", "File path is required");
54
+ const target = await this.resolveWritable(normalized);
55
+ let mode = 0o644;
56
+ try {
57
+ const info = await lstat(target);
58
+ if (!info.isFile())
59
+ throw new ChatRoomError("INVALID_INPUT", `Not a file: ${relativePath}`);
60
+ mode = info.mode & 0o777;
61
+ }
62
+ catch (error) {
63
+ if (error instanceof ChatRoomError)
64
+ throw error;
65
+ if (error.code !== "ENOENT")
66
+ throw error;
67
+ }
68
+ const temporary = `${target}.chatroom-${randomUUID()}.tmp`;
69
+ try {
70
+ await writeFile(temporary, content, {
71
+ encoding: "utf8",
72
+ flag: "wx",
73
+ mode,
74
+ });
75
+ await rename(temporary, target);
76
+ }
77
+ finally {
78
+ await rm(temporary, { force: true }).catch(() => undefined);
79
+ }
80
+ return {
81
+ path: normalized,
82
+ type: "file",
83
+ size: Buffer.byteLength(content, "utf8"),
84
+ };
85
+ }
86
+ async list(relativePath = ".", options = {}) {
87
+ const start = await this.resolveReadable(relativePath);
88
+ if (!(await stat(start)).isDirectory())
89
+ throw new ChatRoomError("INVALID_INPUT", `Not a directory: ${relativePath}`);
90
+ const maxEntries = Math.min(options.maxEntries ?? 1000, 10000);
91
+ const output = [];
92
+ const walk = async (directory) => {
93
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
94
+ if (output.length >= maxEntries)
95
+ return;
96
+ const absolute = path.join(directory, entry.name);
97
+ const info = await lstat(absolute);
98
+ const type = info.isSymbolicLink()
99
+ ? "symlink"
100
+ : info.isDirectory()
101
+ ? "directory"
102
+ : "file";
103
+ output.push({
104
+ path: path.relative(this.root, absolute).split(path.sep).join("/"),
105
+ type,
106
+ size: info.size,
107
+ });
108
+ if (options.recursive && entry.isDirectory() && !entry.isSymbolicLink())
109
+ await walk(absolute);
110
+ }
111
+ };
112
+ await walk(start);
113
+ return output;
114
+ }
115
+ lexical(relativePath) {
116
+ const normalized = normalizeRelative(relativePath);
117
+ const absolute = path.resolve(this.root, ...normalized.split("/"));
118
+ if (!inside(this.root, absolute))
119
+ throw new ChatRoomError("FORBIDDEN", "Path escapes workspace");
120
+ return absolute;
121
+ }
122
+ async resolveWritable(relativePath) {
123
+ const target = this.lexical(relativePath);
124
+ const relative = path.relative(this.root, target);
125
+ const parts = relative.split(path.sep).filter(Boolean);
126
+ let current = this.root;
127
+ for (let index = 0; index < parts.length - 1; index++) {
128
+ current = path.join(current, parts[index]);
129
+ let info;
130
+ try {
131
+ info = await lstat(current);
132
+ }
133
+ catch (error) {
134
+ if (error.code !== "ENOENT")
135
+ throw error;
136
+ try {
137
+ await mkdir(current, { recursive: false, mode: 0o700 });
138
+ }
139
+ catch (mkdirError) {
140
+ if (mkdirError.code !== "EEXIST")
141
+ throw mkdirError;
142
+ }
143
+ info = await lstat(current);
144
+ }
145
+ if (info.isSymbolicLink())
146
+ throw new ChatRoomError("FORBIDDEN", `Writes through symlinked directories are not allowed: ${relativePath}`);
147
+ if (!info.isDirectory())
148
+ throw new ChatRoomError("INVALID_INPUT", `Parent is not a directory: ${parts[index]}`);
149
+ const canonical = await realpath(current);
150
+ if (!inside(this.root, canonical))
151
+ throw new ChatRoomError("FORBIDDEN", "Write path escapes workspace");
152
+ }
153
+ try {
154
+ const targetInfo = await lstat(target);
155
+ if (targetInfo.isSymbolicLink())
156
+ throw new ChatRoomError("FORBIDDEN", `Writes through symlinks are not allowed: ${relativePath}`);
157
+ if (!targetInfo.isFile())
158
+ throw new ChatRoomError("INVALID_INPUT", `Not a file: ${relativePath}`);
159
+ }
160
+ catch (error) {
161
+ if (error instanceof ChatRoomError)
162
+ throw error;
163
+ if (error.code !== "ENOENT")
164
+ throw error;
165
+ }
166
+ return target;
167
+ }
168
+ async resolveReadable(relativePath) {
169
+ const lexical = this.lexical(relativePath);
170
+ const canonical = await realpath(lexical).catch((error) => {
171
+ if (error.code === "ENOENT")
172
+ throw new ChatRoomError("NOT_FOUND", `Path does not exist: ${relativePath}`);
173
+ throw error;
174
+ });
175
+ if (!inside(this.root, canonical))
176
+ throw new ChatRoomError("FORBIDDEN", "Symlink escapes workspace");
177
+ return canonical;
178
+ }
179
+ }
180
+ function normalizeRelative(input) {
181
+ if (typeof input !== "string" || input.includes("\0"))
182
+ throw new ChatRoomError("INVALID_INPUT", "Path must be a valid string");
183
+ if (path.isAbsolute(input) ||
184
+ path.win32.isAbsolute(input) ||
185
+ /^\\\\/.test(input))
186
+ throw new ChatRoomError("FORBIDDEN", "Absolute paths are not allowed");
187
+ const parts = input
188
+ .split(/[\\/]+/)
189
+ .filter((part) => part !== "" && part !== ".");
190
+ if (parts.some((part) => part === ".."))
191
+ throw new ChatRoomError("FORBIDDEN", "Path traversal is not allowed");
192
+ return parts.join("/") || ".";
193
+ }
194
+ function inside(root, candidate) {
195
+ const relative = path.relative(root, candidate);
196
+ return (relative === "" ||
197
+ (!relative.startsWith(`..${path.sep}`) &&
198
+ relative !== ".." &&
199
+ !path.isAbsolute(relative)));
200
+ }
@@ -0,0 +1,15 @@
1
+ import type { WorkspaceEntry, WorkspaceInfo } from "./types.js";
2
+ import { WorkspaceFs } from "./workspace-fs.js";
3
+ export declare class WorkspaceService {
4
+ private readonly allowedRoots;
5
+ private constructor();
6
+ static create(allowedRoots: string[]): Promise<WorkspaceService>;
7
+ roots(): string[];
8
+ list(): Promise<WorkspaceEntry[]>;
9
+ resolve(input: string): Promise<string>;
10
+ createProject(parentInput: string, nameInput: string): Promise<WorkspaceEntry>;
11
+ info(input: string): Promise<WorkspaceInfo>;
12
+ fs(input: string): Promise<WorkspaceFs>;
13
+ private isWorkspaceRoot;
14
+ private resolveAllowedRoot;
15
+ }
@@ -0,0 +1,140 @@
1
+ import { mkdir, readdir, realpath, stat } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { ChatRoomError } from "#core/errors/chatroom-error";
5
+ import { readInstructions, readPresetPrompt, readSkills, readSummary, } from "./metadata.js";
6
+ import { WorkspaceFs } from "./workspace-fs.js";
7
+ export class WorkspaceService {
8
+ allowedRoots;
9
+ constructor(allowedRoots) {
10
+ this.allowedRoots = allowedRoots;
11
+ }
12
+ static async create(allowedRoots) {
13
+ const canonicalRoots = await Promise.all(allowedRoots.map(async (root) => {
14
+ const canonical = await realpath(expandHome(root)).catch(() => {
15
+ throw new ChatRoomError("NOT_FOUND", `Allowed root does not exist: ${root}`);
16
+ });
17
+ if (!(await stat(canonical)).isDirectory())
18
+ throw new ChatRoomError("INVALID_INPUT", `Allowed root is not a directory: ${root}`);
19
+ return canonical;
20
+ }));
21
+ return new WorkspaceService([...new Set(canonicalRoots)]);
22
+ }
23
+ roots() {
24
+ return [...this.allowedRoots];
25
+ }
26
+ async list() {
27
+ const roots = new Set();
28
+ for (const allowedRoot of this.allowedRoots) {
29
+ let entries;
30
+ try {
31
+ entries = await readdir(allowedRoot, { withFileTypes: true });
32
+ }
33
+ catch {
34
+ continue;
35
+ }
36
+ for (const entry of entries) {
37
+ if (!entry.isDirectory() ||
38
+ entry.isSymbolicLink() ||
39
+ entry.name.startsWith("."))
40
+ continue;
41
+ const candidate = await realpath(path.join(allowedRoot, entry.name)).catch(() => null);
42
+ if (candidate && this.isWorkspaceRoot(candidate))
43
+ roots.add(candidate);
44
+ }
45
+ }
46
+ return Promise.all([...roots]
47
+ .sort((a, b) => a.localeCompare(b))
48
+ .map(async (root) => {
49
+ const fs = await WorkspaceFs.create(root);
50
+ return {
51
+ root,
52
+ name: path.basename(root),
53
+ summary: await readSummary(fs),
54
+ };
55
+ }));
56
+ }
57
+ async resolve(input) {
58
+ if (typeof input !== "string" || !input.trim())
59
+ throw new ChatRoomError("INVALID_INPUT", "Workspace root is required");
60
+ const canonical = await realpath(expandHome(input)).catch(() => {
61
+ throw new ChatRoomError("NOT_FOUND", `Workspace root does not exist: ${input}`);
62
+ });
63
+ if (!(await stat(canonical)).isDirectory())
64
+ throw new ChatRoomError("INVALID_INPUT", `Workspace root is not a directory: ${input}`);
65
+ if (!this.isWorkspaceRoot(canonical))
66
+ throw new ChatRoomError("FORBIDDEN", "Workspace must be a direct child of a configured allowed root", { root: canonical });
67
+ return canonical;
68
+ }
69
+ async createProject(parentInput, nameInput) {
70
+ const parent = await this.resolveAllowedRoot(parentInput);
71
+ const name = validateProjectName(nameInput);
72
+ const target = path.join(parent, name);
73
+ try {
74
+ await mkdir(target, { recursive: false });
75
+ }
76
+ catch (error) {
77
+ if (error.code === "EEXIST")
78
+ throw new ChatRoomError("CONFLICT", `Workspace already exists: ${target}`);
79
+ throw error;
80
+ }
81
+ await mkdir(path.join(target, ".chatroom"));
82
+ const root = await realpath(target);
83
+ return { root, name: path.basename(root), summary: null };
84
+ }
85
+ async info(input) {
86
+ const root = await this.resolve(input);
87
+ const fs = await WorkspaceFs.create(root);
88
+ const [summary, presetPrompt, instructions, skills] = await Promise.all([
89
+ readSummary(fs),
90
+ readPresetPrompt(fs),
91
+ readInstructions(fs),
92
+ readSkills(fs),
93
+ ]);
94
+ return {
95
+ root,
96
+ name: path.basename(root),
97
+ summary,
98
+ presetPrompt,
99
+ instructions,
100
+ skills,
101
+ };
102
+ }
103
+ async fs(input) {
104
+ return WorkspaceFs.create(await this.resolve(input));
105
+ }
106
+ isWorkspaceRoot(candidate) {
107
+ return this.allowedRoots.some((root) => path.dirname(candidate) === root);
108
+ }
109
+ async resolveAllowedRoot(input) {
110
+ if (typeof input !== "string" || !input.trim())
111
+ throw new ChatRoomError("INVALID_INPUT", "Allowed root is required");
112
+ const canonical = await realpath(expandHome(input)).catch(() => {
113
+ throw new ChatRoomError("NOT_FOUND", `Allowed root does not exist: ${input}`);
114
+ });
115
+ if (!this.allowedRoots.includes(canonical))
116
+ throw new ChatRoomError("FORBIDDEN", "Project can only be created directly under a configured allowed root");
117
+ return canonical;
118
+ }
119
+ }
120
+ function validateProjectName(input) {
121
+ if (typeof input !== "string")
122
+ throw new ChatRoomError("INVALID_INPUT", "Project name is required");
123
+ const name = input.trim();
124
+ if (!name ||
125
+ name === "." ||
126
+ name === ".." ||
127
+ name.startsWith(".") ||
128
+ name.includes("/") ||
129
+ name.includes("\\") ||
130
+ name.includes("\0"))
131
+ throw new ChatRoomError("INVALID_INPUT", "Invalid project name");
132
+ return name;
133
+ }
134
+ function expandHome(input) {
135
+ if (input === "~")
136
+ return os.homedir();
137
+ if (input.startsWith("~/") || input.startsWith("~\\"))
138
+ return path.join(os.homedir(), input.slice(2));
139
+ return path.resolve(input);
140
+ }
@@ -0,0 +1,9 @@
1
+ import type { Request, Response, NextFunction, RequestHandler } from "express";
2
+ export declare function asyncRoute(handler: (req: Request, res: Response, next: NextFunction) => Promise<unknown>): RequestHandler;
3
+ export declare function errorMiddleware(error: unknown, _req: Request, res: Response, _next: NextFunction): void;
4
+ export declare function requireString(value: unknown, name: string): string;
5
+ export declare function boundedIntegerQuery(value: unknown, fallback: number, min: number, max: number): number;
6
+ export declare function bodyRecord(value: unknown): Record<string, unknown>;
7
+ export declare function requireStringArray(value: unknown, name: string): string[];
8
+ export declare function parseCookie(header: string | undefined, name: string): string | null;
9
+ export declare function escapeHtml(value: string): string;