mulmoclaude 0.3.0 → 0.4.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 (185) hide show
  1. package/bin/mulmoclaude.js +7 -24
  2. package/client/assets/html2canvas-Cx501zZr-Cv5snK9D.js +5 -0
  3. package/client/assets/index-CubzmCVK.css +2 -0
  4. package/client/assets/{index-eHWB79u5.js → index-DtcyExH9.js} +80 -61
  5. package/client/assets/{index.es-D4YyL_Dg-BfRHLTZV.js → index.es-D4YyL_Dg-DnizuhIY.js} +5 -5
  6. package/client/index.html +2 -4
  7. package/package.json +13 -13
  8. package/server/agent/attachmentConverter.ts +2 -2
  9. package/server/agent/index.ts +9 -3
  10. package/server/agent/mcp-tools/index.ts +6 -6
  11. package/server/agent/mcp-tools/x.ts +2 -1
  12. package/server/agent/prompt.ts +187 -26
  13. package/server/agent/resumeFailover.ts +5 -5
  14. package/server/agent/sandboxMounts.ts +3 -3
  15. package/server/api/auth/bearerAuth.ts +3 -3
  16. package/server/api/auth/token.ts +2 -2
  17. package/server/api/routes/agent.ts +21 -3
  18. package/server/api/routes/config.ts +1 -1
  19. package/server/api/routes/files.ts +13 -12
  20. package/server/api/routes/html.ts +2 -2
  21. package/server/api/routes/image.ts +7 -7
  22. package/server/api/routes/mulmo-script.ts +33 -31
  23. package/server/api/routes/pdf.ts +2 -2
  24. package/server/api/routes/plugins.ts +16 -6
  25. package/server/api/routes/roles.ts +2 -2
  26. package/server/api/routes/scheduler.ts +8 -6
  27. package/server/api/routes/schedulerTasks.ts +5 -3
  28. package/server/api/routes/sessions.ts +2 -2
  29. package/server/api/routes/sessionsCursor.ts +4 -4
  30. package/server/api/routes/skills.ts +5 -5
  31. package/server/api/routes/sources.ts +3 -3
  32. package/server/api/routes/todosHandlers.ts +1 -1
  33. package/server/api/routes/todosItemsHandlers.ts +14 -14
  34. package/server/api/routes/wiki.ts +22 -8
  35. package/server/api/sandboxStatus.ts +1 -1
  36. package/server/events/notifications.ts +6 -6
  37. package/server/events/pub-sub/index.ts +3 -3
  38. package/server/events/relay-client.ts +17 -16
  39. package/server/index.ts +40 -46
  40. package/server/system/config.ts +5 -5
  41. package/server/system/credentials.ts +7 -5
  42. package/server/system/env.ts +5 -5
  43. package/server/utils/files/atomic.ts +11 -11
  44. package/server/utils/files/image-store.ts +17 -6
  45. package/server/utils/files/journal-io.ts +2 -2
  46. package/server/utils/files/json.ts +5 -5
  47. package/server/utils/files/markdown-store.ts +4 -4
  48. package/server/utils/files/reference-dirs-io.ts +3 -3
  49. package/server/utils/files/roles-io.ts +4 -4
  50. package/server/utils/files/safe.ts +14 -14
  51. package/server/utils/files/scheduler-overrides-io.ts +2 -2
  52. package/server/utils/files/spreadsheet-store.ts +5 -5
  53. package/server/utils/files/workspace-io.ts +12 -12
  54. package/server/utils/gemini.ts +2 -2
  55. package/server/utils/gitignore.ts +9 -9
  56. package/server/utils/json.ts +5 -5
  57. package/server/utils/logBackgroundError.ts +12 -3
  58. package/server/utils/markdown.ts +5 -5
  59. package/server/utils/port.d.mts +6 -0
  60. package/server/utils/port.mjs +48 -0
  61. package/server/utils/request.ts +12 -6
  62. package/server/utils/spawn.ts +1 -1
  63. package/server/utils/types.ts +2 -2
  64. package/server/workspace/chat-index/summarizer.ts +4 -4
  65. package/server/workspace/custom-dirs.ts +5 -5
  66. package/server/workspace/journal/diff.ts +2 -2
  67. package/server/workspace/journal/index.ts +4 -4
  68. package/server/workspace/journal/optimizationPass.ts +2 -2
  69. package/server/workspace/journal/state.ts +6 -6
  70. package/server/workspace/paths.ts +3 -3
  71. package/server/workspace/reference-dirs.ts +3 -3
  72. package/server/workspace/skills/parser.ts +6 -6
  73. package/server/workspace/skills/scheduler.ts +3 -3
  74. package/server/workspace/skills/writer.ts +3 -3
  75. package/server/workspace/sources/arxivDiscovery.ts +2 -2
  76. package/server/workspace/sources/fetchers/rss.ts +5 -5
  77. package/server/workspace/sources/fetchers/rssParser.ts +4 -4
  78. package/server/workspace/sources/interests.ts +3 -3
  79. package/server/workspace/sources/paths.ts +6 -6
  80. package/server/workspace/sources/pipeline/fetch.ts +36 -13
  81. package/server/workspace/sources/pipeline/index.ts +2 -7
  82. package/server/workspace/sources/pipeline/notify.ts +3 -3
  83. package/server/workspace/sources/pipeline/plan.ts +11 -9
  84. package/server/workspace/sources/pipeline/write.ts +5 -5
  85. package/server/workspace/sources/rateLimiter.ts +1 -1
  86. package/server/workspace/sources/sourceState.ts +9 -4
  87. package/server/workspace/sources/types.ts +9 -0
  88. package/server/workspace/sources/urls.ts +1 -1
  89. package/server/workspace/tool-trace/classify.ts +4 -4
  90. package/server/workspace/workspace.ts +7 -7
  91. package/src/App.vue +286 -112
  92. package/src/components/CanvasViewToggle.vue +10 -7
  93. package/src/components/ChatInput.vue +60 -26
  94. package/src/components/FileContentHeader.vue +7 -4
  95. package/src/components/FileContentRenderer.vue +20 -6
  96. package/src/components/FileTree.vue +6 -3
  97. package/src/components/FileTreePane.vue +11 -8
  98. package/src/components/FilesView.vue +5 -3
  99. package/src/components/LockStatusPopup.vue +15 -12
  100. package/src/components/NotificationBell.vue +14 -5
  101. package/src/components/NotificationToast.vue +4 -1
  102. package/src/components/PluginLauncher.vue +19 -56
  103. package/src/components/RightSidebar.vue +13 -10
  104. package/src/components/SessionHistoryPanel.vue +33 -29
  105. package/src/components/SessionTabBar.vue +8 -10
  106. package/src/components/SettingsMcpTab.vue +43 -30
  107. package/src/components/SettingsModal.vue +21 -19
  108. package/src/components/SettingsReferenceDirsTab.vue +29 -24
  109. package/src/components/SettingsWorkspaceDirsTab.vue +32 -22
  110. package/src/components/SidebarHeader.vue +25 -4
  111. package/src/components/StackView.vue +4 -1
  112. package/src/components/SuggestionsPanel.vue +5 -2
  113. package/src/components/TodoExplorer.vue +26 -15
  114. package/src/components/ToolResultsPanel.vue +27 -13
  115. package/src/components/todo/TodoAddDialog.vue +17 -12
  116. package/src/components/todo/TodoEditDialog.vue +7 -2
  117. package/src/components/todo/TodoEditPanel.vue +15 -10
  118. package/src/components/todo/TodoKanbanView.vue +10 -5
  119. package/src/components/todo/TodoListView.vue +5 -2
  120. package/src/components/todo/TodoTableView.vue +5 -2
  121. package/src/composables/useAppApi.ts +9 -0
  122. package/src/composables/useDynamicFavicon.ts +172 -37
  123. package/src/composables/useEventListeners.ts +7 -8
  124. package/src/composables/useFaviconState.ts +13 -2
  125. package/src/composables/useFileSelection.ts +24 -6
  126. package/src/composables/useLayoutMode.ts +32 -0
  127. package/src/composables/useSessionHistory.ts +7 -17
  128. package/src/composables/useViewLayout.ts +20 -34
  129. package/src/lang/de.ts +536 -0
  130. package/src/lang/en.ts +558 -0
  131. package/src/lang/es.ts +543 -0
  132. package/src/lang/fr.ts +536 -0
  133. package/src/lang/ja.ts +536 -0
  134. package/src/lang/ko.ts +540 -0
  135. package/src/lang/pt-BR.ts +534 -0
  136. package/src/lang/zh.ts +537 -0
  137. package/src/lib/vue-i18n.ts +97 -0
  138. package/src/main.ts +2 -0
  139. package/src/plugins/canvas/View.vue +102 -186
  140. package/src/plugins/canvas/definition.ts +0 -8
  141. package/src/plugins/chart/Preview.vue +1 -1
  142. package/src/plugins/chart/View.vue +9 -4
  143. package/src/plugins/manageRoles/Preview.vue +4 -1
  144. package/src/plugins/manageRoles/View.vue +59 -43
  145. package/src/plugins/manageSkills/Preview.vue +8 -3
  146. package/src/plugins/manageSkills/View.vue +26 -22
  147. package/src/plugins/manageSource/Preview.vue +1 -1
  148. package/src/plugins/manageSource/View.vue +73 -52
  149. package/src/plugins/markdown/Preview.vue +1 -1
  150. package/src/plugins/markdown/View.vue +24 -34
  151. package/src/plugins/presentHtml/Preview.vue +1 -1
  152. package/src/plugins/presentHtml/View.vue +7 -4
  153. package/src/plugins/presentMulmoScript/Preview.vue +1 -1
  154. package/src/plugins/presentMulmoScript/View.vue +36 -26
  155. package/src/plugins/scheduler/Preview.vue +7 -4
  156. package/src/plugins/scheduler/TasksTab.vue +53 -24
  157. package/src/plugins/scheduler/View.vue +28 -19
  158. package/src/plugins/scheduler/formatSchedule.ts +93 -0
  159. package/src/plugins/spreadsheet/Preview.vue +8 -3
  160. package/src/plugins/spreadsheet/View.vue +21 -12
  161. package/src/plugins/textResponse/Preview.vue +15 -58
  162. package/src/plugins/textResponse/View.vue +27 -7
  163. package/src/plugins/todo/Preview.vue +11 -6
  164. package/src/plugins/todo/View.vue +27 -13
  165. package/src/plugins/ui-image/ImagePreview.vue +6 -3
  166. package/src/plugins/ui-image/ImageView.vue +7 -4
  167. package/src/plugins/wiki/Preview.vue +5 -2
  168. package/src/plugins/wiki/View.vue +202 -81
  169. package/src/plugins/wiki/route.ts +112 -0
  170. package/src/router/guards.ts +42 -24
  171. package/src/router/index.ts +41 -26
  172. package/src/types/vue-i18n.d.ts +20 -0
  173. package/src/utils/agent/request.ts +19 -0
  174. package/src/utils/canvas/layoutMode.ts +26 -0
  175. package/src/utils/image/cacheBust.ts +16 -0
  176. package/src/utils/image/resolve.ts +16 -0
  177. package/src/utils/path/workspaceLinkRouter.ts +81 -0
  178. package/src/vite-env.d.ts +9 -0
  179. package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +0 -1
  180. package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +0 -5
  181. package/client/assets/index-Bm70FDU2.css +0 -1
  182. package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +0 -1
  183. package/src/composables/useCanvasViewMode.ts +0 -121
  184. package/src/utils/canvas/viewMode.ts +0 -46
  185. /package/client/assets/{purify.es-Fx1Nqyry-PeS5RUhs.js → purify.es-Fx1Nqyry-BwJECkqS.js} +0 -0
@@ -1,4 +1,4 @@
1
- import fs from "fs/promises";
1
+ import { mkdir, readFile, realpath, writeFile } from "fs/promises";
2
2
  import path from "path";
3
3
  import crypto from "crypto";
4
4
  import { WORKSPACE_DIRS, WORKSPACE_PATHS } from "../../workspace/paths.js";
@@ -12,8 +12,8 @@ let imagesDirReal: string | null = null;
12
12
 
13
13
  async function ensureImagesDir(): Promise<string> {
14
14
  if (imagesDirReal) return imagesDirReal;
15
- await fs.mkdir(IMAGES_DIR, { recursive: true });
16
- imagesDirReal = await fs.realpath(IMAGES_DIR);
15
+ await mkdir(IMAGES_DIR, { recursive: true });
16
+ imagesDirReal = await realpath(IMAGES_DIR);
17
17
  return imagesDirReal;
18
18
  }
19
19
 
@@ -38,20 +38,20 @@ export async function saveImage(base64Data: string): Promise<string> {
38
38
  const imageId = crypto.randomUUID().replace(/-/g, "").slice(0, 16);
39
39
  const filename = `${imageId}.png`;
40
40
  const absPath = path.join(IMAGES_DIR, filename);
41
- await fs.writeFile(absPath, Buffer.from(base64Data, "base64"));
41
+ await writeFile(absPath, Buffer.from(base64Data, "base64"));
42
42
  return path.posix.join(WORKSPACE_DIRS.images, filename);
43
43
  }
44
44
 
45
45
  /** Overwrite an existing image file. The relativePath must start with "images/". */
46
46
  export async function overwriteImage(relativePath: string, base64Data: string): Promise<void> {
47
47
  const absPath = await safeResolve(relativePath);
48
- await fs.writeFile(absPath, Buffer.from(base64Data, "base64"));
48
+ await writeFile(absPath, Buffer.from(base64Data, "base64"));
49
49
  }
50
50
 
51
51
  /** Read an image file and return raw base64 (no data URI prefix). */
52
52
  export async function loadImageBase64(relativePath: string): Promise<string> {
53
53
  const absPath = await safeResolve(relativePath);
54
- const buf = await fs.readFile(absPath);
54
+ const buf = await readFile(absPath);
55
55
  return buf.toString("base64");
56
56
  }
57
57
 
@@ -64,3 +64,14 @@ export function stripDataUri(dataUri: string): string {
64
64
  export function isImagePath(value: string): boolean {
65
65
  return value.startsWith(`${WORKSPACE_DIRS.images}/`) && value.endsWith(".png");
66
66
  }
67
+
68
+ /** Build the workspace-relative image path for a filename, or null
69
+ * if the derived path wouldn't satisfy `isImagePath` (e.g. empty
70
+ * name, wrong extension). Keeps route handlers from reaching for
71
+ * `WORKSPACE_DIRS.images` directly — the images/ convention lives
72
+ * in exactly one place. */
73
+ export function imagePathFromFilename(filename: string): string | null {
74
+ if (!filename) return null;
75
+ const relativePath = path.posix.join(WORKSPACE_DIRS.images, filename);
76
+ return isImagePath(relativePath) ? relativePath : null;
77
+ }
@@ -17,7 +17,7 @@ import { isEnoent } from "./safe.js";
17
17
  import { log } from "../../system/logger/index.js";
18
18
  import { summariesRoot, dailyPathFor, topicPathFor, TOPICS_DIR, INDEX_FILE, STATE_FILE, DAILY_DIR, ARCHIVE_DIR } from "../../workspace/journal/paths.js";
19
19
 
20
- import fs from "node:fs";
20
+ import { statSync } from "node:fs";
21
21
 
22
22
  const root = (rootOverride?: string) => rootOverride ?? workspacePath;
23
23
 
@@ -26,7 +26,7 @@ const root = (rootOverride?: string) => rootOverride ?? workspacePath;
26
26
  export function journalStateExists(rootOverride?: string): boolean {
27
27
  const filePath = path.join(summariesRoot(root(rootOverride)), STATE_FILE);
28
28
  try {
29
- fs.statSync(filePath);
29
+ statSync(filePath);
30
30
  return true;
31
31
  } catch {
32
32
  return false;
@@ -3,7 +3,7 @@
3
3
  // Moved from server/utils/file.ts (issue #366 Phase 1). The old
4
4
  // file re-exports these for backwards compat.
5
5
 
6
- import fs from "fs";
6
+ import { mkdirSync, promises, readFileSync, writeFileSync } from "fs";
7
7
  import path from "path";
8
8
  import { writeFileAtomic } from "./atomic.js";
9
9
  import { isEnoent } from "./safe.js";
@@ -20,7 +20,7 @@ import { log } from "../../system/logger/index.js";
20
20
  export function loadJsonFile<T>(filePath: string, defaultValue: T): T {
21
21
  let raw: string;
22
22
  try {
23
- raw = fs.readFileSync(filePath, "utf-8");
23
+ raw = readFileSync(filePath, "utf-8");
24
24
  } catch (err) {
25
25
  if (isEnoent(err)) return defaultValue;
26
26
  log.error("json", "loadJsonFile read failed", {
@@ -41,8 +41,8 @@ export function loadJsonFile<T>(filePath: string, defaultValue: T): T {
41
41
  }
42
42
 
43
43
  export function saveJsonFile(filePath: string, data: unknown): void {
44
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
45
- fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
44
+ mkdirSync(path.dirname(filePath), { recursive: true });
45
+ writeFileSync(filePath, JSON.stringify(data, null, 2));
46
46
  }
47
47
 
48
48
  // ── Async ───────────────────────────────────────────────────────
@@ -60,7 +60,7 @@ export async function writeJsonAtomic(filePath: string, data: unknown, opts: Par
60
60
  */
61
61
  export async function readJsonOrNull<T>(filePath: string): Promise<T | null> {
62
62
  try {
63
- const content = await fs.promises.readFile(filePath, "utf-8");
63
+ const content = await promises.readFile(filePath, "utf-8");
64
64
  const parsed: T = JSON.parse(content);
65
65
  return parsed;
66
66
  } catch {
@@ -1,4 +1,4 @@
1
- import fs from "fs/promises";
1
+ import { readFile, writeFile } from "fs/promises";
2
2
  import path from "path";
3
3
  import { workspacePath } from "../../workspace/workspace.js";
4
4
  import { WORKSPACE_DIRS } from "../../workspace/paths.js";
@@ -11,20 +11,20 @@ import { buildArtifactPathRandom } from "./naming.js";
11
11
  */
12
12
  export async function saveMarkdown(content: string, prefix: string): Promise<string> {
13
13
  const relPath = buildArtifactPathRandom(WORKSPACE_DIRS.markdowns, prefix, ".md", "document");
14
- await fs.writeFile(path.join(workspacePath, relPath), content, "utf-8");
14
+ await writeFile(path.join(workspacePath, relPath), content, "utf-8");
15
15
  return relPath;
16
16
  }
17
17
 
18
18
  /** Read a markdown file and return its content. */
19
19
  export async function loadMarkdown(relativePath: string): Promise<string> {
20
20
  const absPath = path.join(workspacePath, relativePath);
21
- return fs.readFile(absPath, "utf-8");
21
+ return readFile(absPath, "utf-8");
22
22
  }
23
23
 
24
24
  /** Overwrite an existing markdown file. */
25
25
  export async function overwriteMarkdown(relativePath: string, content: string): Promise<void> {
26
26
  const absPath = path.join(workspacePath, relativePath);
27
- await fs.writeFile(absPath, content, "utf-8");
27
+ await writeFile(absPath, content, "utf-8");
28
28
  }
29
29
 
30
30
  /** Check if a string is a markdown file path (not inline content). */
@@ -4,7 +4,7 @@
4
4
  // All fs access is funneled through shared helpers so path changes
5
5
  // propagate from a single constant.
6
6
 
7
- import fs from "fs";
7
+ import { mkdirSync, statSync } from "fs";
8
8
  import path from "path";
9
9
  import { WORKSPACE_DIRS, workspacePath } from "../../workspace/paths.js";
10
10
  import { loadJsonFile } from "./json.js";
@@ -31,14 +31,14 @@ export function readReferenceDirsJson(root?: string): unknown[] {
31
31
  /** Write reference-dirs.json atomically. Creates config/ if needed. */
32
32
  export function writeReferenceDirsJson(entries: readonly unknown[], root?: string): void {
33
33
  const filePath = configPath(root ?? workspacePath);
34
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
34
+ mkdirSync(path.dirname(filePath), { recursive: true });
35
35
  writeFileAtomicSync(filePath, JSON.stringify(entries, null, 2));
36
36
  }
37
37
 
38
38
  /** Check whether a host path exists and is a directory. */
39
39
  export function isExistingDirectory(hostPath: string): boolean {
40
40
  try {
41
- return fs.statSync(hostPath).isDirectory();
41
+ return statSync(hostPath).isDirectory();
42
42
  } catch {
43
43
  return false;
44
44
  }
@@ -4,7 +4,7 @@
4
4
  // Optional `root` for test DI.
5
5
 
6
6
  import path from "node:path";
7
- import fs from "node:fs";
7
+ import { mkdirSync, statSync, unlinkSync } from "node:fs";
8
8
  import { WORKSPACE_DIRS } from "../../workspace/paths.js";
9
9
  import { workspacePath } from "../../workspace/paths.js";
10
10
  import { writeFileAtomicSync } from "./atomic.js";
@@ -19,7 +19,7 @@ function roleFilePath(roleId: string, workspaceRoot?: string): string {
19
19
  /** Check if a custom role file exists. */
20
20
  export function roleExists(roleId: string, workspaceRoot?: string): boolean {
21
21
  try {
22
- fs.statSync(roleFilePath(roleId, workspaceRoot));
22
+ statSync(roleFilePath(roleId, workspaceRoot));
23
23
  return true;
24
24
  } catch {
25
25
  return false;
@@ -29,7 +29,7 @@ export function roleExists(roleId: string, workspaceRoot?: string): boolean {
29
29
  /** Delete a custom role file. Returns false if not found. */
30
30
  export function deleteRole(roleId: string, workspaceRoot?: string): boolean {
31
31
  try {
32
- fs.unlinkSync(roleFilePath(roleId, workspaceRoot));
32
+ unlinkSync(roleFilePath(roleId, workspaceRoot));
33
33
  return true;
34
34
  } catch (err) {
35
35
  if (isEnoent(err)) return false;
@@ -40,6 +40,6 @@ export function deleteRole(roleId: string, workspaceRoot?: string): boolean {
40
40
  /** Save (create or overwrite) a custom role file atomically. */
41
41
  export function saveRole(roleId: string, data: unknown, workspaceRoot?: string): void {
42
42
  const dir = path.join(root(workspaceRoot), WORKSPACE_DIRS.roles);
43
- fs.mkdirSync(dir, { recursive: true });
43
+ mkdirSync(dir, { recursive: true });
44
44
  writeFileAtomicSync(roleFilePath(roleId, workspaceRoot), JSON.stringify(data, null, 2));
45
45
  }
@@ -7,7 +7,7 @@
7
7
  // Moved from server/utils/fs.ts (issue #366 Phase 1). The old
8
8
  // file re-exports these for backwards compat.
9
9
 
10
- import fs from "fs";
10
+ import { Dirent, Stats, promises, readFileSync, readdirSync, realpathSync, statSync } from "fs";
11
11
  import path from "path";
12
12
  import { isErrorWithCode } from "../types.js";
13
13
 
@@ -19,7 +19,7 @@ export function isEnoent(err: unknown): boolean {
19
19
  /** Read a binary file by absolute path. Null on ENOENT. */
20
20
  export function readBinarySafeSync(absPath: string): Buffer | null {
21
21
  try {
22
- return fs.readFileSync(absPath);
22
+ return readFileSync(absPath);
23
23
  } catch {
24
24
  return null;
25
25
  }
@@ -28,7 +28,7 @@ export function readBinarySafeSync(absPath: string): Buffer | null {
28
28
  /** Read a text file by absolute path (async). Null on ENOENT. */
29
29
  export async function readTextSafe(absPath: string): Promise<string | null> {
30
30
  try {
31
- return await fs.promises.readFile(absPath, "utf-8");
31
+ return await promises.readFile(absPath, "utf-8");
32
32
  } catch {
33
33
  return null;
34
34
  }
@@ -37,39 +37,39 @@ export async function readTextSafe(absPath: string): Promise<string | null> {
37
37
  /** Read a text file by absolute path (sync). Null on ENOENT. */
38
38
  export function readTextSafeSync(absPath: string): string | null {
39
39
  try {
40
- return fs.readFileSync(absPath, "utf-8");
40
+ return readFileSync(absPath, "utf-8");
41
41
  } catch {
42
42
  return null;
43
43
  }
44
44
  }
45
45
 
46
- export function statSafe(absPath: string): fs.Stats | null {
46
+ export function statSafe(absPath: string): Stats | null {
47
47
  try {
48
- return fs.statSync(absPath);
48
+ return statSync(absPath);
49
49
  } catch {
50
50
  return null;
51
51
  }
52
52
  }
53
53
 
54
- export async function statSafeAsync(absPath: string): Promise<fs.Stats | null> {
54
+ export async function statSafeAsync(absPath: string): Promise<Stats | null> {
55
55
  try {
56
- return await fs.promises.stat(absPath);
56
+ return await promises.stat(absPath);
57
57
  } catch {
58
58
  return null;
59
59
  }
60
60
  }
61
61
 
62
- export function readDirSafe(absPath: string): fs.Dirent[] {
62
+ export function readDirSafe(absPath: string): Dirent[] {
63
63
  try {
64
- return fs.readdirSync(absPath, { withFileTypes: true });
64
+ return readdirSync(absPath, { withFileTypes: true });
65
65
  } catch {
66
66
  return [];
67
67
  }
68
68
  }
69
69
 
70
- export async function readDirSafeAsync(absPath: string): Promise<fs.Dirent[]> {
70
+ export async function readDirSafeAsync(absPath: string): Promise<Dirent[]> {
71
71
  try {
72
- return await fs.promises.readdir(absPath, { withFileTypes: true });
72
+ return await promises.readdir(absPath, { withFileTypes: true });
73
73
  } catch {
74
74
  return [];
75
75
  }
@@ -77,7 +77,7 @@ export async function readDirSafeAsync(absPath: string): Promise<fs.Dirent[]> {
77
77
 
78
78
  export async function readTextOrNull(file: string): Promise<string | null> {
79
79
  try {
80
- return await fs.promises.readFile(file, "utf-8");
80
+ return await promises.readFile(file, "utf-8");
81
81
  } catch {
82
82
  return null;
83
83
  }
@@ -95,7 +95,7 @@ export function resolveWithinRoot(rootReal: string, relPath: string): string | n
95
95
  const resolved = path.resolve(rootReal, normalized);
96
96
  let resolvedReal: string;
97
97
  try {
98
- resolvedReal = fs.realpathSync(resolved);
98
+ resolvedReal = realpathSync(resolved);
99
99
  } catch {
100
100
  return null;
101
101
  }
@@ -4,7 +4,7 @@
4
4
  // system task ID (e.g. "system:journal"), value overrides the
5
5
  // default schedule.
6
6
 
7
- import fs from "fs";
7
+ import { mkdirSync } from "fs";
8
8
  import path from "path";
9
9
  import { workspacePath } from "../../workspace/paths.js";
10
10
  import { WORKSPACE_FILES } from "../../../src/config/workspacePaths.js";
@@ -59,6 +59,6 @@ export function loadSchedulerOverrides(root?: string): ScheduleOverrides {
59
59
  /** Save schedule overrides atomically. Creates directory if needed. */
60
60
  export function saveSchedulerOverrides(overrides: ScheduleOverrides, root?: string): void {
61
61
  const filePath = overridesPath(root);
62
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
62
+ mkdirSync(path.dirname(filePath), { recursive: true });
63
63
  writeFileAtomicSync(filePath, JSON.stringify(overrides, null, 2));
64
64
  }
@@ -1,4 +1,4 @@
1
- import fs from "fs/promises";
1
+ import { mkdir, realpath, writeFile } from "fs/promises";
2
2
  import path from "path";
3
3
  import crypto from "crypto";
4
4
  import { WORKSPACE_DIRS, WORKSPACE_PATHS } from "../../workspace/paths.js";
@@ -13,8 +13,8 @@ let spreadsheetsDirReal: string | null = null;
13
13
 
14
14
  async function ensureSpreadsheetsDir(): Promise<string> {
15
15
  if (spreadsheetsDirReal) return spreadsheetsDirReal;
16
- await fs.mkdir(SPREADSHEETS_DIR, { recursive: true });
17
- spreadsheetsDirReal = await fs.realpath(SPREADSHEETS_DIR);
16
+ await mkdir(SPREADSHEETS_DIR, { recursive: true });
17
+ spreadsheetsDirReal = await realpath(SPREADSHEETS_DIR);
18
18
  return spreadsheetsDirReal;
19
19
  }
20
20
 
@@ -38,14 +38,14 @@ export async function saveSpreadsheet(sheets: unknown[]): Promise<string> {
38
38
  await ensureSpreadsheetsDir();
39
39
  const sheetId = crypto.randomUUID().replace(/-/g, "").slice(0, 16);
40
40
  const filename = `${sheetId}.json`;
41
- await fs.writeFile(path.join(SPREADSHEETS_DIR, filename), JSON.stringify(sheets), "utf-8");
41
+ await writeFile(path.join(SPREADSHEETS_DIR, filename), JSON.stringify(sheets), "utf-8");
42
42
  return path.posix.join(WORKSPACE_DIRS.spreadsheets, filename);
43
43
  }
44
44
 
45
45
  /** Overwrite an existing spreadsheet file. */
46
46
  export async function overwriteSpreadsheet(relativePath: string, sheets: unknown[]): Promise<void> {
47
47
  const absPath = await safeResolve(relativePath);
48
- await fs.writeFile(absPath, JSON.stringify(sheets), "utf-8");
48
+ await writeFile(absPath, JSON.stringify(sheets), "utf-8");
49
49
  }
50
50
 
51
51
  /** Check if a string is a spreadsheet file path (not inline data).
@@ -11,7 +11,7 @@
11
11
  // All reads swallow ENOENT and return null / fallback so callers can
12
12
  // do `if (!content)` instead of try/catch.
13
13
 
14
- import fs from "fs";
14
+ import { Stats, mkdirSync, promises, readFileSync, readdirSync, statSync } from "fs";
15
15
  import path from "path";
16
16
  import { workspacePath } from "../../workspace/paths.js";
17
17
  import { writeFileAtomic, writeFileAtomicSync } from "./atomic.js";
@@ -44,7 +44,7 @@ export function resolveWorkspacePath(relPath: string): string {
44
44
  */
45
45
  export async function readWorkspaceText(relPath: string): Promise<string | null> {
46
46
  try {
47
- return await fs.promises.readFile(resolveWorkspacePath(relPath), "utf-8");
47
+ return await promises.readFile(resolveWorkspacePath(relPath), "utf-8");
48
48
  } catch (err) {
49
49
  return rethrowUnexpected(err, `readWorkspaceText(${relPath})`);
50
50
  }
@@ -53,7 +53,7 @@ export async function readWorkspaceText(relPath: string): Promise<string | null>
53
53
  /** Sync variant. Same ENOENT-only swallow contract. */
54
54
  export function readWorkspaceTextSync(relPath: string): string | null {
55
55
  try {
56
- return fs.readFileSync(resolveWorkspacePath(relPath), "utf-8");
56
+ return readFileSync(resolveWorkspacePath(relPath), "utf-8");
57
57
  } catch (err) {
58
58
  return rethrowUnexpected(err, `readWorkspaceTextSync(${relPath})`);
59
59
  }
@@ -133,7 +133,7 @@ export function resolvePath(root: string, relPath: string): string {
133
133
  * unexpected errors. */
134
134
  export async function readTextUnder(root: string, relPath: string): Promise<string | null> {
135
135
  try {
136
- return await fs.promises.readFile(path.join(root, relPath), "utf-8");
136
+ return await promises.readFile(path.join(root, relPath), "utf-8");
137
137
  } catch (err) {
138
138
  return rethrowUnexpected(err, `readTextUnder(${relPath})`);
139
139
  }
@@ -147,7 +147,7 @@ export async function writeTextUnder(root: string, relPath: string, content: str
147
147
  /** Sync read text under a root. Null on ENOENT. */
148
148
  export function readTextUnderSync(root: string, relPath: string): string | null {
149
149
  try {
150
- return fs.readFileSync(path.join(root, relPath), "utf-8");
150
+ return readFileSync(path.join(root, relPath), "utf-8");
151
151
  } catch (err) {
152
152
  return rethrowUnexpected(err, `readTextUnderSync(${relPath})`);
153
153
  }
@@ -156,7 +156,7 @@ export function readTextUnderSync(root: string, relPath: string): string | null
156
156
  /** Sync readdir under a root. Empty on ENOENT. */
157
157
  export function readdirUnderSync(root: string, relPath: string): string[] {
158
158
  try {
159
- return fs.readdirSync(path.join(root, relPath));
159
+ return readdirSync(path.join(root, relPath));
160
160
  } catch (err) {
161
161
  if (isEnoent(err)) return [];
162
162
  log.error("workspace-io", `readdirUnderSync(${relPath})`, {
@@ -169,7 +169,7 @@ export function readdirUnderSync(root: string, relPath: string): string[] {
169
169
  /** Readdir under a root. Empty on ENOENT; rethrows unexpected. */
170
170
  export async function readdirUnder(root: string, relPath: string): Promise<string[]> {
171
171
  try {
172
- return await fs.promises.readdir(path.join(root, relPath));
172
+ return await promises.readdir(path.join(root, relPath));
173
173
  } catch (err) {
174
174
  if (isEnoent(err)) return [];
175
175
  log.error("workspace-io", `readdirUnder(${relPath})`, {
@@ -180,9 +180,9 @@ export async function readdirUnder(root: string, relPath: string): Promise<strin
180
180
  }
181
181
 
182
182
  /** Stat under a root. Null on ENOENT; rethrows unexpected. */
183
- export async function statUnder(root: string, relPath: string): Promise<fs.Stats | null> {
183
+ export async function statUnder(root: string, relPath: string): Promise<Stats | null> {
184
184
  try {
185
- return await fs.promises.stat(path.join(root, relPath));
185
+ return await promises.stat(path.join(root, relPath));
186
186
  } catch (err) {
187
187
  return rethrowUnexpected(err, `statUnder(${relPath})`);
188
188
  }
@@ -190,7 +190,7 @@ export async function statUnder(root: string, relPath: string): Promise<fs.Stats
190
190
 
191
191
  /** Ensure a directory exists under a root. */
192
192
  export async function ensureDirUnder(root: string, relPath: string): Promise<void> {
193
- await fs.promises.mkdir(path.join(root, relPath), { recursive: true });
193
+ await promises.mkdir(path.join(root, relPath), { recursive: true });
194
194
  }
195
195
 
196
196
  // ── Existence ───────────────────────────────────────────────────
@@ -201,7 +201,7 @@ export async function ensureDirUnder(root: string, relPath: string): Promise<voi
201
201
  */
202
202
  export function existsInWorkspace(relPath: string): boolean {
203
203
  try {
204
- fs.statSync(resolveWorkspacePath(relPath));
204
+ statSync(resolveWorkspacePath(relPath));
205
205
  return true;
206
206
  } catch (err) {
207
207
  if (isEnoent(err)) return false;
@@ -217,5 +217,5 @@ export function existsInWorkspace(relPath: string): boolean {
217
217
  * (including parents) if missing. Idempotent.
218
218
  */
219
219
  export function ensureWorkspaceDir(relPath: string): void {
220
- fs.mkdirSync(resolveWorkspacePath(relPath), { recursive: true });
220
+ mkdirSync(resolveWorkspacePath(relPath), { recursive: true });
221
221
  }
@@ -37,8 +37,8 @@ export async function generateGeminiImageContent(
37
37
  config?: GenerateContentParameters["config"],
38
38
  model: string = DEFAULT_IMAGE_MODEL,
39
39
  ): Promise<GeminiImageResult> {
40
- const ai = getGeminiClient();
41
- const response = await ai.models.generateContent({
40
+ const client = getGeminiClient();
41
+ const response = await client.models.generateContent({
42
42
  model,
43
43
  contents,
44
44
  ...(config && { config }),
@@ -15,24 +15,24 @@
15
15
  // the workspace scale (~hundreds of dirs) this is negligible. If
16
16
  // profiling shows otherwise, cache the parsed ignore instances.
17
17
 
18
- import fs from "fs";
18
+ import { readFileSync } from "fs";
19
19
  import path from "path";
20
20
  import ignore, { type Ignore } from "ignore";
21
21
 
22
22
  export class GitignoreFilter {
23
- private ig: Ignore;
23
+ private rules: Ignore;
24
24
 
25
25
  constructor(rules?: string) {
26
- this.ig = ignore();
26
+ this.rules = ignore();
27
27
  if (rules) {
28
- this.ig.add(rules);
28
+ this.rules.add(rules);
29
29
  }
30
30
  }
31
31
 
32
32
  /** Test whether a workspace-relative path should be hidden. */
33
33
  ignores(relPath: string): boolean {
34
34
  if (!relPath) return false;
35
- return this.ig.ignores(relPath);
35
+ return this.rules.ignores(relPath);
36
36
  }
37
37
 
38
38
  /** Create a child filter that inherits this filter's rules and
@@ -40,12 +40,12 @@ export class GitignoreFilter {
40
40
  childForDir(dirAbsPath: string): GitignoreFilter {
41
41
  const child = new GitignoreFilter();
42
42
  // Inherit parent rules
43
- child.ig = ignore().add(this.ig);
43
+ child.rules = ignore().add(this.rules);
44
44
  // Add local .gitignore if present
45
45
  const gitignorePath = path.join(dirAbsPath, ".gitignore");
46
46
  try {
47
- const content = fs.readFileSync(gitignorePath, "utf-8");
48
- child.ig.add(content);
47
+ const content = readFileSync(gitignorePath, "utf-8");
48
+ child.rules.add(content);
49
49
  } catch {
50
50
  // No .gitignore in this directory — just inherit parent
51
51
  }
@@ -61,7 +61,7 @@ export class GitignoreFilter {
61
61
  export function createRootFilter(workspaceRoot: string): GitignoreFilter {
62
62
  const gitignorePath = path.join(workspaceRoot, ".gitignore");
63
63
  try {
64
- const content = fs.readFileSync(gitignorePath, "utf-8");
64
+ const content = readFileSync(gitignorePath, "utf-8");
65
65
  return new GitignoreFilter(content);
66
66
  } catch {
67
67
  return new GitignoreFilter();
@@ -44,22 +44,22 @@ export function findBalancedBraceBlock(raw: string): string | null {
44
44
  let inString = false;
45
45
  let escape = false;
46
46
  for (let i = start; i < raw.length; i++) {
47
- const ch = raw[i];
47
+ const char = raw[i];
48
48
  if (escape) {
49
49
  escape = false;
50
50
  continue;
51
51
  }
52
- if (ch === "\\") {
52
+ if (char === "\\") {
53
53
  escape = true;
54
54
  continue;
55
55
  }
56
- if (ch === '"') {
56
+ if (char === '"') {
57
57
  inString = !inString;
58
58
  continue;
59
59
  }
60
60
  if (inString) continue;
61
- if (ch === "{") depth++;
62
- if (ch === "}" && --depth === 0) return raw.slice(start, i + 1);
61
+ if (char === "{") depth++;
62
+ if (char === "}" && --depth === 0) return raw.slice(start, i + 1);
63
63
  }
64
64
  return null;
65
65
  }
@@ -1,4 +1,5 @@
1
1
  import { log } from "../system/logger/index.js";
2
+ import { errorMessage } from "./errors.js";
2
3
 
3
4
  /**
4
5
  * Build a `.catch` handler for a fire-and-forget background job that
@@ -8,15 +9,23 @@ import { log } from "../system/logger/index.js";
8
9
  *
9
10
  * Usage:
10
11
  *
12
+ * // Default message — good for generic background scans.
11
13
  * maybeRunJournal({ ... }).catch(logBackgroundError("journal"));
12
14
  *
15
+ * // Custom message — pass context when the failure mode is
16
+ * // worth surfacing distinctly (e.g. "failed to register
17
+ * // scheduled skills" vs other `skills` warnings).
18
+ * registerScheduledSkills(...).catch(
19
+ * logBackgroundError("skills", "failed to register scheduled skills"),
20
+ * );
21
+ *
13
22
  * The handler never rethrows — the caller's promise chain is
14
23
  * terminated cleanly so nothing propagates into the request path.
15
24
  */
16
- export function logBackgroundError(prefix: string): (err: unknown) => void {
25
+ export function logBackgroundError(prefix: string, message = "unexpected error in background"): (err: unknown) => void {
17
26
  return (err) => {
18
- log.warn(prefix, "unexpected error in background", {
19
- error: String(err),
27
+ log.warn(prefix, message, {
28
+ error: errorMessage(err),
20
29
  });
21
30
  };
22
31
  }
@@ -68,15 +68,15 @@ export function rewriteMarkdownLinks(input: string, rewrite: (href: string) => s
68
68
  * Split a trailing `#fragment` or `?query` off a path so the caller
69
69
  * can rewrite the path portion and concatenate the suffix back.
70
70
  */
71
- export function splitFragmentAndQuery(s: string): {
71
+ export function splitFragmentAndQuery(href: string): {
72
72
  pathPart: string;
73
73
  suffix: string;
74
74
  } {
75
- const hashIdx = s.indexOf("#");
76
- const queryIdx = s.indexOf("?");
75
+ const hashIdx = href.indexOf("#");
76
+ const queryIdx = href.indexOf("?");
77
77
  let cut = -1;
78
78
  if (hashIdx !== -1) cut = hashIdx;
79
79
  if (queryIdx !== -1 && (cut === -1 || queryIdx < cut)) cut = queryIdx;
80
- if (cut === -1) return { pathPart: s, suffix: "" };
81
- return { pathPart: s.slice(0, cut), suffix: s.slice(cut) };
80
+ if (cut === -1) return { pathPart: href, suffix: "" };
81
+ return { pathPart: href.slice(0, cut), suffix: href.slice(cut) };
82
82
  }
@@ -0,0 +1,6 @@
1
+ // Type declarations for port.mjs. See port.mjs for rationale on why
2
+ // the shared helper lives in plain JS.
3
+
4
+ export const MAX_PORT_PROBES: number;
5
+ export function isPortFree(port: number): Promise<boolean>;
6
+ export function findAvailablePort(start: number): Promise<number | null>;
@@ -0,0 +1,48 @@
1
+ // Shared port-resolution helpers for the dev server and the npm
2
+ // launcher (`packages/mulmoclaude/bin/mulmoclaude.js`).
3
+ //
4
+ // Kept as plain `.mjs` (no TypeScript) because the launcher runs
5
+ // directly under Node — it boots BEFORE tsx is wired up, so it can't
6
+ // import from a `.ts` file. The server-side TypeScript imports this
7
+ // through Node's ESM resolution; `moduleResolution: "bundler"` + the
8
+ // sibling `port.d.mts` declarations give us the type coverage without
9
+ // turning the whole `server/` tree into mixed JS/TS.
10
+
11
+ import net from "node:net";
12
+
13
+ // Scan cap: 20 slots is enough to step around the occasional stale
14
+ // server on `localhost` without spinning forever on a pathologically
15
+ // saturated machine.
16
+ export const MAX_PORT_PROBES = 20;
17
+
18
+ /**
19
+ * Returns true iff binding `127.0.0.1:port` would succeed right now.
20
+ * @param {number} port
21
+ * @returns {Promise<boolean>}
22
+ */
23
+ export function isPortFree(port) {
24
+ return new Promise((resolve) => {
25
+ const server = net.createServer();
26
+ server.once("error", () => resolve(false));
27
+ server.once("listening", () => {
28
+ server.close(() => resolve(true));
29
+ });
30
+ // Probe the same interface we'll actually bind to so a port
31
+ // held by a different process on a different interface doesn't
32
+ // give us a false "free" reading.
33
+ server.listen(port, "127.0.0.1");
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Walk forward from `start` until we find a free port. Returns `null`
39
+ * if every slot in `[start, start + MAX_PORT_PROBES)` is busy.
40
+ * @param {number} start
41
+ * @returns {Promise<number | null>}
42
+ */
43
+ export async function findAvailablePort(start) {
44
+ for (let candidate = start; candidate < start + MAX_PORT_PROBES; candidate++) {
45
+ if (await isPortFree(candidate)) return candidate;
46
+ }
47
+ return null;
48
+ }