@zhanguym/chatroom 1.1.0-beta.0 → 1.1.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 (70) hide show
  1. package/dist/app/external-access-registry.d.ts +1 -0
  2. package/dist/app/external-access-registry.js +3 -0
  3. package/dist/infrastructure/logging/system-log.js +2 -1
  4. package/dist/native/macos/ChatRoomComputerHelper.app/Contents/MacOS/chatroom-computer-helper +0 -0
  5. package/dist/native/windows/chatroom-computer-helper.exe +0 -0
  6. package/dist/plugins/computer/computer-native-backend.js +9 -1
  7. package/dist/plugins/computer/computer-native-host.js +21 -5
  8. package/dist/plugins/computer/types.d.ts +1 -0
  9. package/dist/plugins/web/http/workspace-api-router.js +119 -2
  10. package/dist/plugins/workspace/mcp.d.ts +1 -1
  11. package/dist/plugins/workspace/mcp.js +109 -1
  12. package/dist/plugins/workspace/plugin.js +4 -1
  13. package/dist/plugins/workspace/workspace-fs.d.ts +10 -0
  14. package/dist/plugins/workspace/workspace-fs.js +39 -0
  15. package/dist/plugins/workspace/workspace-service.d.ts +2 -0
  16. package/dist/plugins/workspace/workspace-service.js +18 -0
  17. package/dist/web/assets/{AgentView-DYJeMJLX.css → AgentView-BGLIBoxF.css} +1 -1
  18. package/dist/web/assets/AgentView-Dd6zCbr-.js +4 -0
  19. package/dist/web/assets/CloudView-DquY1HDZ.js +1 -0
  20. package/dist/web/assets/CodeViewer.vue_vue_type_script_setup_true_lang-ekOImmBP.js +3 -0
  21. package/dist/web/assets/{ComputerView-BM0u3wSF.css → ComputerView-DWzfSYJ7.css} +1 -1
  22. package/dist/web/assets/ComputerView-RD2gMxJZ.js +1 -0
  23. package/dist/web/assets/{VTextarea-DG1zE1as.css → MarkdownContent-B_5zTG8c.css} +1 -1
  24. package/dist/web/assets/MarkdownContent-CkjR5fs7.js +16 -0
  25. package/dist/web/assets/McpToolsView-Dg2-iaDP.js +1 -0
  26. package/dist/web/assets/McpToolsView-DjxPbTHj.css +1 -0
  27. package/dist/web/assets/OperationsView-C8yCX7WZ.js +4 -0
  28. package/dist/web/assets/ProcessesView-C834LC-f.js +4 -0
  29. package/dist/web/assets/StateChip.vue_vue_type_script_setup_true_lang-By2gilBw.js +1 -0
  30. package/dist/web/assets/SystemLogsView-B6KcZv1J.js +1 -0
  31. package/dist/web/assets/VSelect-BtAJX-XJ.js +1 -0
  32. package/dist/web/assets/VSpacer-M8bpkhTH.js +1 -0
  33. package/dist/web/assets/VSwitch-C0VTnwLJ.js +1 -0
  34. package/dist/web/assets/VTabs-g61mykvY.js +1 -0
  35. package/dist/web/assets/VTextarea-CRYQYRqg.js +1 -0
  36. package/dist/web/assets/VTextarea-xiMOHEu-.css +1 -0
  37. package/dist/web/assets/WorkspaceDownloadPreview-BrESF-82.js +1 -0
  38. package/dist/web/assets/WorkspaceDownloadPreview-DnxhH5Q7.css +1 -0
  39. package/dist/web/assets/WorkspaceMediaPreview-CwtP9xWT.css +1 -0
  40. package/dist/web/assets/WorkspaceMediaPreview-HYajqgZ3.js +4 -0
  41. package/dist/web/assets/WorkspacePdfPreview-BtZkUHzV.css +1 -0
  42. package/dist/web/assets/WorkspacePdfPreview-CO-ZtDHG.js +55 -0
  43. package/dist/web/assets/WorkspacesView-BJCnzBLj.js +2 -0
  44. package/dist/web/assets/WorkspacesView-ClA6xcjn.css +1 -0
  45. package/dist/web/assets/index-CZ-4tJlF.css +1 -0
  46. package/dist/web/assets/index-u-aADQP-.js +19 -0
  47. package/dist/web/assets/pdf.worker.min-CjEcRF4W.mjs +28 -0
  48. package/dist/web/assets/{useMasterDetailFocus-D0j-KcXv.js → useMasterDetailFocus-D1mQ8Qjk.js} +1 -1
  49. package/dist/web/assets/{useOperationLabels-DR8r6Vrl.js → useOperationLabels-BjPIVnJW.js} +1 -1
  50. package/dist/web/index.html +2 -2
  51. package/package.json +2 -1
  52. package/dist/web/assets/AgentView-DKuspiki.js +0 -4
  53. package/dist/web/assets/CloudView-PbREbJgz.js +0 -1
  54. package/dist/web/assets/CodeViewer.vue_vue_type_script_setup_true_lang-CiFNEpWo.js +0 -3
  55. package/dist/web/assets/ComputerView-BRb2sE0Z.js +0 -1
  56. package/dist/web/assets/McpToolsView-B8NSFdf4.js +0 -1
  57. package/dist/web/assets/McpToolsView-C22ZbSuV.css +0 -1
  58. package/dist/web/assets/OperationsView-Cavi-6SZ.js +0 -4
  59. package/dist/web/assets/ProcessesView-UO48Nh2q.js +0 -4
  60. package/dist/web/assets/StateChip.vue_vue_type_script_setup_true_lang-Bvc9wRyA.js +0 -1
  61. package/dist/web/assets/SystemLogsView-BXmOrmu5.js +0 -1
  62. package/dist/web/assets/VSelect-BPRXx8KM.js +0 -1
  63. package/dist/web/assets/VSpacer-Cl-QiwaM.js +0 -1
  64. package/dist/web/assets/VSwitch-C0XNj1lR.js +0 -1
  65. package/dist/web/assets/VTabs-CHj39Ket.js +0 -1
  66. package/dist/web/assets/VTextarea-BIcUWOU6.js +0 -16
  67. package/dist/web/assets/WorkspacesView-DPCt6kYy.css +0 -1
  68. package/dist/web/assets/WorkspacesView-rY6RXeiR.js +0 -2
  69. package/dist/web/assets/index-BPpl3-sP.css +0 -1
  70. package/dist/web/assets/index-Dt-pWiW1.js +0 -19
@@ -10,6 +10,7 @@ export declare class ExternalAccessRegistry {
10
10
  constructor(config: ChatRoomConfig["auth"]);
11
11
  setCloud(state: ExternalAccessState): void;
12
12
  clearCloud(): void;
13
+ publicWebBaseUrl(): string | null;
13
14
  baseUrlForHost(kind: ExternalAccessKind, hostname: string): string | null;
14
15
  matches(kind: ExternalAccessKind, hostname: string): boolean;
15
16
  hasHost(hostname: string): boolean;
@@ -13,6 +13,9 @@ export class ExternalAccessRegistry {
13
13
  clearCloud() {
14
14
  this.cloud = { mcpBaseUrl: null, webBaseUrl: null };
15
15
  }
16
+ publicWebBaseUrl() {
17
+ return this.cloud.webBaseUrl ?? this.selfHosted.webBaseUrl;
18
+ }
16
19
  baseUrlForHost(kind, hostname) {
17
20
  for (const url of this.urls(kind))
18
21
  if (new URL(url).hostname === hostname)
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { stripVTControlCharacters } from "node:util";
2
3
  import { SecretRedactor } from "#core/operations/redactor";
3
4
  export class SystemLog {
4
5
  store;
@@ -35,7 +36,7 @@ export class SystemLog {
35
36
  }
36
37
  write(level, module, event, message, data) {
37
38
  const redacted = this.redactor.redact({
38
- message,
39
+ message: stripVTControlCharacters(message),
39
40
  ...(data ? { data: normalizeValue(data) } : {}),
40
41
  });
41
42
  const record = {
@@ -5,9 +5,17 @@ export class NativeComputerBackend {
5
5
  host = new ComputerNativeHost();
6
6
  restartWhenGranted = new Set();
7
7
  async status() {
8
- const value = await this.readStatus().catch(() => ({
8
+ const value = await this.readStatus().catch((error) => ({
9
9
  platform: this.host.platform,
10
10
  helper: "unavailable",
11
+ ...(error instanceof ChatRoomError &&
12
+ error.code === "UNSUPPORTED" &&
13
+ error.details &&
14
+ typeof error.details === "object" &&
15
+ "reason" in error.details &&
16
+ error.details.reason === "no_x11_session"
17
+ ? { unavailableReason: "no_x11_session" }
18
+ : {}),
11
19
  permissions: {
12
20
  accessibility: "unknown",
13
21
  screenRecording: "unknown",
@@ -62,8 +62,17 @@ export class ComputerNativeHost {
62
62
  const child = this.child;
63
63
  if (!child || child.killed || child.stdin.destroyed)
64
64
  this.rejectRequest(id, transportError);
65
- else
66
- child.stdin.write(line);
65
+ else {
66
+ try {
67
+ child.stdin.write(line, (error) => {
68
+ if (error)
69
+ this.failTransport(child, error);
70
+ });
71
+ }
72
+ catch (error) {
73
+ this.failTransport(child, error instanceof Error ? error : new Error(String(error)));
74
+ }
75
+ }
67
76
  }
68
77
  return promise;
69
78
  }
@@ -104,8 +113,12 @@ export class ComputerNativeHost {
104
113
  return this.startMacHelper(generation);
105
114
  case "windows":
106
115
  return this.startPipeHelper(windowsHelperPath(), "Windows", process.env, generation);
107
- case "linux":
108
- return this.startPipeHelper(linuxHelperPath(), "Linux X11", linuxDesktopEnvironment(), generation);
116
+ case "linux": {
117
+ const env = linuxDesktopEnvironment();
118
+ if (!env.DISPLAY)
119
+ return Promise.reject(new ChatRoomError("UNSUPPORTED", "Linux Computer Use requires an active X11 graphical session", { reason: "no_x11_session" }));
120
+ return this.startPipeHelper(linuxHelperPath(), "Linux X11", env, generation);
121
+ }
109
122
  case "unsupported":
110
123
  return Promise.reject(new ChatRoomError("UNSUPPORTED", "Computer Use is supported only on macOS, Windows, and Linux X11"));
111
124
  }
@@ -182,6 +195,7 @@ export class ComputerNativeHost {
182
195
  env,
183
196
  });
184
197
  this.child = child;
198
+ child.stdin.on("error", (error) => this.failTransport(child, error));
185
199
  this.lines = createInterface({ input: child.stdout });
186
200
  this.lines.on("line", (line) => this.handleLine(line));
187
201
  child.stderr.on("data", (chunk) => console.error("[computer-helper]", String(chunk).trimEnd()));
@@ -204,8 +218,10 @@ export class ComputerNativeHost {
204
218
  cancelStarting?.(error);
205
219
  if (isCurrentSocket)
206
220
  this.socket = null;
207
- if (isCurrentChild)
221
+ if (isCurrentChild) {
222
+ this.child?.kill();
208
223
  this.child = null;
224
+ }
209
225
  this.lines?.close();
210
226
  this.lines = null;
211
227
  this.cleanupSocketPath();
@@ -23,6 +23,7 @@ export interface ComputerDisplay {
23
23
  export interface ComputerStatus {
24
24
  platform: ComputerPlatform;
25
25
  helper: ComputerHelperState;
26
+ unavailableReason?: "no_x11_session";
26
27
  permissions: ComputerPermissions;
27
28
  displays: ComputerDisplay[];
28
29
  settings: ComputerSettings;
@@ -47,7 +47,7 @@ export function createWorkspaceApiRouter(application) {
47
47
  res.json(await application.workspaces.projectFilesResult(requireString(req.query.root, "root")));
48
48
  }));
49
49
  router.get("/workspace/file", asyncRoute(async (req, res) => {
50
- const fs = await application.workspaces.fs(requireString(req.query.root, "root"));
50
+ const fs = await requestWorkspaceFs(req, application);
51
51
  res.json(await fs.read(requireString(req.query.path, "path"), {
52
52
  maxBytes: 2 * 1024 * 1024,
53
53
  }));
@@ -103,12 +103,25 @@ export function createWorkspaceApiRouter(application) {
103
103
  : null,
104
104
  });
105
105
  }));
106
+ router.get("/workspace/file/download", asyncRoute(async (req, res) => {
107
+ const filePath = requireString(req.query.path, "path");
108
+ const fs = await requestWorkspaceFs(req, application);
109
+ await streamWorkspaceFile(req, res, fs, filePath, "application/octet-stream", true);
110
+ }));
111
+ router.get("/workspace/file/preview", asyncRoute(async (req, res) => {
112
+ const filePath = requireString(req.query.path, "path");
113
+ const mime = previewMime(filePath);
114
+ if (!mime)
115
+ throw new ChatRoomError("INVALID_INPUT", "Unsupported file preview type");
116
+ const fs = await requestWorkspaceFs(req, application);
117
+ await streamWorkspaceFile(req, res, fs, filePath, mime, false);
118
+ }));
106
119
  router.get("/workspace/file/image", asyncRoute(async (req, res) => {
107
120
  const filePath = requireString(req.query.path, "path");
108
121
  const mime = imageMime(filePath);
109
122
  if (!mime)
110
123
  throw new ChatRoomError("INVALID_INPUT", "File type is not supported for image preview");
111
- const fs = await application.workspaces.fs(requireString(req.query.root, "root"));
124
+ const fs = await requestWorkspaceFs(req, application);
112
125
  const file = await fs.readBytes(filePath, {
113
126
  maxBytes: MAX_IMAGE_PREVIEW_BYTES,
114
127
  });
@@ -121,6 +134,8 @@ export function createWorkspaceApiRouter(application) {
121
134
  '"';
122
135
  const requestedVersion = typeof req.query.v === "string" ? req.query.v : null;
123
136
  res.setHeader("Content-Type", mime);
137
+ if (mime === "image/svg+xml")
138
+ res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'");
124
139
  res.setHeader("ETag", etag);
125
140
  res.setHeader("Last-Modified", file.modifiedAt);
126
141
  res.setHeader("Cache-Control", requestedVersion === file.modifiedAt
@@ -136,6 +151,11 @@ export function createWorkspaceApiRouter(application) {
136
151
  }));
137
152
  return router;
138
153
  }
154
+ function requestWorkspaceFs(req, application) {
155
+ if (req.query.workspace !== undefined)
156
+ return application.workspaces.fsByAlias(requireString(req.query.workspace, "workspace"));
157
+ return application.workspaces.fs(requireString(req.query.root, "root"));
158
+ }
139
159
  function imageMime(filePath) {
140
160
  switch (filePath.split(".").pop()?.toLowerCase()) {
141
161
  case "png":
@@ -153,6 +173,8 @@ function imageMime(filePath) {
153
173
  return "image/bmp";
154
174
  case "ico":
155
175
  return "image/x-icon";
176
+ case "svg":
177
+ return "image/svg+xml";
156
178
  default:
157
179
  return null;
158
180
  }
@@ -162,3 +184,98 @@ function attachmentFileName(value) {
162
184
  const safe = name.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
163
185
  return (safe || "attachment").slice(0, 160);
164
186
  }
187
+ function previewMime(filePath) {
188
+ switch (filePath.split(".").pop()?.toLowerCase()) {
189
+ case "pdf":
190
+ return "application/pdf";
191
+ case "mp4":
192
+ case "m4v":
193
+ return "video/mp4";
194
+ case "webm":
195
+ return "video/webm";
196
+ case "ogv":
197
+ return "video/ogg";
198
+ case "mov":
199
+ return "video/quicktime";
200
+ case "mp3":
201
+ return "audio/mpeg";
202
+ case "m4a":
203
+ return "audio/mp4";
204
+ case "aac":
205
+ return "audio/aac";
206
+ case "wav":
207
+ return "audio/wav";
208
+ case "flac":
209
+ return "audio/flac";
210
+ case "oga":
211
+ case "opus":
212
+ case "ogg":
213
+ return "audio/ogg";
214
+ default:
215
+ return null;
216
+ }
217
+ }
218
+ function parseByteRange(header, size) {
219
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
220
+ if (!match || size === 0 || (!match[1] && !match[2]))
221
+ return null;
222
+ let start;
223
+ let end;
224
+ if (!match[1]) {
225
+ const suffix = Number(match[2]);
226
+ if (!Number.isSafeInteger(suffix) || suffix <= 0)
227
+ return null;
228
+ start = Math.max(0, size - suffix);
229
+ end = size - 1;
230
+ }
231
+ else {
232
+ start = Number(match[1]);
233
+ end = match[2] ? Number(match[2]) : size - 1;
234
+ if (!Number.isSafeInteger(start) ||
235
+ !Number.isSafeInteger(end) ||
236
+ start >= size ||
237
+ end < start)
238
+ return null;
239
+ end = Math.min(end, size - 1);
240
+ }
241
+ return { start, end };
242
+ }
243
+ async function streamWorkspaceFile(req, res, fs, relativePath, mime, download) {
244
+ const { handle, bytes } = await fs.openReadable(relativePath);
245
+ const isRange = typeof req.headers.range === "string";
246
+ const range = isRange ? parseByteRange(req.headers.range, bytes) : null;
247
+ res.setHeader("Content-Type", mime);
248
+ res.setHeader("Cache-Control", "private, no-store, no-transform");
249
+ res.setHeader("X-Content-Type-Options", "nosniff");
250
+ res.setHeader("Accept-Ranges", "bytes");
251
+ if (download) {
252
+ const name = relativePath.replaceAll("\\", "/").split("/").pop() || "download";
253
+ const fallback = name.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
254
+ res.setHeader("Content-Disposition", `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(name)}`);
255
+ }
256
+ else {
257
+ res.setHeader("Content-Disposition", "inline");
258
+ }
259
+ if (isRange && !range) {
260
+ res.setHeader("Content-Range", `bytes */${bytes}`);
261
+ await handle.close();
262
+ res.status(416).end();
263
+ return;
264
+ }
265
+ const start = range?.start ?? 0;
266
+ const end = range?.end ?? bytes - 1;
267
+ if (range) {
268
+ res.status(206);
269
+ res.setHeader("Content-Range", `bytes ${start}-${end}/${bytes}`);
270
+ }
271
+ res.setHeader("Content-Length", String(Math.max(0, end - start + 1)));
272
+ if (req.method === "HEAD" || bytes === 0) {
273
+ await handle.close();
274
+ res.end();
275
+ return;
276
+ }
277
+ const stream = handle.createReadStream({ start, end, autoClose: true });
278
+ res.on("close", () => stream.destroy());
279
+ stream.on("error", (error) => res.destroy(error));
280
+ stream.pipe(res);
281
+ }
@@ -1,3 +1,3 @@
1
1
  import type { PluginMcpRegistrar } from "#mcp/server/plugin-mcp-registrar";
2
2
  import type { WorkspaceService } from "./workspace-service.js";
3
- export declare function registerWorkspaceTools(mcp: PluginMcpRegistrar, workspaces: WorkspaceService): void;
3
+ export declare function registerWorkspaceTools(mcp: PluginMcpRegistrar, workspaces: WorkspaceService, webBaseUrl: () => string | null): void;
@@ -1,3 +1,5 @@
1
+ import path from "node:path";
2
+ import { ChatRoomError } from "#core/errors/chatroom-error";
1
3
  import { z } from "zod";
2
4
  import { closedRead } from "#mcp/server/tool-support";
3
5
  const workspaceEntrySchema = z.object({
@@ -20,7 +22,7 @@ const workspaceInfoSchema = z.object({
20
22
  path: z.string(),
21
23
  })),
22
24
  });
23
- export function registerWorkspaceTools(mcp, workspaces) {
25
+ export function registerWorkspaceTools(mcp, workspaces, webBaseUrl) {
24
26
  mcp.registerTool("workspace_list", {
25
27
  title: "List workspaces",
26
28
  description: "List available projects with their workspace roots, names, and project summaries from the workspace prompt metadata. Use this first to identify the relevant project before calling workspace_info for its full prompt, instructions, and skills.",
@@ -37,4 +39,110 @@ export function registerWorkspaceTools(mcp, workspaces) {
37
39
  annotations: closedRead,
38
40
  action: "info",
39
41
  }, (input) => workspaces.info(input.root));
42
+ mcp.registerTool("workspace_file_get", {
43
+ title: "Get workspace file",
44
+ description: "Read a file from a workspace in bounded chunks, or return a browser download link protected by the normal ChatRoom Web login. " +
45
+ "Use mode=content with encoding=utf8 for text and encoding=base64 for binary files; advance offset with nextOffset for large files. " +
46
+ "Use mode=link to give the user a download link when the Web service has a configured public address. " +
47
+ "Links do not contain credentials and the user must log in to ChatRoom Web to download.",
48
+ inputSchema: z.object({
49
+ root: z.string(),
50
+ path: z.string(),
51
+ mode: z.enum(["content", "link"]).default("content"),
52
+ encoding: z.enum(["utf8", "base64"]).default("utf8"),
53
+ offset: z.number().int().nonnegative().default(0),
54
+ maxBytes: z.number().int().min(1).max(262144).default(65536),
55
+ }),
56
+ outputSchema: z.object({
57
+ mode: z.enum(["content", "link"]),
58
+ path: z.string(),
59
+ name: z.string(),
60
+ bytes: z.number(),
61
+ modifiedAt: z.string(),
62
+ encoding: z.enum(["utf8", "base64"]).optional(),
63
+ content: z.string().optional(),
64
+ offset: z.number().optional(),
65
+ nextOffset: z.number().nullable().optional(),
66
+ url: z.string().optional(),
67
+ }),
68
+ annotations: closedRead,
69
+ action: "file.get",
70
+ audit: {
71
+ input: (input) => ({
72
+ root: input.root,
73
+ path: input.path,
74
+ mode: input.mode,
75
+ offset: input.mode === "content" ? input.offset : undefined,
76
+ }),
77
+ output: (output) => {
78
+ const result = output;
79
+ return {
80
+ mode: result.mode,
81
+ path: result.path,
82
+ bytes: result.bytes,
83
+ nextOffset: result.nextOffset,
84
+ };
85
+ },
86
+ },
87
+ }, async (input) => {
88
+ const fs = await workspaces.fs(input.root);
89
+ const name = path.posix.basename(input.path.replaceAll("\\", "/"));
90
+ if (input.mode === "link") {
91
+ const base = webBaseUrl();
92
+ if (!base)
93
+ throw new ChatRoomError("INVALID_INPUT", "No public Web URL is configured; download link is unavailable");
94
+ const info = await fs.fileInfo(input.path);
95
+ const url = new URL("/download", base);
96
+ url.searchParams.set("workspace", path.basename(fs.root));
97
+ url.searchParams.set("path", input.path);
98
+ return {
99
+ mode: "link",
100
+ path: input.path,
101
+ name,
102
+ ...info,
103
+ url: url.toString(),
104
+ };
105
+ }
106
+ const chunk = await fs.readChunk(input.path, input.offset, input.maxBytes);
107
+ let content;
108
+ let consumed = chunk.data.byteLength;
109
+ if (input.encoding === "utf8") {
110
+ const decoder = new TextDecoder("utf-8", { fatal: true });
111
+ let decoded = null;
112
+ for (let trim = 0; trim < 4 && trim < chunk.data.byteLength; trim += 1) {
113
+ if (trim > 0 && input.offset + chunk.data.byteLength >= chunk.bytes)
114
+ break;
115
+ try {
116
+ consumed = chunk.data.byteLength - trim;
117
+ decoded = decoder.decode(chunk.data.subarray(0, consumed));
118
+ break;
119
+ }
120
+ catch {
121
+ // Only incomplete trailing UTF-8 sequences can be repaired.
122
+ }
123
+ }
124
+ if (decoded === null) {
125
+ if (chunk.data.byteLength === 0)
126
+ decoded = "";
127
+ else
128
+ throw new ChatRoomError("INVALID_INPUT", "Invalid UTF-8 data; use base64");
129
+ }
130
+ content = decoded;
131
+ }
132
+ else {
133
+ content = chunk.data.toString("base64");
134
+ }
135
+ const next = input.offset + consumed;
136
+ return {
137
+ mode: "content",
138
+ path: input.path,
139
+ name,
140
+ bytes: chunk.bytes,
141
+ modifiedAt: chunk.modifiedAt,
142
+ encoding: input.encoding,
143
+ content,
144
+ offset: input.offset,
145
+ nextOffset: next < chunk.bytes ? next : null,
146
+ };
147
+ });
40
148
  }
@@ -6,9 +6,11 @@ import { WorkspaceService } from "./workspace-service.js";
6
6
  export const WorkspaceServiceToken = createServiceToken("workspace");
7
7
  export function createWorkspacePlugin() {
8
8
  let service = null;
9
+ let webBaseUrl = () => null;
9
10
  return {
10
11
  id: "workspace",
11
12
  async activate(context) {
13
+ webBaseUrl = () => context.externalAccess.publicWebBaseUrl();
12
14
  service = await WorkspaceService.create(context.config.allowedRoots, context.services.require(GitAccessToken));
13
15
  context.services.provide(WorkspaceServiceToken, service);
14
16
  context.services.provide(WorkspaceAccessToken, service);
@@ -16,10 +18,11 @@ export function createWorkspacePlugin() {
16
18
  registerMcp(mcp) {
17
19
  if (!service)
18
20
  throw new Error("Workspace plugin is not active");
19
- registerWorkspaceTools(mcp, service);
21
+ registerWorkspaceTools(mcp, service, webBaseUrl);
20
22
  },
21
23
  deactivate() {
22
24
  service = null;
25
+ webBaseUrl = () => null;
23
26
  },
24
27
  };
25
28
  }
@@ -23,6 +23,16 @@ export declare class WorkspaceFs {
23
23
  truncated: boolean;
24
24
  modifiedAt: string;
25
25
  }>;
26
+ openReadable(relativePath: string): Promise<{
27
+ handle: import("fs/promises").FileHandle;
28
+ bytes: number;
29
+ modifiedAt: string;
30
+ }>;
31
+ readChunk(relativePath: string, offset: number, maxBytes: number): Promise<{
32
+ data: Buffer;
33
+ bytes: number;
34
+ modifiedAt: string;
35
+ }>;
26
36
  write(relativePath: string, content: string): Promise<WorkspaceFile>;
27
37
  writeBytes(relativePath: string, content: Uint8Array): Promise<WorkspaceFile>;
28
38
  list(relativePath?: string, options?: {
@@ -68,6 +68,45 @@ export class WorkspaceFs {
68
68
  }
69
69
  throw new ChatRoomError("CONFLICT", `File changed while being read: ${relativePath}`);
70
70
  }
71
+ async openReadable(relativePath) {
72
+ const target = await resolveReadableWithin(this.root, relativePath);
73
+ const handle = await open(target, "r");
74
+ try {
75
+ const info = await handle.stat();
76
+ if (!info.isFile())
77
+ throw new ChatRoomError("INVALID_INPUT", `Not a file: ${relativePath}`);
78
+ return { handle, bytes: info.size, modifiedAt: info.mtime.toISOString() };
79
+ }
80
+ catch (error) {
81
+ await handle.close();
82
+ throw error;
83
+ }
84
+ }
85
+ async readChunk(relativePath, offset, maxBytes) {
86
+ const { handle, bytes, modifiedAt } = await this.openReadable(relativePath);
87
+ try {
88
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > bytes)
89
+ throw new ChatRoomError("INVALID_INPUT", "Invalid file offset");
90
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 262144)
91
+ throw new ChatRoomError("INVALID_INPUT", "Invalid file chunk size");
92
+ const length = Math.min(maxBytes, bytes - offset);
93
+ const buffer = Buffer.alloc(length);
94
+ let position = 0;
95
+ while (position < length) {
96
+ const { bytesRead } = await handle.read(buffer, position, length - position, offset + position);
97
+ if (bytesRead === 0)
98
+ break;
99
+ position += bytesRead;
100
+ }
101
+ const after = await handle.stat();
102
+ if (after.size !== bytes || after.mtime.toISOString() !== modifiedAt)
103
+ throw new ChatRoomError("CONFLICT", "File changed while reading");
104
+ return { data: buffer.subarray(0, position), bytes, modifiedAt };
105
+ }
106
+ finally {
107
+ await handle.close();
108
+ }
109
+ }
71
110
  async write(relativePath, content) {
72
111
  if (typeof content !== "string")
73
112
  throw new ChatRoomError("INVALID_INPUT", "File content must be a string");
@@ -9,6 +9,8 @@ export declare class WorkspaceService implements WorkspaceAccess {
9
9
  roots(): string[];
10
10
  list(): Promise<WorkspaceEntry[]>;
11
11
  resolve(input: string): Promise<string>;
12
+ resolveAlias(alias: string): Promise<string>;
13
+ fsByAlias(alias: string): Promise<WorkspaceFs>;
12
14
  createProject(parentInput: string, nameInput: string): Promise<WorkspaceEntry>;
13
15
  projectFiles(input: string, maxFiles?: number): Promise<string[]>;
14
16
  projectFilesResult(input: string, maxFiles?: number): Promise<{
@@ -51,6 +51,24 @@ export class WorkspaceService {
51
51
  async resolve(input) {
52
52
  return this.rootsPolicy.resolveWorkspace(input);
53
53
  }
54
+ async resolveAlias(alias) {
55
+ const name = validateProjectName(alias);
56
+ const matches = [];
57
+ for (const parent of this.rootsPolicy.roots()) {
58
+ const candidate = path.join(parent, name);
59
+ const canonical = await realpath(candidate).catch(() => null);
60
+ if (canonical === candidate)
61
+ matches.push(candidate);
62
+ }
63
+ if (matches.length === 0)
64
+ throw new ChatRoomError("NOT_FOUND", "Workspace not found");
65
+ if (matches.length > 1)
66
+ throw new ChatRoomError("CONFLICT", "Workspace alias is ambiguous");
67
+ return this.resolve(matches[0]);
68
+ }
69
+ async fsByAlias(alias) {
70
+ return WorkspaceFs.create(await this.resolveAlias(alias));
71
+ }
54
72
  async createProject(parentInput, nameInput) {
55
73
  const parent = await this.rootsPolicy.resolveAllowedRoot(parentInput);
56
74
  const name = validateProjectName(nameInput);