mulmoclaude 0.1.2 → 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 (251) 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-D8rhwXLq.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/config.ts +12 -12
  10. package/server/agent/index.ts +9 -3
  11. package/server/agent/mcp-server.ts +19 -19
  12. package/server/agent/mcp-tools/index.ts +6 -6
  13. package/server/agent/mcp-tools/x.ts +7 -6
  14. package/server/agent/prompt.ts +195 -29
  15. package/server/agent/resumeFailover.ts +5 -5
  16. package/server/agent/sandboxMounts.ts +10 -10
  17. package/server/agent/stream.ts +4 -4
  18. package/server/api/auth/bearerAuth.ts +3 -3
  19. package/server/api/auth/token.ts +2 -2
  20. package/server/api/routes/agent.ts +21 -3
  21. package/server/api/routes/config.ts +1 -1
  22. package/server/api/routes/files.ts +22 -21
  23. package/server/api/routes/html.ts +2 -2
  24. package/server/api/routes/image.ts +7 -7
  25. package/server/api/routes/mulmo-script.ts +33 -31
  26. package/server/api/routes/pdf.ts +2 -2
  27. package/server/api/routes/plugins.ts +16 -6
  28. package/server/api/routes/roles.ts +2 -2
  29. package/server/api/routes/scheduler.ts +14 -12
  30. package/server/api/routes/schedulerHandlers.ts +12 -12
  31. package/server/api/routes/schedulerTasks.ts +19 -17
  32. package/server/api/routes/sessions.ts +26 -26
  33. package/server/api/routes/sessionsCursor.ts +4 -4
  34. package/server/api/routes/skills.ts +5 -5
  35. package/server/api/routes/sources.ts +3 -3
  36. package/server/api/routes/todosColumnsHandlers.ts +30 -30
  37. package/server/api/routes/todosHandlers.ts +1 -1
  38. package/server/api/routes/todosItemsHandlers.ts +14 -14
  39. package/server/api/routes/wiki.ts +36 -22
  40. package/server/api/sandboxStatus.ts +1 -1
  41. package/server/events/notifications.ts +6 -6
  42. package/server/events/pub-sub/index.ts +3 -3
  43. package/server/events/relay-client.ts +17 -16
  44. package/server/events/scheduler-adapter.ts +20 -20
  45. package/server/events/session-store/index.ts +10 -10
  46. package/server/events/task-manager/index.ts +7 -7
  47. package/server/index.ts +59 -65
  48. package/server/system/config.ts +5 -5
  49. package/server/system/credentials.ts +7 -5
  50. package/server/system/env.ts +5 -5
  51. package/server/utils/date.ts +18 -18
  52. package/server/utils/files/atomic.ts +16 -16
  53. package/server/utils/files/html-io.ts +5 -5
  54. package/server/utils/files/image-store.ts +19 -8
  55. package/server/utils/files/journal-io.ts +4 -4
  56. package/server/utils/files/json.ts +5 -5
  57. package/server/utils/files/markdown-store.ts +4 -4
  58. package/server/utils/files/naming.ts +2 -2
  59. package/server/utils/files/reference-dirs-io.ts +3 -3
  60. package/server/utils/files/roles-io.ts +12 -12
  61. package/server/utils/files/safe.ts +14 -14
  62. package/server/utils/files/scheduler-io.ts +5 -5
  63. package/server/utils/files/scheduler-overrides-io.ts +2 -2
  64. package/server/utils/files/session-io.ts +35 -35
  65. package/server/utils/files/spreadsheet-store.ts +7 -7
  66. package/server/utils/files/todos-io.ts +9 -9
  67. package/server/utils/files/user-tasks-io.ts +5 -5
  68. package/server/utils/files/workspace-io.ts +12 -12
  69. package/server/utils/gemini.ts +2 -2
  70. package/server/utils/gitignore.ts +9 -9
  71. package/server/utils/json.ts +5 -5
  72. package/server/utils/logBackgroundError.ts +12 -3
  73. package/server/utils/markdown.ts +5 -5
  74. package/server/utils/port.d.mts +6 -0
  75. package/server/utils/port.mjs +48 -0
  76. package/server/utils/request.ts +12 -6
  77. package/server/utils/spawn.ts +1 -1
  78. package/server/utils/types.ts +2 -2
  79. package/server/workspace/chat-index/indexer.ts +15 -15
  80. package/server/workspace/chat-index/summarizer.ts +4 -4
  81. package/server/workspace/custom-dirs.ts +16 -16
  82. package/server/workspace/journal/archivist.ts +35 -35
  83. package/server/workspace/journal/dailyPass.ts +31 -28
  84. package/server/workspace/journal/diff.ts +2 -2
  85. package/server/workspace/journal/index.ts +4 -4
  86. package/server/workspace/journal/indexFile.ts +29 -25
  87. package/server/workspace/journal/optimizationPass.ts +2 -2
  88. package/server/workspace/journal/state.ts +6 -6
  89. package/server/workspace/paths.ts +3 -3
  90. package/server/workspace/reference-dirs.ts +20 -20
  91. package/server/workspace/roles.ts +6 -6
  92. package/server/workspace/skills/discovery.ts +4 -4
  93. package/server/workspace/skills/parser.ts +6 -6
  94. package/server/workspace/skills/scheduler.ts +3 -3
  95. package/server/workspace/skills/user-tasks.ts +34 -34
  96. package/server/workspace/skills/writer.ts +3 -3
  97. package/server/workspace/sources/arxivDiscovery.ts +10 -10
  98. package/server/workspace/sources/classifier.ts +7 -7
  99. package/server/workspace/sources/fetchers/arxiv.ts +7 -7
  100. package/server/workspace/sources/fetchers/githubIssues.ts +7 -7
  101. package/server/workspace/sources/fetchers/githubReleases.ts +7 -7
  102. package/server/workspace/sources/fetchers/rss.ts +5 -5
  103. package/server/workspace/sources/fetchers/rssParser.ts +4 -4
  104. package/server/workspace/sources/interests.ts +12 -12
  105. package/server/workspace/sources/paths.ts +6 -6
  106. package/server/workspace/sources/pipeline/fetch.ts +36 -13
  107. package/server/workspace/sources/pipeline/index.ts +8 -13
  108. package/server/workspace/sources/pipeline/notify.ts +3 -3
  109. package/server/workspace/sources/pipeline/plan.ts +15 -13
  110. package/server/workspace/sources/pipeline/write.ts +5 -5
  111. package/server/workspace/sources/rateLimiter.ts +1 -1
  112. package/server/workspace/sources/registry.ts +16 -16
  113. package/server/workspace/sources/robots.ts +14 -14
  114. package/server/workspace/sources/sourceState.ts +17 -10
  115. package/server/workspace/sources/types.ts +9 -0
  116. package/server/workspace/sources/urls.ts +1 -1
  117. package/server/workspace/tool-trace/classify.ts +4 -4
  118. package/server/workspace/tool-trace/index.ts +1 -1
  119. package/server/workspace/tool-trace/writeSearch.ts +26 -16
  120. package/server/workspace/wiki-backlinks/index.ts +8 -8
  121. package/server/workspace/wiki-backlinks/sessionBacklinks.ts +15 -15
  122. package/server/workspace/workspace.ts +7 -7
  123. package/src/App.vue +315 -141
  124. package/src/components/CanvasViewToggle.vue +10 -7
  125. package/src/components/ChatInput.vue +67 -33
  126. package/src/components/FileContentHeader.vue +7 -4
  127. package/src/components/FileContentRenderer.vue +20 -6
  128. package/src/components/FileTree.vue +6 -3
  129. package/src/components/FileTreePane.vue +11 -8
  130. package/src/components/FilesView.vue +5 -3
  131. package/src/components/LockStatusPopup.vue +17 -14
  132. package/src/components/NotificationBell.vue +14 -5
  133. package/src/components/NotificationToast.vue +6 -3
  134. package/src/components/PluginLauncher.vue +19 -56
  135. package/src/components/RightSidebar.vue +13 -10
  136. package/src/components/RoleSelector.vue +2 -2
  137. package/src/components/SessionHistoryPanel.vue +38 -34
  138. package/src/components/SessionTabBar.vue +8 -10
  139. package/src/components/SettingsMcpTab.vue +49 -36
  140. package/src/components/SettingsModal.vue +24 -22
  141. package/src/components/SettingsReferenceDirsTab.vue +39 -34
  142. package/src/components/SettingsWorkspaceDirsTab.vue +37 -27
  143. package/src/components/SidebarHeader.vue +25 -4
  144. package/src/components/StackView.vue +4 -1
  145. package/src/components/SuggestionsPanel.vue +7 -4
  146. package/src/components/TodoExplorer.vue +26 -15
  147. package/src/components/ToolResultsPanel.vue +27 -13
  148. package/src/components/todo/TodoAddDialog.vue +19 -14
  149. package/src/components/todo/TodoEditDialog.vue +7 -2
  150. package/src/components/todo/TodoEditPanel.vue +17 -12
  151. package/src/components/todo/TodoKanbanView.vue +10 -5
  152. package/src/components/todo/TodoListView.vue +10 -7
  153. package/src/components/todo/TodoTableView.vue +5 -2
  154. package/src/composables/useAppApi.ts +9 -0
  155. package/src/composables/useClickOutside.ts +2 -2
  156. package/src/composables/useDynamicFavicon.ts +172 -37
  157. package/src/composables/useEventListeners.ts +7 -8
  158. package/src/composables/useFaviconState.ts +13 -2
  159. package/src/composables/useFileSelection.ts +24 -6
  160. package/src/composables/useFreshPluginData.ts +3 -3
  161. package/src/composables/useKeyNavigation.ts +11 -11
  162. package/src/composables/useLayoutMode.ts +32 -0
  163. package/src/composables/useMcpTools.ts +2 -2
  164. package/src/composables/useNotifications.ts +3 -3
  165. package/src/composables/usePdfDownload.ts +4 -4
  166. package/src/composables/usePendingCalls.ts +1 -1
  167. package/src/composables/usePubSub.ts +10 -10
  168. package/src/composables/useRoles.ts +1 -1
  169. package/src/composables/useSandboxStatus.ts +1 -1
  170. package/src/composables/useSessionDerived.ts +3 -3
  171. package/src/composables/useSessionHistory.ts +7 -17
  172. package/src/composables/useSessionSync.ts +8 -8
  173. package/src/composables/useViewLayout.ts +20 -34
  174. package/src/config/roles.ts +2 -2
  175. package/src/lang/de.ts +536 -0
  176. package/src/lang/en.ts +558 -0
  177. package/src/lang/es.ts +543 -0
  178. package/src/lang/fr.ts +536 -0
  179. package/src/lang/ja.ts +536 -0
  180. package/src/lang/ko.ts +540 -0
  181. package/src/lang/pt-BR.ts +534 -0
  182. package/src/lang/zh.ts +537 -0
  183. package/src/lib/vue-i18n.ts +97 -0
  184. package/src/main.ts +2 -0
  185. package/src/plugins/canvas/View.vue +102 -186
  186. package/src/plugins/canvas/definition.ts +0 -8
  187. package/src/plugins/chart/Preview.vue +5 -5
  188. package/src/plugins/chart/View.vue +9 -4
  189. package/src/plugins/manageRoles/Preview.vue +4 -1
  190. package/src/plugins/manageRoles/View.vue +59 -43
  191. package/src/plugins/manageSkills/Preview.vue +8 -3
  192. package/src/plugins/manageSkills/View.vue +29 -25
  193. package/src/plugins/manageSource/Preview.vue +2 -2
  194. package/src/plugins/manageSource/View.vue +73 -52
  195. package/src/plugins/markdown/Preview.vue +1 -1
  196. package/src/plugins/markdown/View.vue +26 -36
  197. package/src/plugins/presentHtml/Preview.vue +1 -1
  198. package/src/plugins/presentHtml/View.vue +7 -4
  199. package/src/plugins/presentHtml/helpers.ts +8 -8
  200. package/src/plugins/presentMulmoScript/Preview.vue +1 -1
  201. package/src/plugins/presentMulmoScript/View.vue +40 -30
  202. package/src/plugins/presentMulmoScript/helpers.ts +1 -1
  203. package/src/plugins/scheduler/Preview.vue +13 -10
  204. package/src/plugins/scheduler/TasksTab.vue +57 -28
  205. package/src/plugins/scheduler/View.vue +28 -19
  206. package/src/plugins/scheduler/formatSchedule.ts +93 -0
  207. package/src/plugins/spreadsheet/Preview.vue +8 -3
  208. package/src/plugins/spreadsheet/View.vue +21 -12
  209. package/src/plugins/textResponse/Preview.vue +15 -58
  210. package/src/plugins/textResponse/View.vue +29 -9
  211. package/src/plugins/todo/Preview.vue +13 -8
  212. package/src/plugins/todo/View.vue +38 -24
  213. package/src/plugins/todo/composables/useTodos.ts +5 -5
  214. package/src/plugins/ui-image/ImagePreview.vue +6 -3
  215. package/src/plugins/ui-image/ImageView.vue +7 -4
  216. package/src/plugins/wiki/Preview.vue +10 -7
  217. package/src/plugins/wiki/View.vue +202 -81
  218. package/src/plugins/wiki/helpers.ts +4 -4
  219. package/src/plugins/wiki/route.ts +112 -0
  220. package/src/router/guards.ts +46 -28
  221. package/src/router/index.ts +41 -26
  222. package/src/types/session.ts +4 -3
  223. package/src/types/vue-i18n.d.ts +20 -0
  224. package/src/utils/agent/request.ts +22 -3
  225. package/src/utils/canvas/layoutMode.ts +26 -0
  226. package/src/utils/dom/scrollable.ts +2 -2
  227. package/src/utils/files/expandedDirs.ts +1 -1
  228. package/src/utils/files/sortChildren.ts +6 -6
  229. package/src/utils/format/frontmatter.ts +6 -6
  230. package/src/utils/image/cacheBust.ts +16 -0
  231. package/src/utils/image/resolve.ts +16 -0
  232. package/src/utils/image/rewriteMarkdownImageRefs.ts +5 -5
  233. package/src/utils/markdown/extractFirstH1.ts +2 -2
  234. package/src/utils/path/relativeLink.ts +15 -15
  235. package/src/utils/path/workspaceLinkRouter.ts +81 -0
  236. package/src/utils/role/icon.ts +2 -2
  237. package/src/utils/role/merge.ts +2 -2
  238. package/src/utils/role/plugins.ts +1 -1
  239. package/src/utils/session/sessionFactory.ts +2 -2
  240. package/src/utils/session/sessionHelpers.ts +2 -2
  241. package/src/utils/tools/dedup.ts +4 -4
  242. package/src/utils/tools/result.ts +3 -3
  243. package/src/utils/types.ts +2 -2
  244. package/src/vite-env.d.ts +9 -0
  245. package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +0 -1
  246. package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +0 -5
  247. package/client/assets/index-KNLBjwuh.css +0 -1
  248. package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +0 -1
  249. package/src/composables/useCanvasViewMode.ts +0 -121
  250. package/src/utils/canvas/viewMode.ts +0 -46
  251. /package/client/assets/{purify.es-Fx1Nqyry-PeS5RUhs.js → purify.es-Fx1Nqyry-BwJECkqS.js} +0 -0
@@ -6,12 +6,13 @@
6
6
  //
7
7
  // NOTE: packages/relay/src/client.ts is a parallel implementation
8
8
  // for browser/edge environments using the global WebSocket API.
9
- // This module uses the `ws` npm package for Node.js. If you change
9
+ // This module uses the `socket` npm package for Node.js. If you change
10
10
  // reconnection logic or URL handling here, check the other file too.
11
11
 
12
12
  import WebSocket from "ws";
13
13
  import type { ChatService } from "@mulmobridge/chat-service";
14
14
  import { ONE_SECOND_MS } from "../utils/time.js";
15
+ import { errorMessage } from "../utils/errors.js";
15
16
 
16
17
  type RelayFn = ChatService["relay"];
17
18
 
@@ -87,7 +88,7 @@ function isRelayMessage(value: unknown): value is RelayMessage {
87
88
  export function connectRelay(deps: RelayClientDeps): RelayClientHandle {
88
89
  const { relayUrl, relayToken, relay, logger } = deps;
89
90
 
90
- let ws: WebSocket | null = null;
91
+ let socket: WebSocket | null = null;
91
92
  let reconnectMs = MIN_RECONNECT_MS;
92
93
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
93
94
  let stopped = false;
@@ -103,27 +104,27 @@ export function connectRelay(deps: RelayClientDeps): RelayClientHandle {
103
104
  if (stopped) return;
104
105
 
105
106
  try {
106
- ws = new WebSocket(buildUrl());
107
+ socket = new WebSocket(buildUrl());
107
108
  } catch (err) {
108
109
  logger.error(LOG_PREFIX, "failed to create WebSocket", {
109
- error: err instanceof Error ? err.message : String(err),
110
+ error: errorMessage(err),
110
111
  });
111
112
  scheduleReconnect();
112
113
  return;
113
114
  }
114
115
 
115
- ws.on("open", () => {
116
+ socket.on("open", () => {
116
117
  logger.info(LOG_PREFIX, "connected", { url: relayUrl });
117
118
  reconnectMs = MIN_RECONNECT_MS;
118
119
  flushResponseQueue();
119
120
  });
120
121
 
121
- ws.on("message", (data) => {
122
+ socket.on("message", (data) => {
122
123
  handleMessage(String(data));
123
124
  });
124
125
 
125
- ws.on("close", (code, reason) => {
126
- ws = null;
126
+ socket.on("close", (code, reason) => {
127
+ socket = null;
127
128
  if (TERMINAL_CLOSE_CODES.has(code)) {
128
129
  logger.error(LOG_PREFIX, "terminal close, not reconnecting", {
129
130
  code,
@@ -138,7 +139,7 @@ export function connectRelay(deps: RelayClientDeps): RelayClientHandle {
138
139
  scheduleReconnect();
139
140
  });
140
141
 
141
- ws.on("error", (err) => {
142
+ socket.on("error", (err) => {
142
143
  logger.warn(LOG_PREFIX, "connection error", {
143
144
  error: err.message,
144
145
  });
@@ -204,7 +205,7 @@ export function connectRelay(deps: RelayClientDeps): RelayClientHandle {
204
205
  } catch (err) {
205
206
  logger.error(LOG_PREFIX, "relay processing failed", {
206
207
  id: msg.id,
207
- error: err instanceof Error ? err.message : String(err),
208
+ error: errorMessage(err),
208
209
  });
209
210
  sendResponse({
210
211
  platform: msg.platform,
@@ -227,9 +228,9 @@ export function connectRelay(deps: RelayClientDeps): RelayClientHandle {
227
228
  }
228
229
 
229
230
  function trySend(response: RelayResponse): boolean {
230
- if (!ws || ws.readyState !== WebSocket.OPEN) return false;
231
+ if (!socket || socket.readyState !== WebSocket.OPEN) return false;
231
232
  try {
232
- ws.send(JSON.stringify(response), (err) => {
233
+ socket.send(JSON.stringify(response), (err) => {
233
234
  if (err && !stopped) {
234
235
  logger.warn(LOG_PREFIX, "send failed, requeueing", {
235
236
  platform: response.platform,
@@ -261,7 +262,7 @@ export function connectRelay(deps: RelayClientDeps): RelayClientHandle {
261
262
  count: responseQueue.length,
262
263
  });
263
264
  while (responseQueue.length > 0) {
264
- if (!ws || ws.readyState !== WebSocket.OPEN) break;
265
+ if (!socket || socket.readyState !== WebSocket.OPEN) break;
265
266
  const response = responseQueue[0]!;
266
267
  if (!trySend(response)) break;
267
268
  responseQueue.shift();
@@ -274,9 +275,9 @@ export function connectRelay(deps: RelayClientDeps): RelayClientHandle {
274
275
  clearTimeout(reconnectTimer);
275
276
  reconnectTimer = null;
276
277
  }
277
- if (ws) {
278
- ws.close(1000, "shutdown");
279
- ws = null;
278
+ if (socket) {
279
+ socket.close(1000, "shutdown");
280
+ socket = null;
280
281
  }
281
282
  logger.info(LOG_PREFIX, "stopped");
282
283
  }
@@ -52,16 +52,16 @@ function logsDir(root = workspacePath): string {
52
52
  // ── I/O deps (real filesystem) ───────────────────────────────────
53
53
 
54
54
  const stateDeps: StateDeps = {
55
- readFile: (p: string) => readFile(p, "utf-8"),
56
- writeFileAtomic: (p: string, content: string) => writeFileAtomic(p, content),
55
+ readFile: (filePath: string) => readFile(filePath, "utf-8"),
56
+ writeFileAtomic: (filePath: string, content: string) => writeFileAtomic(filePath, content),
57
57
  exists: existsSync,
58
58
  };
59
59
 
60
60
  const logDeps: LogDeps = {
61
- appendFile: (p: string, content: string) => appendFile(p, content),
62
- readFile: (p: string) => readFile(p, "utf-8"),
61
+ appendFile: (filePath: string, content: string) => appendFile(filePath, content),
62
+ readFile: (filePath: string) => readFile(filePath, "utf-8"),
63
63
  exists: existsSync,
64
- ensureDir: (p: string) => mkdir(p, { recursive: true }).then(() => {}),
64
+ ensureDir: (directoryPath: string) => mkdir(directoryPath, { recursive: true }).then(() => {}),
65
65
  };
66
66
 
67
67
  // ── System task registry ─────────────────────────────────────────
@@ -95,11 +95,11 @@ export async function initScheduler(taskManager: ITaskManager, tasks: SystemTask
95
95
  taskManagerRef = taskManager;
96
96
 
97
97
  // Run catch-up
98
- const catchUpTasks: CatchUpTask[] = tasks.map((t) => ({
99
- id: t.id,
100
- name: t.name,
101
- schedule: toCoreSchedule(t.schedule),
102
- missedRunPolicy: t.missedRunPolicy,
98
+ const catchUpTasks: CatchUpTask[] = tasks.map((taskDef) => ({
99
+ id: taskDef.id,
100
+ name: taskDef.name,
101
+ schedule: toCoreSchedule(taskDef.schedule),
102
+ missedRunPolicy: taskDef.missedRunPolicy,
103
103
  enabled: true,
104
104
  }));
105
105
  const plan = computeCatchUpPlan(catchUpTasks, stateMap, Date.now());
@@ -117,7 +117,7 @@ export async function initScheduler(taskManager: ITaskManager, tasks: SystemTask
117
117
  runs: plan.runs.length,
118
118
  });
119
119
  for (const run of plan.runs) {
120
- const task = tasks.find((t) => t.id === run.taskId);
120
+ const task = tasks.find((taskDef) => taskDef.id === run.taskId);
121
121
  if (!task) continue;
122
122
  await executeAndLog(task, run.context.scheduledFor, TASK_TRIGGERS.catchUp);
123
123
  }
@@ -137,7 +137,7 @@ export async function initScheduler(taskManager: ITaskManager, tasks: SystemTask
137
137
  }
138
138
 
139
139
  log.info("scheduler", "initialized", {
140
- tasks: tasks.map((t) => t.id),
140
+ tasks: tasks.map((taskDef) => taskDef.id),
141
141
  stateEntries: stateMap.size,
142
142
  });
143
143
  }
@@ -146,7 +146,7 @@ export async function initScheduler(taskManager: ITaskManager, tasks: SystemTask
146
146
  * Updates the in-memory task definition, the task-manager, and
147
147
  * recalculates nextScheduledAt in persisted state. */
148
148
  export async function applyScheduleOverride(taskId: string, schedule: SystemTaskDef["schedule"]): Promise<boolean> {
149
- const task = systemTasks.find((t) => t.id === taskId);
149
+ const task = systemTasks.find((taskDef) => taskDef.id === taskId);
150
150
  if (!task || !taskManagerRef) return false;
151
151
  if (!taskManagerRef.updateSchedule(taskId, schedule)) return false;
152
152
  task.schedule = schedule;
@@ -172,13 +172,13 @@ export function getSchedulerTasks(): Array<{
172
172
  missedRunPolicy: string;
173
173
  state: TaskExecutionState;
174
174
  }> {
175
- return systemTasks.map((t) => ({
176
- id: t.id,
177
- name: t.name,
178
- description: t.description,
179
- schedule: t.schedule,
180
- missedRunPolicy: t.missedRunPolicy,
181
- state: stateMap.get(t.id) ?? emptyState(t.id),
175
+ return systemTasks.map((taskDef) => ({
176
+ id: taskDef.id,
177
+ name: taskDef.name,
178
+ description: taskDef.description,
179
+ schedule: taskDef.schedule,
180
+ missedRunPolicy: taskDef.missedRunPolicy,
181
+ state: stateMap.get(taskDef.id) ?? emptyState(taskDef.id),
182
182
  }));
183
183
  }
184
184
 
@@ -65,8 +65,8 @@ const storelessPending = new Map<string, Set<string>>();
65
65
  let pubsub: IPubSub | null = null;
66
66
  let evictionTimer: ReturnType<typeof setInterval> | null = null;
67
67
 
68
- export function initSessionStore(ps: IPubSub): void {
69
- pubsub = ps;
68
+ export function initSessionStore(pubSubInstance: IPubSub): void {
69
+ pubsub = pubSubInstance;
70
70
  if (evictionTimer) clearInterval(evictionTimer);
71
71
  evictionTimer = setInterval(evictIdleSessions, EVICTION_CHECK_INTERVAL_MS);
72
72
  }
@@ -288,8 +288,8 @@ interface GenerationPayload {
288
288
 
289
289
  const GENERATION_KIND_VALUES: ReadonlySet<string> = new Set(Object.values(GENERATION_KINDS));
290
290
 
291
- function isGenerationKind(v: unknown): v is GenerationKind {
292
- return typeof v === "string" && GENERATION_KIND_VALUES.has(v);
291
+ function isGenerationKind(value: unknown): value is GenerationKind {
292
+ return typeof value === "string" && GENERATION_KIND_VALUES.has(value);
293
293
  }
294
294
 
295
295
  /**
@@ -314,7 +314,7 @@ function applyEventToSession(session: ServerSession, type: string, event: Record
314
314
  timestamp: Date.now(),
315
315
  });
316
316
  } else if (type === EVENT_TYPES.toolCallResult) {
317
- const entry = session.toolCallHistory.find((e) => e.toolUseId === event.toolUseId);
317
+ const entry = session.toolCallHistory.find((historyEntry) => historyEntry.toolUseId === event.toolUseId);
318
318
  if (entry) entry.result = event.content as string;
319
319
  } else if (type === EVENT_TYPES.status) {
320
320
  session.statusMessage = event.message as string;
@@ -404,8 +404,8 @@ export function getSessionImageData(chatSessionId: string): string | undefined {
404
404
 
405
405
  export function getActiveSessionIds(): Set<string> {
406
406
  const ids = new Set<string>();
407
- for (const [id, session] of store) {
408
- if (session.isRunning) ids.add(id);
407
+ for (const [chatSessionId, session] of store) {
408
+ if (session.isRunning) ids.add(chatSessionId);
409
409
  }
410
410
  return ids;
411
411
  }
@@ -465,14 +465,14 @@ function notifySessionsChanged(): void {
465
465
 
466
466
  function evictIdleSessions(): void {
467
467
  const now = Date.now();
468
- for (const [id, session] of store) {
468
+ for (const [chatSessionId, session] of store) {
469
469
  if (session.isRunning) continue;
470
470
  const age = now - new Date(session.updatedAt).getTime();
471
471
  if (age > IDLE_EVICTION_MS) {
472
472
  log.info("session-store", "evicting idle session", {
473
- chatSessionId: id,
473
+ chatSessionId,
474
474
  });
475
- removeSession(id);
475
+ removeSession(chatSessionId);
476
476
  }
477
477
  }
478
478
  }
@@ -52,8 +52,8 @@ function isDue(now: Date, schedule: TaskSchedule, tickMs: number): boolean {
52
52
  }
53
53
 
54
54
  if (schedule.type === SCHEDULE_TYPES.daily) {
55
- const [hh, mm] = schedule.time.split(":").map(Number);
56
- const targetMs = hh * ONE_HOUR_MS + mm * ONE_MINUTE_MS;
55
+ const [hours, minutes] = schedule.time.split(":").map(Number);
56
+ const targetMs = hours * ONE_HOUR_MS + minutes * ONE_MINUTE_MS;
57
57
  const msSinceMidnight = now.getUTCHours() * ONE_HOUR_MS + now.getUTCMinutes() * ONE_MINUTE_MS + now.getUTCSeconds() * ONE_SECOND_MS;
58
58
  const rounded = Math.floor(msSinceMidnight / tickMs) * tickMs;
59
59
  return rounded === targetMs;
@@ -170,11 +170,11 @@ export function createTaskManager(options?: TaskManagerOptions): ITaskManager {
170
170
  },
171
171
 
172
172
  listTasks() {
173
- return [...registry.values()].map((d) => ({
174
- id: d.id,
175
- description: d.description,
176
- schedule: d.schedule,
177
- dependsOn: d.dependsOn,
173
+ return [...registry.values()].map((taskDef) => ({
174
+ id: taskDef.id,
175
+ description: taskDef.description,
176
+ schedule: taskDef.schedule,
177
+ dependsOn: taskDef.dependsOn,
178
178
  }));
179
179
  },
180
180
  };
package/server/index.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import "dotenv/config";
2
2
  import express, { Request, Response, NextFunction } from "express";
3
- import net from "net";
4
3
  import path from "path";
5
4
  import { fileURLToPath } from "url";
6
5
  import agentRoutes from "./api/routes/agent.js";
@@ -34,8 +33,8 @@ import { mcpToolsRouter, mcpTools, isMcpToolEnabled } from "./agent/mcp-tools/in
34
33
  import { initWorkspace, workspacePath } from "./workspace/workspace.js";
35
34
  import { env, isGeminiAvailable } from "./system/env.js";
36
35
  import { buildSandboxStatus } from "./api/sandboxStatus.js";
37
- import fs from "fs";
38
- import os from "os";
36
+ import { existsSync, readFileSync } from "fs";
37
+ import { homedir } from "os";
39
38
  import { isDockerAvailable, ensureSandboxImage } from "./system/docker.js";
40
39
  import { maybeRunJournal } from "./workspace/journal/index.js";
41
40
  import { backfillAllSessions } from "./workspace/chat-index/index.js";
@@ -53,6 +52,7 @@ import { requireSameOrigin } from "./api/csrfGuard.js";
53
52
  import { bearerAuth } from "./api/auth/bearerAuth.js";
54
53
  import { deleteTokenFile, generateAndWriteToken, getCurrentToken } from "./api/auth/token.js";
55
54
  import { log } from "./system/logger/index.js";
55
+ import { logBackgroundError } from "./utils/logBackgroundError.js";
56
56
  import { startChat } from "./api/routes/agent.js";
57
57
  import { registerScheduledSkills } from "./workspace/skills/scheduler.js";
58
58
  import { registerUserTasks } from "./workspace/skills/user-tasks.js";
@@ -60,6 +60,7 @@ import { API_ROUTES } from "../src/config/apiRoutes.js";
60
60
  import { EVENT_TYPES } from "../src/types/events.js";
61
61
  import { SESSION_ORIGINS } from "../src/types/session.js";
62
62
  import { ONE_SECOND_MS, ONE_MINUTE_MS, ONE_HOUR_MS } from "./utils/time.js";
63
+ import { isPortFree, findAvailablePort, MAX_PORT_PROBES } from "./utils/port.mjs";
63
64
  import { SCHEDULE_TYPES, MISSED_RUN_POLICIES } from "@receptron/task-scheduler";
64
65
 
65
66
  const HTML_TOKEN_PLACEHOLDER = "__MULMOCLAUDE_AUTH_TOKEN__";
@@ -74,7 +75,6 @@ initWorkspace();
74
75
  let sandboxEnabled = false;
75
76
 
76
77
  const app = express();
77
- const PORT = env.port;
78
78
 
79
79
  app.disable("x-powered-by");
80
80
  // No `cors()` middleware. The Vite dev proxy forwards `/api/*`
@@ -157,13 +157,13 @@ app.use(configRoutes);
157
157
  app.use(skillsRoutes);
158
158
  async function listSessionsForBridge(opts: { limit: number; offset: number }) {
159
159
  const rows = await loadAllSessions();
160
- const sorted = rows.sort((a, b) => b.changeMs - a.changeMs);
160
+ const sorted = rows.sort((leftSession, rightSession) => rightSession.changeMs - leftSession.changeMs);
161
161
  const total = sorted.length;
162
- const sessions = sorted.slice(opts.offset, opts.offset + opts.limit).map((r) => ({
163
- id: r.summary.id,
164
- roleId: r.summary.roleId,
165
- preview: r.summary.preview,
166
- updatedAt: r.summary.updatedAt,
162
+ const sessions = sorted.slice(opts.offset, opts.offset + opts.limit).map((row) => ({
163
+ id: row.summary.id,
164
+ roleId: row.summary.roleId,
165
+ preview: row.summary.preview,
166
+ updatedAt: row.summary.updatedAt,
167
167
  }));
168
168
  return { sessions, total };
169
169
  }
@@ -234,7 +234,7 @@ if (env.isProduction) {
234
234
  app.get("/{*splat}", (_req: Request, res: Response) => {
235
235
  let html: string;
236
236
  try {
237
- html = fs.readFileSync(indexHtmlPath, "utf-8");
237
+ html = readFileSync(indexHtmlPath, "utf-8");
238
238
  } catch (err) {
239
239
  log.error("server", "failed to read index.html", { error: String(err) });
240
240
  serverError(res, "Internal Server Error");
@@ -255,28 +255,40 @@ app.use((err: Error, _req: Request, res: Response, __next: NextFunction) => {
255
255
  serverError(res, "Internal Server Error");
256
256
  });
257
257
 
258
- function isPortFree(port: number): Promise<boolean> {
259
- return new Promise((resolve) => {
260
- const server = net.createServer();
261
- server.once("error", () => resolve(false));
262
- server.once("listening", () => {
263
- server.close(() => resolve(true));
264
- });
265
- // Probe the same interface we'll actually bind to so a port
266
- // held by a different process on a different interface doesn't
267
- // give us a false "free" reading.
268
- server.listen(port, "127.0.0.1");
269
- });
258
+ // True iff the user set `PORT` explicitly; empty string counts as "not
259
+ // set". We use this to decide between "walk forward when busy" (friendly
260
+ // dev behaviour) and "fail loudly" (respect the user's choice).
261
+ const portExplicit = typeof process.env.PORT === "string" && process.env.PORT.trim() !== "";
262
+
263
+ // Resolve the port we'll actually bind to. Default PORT (3001) + busy
264
+ // walks forward so a stale `yarn dev` or a parallel test run doesn't
265
+ // crash the launch. Explicit PORT + busy exits matches the launcher's
266
+ // `--port` semantics so `PORT=3099 yarn dev` behaves the same as
267
+ // `npx mulmoclaude --port 3099`.
268
+ async function resolvePort(): Promise<number> {
269
+ const requested = env.port;
270
+ if (await isPortFree(requested)) return requested;
271
+ if (portExplicit) {
272
+ log.error("server", `Port ${requested} is already in use. Stop the other process or pick a different PORT.`);
273
+ process.exit(1);
274
+ }
275
+ const fallback = await findAvailablePort(requested + 1);
276
+ if (fallback === null) {
277
+ log.error("server", `Port ${requested} is in use and no free port found in ${requested}..${requested + MAX_PORT_PROBES - 1}.`);
278
+ process.exit(1);
279
+ }
280
+ log.info("server", `Port ${requested} busy → using ${fallback} instead`);
281
+ return fallback;
270
282
  }
271
283
 
272
284
  async function ensureCredentialsAvailable(): Promise<void> {
273
- const credentialsPath = path.join(os.homedir(), ".claude", ".credentials.json");
274
- if (fs.existsSync(credentialsPath)) return;
285
+ const credentialsPath = path.join(homedir(), ".claude", ".credentials.json");
286
+ if (existsSync(credentialsPath)) return;
275
287
 
276
288
  if (process.platform === "darwin") {
277
289
  const { refreshCredentials } = await import("./system/credentials.js");
278
- const ok = await refreshCredentials();
279
- if (ok) return;
290
+ const refreshSucceeded = await refreshCredentials();
291
+ if (refreshSucceeded) return;
280
292
  log.error("sandbox", "Failed to export credentials from macOS Keychain. Run `npm run sandbox:login` manually.");
281
293
  process.exit(1);
282
294
  }
@@ -310,14 +322,14 @@ async function setupSandbox(): Promise<boolean> {
310
322
 
311
323
  function logMcpStatus(): void {
312
324
  const enabledMcpTools = mcpTools.filter(isMcpToolEnabled);
313
- const disabledMcpTools = mcpTools.filter((t) => !isMcpToolEnabled(t));
325
+ const disabledMcpTools = mcpTools.filter((toolDef) => !isMcpToolEnabled(toolDef));
314
326
  if (enabledMcpTools.length > 0) {
315
327
  log.info("mcp", "Available", {
316
- tools: enabledMcpTools.map((t) => t.definition.name).join(", "),
328
+ tools: enabledMcpTools.map((toolDef) => toolDef.definition.name).join(", "),
317
329
  });
318
330
  }
319
331
  if (disabledMcpTools.length > 0) {
320
- const names = disabledMcpTools.map((t) => t.definition.name + " (" + (t.requiredEnv ?? []).join(", ") + ")").join(", ");
332
+ const names = disabledMcpTools.map((toolDef) => toolDef.definition.name + " (" + (toolDef.requiredEnv ?? []).join(", ") + ")").join(", ");
321
333
  log.info("mcp", "Unavailable (missing env)", { tools: names });
322
334
  }
323
335
  }
@@ -329,9 +341,7 @@ function maybeForceJournalRun(): void {
329
341
  // propagate out of maybeRunJournal.
330
342
  if (!env.journalForceRunOnStartup) return;
331
343
  log.info("journal", "JOURNAL_FORCE_RUN_ON_STARTUP=1 — running now");
332
- maybeRunJournal({ force: true }).catch((err) => {
333
- log.warn("journal", "forced startup run failed", { error: String(err) });
334
- });
344
+ maybeRunJournal({ force: true }).catch(logBackgroundError("journal", "forced startup run failed"));
335
345
  }
336
346
 
337
347
  function maybeForceChatIndexBackfill(): void {
@@ -349,15 +359,11 @@ function maybeForceChatIndexBackfill(): void {
349
359
  skipped: result.skipped,
350
360
  });
351
361
  })
352
- .catch((err) => {
353
- log.warn("chat-index", "forced startup backfill failed", {
354
- error: String(err),
355
- });
356
- });
362
+ .catch(logBackgroundError("chat-index", "forced startup backfill failed"));
357
363
  }
358
364
 
359
- function startRuntimeServices(httpServer: ReturnType<typeof app.listen>): void {
360
- log.info("server", "listening", { port: PORT });
365
+ function startRuntimeServices(httpServer: ReturnType<typeof app.listen>, port: number): void {
366
+ log.info("server", "listening", { port });
361
367
 
362
368
  // --- Pub/Sub ---
363
369
  const pubsub = createPubSub(httpServer);
@@ -424,24 +430,24 @@ function startRuntimeServices(httpServer: ReturnType<typeof app.listen>): void {
424
430
  // are silently ignored — the hardcoded defaults above remain.
425
431
  const overrides = loadSchedulerOverrides();
426
432
  for (const task of systemTasks) {
427
- const ovr = overrides[task.id];
428
- if (!ovr) continue;
429
- if (task.schedule.type === SCHEDULE_TYPES.interval && typeof ovr.intervalMs === "number" && ovr.intervalMs > 0) {
433
+ const override = overrides[task.id];
434
+ if (!override) continue;
435
+ if (task.schedule.type === SCHEDULE_TYPES.interval && typeof override.intervalMs === "number" && override.intervalMs > 0) {
430
436
  log.info("scheduler", "applying override", {
431
437
  id: task.id,
432
- intervalMs: ovr.intervalMs,
438
+ intervalMs: override.intervalMs,
433
439
  });
434
440
  task.schedule = {
435
441
  type: SCHEDULE_TYPES.interval,
436
- intervalMs: ovr.intervalMs,
442
+ intervalMs: override.intervalMs,
437
443
  };
438
444
  }
439
- if (task.schedule.type === SCHEDULE_TYPES.daily && typeof ovr.time === "string" && UTC_HH_MM_RE.test(ovr.time)) {
445
+ if (task.schedule.type === SCHEDULE_TYPES.daily && typeof override.time === "string" && UTC_HH_MM_RE.test(override.time)) {
440
446
  log.info("scheduler", "applying override", {
441
447
  id: task.id,
442
- time: ovr.time,
448
+ time: override.time,
443
449
  });
444
- task.schedule = { type: SCHEDULE_TYPES.daily, time: ovr.time };
450
+ task.schedule = { type: SCHEDULE_TYPES.daily, time: override.time };
445
451
  }
446
452
  }
447
453
 
@@ -464,11 +470,7 @@ function startRuntimeServices(httpServer: ReturnType<typeof app.listen>): void {
464
470
  log.info("skills", "scheduled skills registered", { count });
465
471
  }
466
472
  })
467
- .catch((err) => {
468
- log.warn("skills", "failed to register scheduled skills", {
469
- error: String(err),
470
- });
471
- });
473
+ .catch(logBackgroundError("skills", "failed to register scheduled skills"));
472
474
 
473
475
  // Register user-created scheduled tasks from tasks.json.
474
476
  registerUserTasks({ taskManager, startChat })
@@ -477,11 +479,7 @@ function startRuntimeServices(httpServer: ReturnType<typeof app.listen>): void {
477
479
  log.info("user-tasks", "user tasks registered", { count });
478
480
  }
479
481
  })
480
- .catch((err) => {
481
- log.warn("user-tasks", "failed to register user tasks", {
482
- error: String(err),
483
- });
484
- });
482
+ .catch(logBackgroundError("user-tasks", "failed to register user tasks"));
485
483
 
486
484
  taskManager.start();
487
485
 
@@ -510,11 +508,7 @@ process.on("SIGTERM", () => {
510
508
  });
511
509
 
512
510
  (async () => {
513
- const portFree = await isPortFree(PORT);
514
- if (!portFree) {
515
- log.error("server", `Port ${PORT} is already in use. Stop the other process and try again.`);
516
- process.exit(1);
517
- }
511
+ const port = await resolvePort();
518
512
 
519
513
  // Generate the bearer token before `app.listen` so the first
520
514
  // request cannot race an uninitialised `getCurrentToken()`. The
@@ -535,8 +529,8 @@ process.on("SIGTERM", () => {
535
529
  // `http://<laptop-ip>:3001/api/*`), which combined with the
536
530
  // workspace file API is a credential-theft risk. Personal dev
537
531
  // tool — localhost is the right default.
538
- const httpServer = app.listen(PORT, "127.0.0.1", () => {
539
- startRuntimeServices(httpServer);
532
+ const httpServer = app.listen(port, "127.0.0.1", () => {
533
+ startRuntimeServices(httpServer, port);
540
534
  });
541
535
  })();
542
536
 
@@ -9,7 +9,7 @@
9
9
  // defaults. Writers perform an atomic replace (tmp + rename) so a
10
10
  // reader never observes a half-written file.
11
11
 
12
- import fs from "fs";
12
+ import { mkdirSync } from "fs";
13
13
  import path from "path";
14
14
  import { log } from "./logger/index.js";
15
15
  import { WORKSPACE_PATHS } from "../workspace/paths.js";
@@ -44,7 +44,7 @@ export function mcpConfigPath(): string {
44
44
  }
45
45
 
46
46
  export function ensureConfigsDir(): void {
47
- fs.mkdirSync(configsDir(), { recursive: true });
47
+ mkdirSync(configsDir(), { recursive: true });
48
48
  }
49
49
 
50
50
  export function isAppSettings(value: unknown): value is AppSettings {
@@ -181,8 +181,8 @@ export function isMcpConfigFile(value: unknown): value is McpConfigFile {
181
181
 
182
182
  const servers = value.mcpServers;
183
183
  if (!isRecord(servers)) return false;
184
- for (const [id, spec] of Object.entries(servers)) {
185
- if (!isMcpServerId(id)) return false;
184
+ for (const [serverId, spec] of Object.entries(servers)) {
185
+ if (!isMcpServerId(serverId)) return false;
186
186
  if (!isMcpServerSpec(spec)) return false;
187
187
  }
188
188
  return true;
@@ -220,7 +220,7 @@ export function saveMcpConfig(cfg: McpConfigFile): void {
220
220
 
221
221
  // Flatten storage form to UI-friendly array.
222
222
  export function toMcpEntries(cfg: McpConfigFile): McpServerEntry[] {
223
- return Object.entries(cfg.mcpServers).map(([id, spec]) => ({ id, spec }));
223
+ return Object.entries(cfg.mcpServers).map(([serverId, spec]) => ({ id: serverId, spec }));
224
224
  }
225
225
 
226
226
  // Re-inflate UI-friendly array to storage form. Duplicate ids are
@@ -1,10 +1,10 @@
1
1
  import { execFile } from "child_process";
2
- import { writeFile } from "fs/promises";
3
2
  import { homedir } from "os";
4
3
  import { join } from "path";
5
4
  import { promisify } from "util";
6
5
  import { log } from "./logger/index.js";
7
6
  import { ONE_SECOND_MS, ONE_MINUTE_MS } from "../utils/time.js";
7
+ import { writeFileAtomic } from "../utils/files/atomic.js";
8
8
 
9
9
  const execFileAsync = promisify(execFile);
10
10
 
@@ -121,13 +121,13 @@ async function renewTokenViaPty(): Promise<boolean> {
121
121
  buffer += data;
122
122
 
123
123
  if (!responded) {
124
- const m = ECHO_RE.exec(buffer);
125
- if (m) {
124
+ const match = ECHO_RE.exec(buffer);
125
+ if (match) {
126
126
  // Claude echoed our "hi" — remember where the response
127
127
  // window starts so the success check looks only at bytes
128
128
  // that arrived AFTER the echo.
129
129
  responded = true;
130
- echoEndIdx = m.index + m[0].length;
130
+ echoEndIdx = match.index + match[0].length;
131
131
  }
132
132
  return;
133
133
  }
@@ -208,7 +208,9 @@ export async function refreshCredentials(): Promise<boolean> {
208
208
  }
209
209
  }
210
210
 
211
- await writeFile(CREDENTIALS_PATH, credentials + "\n", { mode: 0o600 });
211
+ // Atomic so a readers mid-refresh can't see a truncated creds
212
+ // file; mode preserves the 0o600 we always set on this file.
213
+ await writeFileAtomic(CREDENTIALS_PATH, credentials + "\n", { mode: 0o600 });
212
214
  log.info("credentials", "Fresh credentials written to ~/.claude/.credentials.json");
213
215
  return true;
214
216
  } catch (err) {
@@ -19,11 +19,11 @@
19
19
 
20
20
  function asInt(value: string | undefined, fallback: number, opts: { min?: number; max?: number } = {}): number {
21
21
  if (value === undefined || value === "") return fallback;
22
- const n = Number(value);
23
- if (!Number.isInteger(n)) return fallback;
24
- if (opts.min !== undefined && n < opts.min) return fallback;
25
- if (opts.max !== undefined && n > opts.max) return fallback;
26
- return n;
22
+ const parsed = Number(value);
23
+ if (!Number.isInteger(parsed)) return fallback;
24
+ if (opts.min !== undefined && parsed < opts.min) return fallback;
25
+ if (opts.max !== undefined && parsed > opts.max) return fallback;
26
+ return parsed;
27
27
  }
28
28
 
29
29
  function asFlag(value: string | undefined): boolean {